From 69ea44776d8e01fa96ad1c4766c2675896f4df01 Mon Sep 17 00:00:00 2001 From: batistalucasv Date: Fri, 13 Nov 2026 15:21:30 -0300 Subject: [PATCH 1/5] Fix: arrange.bounds on header picture writes page-anchored ODT frame (#5858) * Fix #5812: arrange.bounds on header picture writes page-anchored ODT frame * Address review feedback on PR #5858: preserve horizontal anchor in move_with_text --- crates/engine/src/cmd/objects/arrange.rs | 20 ++- crates/engine/src/cmd/objects/tests.rs | 159 +++++++++++++++++++ crates/formats/src/odt/frames.rs | 4 +- crates/formats/src/odt/tests_more/headers.rs | 79 +++++++++ 4 files changed, 256 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/objects/arrange.rs b/crates/engine/src/cmd/objects/arrange.rs index abdc85695a..15cc5555d7 100644 --- a/crates/engine/src/cmd/objects/arrange.rs +++ b/crates/engine/src/cmd/objects/arrange.rs @@ -147,7 +147,8 @@ pub(super) fn move_object(s: &mut Session, pos: Pos, page: Option, x: f32, } /// [`move_object`]'s one placement: move the object's character to the anchor under (x, y) on -/// `page` and offset it from there. +/// `page` and offset it from there. Objects in headers/footers, on pages without anchoring text, +/// or configured not to move with text retain page-relative anchoring. fn move_object_once(s: &mut Session, pos: &Pos, page: usize, x: f32, y: f32, (w, h): (f32, f32)) -> Result { use wordcraft_doc::para::Anchor; if pos.path.depth() > 0 || !movable_story(s, pos.story) { @@ -164,6 +165,8 @@ fn move_object_once(s: &mut Session, pos: &Pos, page: usize, x: f32, y: f32, (w, Some(path) => (Pos { story, path, off: 0 }, false), None => (nearest_line(&layout, page, y, story).ok_or_else(|| CmdError::Params("no text on that page to anchor to".into()))?, true), }; + let was_with_text = + s.doc.para_at(pos).and_then(|p| p.object_at(pos.off)).and_then(|o| o.frame()).map_or(story == StoryRef::Body, |(_, _, f)| moves_with_text(f)); // Move its character to the anchor, unless it's already in that paragraph and drawn on this // page (one anchored on a later page of it is drawn there, #4250). let at = if anchor.story == pos.story && anchor.path == pos.path && !on_page && layout.object(pos, page).is_some_and(|o| o.page == page) { @@ -196,7 +199,7 @@ fn move_object_once(s: &mut Session, pos: &Pos, page: usize, x: f32, y: f32, (w, f.x = x.clamp(-MAX_OFFSET, MAX_OFFSET); f.y = y.clamp(-MAX_OFFSET, MAX_OFFSET); })?; - if on_page { + if on_page || story != StoryRef::Body || !was_with_text { return Ok(at); } // Then offsets from its paragraph, as laid out with the object there. @@ -223,16 +226,23 @@ pub(super) fn unalign(s: &mut Session) -> Result<(), CmdError> { return Ok(()); } let hit = s.layout().object(&pos, s.page_hint).ok_or_else(|| CmdError::Failed("the object isn't laid out".into()))?; - let (ox, oy) = (hit.rect.x - hit.origin.x, hit.rect.y - hit.origin.y); + let (ox, oy) = ( + if float.h_rel == Anchor::Page { hit.rect.x } else { hit.rect.x - hit.origin.x }, + if float.v_rel == Anchor::Page { hit.rect.y } else { hit.rect.y - hit.origin.y }, + ); edit_float(s, &pos, |f| { if f.h_align.is_some() { f.h_align = None; - f.h_rel = Anchor::Column; + if f.h_rel != Anchor::Page { + f.h_rel = Anchor::Column; + } f.x = ox.clamp(-MAX_OFFSET, MAX_OFFSET); } if f.v_align.is_some() { f.v_align = None; - f.v_rel = Anchor::Paragraph; + if f.v_rel != Anchor::Page { + f.v_rel = Anchor::Paragraph; + } f.y = oy.clamp(-MAX_OFFSET, MAX_OFFSET); } })?; diff --git a/crates/engine/src/cmd/objects/tests.rs b/crates/engine/src/cmd/objects/tests.rs index 13836d60c5..cf302543ac 100644 --- a/crates/engine/src/cmd/objects/tests.rs +++ b/crates/engine/src/cmd/objects/tests.rs @@ -1254,6 +1254,165 @@ fn header_pictures_can_be_resized_and_dragged() { s.run("edit.undo", &json!({})).unwrap(); let u = s.layout().object(&selected(&s).unwrap().0, 0).expect("laid out"); assert!((u.rect.x - 200.0).abs() < 1.0 && (u.rect.y - 60.0).abs() < 1.0, "{:?}", u.rect); + let float = selected(&s).unwrap().1.frame().unwrap().2.clone(); + assert_eq!((float.h_rel, float.v_rel), (wordcraft_doc::para::Anchor::Page, wordcraft_doc::para::Anchor::Page)); + assert_eq!((float.x, float.y), (200.0, 60.0)); +} + +/// #5812: arrange.bounds on a header picture (or after moveWithText false) keeps page anchoring +/// and exports a page-anchored frame in .odt with coordinates relative to the page. +#[test] +fn arrange_bounds_on_header_picture_writes_page_anchored_odt_frame() { + use wordcraft_doc::para::Anchor; + let mut s = Session::new(wordcraft_doc::Document::new()); + s.run("insert.editHeader", &json!({})).unwrap(); + let data = super::super::insert::base64_encode(&png(20, 10)); + s.run("insert.picture", &json!({"data": data, "width": 100})).unwrap(); + s.run("arrange.wrap", &json!({"wrap": "square"})).unwrap(); + s.run("arrange.bounds", &json!({"x": 0.0, "y": 0.0, "page": 0})).unwrap(); + let float = selected(&s).unwrap().1.frame().unwrap().2.clone(); + assert_eq!((float.h_rel, float.v_rel), (Anchor::Page, Anchor::Page)); + assert_eq!((float.x, float.y), (0.0, 0.0)); + + let mut z = zip::ZipArchive::new(std::io::Cursor::new(wordcraft_formats::odt::export(&s.doc).unwrap())).unwrap(); + let mut styles = String::new(); + std::io::Read::read_to_string(&mut z.by_name("styles.xml").unwrap(), &mut styles).unwrap(); + assert!(styles.contains(r#"text:anchor-type="page""#), "frame should be page-anchored: {styles}"); + assert!(styles.contains(r#"svg:x="0pt""#) && styles.contains(r#"svg:y="0pt""#), "offsets should be 0pt: {styles}"); + assert!(!styles.contains(r#"text:anchor-type="paragraph""#), "frame should not be paragraph-anchored: {styles}"); + + // Also test after arrange.moveWithText false + s.run("arrange.moveWithText", &json!({"value": false})).unwrap(); + s.run("arrange.bounds", &json!({"x": 10.0, "y": 20.0, "page": 0})).unwrap(); + let float = selected(&s).unwrap().1.frame().unwrap().2.clone(); + assert_eq!((float.h_rel, float.v_rel), (Anchor::Page, Anchor::Page)); + assert_eq!((float.x, float.y), (10.0, 20.0)); + + let mut z = zip::ZipArchive::new(std::io::Cursor::new(wordcraft_formats::odt::export(&s.doc).unwrap())).unwrap(); + let mut styles = String::new(); + std::io::Read::read_to_string(&mut z.by_name("styles.xml").unwrap(), &mut styles).unwrap(); + assert!(styles.contains(r#"text:anchor-type="page""#), "{styles}"); + assert!(styles.contains(r#"svg:x="10pt""#) && styles.contains(r#"svg:y="20pt""#), "{styles}"); +} + +/// Turning off `moveWithText` on a body picture anchors it to the page, and subsequent +/// `arrange.bounds` retains page-relative anchoring and writes a page-anchored ODT frame. +#[test] +fn body_picture_with_move_with_text_false_retains_page_anchor_and_coordinates() { + use wordcraft_doc::para::Anchor; + let mut s = Session::new(wordcraft_doc::Document::new()); + s.run("text.insert", &json!({"text": "First line of paragraph.\n"})).unwrap(); + let data = super::super::insert::base64_encode(&png(20, 10)); + s.run("insert.picture", &json!({"data": data, "width": 100})).unwrap(); + s.run("arrange.wrap", &json!({"wrap": "square"})).unwrap(); + // Default body float moves with text + let (_pos, obj) = selected(&s).unwrap(); + let float = obj.frame().unwrap().2; + assert_eq!((float.h_rel, float.v_rel), (Anchor::Column, Anchor::Column)); + + // Turn off moveWithText + s.run("arrange.moveWithText", &json!({"value": false})).unwrap(); + let (_, obj) = selected(&s).unwrap(); + let float = obj.frame().unwrap().2; + assert_eq!((float.h_rel, float.v_rel), (Anchor::Column, Anchor::Page)); + + // Now call arrange.bounds on a page where a paragraph starts + s.run("arrange.bounds", &json!({"page": 0, "x": 50.0, "y": 80.0})).unwrap(); + let (_, obj) = selected(&s).unwrap(); + let float = obj.frame().unwrap().2; + assert_eq!((float.h_rel, float.v_rel), (Anchor::Page, Anchor::Page)); + assert_eq!((float.x, float.y), (50.0, 80.0)); + + let mut z = zip::ZipArchive::new(std::io::Cursor::new(wordcraft_formats::odt::export(&s.doc).unwrap())).unwrap(); + let mut content = String::new(); + std::io::Read::read_to_string(&mut z.by_name("content.xml").unwrap(), &mut content).unwrap(); + assert!(content.contains(r#"text:anchor-type="page""#), "body picture should export with page anchor: {content}"); + assert!(content.contains(r#"svg:x="50pt""#) && content.contains(r#"svg:y="80pt""#), "{content}"); +} + +/// Changing `moveWithText` on an object with `h_align` set preserves its horizontal +/// anchor relation and layout position without displacement. +#[test] +fn move_with_text_preserves_horizontal_position_when_h_align_set() { + use wordcraft_doc::para::{Anchor, FloatAlign}; + let mut s = Session::new(wordcraft_doc::Document::new()); + s.run("text.insert", &json!({"text": "First body paragraph with some text.\n"})).unwrap(); + let data = super::super::insert::base64_encode(&png(20, 10)); + s.run("insert.picture", &json!({"data": data, "width": 100})).unwrap(); + s.run("arrange.wrap", &json!({"wrap": "square"})).unwrap(); + + // Set horizontal alignment to End (right) + let (pos, _) = selected(&s).unwrap(); + super::select::edit_float(&mut s, &pos, |f| { + f.h_align = Some(FloatAlign::End); + f.h_rel = Anchor::Margin; + }) + .unwrap(); + s.touch(); + + let hit_before = s.layout().object(&pos, 0).unwrap(); + let x_before = hit_before.rect.x; + + // Toggle moveWithText false + s.run("arrange.moveWithText", &json!({"value": false})).unwrap(); + let (pos, obj) = selected(&s).unwrap(); + let float = obj.frame().unwrap().2; + assert_eq!(float.v_rel, Anchor::Page); + assert_eq!(float.h_align, Some(FloatAlign::End)); + assert_eq!(float.h_rel, Anchor::Margin); + s.touch(); + let hit_after = s.layout().object(&pos, 0).unwrap(); + assert!((hit_after.rect.x - x_before).abs() < 0.01, "{:?} vs {:?}", hit_after.rect.x, x_before); + + // Toggle moveWithText true + s.run("arrange.moveWithText", &json!({"value": true})).unwrap(); + let (pos, obj) = selected(&s).unwrap(); + let float = obj.frame().unwrap().2; + assert_eq!(float.v_rel, Anchor::Paragraph); + assert_eq!(float.h_align, Some(FloatAlign::End)); + assert_eq!(float.h_rel, Anchor::Margin); + s.touch(); + let hit_after2 = s.layout().object(&pos, 0).unwrap(); + assert!((hit_after2.rect.x - x_before).abs() < 0.01, "{:?} vs {:?}", hit_after2.rect.x, x_before); +} + +/// Toggling `moveWithText` preserves horizontal anchor relations (`Margin`, `RightMargin`, +/// `Character`, `Column`, `Page`) and offset when the object has no horizontal alignment. +#[test] +fn move_with_text_preserves_horizontal_anchor_relations_without_alignment() { + use wordcraft_doc::para::Anchor; + for rel in [Anchor::Margin, Anchor::RightMargin, Anchor::Character, Anchor::Column, Anchor::Page] { + let mut s = Session::new(wordcraft_doc::Document::new()); + s.run("text.insert", &json!({"text": "A line of body text.\n"})).unwrap(); + let data = super::super::insert::base64_encode(&png(20, 10)); + s.run("insert.picture", &json!({"data": data, "width": 100})).unwrap(); + s.run("arrange.wrap", &json!({"wrap": "square"})).unwrap(); + + let (pos, _) = selected(&s).unwrap(); + super::select::edit_float(&mut s, &pos, |f| { + f.h_align = None; + f.h_rel = rel; + f.x = 35.0; + }) + .unwrap(); + s.touch(); + + // Toggle moveWithText false + s.run("arrange.moveWithText", &json!({"value": false})).unwrap(); + let (_, obj) = selected(&s).unwrap(); + let float = obj.frame().unwrap().2; + assert_eq!(float.v_rel, Anchor::Page, "expected Page v_rel for {rel:?}"); + assert_eq!(float.h_rel, rel, "expected {rel:?} preserved on moveWithText false"); + assert!((float.x - 35.0).abs() < 0.01, "expected x preserved on moveWithText false"); + + // Toggle moveWithText true + s.run("arrange.moveWithText", &json!({"value": true})).unwrap(); + let (_, obj) = selected(&s).unwrap(); + let float = obj.frame().unwrap().2; + assert_eq!(float.v_rel, Anchor::Paragraph, "expected Paragraph v_rel for {rel:?}"); + assert_eq!(float.h_rel, rel, "expected {rel:?} preserved on moveWithText true"); + assert!((float.x - 35.0).abs() < 0.01, "expected x preserved on moveWithText true"); + } } /// Shape Format › Text (#369): direction and alignment on the box the caret is in, then diff --git a/crates/formats/src/odt/frames.rs b/crates/formats/src/odt/frames.rs index 51accf7fb8..0d13c7a874 100644 --- a/crates/formats/src/odt/frames.rs +++ b/crates/formats/src/odt/frames.rs @@ -576,7 +576,9 @@ pub(super) fn turned(f: &Float) -> bool { /// How a floating picture is written: its `text:anchor-type`, its graphic style's /// `style:graphic-properties` attributes, and the frame's `svg:x`/`svg:y` attributes, or its -/// `draw:transform` when it is turned (`w` × `h` its size). +/// `draw:transform` when it is turned (`w` × `h` its size). Frames anchored to the page on both +/// horizontal and vertical axes export with `page` anchor; mixed or paragraph-relative frames +/// export with `paragraph` (or `char` for line-relative vertical) anchor to preserve placement. pub(super) fn frame_of(f: &Float, w: f32, h: f32) -> (&'static str, String, String) { // A page number holds until it is placed by its paragraph: moved in WordCraft. let by_paragraph = diff --git a/crates/formats/src/odt/tests_more/headers.rs b/crates/formats/src/odt/tests_more/headers.rs index 80478447d4..595abc2838 100644 --- a/crates/formats/src/odt/tests_more/headers.rs +++ b/crates/formats/src/odt/tests_more/headers.rs @@ -395,3 +395,82 @@ fn libreoffices_watermark_reads_as_the_watermark() { let [w, h] = wm.size.unwrap(); assert!((w - 6.69 * 72.0).abs() < 0.5 && (h - 2.24 * 72.0).abs() < 0.5); } + +/// A picture in a header anchored to the page on both axes exports as a page-anchored +/// ODT frame with matching coordinates and round-trips with page-relative relations. +#[test] +fn a_page_anchored_picture_in_a_header_exports_with_page_anchor() { + let mut d = Document::new(); + d.body = vec![text_para("Body", Default::default())]; + let pic = { + let img = image::RgbaImage::from_fn(4, 4, |_, _| image::Rgba([10, 20, 30, 255])); + let mut png = Vec::new(); + img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + png + }; + let media = d.add_media(pic, "png"); + let float = wordcraft_doc::para::Float { + wrap: wordcraft_doc::para::Wrap::Square, + h_rel: wordcraft_doc::para::Anchor::Page, + v_rel: wordcraft_doc::para::Anchor::Page, + x: 0.0, + y: 0.0, + ..Default::default() + }; + let mut hp = Paragraph::new(); + hp.text = wordcraft_doc::para::OBJ.into(); + hp.objects.push(InlineObject::Image { media, w: 100.0, h: 50.0, alt: "pic".into(), float, crop: [0.0; 4], fx: Default::default(), ole: None }); + hp.runs = vec![wordcraft_doc::para::Run { len: hp.text.len(), props: Default::default() }]; + d.parts.insert(1, part(PartKind::Header, vec![para_block(hp)])); + d.last_section.headers.default = Some(1); + + let saved = export(&d).unwrap(); + let styles = crate::odt::tests::entry(&saved, "styles.xml"); + assert!(styles.contains(r#"text:anchor-type="page""#), "{styles}"); + assert!(styles.contains(r#"svg:x="0pt""#) && styles.contains(r#"svg:y="0pt""#), "{styles}"); + assert!(!styles.contains(r#"text:anchor-type="paragraph""#), "{styles}"); + + let back = import(&saved).unwrap(); + let h = back.parts.get(&back.last_section.headers.default.unwrap()).unwrap().blocks[0].as_para().unwrap(); + let InlineObject::Image { float, .. } = &h.objects[0] else { panic!("not an image") }; + assert_eq!((float.h_rel, float.v_rel, float.x, float.y), (wordcraft_doc::para::Anchor::Page, wordcraft_doc::para::Anchor::Page, 0.0, 0.0)); +} + +/// Mixed page-relative and paragraph/line/char anchor relations export with the proper +/// anchor type and retain placement and anchor relationships through ODT export and import. +#[test] +fn mixed_anchor_relations_preserve_placement_and_roundtrip() { + for (h_rel, v_rel, expected_anchor, expected_hrel, expected_vrel) in [ + (wordcraft_doc::para::Anchor::Page, wordcraft_doc::para::Anchor::Paragraph, "paragraph", "page", "paragraph"), + (wordcraft_doc::para::Anchor::Column, wordcraft_doc::para::Anchor::Page, "paragraph", "paragraph-content", "page"), + (wordcraft_doc::para::Anchor::Character, wordcraft_doc::para::Anchor::Page, "paragraph", "char", "page"), + (wordcraft_doc::para::Anchor::Page, wordcraft_doc::para::Anchor::Line, "char", "page", "line"), + ] { + let mut d = Document::new(); + let pic = { + let img = image::RgbaImage::from_fn(4, 4, |_, _| image::Rgba([10, 20, 30, 255])); + let mut png = Vec::new(); + img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + png + }; + let media = d.add_media(pic, "png"); + let float = wordcraft_doc::para::Float { wrap: wordcraft_doc::para::Wrap::Square, h_rel, v_rel, x: 15.0, y: 25.0, ..Default::default() }; + let mut p = Paragraph::new(); + p.text = wordcraft_doc::para::OBJ.into(); + p.objects.push(InlineObject::Image { media, w: 100.0, h: 50.0, alt: "pic".into(), float, crop: [0.0; 4], fx: Default::default(), ole: None }); + p.runs = vec![wordcraft_doc::para::Run { len: p.text.len(), props: Default::default() }]; + d.body = vec![para_block(p)]; + + let saved = export(&d).unwrap(); + let content = crate::odt::tests::entry(&saved, "content.xml"); + assert!(content.contains(&format!(r#"text:anchor-type="{expected_anchor}""#)), "missing {expected_anchor}: {content}"); + assert!(content.contains(&format!(r#"style:horizontal-rel="{expected_hrel}""#)), "missing {expected_hrel}: {content}"); + assert!(content.contains(&format!(r#"style:vertical-rel="{expected_vrel}""#)), "missing {expected_vrel}: {content}"); + + let back = import(&saved).unwrap(); + let p_back = back.body[0].as_para().unwrap(); + let InlineObject::Image { float: back_float, .. } = &p_back.objects[0] else { panic!("not an image") }; + assert_eq!((back_float.h_rel, back_float.v_rel), (h_rel, v_rel)); + assert!((back_float.x - 15.0).abs() < 0.01 && (back_float.y - 25.0).abs() < 0.01, "{back_float:?}"); + } +} From ef3626dbb6664333491e9cc418ebc51ae4e674f3 Mon Sep 17 00:00:00 2001 From: FredApps Date: Fri, 13 Nov 2026 20:03:37 +0100 Subject: [PATCH 2/5] Tests: a page-fixed ODT frame keeps its page when moved; a frame set to move with text drops the page number --- crates/engine/src/doc_state.rs | 364 +- crates/engine/src/file_access.rs | 1068 +- crates/engine/src/ime.rs | 234 +- crates/engine/src/io.rs | 1756 +- crates/engine/src/io/safe_save.rs | 1042 +- crates/engine/src/io_ext.rs | 180 +- crates/engine/src/keymap.rs | 550 +- crates/engine/src/lib.rs | 424 +- crates/engine/src/locale.rs | 30 +- crates/engine/src/math_gallery.rs | 1182 +- crates/engine/src/packs.rs | 2326 +- crates/engine/src/packs/store.rs | 208 +- crates/engine/src/prefs.rs | 284 +- crates/engine/src/print.rs | 850 +- crates/engine/src/recovery.rs | 458 +- crates/engine/src/recovery/desktop.rs | 1442 +- crates/engine/src/recovery/desktop/tests.rs | 1242 +- crates/engine/src/ribbon.rs | 1012 +- crates/engine/src/sample.rs | 448 +- crates/engine/src/session.rs | 518 +- crates/engine/src/session/documents.rs | 1378 +- crates/engine/src/session/history.rs | 526 +- crates/engine/src/session/ime.rs | 170 +- crates/engine/src/session/layout.rs | 436 +- crates/engine/src/session/lifecycle.rs | 362 +- crates/engine/src/session/query.rs | 604 +- crates/engine/src/session/run.rs | 962 +- crates/engine/src/session/snapshot.rs | 604 +- crates/engine/src/session/views.rs | 340 +- crates/engine/src/snapshot.rs | 160 +- crates/engine/src/speech.rs | 2164 +- crates/engine/src/speech/ssip.rs | 1854 +- crates/engine/src/speech/tests.rs | 1000 +- crates/engine/src/strict.rs | 466 +- crates/engine/src/tests.rs | 26999 +++++++++--------- crates/engine/src/tests/bidi.rs | 650 +- crates/engine/src/tests/czech_test_dict.rs | 118 +- crates/engine/src/tests/documents.rs | 820 +- crates/engine/src/tests/find_apostrophes.rs | 96 +- crates/engine/src/tests/fit_one_page.rs | 2300 +- crates/engine/src/tests/font_sizes.rs | 460 +- crates/engine/src/tests/fonts.rs | 1022 +- crates/engine/src/tests/ime_noop.rs | 244 +- crates/engine/src/tests/para_tools.rs | 968 +- crates/engine/src/tests/revision_checks.rs | 948 +- crates/engine/src/tests/typing.rs | 8076 +++--- crates/engine/src/tests/wordart.rs | 404 +- crates/engine/src/view.rs | 442 +- rustfmt.toml | 4 +- sonar-project.properties | 18 +- 50 files changed, 35111 insertions(+), 35102 deletions(-) diff --git a/crates/engine/src/doc_state.rs b/crates/engine/src/doc_state.rs index c4543bc2d3..5fd9436dcc 100644 --- a/crates/engine/src/doc_state.rs +++ b/crates/engine/src/doc_state.rs @@ -1,182 +1,182 @@ -use serde::{Deserialize, Serialize}; -use wordcraft_doc::props::CharProps; -use wordcraft_doc::{Document, Path, Pos, StoryRef, Table}; - -/// A selection: anchor (where it started) and focus (where the caret is). -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] -pub struct Selection { - pub anchor: Pos, - pub focus: Pos, -} - -impl Selection { - pub fn caret(p: Pos) -> Self { - Selection { anchor: p.clone(), focus: p } - } - pub fn is_collapsed(&self) -> bool { - self.anchor == self.focus - } - /// (start, end) in document order. - pub fn ordered(&self) -> (Pos, Pos) { - if self.anchor <= self.focus { (self.anchor.clone(), self.focus.clone()) } else { (self.focus.clone(), self.anchor.clone()) } - } -} - -/// The word being typed, left without a spelling squiggle until the caret leaves it (#1552). -#[derive(Clone, Debug, PartialEq, Eq)] -pub(super) struct TypedWord { - /// Where the caret was when the word was typed. - pub(super) at: Pos, - /// The word's byte range in its paragraph. - pub(super) start: usize, - pub(super) end: usize, -} - -/// A column (block) selection: the same x range on every line between two corners. -#[derive(Clone, Debug, PartialEq)] -pub struct ColumnBlock { - /// The stream selection the block was made with (its corners). The block is current only - /// while `Session::sel` still equals it, so any ordinary caret move or selection drops it. - pub sel: Selection, - /// Page x of the anchor and focus corners (points); the block spans the range between. - pub anchor_x: f32, - pub focus_x: f32, - /// One `(start, end)` per line, in document order, each inside one paragraph. - pub segments: Vec<(Pos, Pos)>, - /// Set when the block is whole table cells (Select Cell, Row, Column or Table, or the mouse in - /// a table's selection areas): one segment per paragraph of the cells. - pub cells: Option, -} - -/// Whole table cells selected: rows `rows.0..=rows.1` and every cell of them that reaches into -/// grid columns `cols.0..=cols.1`. -#[derive(Clone, Debug, PartialEq)] -pub struct CellRange { - pub story: StoryRef, - pub table: Path, - pub rows: (usize, usize), - pub cols: (usize, usize), - /// The cells are one run of the text (cells of one row, or whole rows), so the stream - /// selection between the corners holds just them and commands act on that as usual. - /// Otherwise (part of a column) text edits and formatting go cell by cell. - pub stream: bool, -} - -impl CellRange { - /// The selected cells of table `t` (row, cell index), in document order. - pub fn cells(&self, t: &Table) -> Vec<(usize, usize)> { - let ((r0, r1), (g0, g1)) = (self.rows, self.cols); - let mut out = Vec::new(); - for (r, row) in t.rows.iter().enumerate().take(r1.saturating_add(1)).skip(r0) { - let mut g = row.first_grid(); - for (c, cell) in row.cells.iter().enumerate() { - let end = g.saturating_add(cell.span()); - if g <= g1 && end > g0 { - out.push((r, c)); - } - g = end; - } - } - out - } -} - -/// A selection Word's table rule widened to whole cells or rows (`cmd::table::extend_selection`), -/// and where it really runs. -#[derive(Clone, Debug, PartialEq)] -pub(crate) struct Widened { - /// The selection it became; this is current while `Session::sel` still equals it. - pub sel: Selection, - /// Where the selection was really extended from and to: extending it again goes on from here. - pub ends: Selection, -} - -/// Find/replace state. -#[derive(Clone, Debug, Default, Serialize, Deserialize)] -#[serde(default, rename_all = "camelCase")] -pub struct FindState { - pub query: String, - pub replace: String, - pub match_case: bool, - pub whole_word: bool, - pub regex: bool, - /// Word's "Use wildcards" (#543). - pub wildcards: bool, - /// Take Find what literally: no `^` codes (`special: false`). - pub literal: bool, - pub match_prefix: bool, - pub match_suffix: bool, - pub ignore_punct: bool, - pub ignore_space: bool, - /// Matches from the last search: (start, end). - #[serde(skip)] - pub results: Vec<(Pos, Pos)>, - pub current: usize, - /// Reading Highlight: mark every match of `query` in the body. - pub highlight: bool, - /// Highlighted matches, for the document revision they were found in. - #[serde(skip)] - pub highlights: Option<(u64, Vec<(Pos, Pos)>)>, -} - -#[derive(Clone)] -pub(super) struct Undo { - pub(super) label: String, - pub(super) doc: Document, - pub(super) sel: Selection, - /// Formatting waiting at the caret (Bold with nothing selected), so Undo takes it back too. - pub(super) pending: Option, - /// The column block or whole cells the selection was (undoing brings the selection back as - /// it was). - pub(super) column: Option, -} - -/// The number of edit locations Go Back cycles through (Word: four). -pub(super) const EDIT_MARKS: usize = 4; - -/// The equation being edited. -#[derive(Clone, Debug, PartialEq)] -pub struct MathEdit { - /// The equation object (its U+FFFC). This moves as the document is edited, so it is not an - /// IME owner; [`Self::session`] is. - pub at: Pos, - /// The caret inside it. - pub pos: wordcraft_doc::math_edit::MathPos, - /// A stable token for this editing session, unique across documents and re-entry. - pub session: u64, -} - -/// Next stable token for an equation editing session, or `None` when the counter is exhausted. -/// -/// Exhaustion fails closed: a repeated token could let a cached IME context outlive the -/// equation it described, so the caller refuses to start editing instead. -pub fn next_math_session() -> Option { - static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); - NEXT.try_update(std::sync::atomic::Ordering::Relaxed, std::sync::atomic::Ordering::Relaxed, |n| n.checked_add(1)).ok() -} - -/// The citation style a document records (References › Style), APA when it records none or -/// one we don't know. -/// The byte range of the word the caret at `off` ends or sits in, counting apostrophes and -/// hyphens as the spelling check does; `None` unless a letter or digit comes before the caret. -pub(super) fn typed_range(text: &str, off: usize) -> Option<(usize, usize)> { - let in_word = |c: char| wordcraft_doc::bidi::is_word_char(c) || matches!(c, '’' | '-'); - let before = text.get(..off)?; - let start = before.char_indices().rev().take_while(|&(_, c)| in_word(c)).last().map(|(i, _)| i)?; - let end = off.saturating_add(text.get(off..)?.chars().take_while(|&c| in_word(c)).map(char::len_utf8).sum::()); - text.get(start..off)?.chars().any(char::is_alphanumeric).then_some((start, end)) -} - -/// The name of `doc`'s bibliography style, APA when it names none we know. -pub(super) fn doc_bib_style(doc: &Document) -> String { - doc.settings.bib_style.as_deref().and_then(crate::cmd::citations::find_style).map_or("APA", |st| st.name).to_string() -} - -/// Most pending front-end requests a session keeps. -pub const MAX_UI_REQUESTS: usize = 64; - -/// Maximum undo depth. -pub(super) const MAX_UNDO: usize = 500; - -/// How many steps the Undo button's list shows (#701); `edit.undoTo` reaches no further. -pub const UNDO_LIST_MAX: usize = 100; +use serde::{Deserialize, Serialize}; +use wordcraft_doc::props::CharProps; +use wordcraft_doc::{Document, Path, Pos, StoryRef, Table}; + +/// A selection: anchor (where it started) and focus (where the caret is). +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct Selection { + pub anchor: Pos, + pub focus: Pos, +} + +impl Selection { + pub fn caret(p: Pos) -> Self { + Selection { anchor: p.clone(), focus: p } + } + pub fn is_collapsed(&self) -> bool { + self.anchor == self.focus + } + /// (start, end) in document order. + pub fn ordered(&self) -> (Pos, Pos) { + if self.anchor <= self.focus { (self.anchor.clone(), self.focus.clone()) } else { (self.focus.clone(), self.anchor.clone()) } + } +} + +/// The word being typed, left without a spelling squiggle until the caret leaves it (#1552). +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) struct TypedWord { + /// Where the caret was when the word was typed. + pub(super) at: Pos, + /// The word's byte range in its paragraph. + pub(super) start: usize, + pub(super) end: usize, +} + +/// A column (block) selection: the same x range on every line between two corners. +#[derive(Clone, Debug, PartialEq)] +pub struct ColumnBlock { + /// The stream selection the block was made with (its corners). The block is current only + /// while `Session::sel` still equals it, so any ordinary caret move or selection drops it. + pub sel: Selection, + /// Page x of the anchor and focus corners (points); the block spans the range between. + pub anchor_x: f32, + pub focus_x: f32, + /// One `(start, end)` per line, in document order, each inside one paragraph. + pub segments: Vec<(Pos, Pos)>, + /// Set when the block is whole table cells (Select Cell, Row, Column or Table, or the mouse in + /// a table's selection areas): one segment per paragraph of the cells. + pub cells: Option, +} + +/// Whole table cells selected: rows `rows.0..=rows.1` and every cell of them that reaches into +/// grid columns `cols.0..=cols.1`. +#[derive(Clone, Debug, PartialEq)] +pub struct CellRange { + pub story: StoryRef, + pub table: Path, + pub rows: (usize, usize), + pub cols: (usize, usize), + /// The cells are one run of the text (cells of one row, or whole rows), so the stream + /// selection between the corners holds just them and commands act on that as usual. + /// Otherwise (part of a column) text edits and formatting go cell by cell. + pub stream: bool, +} + +impl CellRange { + /// The selected cells of table `t` (row, cell index), in document order. + pub fn cells(&self, t: &Table) -> Vec<(usize, usize)> { + let ((r0, r1), (g0, g1)) = (self.rows, self.cols); + let mut out = Vec::new(); + for (r, row) in t.rows.iter().enumerate().take(r1.saturating_add(1)).skip(r0) { + let mut g = row.first_grid(); + for (c, cell) in row.cells.iter().enumerate() { + let end = g.saturating_add(cell.span()); + if g <= g1 && end > g0 { + out.push((r, c)); + } + g = end; + } + } + out + } +} + +/// A selection Word's table rule widened to whole cells or rows (`cmd::table::extend_selection`), +/// and where it really runs. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct Widened { + /// The selection it became; this is current while `Session::sel` still equals it. + pub sel: Selection, + /// Where the selection was really extended from and to: extending it again goes on from here. + pub ends: Selection, +} + +/// Find/replace state. +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct FindState { + pub query: String, + pub replace: String, + pub match_case: bool, + pub whole_word: bool, + pub regex: bool, + /// Word's "Use wildcards" (#543). + pub wildcards: bool, + /// Take Find what literally: no `^` codes (`special: false`). + pub literal: bool, + pub match_prefix: bool, + pub match_suffix: bool, + pub ignore_punct: bool, + pub ignore_space: bool, + /// Matches from the last search: (start, end). + #[serde(skip)] + pub results: Vec<(Pos, Pos)>, + pub current: usize, + /// Reading Highlight: mark every match of `query` in the body. + pub highlight: bool, + /// Highlighted matches, for the document revision they were found in. + #[serde(skip)] + pub highlights: Option<(u64, Vec<(Pos, Pos)>)>, +} + +#[derive(Clone)] +pub(super) struct Undo { + pub(super) label: String, + pub(super) doc: Document, + pub(super) sel: Selection, + /// Formatting waiting at the caret (Bold with nothing selected), so Undo takes it back too. + pub(super) pending: Option, + /// The column block or whole cells the selection was (undoing brings the selection back as + /// it was). + pub(super) column: Option, +} + +/// The number of edit locations Go Back cycles through (Word: four). +pub(super) const EDIT_MARKS: usize = 4; + +/// The equation being edited. +#[derive(Clone, Debug, PartialEq)] +pub struct MathEdit { + /// The equation object (its U+FFFC). This moves as the document is edited, so it is not an + /// IME owner; [`Self::session`] is. + pub at: Pos, + /// The caret inside it. + pub pos: wordcraft_doc::math_edit::MathPos, + /// A stable token for this editing session, unique across documents and re-entry. + pub session: u64, +} + +/// Next stable token for an equation editing session, or `None` when the counter is exhausted. +/// +/// Exhaustion fails closed: a repeated token could let a cached IME context outlive the +/// equation it described, so the caller refuses to start editing instead. +pub fn next_math_session() -> Option { + static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); + NEXT.try_update(std::sync::atomic::Ordering::Relaxed, std::sync::atomic::Ordering::Relaxed, |n| n.checked_add(1)).ok() +} + +/// The citation style a document records (References › Style), APA when it records none or +/// one we don't know. +/// The byte range of the word the caret at `off` ends or sits in, counting apostrophes and +/// hyphens as the spelling check does; `None` unless a letter or digit comes before the caret. +pub(super) fn typed_range(text: &str, off: usize) -> Option<(usize, usize)> { + let in_word = |c: char| wordcraft_doc::bidi::is_word_char(c) || matches!(c, '’' | '-'); + let before = text.get(..off)?; + let start = before.char_indices().rev().take_while(|&(_, c)| in_word(c)).last().map(|(i, _)| i)?; + let end = off.saturating_add(text.get(off..)?.chars().take_while(|&c| in_word(c)).map(char::len_utf8).sum::()); + text.get(start..off)?.chars().any(char::is_alphanumeric).then_some((start, end)) +} + +/// The name of `doc`'s bibliography style, APA when it names none we know. +pub(super) fn doc_bib_style(doc: &Document) -> String { + doc.settings.bib_style.as_deref().and_then(crate::cmd::citations::find_style).map_or("APA", |st| st.name).to_string() +} + +/// Most pending front-end requests a session keeps. +pub const MAX_UI_REQUESTS: usize = 64; + +/// Maximum undo depth. +pub(super) const MAX_UNDO: usize = 500; + +/// How many steps the Undo button's list shows (#701); `edit.undoTo` reaches no further. +pub const UNDO_LIST_MAX: usize = 100; diff --git a/crates/engine/src/file_access.rs b/crates/engine/src/file_access.rs index c8e08ffe04..ef6479916e 100644 --- a/crates/engine/src/file_access.rs +++ b/crates/engine/src/file_access.rs @@ -1,534 +1,534 @@ -//! The folders automation may read and write (#769). -//! -//! `wordcraft-cli mcp --automation-read-root --automation-write-root ` confines the -//! files an agent's commands reach. [`AutomationRoots`] holds the two folders. While they are in force, every path a command -//! reads goes through [`check_read`] and every path it writes through [`check_write`]: -//! -//! - The path is made absolute against the working directory, as the file system reads it. Its -//! links are followed: for a file that doesn't exist yet, the links of its nearest existing -//! folder, the rest of the path being plain names (`..` below a missing folder is refused, and so -//! is a link that leads nowhere). -//! - The result must lie inside the root, compared by whole components (`/work2` isn't inside -//! `/work`), without regard to case on Windows. -//! - Writing replaces or removes a directory entry, so for write access the entry itself (its -//! folder's links followed, its own name as given) must lie inside the root as well: a link -//! outside the root that leads into it is not a way to write outside (#3735). -//! - Read and write access are separate: a root given alone grants only its own kind of access. -//! -//! Roots are in force on a thread inside [`confine`], and in the whole process after -//! [`confine_process`] (`wordcraft-cli mcp`), except inside [`unconfined`]. Without roots -//! nothing is checked. -//! -//! A path is checked, then opened: a process that can change the folders in between (swapping in -//! a link) can still win that race. The roots keep an agent to the files it was given; they don't -//! guard against other programs on the computer. - -use std::cell::RefCell; -use std::ffi::OsString; -use std::path::{Component, Path, PathBuf}; -use std::sync::{Arc, OnceLock}; - -/// Read access or write access. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum Access { - Read, - Write, -} - -/// The folders automation may read and write; a root left out grants no access of its kind. -#[derive(Debug)] -pub struct AutomationRoots { - read: Option, - write: Option, -} - -#[derive(Debug)] -struct Root { - /// The folder made absolute, links followed: what paths are compared with. - resolved: PathBuf, - /// The same without Windows' `\\?\` prefix, for people. - shown: PathBuf, -} - -impl Root { - /// The folder at `path`, which must exist. - fn open(path: &Path, access: Access) -> Result { - let kind = match access { - Access::Read => "read", - Access::Write => "write", - }; - let resolved = std::fs::canonicalize(path) - .and_then(|p| if p.is_dir() { Ok(p) } else { Err(std::io::ErrorKind::NotADirectory.into()) }) - .map_err(|e| format!("cannot open the automation {kind} root `{}`: {e}", path.display()))?; - Ok(Self { shown: plain(resolved.clone()), resolved }) - } -} - -impl AutomationRoots { - /// The roots `read` and `write`, which must be existing folders; `None` when neither is given - /// (nothing is confined then). - pub fn new(read: Option<&Path>, write: Option<&Path>) -> Result>, String> { - if read.is_none() && write.is_none() { - return Ok(None); - } - let read = read.map(|p| Root::open(p, Access::Read)).transpose()?; - let write = write.map(|p| Root::open(p, Access::Write)).transpose()?; - Ok(Some(Arc::new(Self { read, write }))) - } - - /// The read root, absolute with its links followed. - pub fn read_root(&self) -> Option<&Path> { - self.read.as_ref().map(|r| r.shown.as_path()) - } - - /// The write root, absolute with its links followed. - pub fn write_root(&self) -> Option<&Path> { - self.write.as_ref().map(|r| r.shown.as_path()) - } - - /// May the file or folder at `path` be read (or written)? `Err` says why not. - pub fn check(&self, path: &str, access: Access) -> Result<(), String> { - let root = match access { - Access::Read => self.read.as_ref(), - Access::Write => self.write.as_ref(), - }; - let Some(Root { resolved: folder, shown }) = root else { - return match access { - Access::Read => Err(format!("automation filesystem access is not granted: read authority is absent: {path}")), - Access::Write => Err(format!("automation filesystem access is not granted: write authority is absent: {path}")), - }; - }; - // Writing replaces or removes the directory entry `path` names (a safe save renames its - // temporary file over it, a rename removes it): that entry must be inside the root too, - // not only what a link there leads to (#3735). - let entry_inside = || match access { - Access::Read => Ok(true), - Access::Write => resolve_entry(path).map(|entry| inside(&entry, folder)), - }; - if inside(&resolve(path)?, folder) && entry_inside()? { - return Ok(()); - } - let root = shown.display(); - match access { - Access::Read => Err(format!("automation path rejected: outside the read root {root}: {path}")), - Access::Write => Err(format!("automation path rejected: outside the write root {root}: {path}")), - } - } -} - -/// Where the roots are in force on a thread ([`confine`], [`unconfined`]). -#[derive(Clone, Debug)] -enum Scope { - Confined(Arc), - Unconfined, -} - -thread_local! { - /// This thread's scope; `None`: the process's roots ([`confine_process`]). - static SCOPE: RefCell> = const { RefCell::new(None) }; -} - -/// The roots in force in every thread outside [`confine`] and [`unconfined`]. -static PROCESS: OnceLock> = OnceLock::new(); - -/// Put `roots` in force in the whole process, for good. `false` when roots were in force already -/// (they stay as they were). -pub fn confine_process(roots: Arc) -> bool { - PROCESS.set(roots).is_ok() -} - -/// Puts the scope it replaced back when dropped (also when `f` panics). -struct Restore(Option); - -impl Drop for Restore { - fn drop(&mut self) { - let previous = self.0.take(); - SCOPE.with(|s| *s.borrow_mut() = previous); - } -} - -fn with_scope(scope: Option, f: impl FnOnce() -> T) -> T { - let _restore = Restore(SCOPE.with(|s| s.replace(scope))); - f() -} - -/// Run `f` with `roots` in force on this thread (`None`: as things stand). -pub fn confine(roots: Option<&Arc>, f: impl FnOnce() -> T) -> T { - match roots { - Some(r) => with_scope(Some(Scope::Confined(r.clone())), f), - None => f(), - } -} - -/// Run `f` with no roots in force on this thread: what the person at the keyboard does, and the -/// app's own folders. -pub fn unconfined(f: impl FnOnce() -> T) -> T { - with_scope(Some(Scope::Unconfined), f) -} - -/// The roots in force on this thread, if any. -pub fn current() -> Option> { - match SCOPE.with(|s| s.borrow().clone()) { - Some(Scope::Confined(r)) => Some(r), - Some(Scope::Unconfined) => None, - None => PROCESS.get().cloned(), - } -} - -/// `path` without Windows' `\\?\` prefix (which `canonicalize` adds), for people to read. -fn plain(path: PathBuf) -> PathBuf { - if !cfg!(windows) { - return path; - } - let text = path.to_string_lossy(); - if let Some(rest) = text.strip_prefix(r"\\?\UNC\") { - return PathBuf::from(format!(r"\\{rest}")); - } - match text.strip_prefix(r"\\?\") { - Some(rest) => PathBuf::from(rest), - None => path, - } -} - -/// May `path` be read here? Always, without roots in force. -pub fn check_read(path: &str) -> Result<(), String> { - current().map_or(Ok(()), |r| r.check(path, Access::Read)) -} - -/// May `path` be written here? Always, without roots in force. -pub fn check_write(path: &str) -> Result<(), String> { - current().map_or(Ok(()), |r| r.check(path, Access::Write)) -} - -/// `path` made absolute with its links followed; for a path that doesn't exist (yet), its nearest -/// existing folder's links followed and the rest appended. -fn resolve(path: &str) -> Result { - if path.is_empty() { - return Err("automation path rejected: path is empty".into()); - } - // Checked as given: making `…\NUL` absolute gives the device path `\\.\NUL` on Windows. - if cfg!(windows) && Path::new(path).components().any(|c| matches!(c, Component::Normal(n) if is_device_name(&n.to_string_lossy()))) { - return Err(format!("automation path rejected: reserved device names are not allowed: {path}")); - } - let absolute = std::path::absolute(path).map_err(|e| format!("automation path rejected: cannot resolve {path}: {e}"))?; - resolve_absolute(path, &absolute) -} - -/// The directory entry `path` names: its folder resolved as [`resolve`] resolves it, then its own -/// name as given, so a link there is the entry itself, not what it leads to. That entry is what -/// replacing or removing the file changes. -fn resolve_entry(path: &str) -> Result { - let absolute = std::path::absolute(path).map_err(|e| format!("automation path rejected: cannot resolve {path}: {e}"))?; - let (Some(folder), Some(name)) = (absolute.parent(), absolute.file_name()) else { - return Err(format!("automation path rejected: not a file name: {path}")); - }; - let mut entry = resolve_absolute(path, folder)?; - entry.push(name); - Ok(entry) -} - -/// [`resolve`] for `absolute`, `path` made absolute (`path` as given, for messages). -fn resolve_absolute(path: &str, absolute: &Path) -> Result { - // The names below the nearest existing folder, last first. - let mut missing: Vec = vec![]; - let mut at = absolute; - loop { - match std::fs::canonicalize(at) { - Ok(mut found) => { - found.extend(missing.iter().rev()); - return Ok(found); - } - Err(e) => match std::fs::symlink_metadata(at) { - Ok(m) if m.file_type().is_symlink() => return Err(format!("automation path rejected: a link that leads nowhere: {path}")), - // There, yet its real path can't be had (no permission): nothing to compare. - Ok(_) => return Err(format!("automation path rejected: cannot resolve {path}: {e}")), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} - Err(e) => return Err(format!("automation path rejected: cannot resolve {path}: {e}")), - }, - } - let parent = at.parent(); - match (at.components().next_back(), parent) { - (Some(Component::Normal(name)), Some(parent)) => { - missing.push(name.to_os_string()); - at = parent; - } - (Some(Component::ParentDir), _) => { - return Err(format!("automation path rejected: parent traversal below a folder that doesn't exist: {path}")); - } - // Down to the root (or drive) and it doesn't exist either. - _ => { - let e = std::io::Error::from(std::io::ErrorKind::NotFound); - return Err(format!("automation path rejected: cannot resolve {path}: {e}")); - } - } - } -} - -/// Is `path` (resolved) `root` or inside it, component by component (without regard to case on -/// Windows, whose file systems ignore it)? -fn inside(path: &Path, root: &Path) -> bool { - let mut path = path.components(); - root.components().all(|r| path.next().is_some_and(|p| same_component(p, r))) -} - -fn same_component(a: Component, b: Component) -> bool { - if a == b { - return true; - } - if !cfg!(windows) { - return false; - } - match (a.as_os_str().to_str(), b.as_os_str().to_str()) { - (Some(a), Some(b)) => a.to_lowercase() == b.to_lowercase(), - _ => false, - } -} - -/// A Windows device name (`CON`, `NUL`, `COM1`… also with an extension), which names no file. -fn is_device_name(component: &str) -> bool { - let stem = component.split('.').next().unwrap_or(component).trim_end_matches(' ').to_ascii_uppercase(); - let numbered = |prefix: &str| stem.strip_prefix(prefix).is_some_and(|n| matches!(n, "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9")); - matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL" | "CLOCK$" | "CONIN$" | "CONOUT$") || numbered("COM") || numbered("LPT") -} - -#[cfg(test)] -mod tests { - use super::*; - - /// A fresh folder holding `in` (read root), `out` (write root) and `outside`. - fn folders(name: &str) -> (PathBuf, Arc) { - let base = std::env::temp_dir().join(format!("wc-file-access-{name}-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&base); - for d in ["in/sub", "out", "outside", "in2"] { - std::fs::create_dir_all(base.join(d)).unwrap(); - } - std::fs::write(base.join("in/a.docx"), "x").unwrap(); - std::fs::write(base.join("outside/secret.docx"), "x").unwrap(); - let roots = AutomationRoots::new(Some(&base.join("in")), Some(&base.join("out"))).unwrap().unwrap(); - (base, roots) - } - - fn s(p: PathBuf) -> String { - p.to_string_lossy().into_owned() - } - - #[test] - fn paths_inside_the_roots_pass_and_others_dont() { - let (base, roots) = folders("inside"); - assert_eq!(roots.check(&s(base.join("in/a.docx")), Access::Read), Ok(())); - assert_eq!(roots.check(&s(base.join("in")), Access::Read), Ok(()), "the root itself"); - assert_eq!(roots.check(&s(base.join("in/sub/new.docx")), Access::Read), Ok(()), "missing files are checked by their folder"); - assert_eq!(roots.check(&s(base.join("out/new/deeper/x.png")), Access::Write), Ok(()), "and by their nearest folder"); - let e = roots.check(&s(base.join("outside/secret.docx")), Access::Read).unwrap_err(); - assert!(e.starts_with("automation path rejected: outside the read root"), "{e}"); - // Read and write access are separate. - let e = roots.check(&s(base.join("in/a.docx")), Access::Write).unwrap_err(); - assert!(e.contains("outside the write root"), "{e}"); - assert!(roots.check(&s(base.join("out/x.png")), Access::Read).unwrap_err().contains("outside the read root")); - let _ = std::fs::remove_dir_all(base); - } - - #[test] - fn a_sibling_with_the_same_prefix_is_outside() { - let (base, roots) = folders("prefix"); - std::fs::write(base.join("in2/b.docx"), "x").unwrap(); - assert!(roots.check(&s(base.join("in2/b.docx")), Access::Read).is_err(), "in2 is not inside in"); - assert!(roots.check(&format!("{}2/c.docx", s(base.join("in"))), Access::Read).is_err()); - let _ = std::fs::remove_dir_all(base); - } - - #[test] - fn parent_traversal_is_resolved_or_refused() { - let (base, roots) = folders("dotdot"); - let sep = std::path::MAIN_SEPARATOR; - let up = format!("{}{sep}..{sep}outside{sep}secret.docx", s(base.join("in"))); - assert!(roots.check(&up, Access::Read).unwrap_err().contains("outside the read root"), "`..` out of the root"); - let back = format!("{}{sep}..{sep}a.docx", s(base.join("in/sub"))); - assert_eq!(roots.check(&back, Access::Read), Ok(()), "`..` that stays inside"); - let missing = format!("{}{sep}nope{sep}..{sep}..{sep}outside{sep}x.docx", s(base.join("out"))); - let e = roots.check(&missing, Access::Write).unwrap_err(); - // Windows resolves `..` by name first (so does opening the file there): then it's outside. - assert!(e.contains("parent traversal below a folder that doesn't exist") || e.contains("outside the write root"), "{e}"); - let _ = std::fs::remove_dir_all(base); - } - - #[test] - fn relative_paths_resolve_against_the_working_directory() { - let (base, roots) = folders("relative"); - // The tests' working directory is the crate's folder: not inside the roots. - assert!(roots.check("a.docx", Access::Read).unwrap_err().contains("outside the read root")); - assert!(roots.check("", Access::Read).unwrap_err().contains("path is empty")); - let _ = std::fs::remove_dir_all(base); - } - - #[test] - fn a_missing_root_is_an_error_and_none_is_no_confinement() { - let (base, _) = folders("missing"); - let e = AutomationRoots::new(Some(&base.join("nope")), None).unwrap_err(); - assert!(e.starts_with("cannot open the automation read root"), "{e}"); - let e = AutomationRoots::new(None, Some(&base.join("in/a.docx"))).unwrap_err(); - assert!( - e.starts_with("cannot open the automation write root") - && e.ends_with(&std::io::Error::from(std::io::ErrorKind::NotADirectory).to_string()), - "{e}" - ); - assert!(AutomationRoots::new(Some(Path::new("")), None).unwrap_err().starts_with("cannot open the automation read root ``")); - assert!(AutomationRoots::new(None, None).unwrap().is_none()); - // One root alone grants only its own access. - let read_only = AutomationRoots::new(Some(&base.join("in")), None).unwrap().unwrap(); - let e = read_only.check(&s(base.join("in/x.docx")), Access::Write).unwrap_err(); - assert!(e.contains("write authority is absent"), "{e}"); - let _ = std::fs::remove_dir_all(base); - } - - #[test] - fn links_are_followed_before_comparing() { - let (base, roots) = folders("links"); - #[cfg(unix)] - let made = |target: &Path, link: &Path| std::os::unix::fs::symlink(target, link).is_ok(); - #[cfg(windows)] - let made = |target: &Path, link: &Path| { - let made = - if target.is_dir() { std::os::windows::fs::symlink_dir(target, link) } else { std::os::windows::fs::symlink_file(target, link) }; - made.is_ok() - }; - #[cfg(not(any(unix, windows)))] - let made = |_: &Path, _: &Path| false; - // Windows needs Developer Mode or elevation to make links: nothing to test without them. - if !made(&base.join("outside/secret.docx"), &base.join("in/leak.docx")) { - eprintln!("skipped: symbolic links can't be made here"); - let _ = std::fs::remove_dir_all(base); - return; - } - assert!(roots.check(&s(base.join("in/leak.docx")), Access::Read).unwrap_err().contains("outside the read root"), "a file link out"); - assert!(made(&base.join("outside"), &base.join("out/away"))); - let e = roots.check(&s(base.join("out/away/new.png")), Access::Write).unwrap_err(); - assert!(e.contains("outside the write root"), "a folder link out: {e}"); - assert!(made(&base.join("outside/gone.docx"), &base.join("out/dangling.png"))); - let e = roots.check(&s(base.join("out/dangling.png")), Access::Write).unwrap_err(); - assert!(e.contains("a link that leads nowhere"), "writing would create the file outside: {e}"); - // A link to a folder inside the root stays inside. - assert!(made(&base.join("in/sub"), &base.join("in/alias"))); - assert_eq!(roots.check(&s(base.join("in/alias/x.docx")), Access::Read), Ok(())); - // A link outside the write root that leads into it: writing would replace the link, - // outside (#3735). One inside that leads to a file inside is fine. - std::fs::write(base.join("out/doc.docx"), "x").unwrap(); - assert!(made(&base.join("out/doc.docx"), &base.join("outside/alias.docx"))); - let e = roots.check(&s(base.join("outside/alias.docx")), Access::Write).unwrap_err(); - assert!(e.contains("outside the write root"), "the link itself is outside: {e}"); - assert!(made(&base.join("out/doc.docx"), &base.join("out/alias.docx"))); - assert_eq!(roots.check(&s(base.join("out/alias.docx")), Access::Write), Ok(())); - // A folder link outside that leads into the root: the entry is inside, as the system opens it. - assert!(made(&base.join("out"), &base.join("outside/to-out"))); - assert_eq!(roots.check(&s(base.join("outside/to-out/new.docx")), Access::Write), Ok(())); - // A root given through a link is the folder it leads to. - assert!(made(&base.join("in"), &base.join("in-link"))); - let linked = AutomationRoots::new(Some(&base.join("in-link")), None).unwrap().unwrap(); - assert_eq!(linked.check(&s(base.join("in/a.docx")), Access::Read), Ok(())); - let _ = std::fs::remove_dir_all(base); - } - - #[cfg(windows)] - #[test] - fn windows_ignores_case_and_refuses_devices() { - let (base, roots) = folders("case"); - let upper = s(base.join("IN/A.SVG")); - assert_eq!(roots.check(&upper, Access::Read), Ok(()), "{upper}"); - assert_eq!(roots.check(&s(base.join("In/Sub/New.docx")), Access::Read), Ok(())); - let e = roots.check(&s(base.join("out/NUL")), Access::Write).unwrap_err(); - assert!(e.contains("reserved device names"), "{e}"); - assert!(roots.check(&s(base.join("out/com1.txt")), Access::Write).is_err()); - assert_eq!(roots.check(&s(base.join("out/console.txt")), Access::Write), Ok(())); - let _ = std::fs::remove_dir_all(base); - } - - #[test] - fn scopes_nest_and_stay_on_their_thread() { - let (base, roots) = folders("scopes"); - let outside = s(base.join("outside/secret.docx")); - assert_eq!(check_read(&outside), Ok(()), "nothing is confined by default"); - confine(Some(&roots), || { - assert!(check_read(&outside).is_err()); - assert_eq!(unconfined(|| check_read(&outside)), Ok(())); - assert!(check_read(&outside).is_err(), "restored after unconfined"); - let o = outside.clone(); - let there = std::thread::spawn(move || check_read(&o).is_ok()).join().unwrap(); - assert!(there, "another thread is not confined by this thread's scope"); - }); - assert_eq!(check_read(&outside), Ok(()), "restored after confine"); - let r = std::panic::catch_unwind(|| confine(Some(&roots), || panic!("boom"))); - assert!(r.is_err()); - assert_eq!(check_read(&outside), Ok(()), "restored after a panic"); - assert_eq!(confine(None, || check_read(&outside)), Ok(())); - let _ = std::fs::remove_dir_all(base); - } - - #[test] - fn device_names() { - for d in ["CON", "nul", "Com1", "LPT9.txt", "aux.docx", "NUL "] { - assert!(is_device_name(d), "{d}"); - } - for f in ["console", "com10", "COM0", "lpt", "a.docx", "nul-x"] { - assert!(!is_device_name(f), "{f}"); - } - } - - /// Every file a command reads or writes by path goes through `io` (`open_path_with`, - /// `save_path_with`, `read_file`, `write_file`), which check the roots. Engine code that - /// reaches for `std::fs` itself would bypass them, so none may: every file under `src/` - /// (commands, `print.rs`, `session.rs`…) except `io.rs` with its `io/` helpers (`safe_save`, - /// reached only after `io` checked the roots), this module, and the test files. - /// The host-enabled recovery store owns its profile files; commands address known snapshot - /// IDs, never paths. Only that storage module is exempt, not recovery commands in general. - #[test] - fn commands_do_not_touch_the_file_system_themselves() { - fn walk(dir: &Path, files: &mut Vec) { - for entry in std::fs::read_dir(dir).unwrap() { - let path = entry.unwrap().path(); - if path.is_dir() { - walk(&path, files); - } else if path.extension().is_some_and(|e| e == "rs") { - files.push(path); - } - } - } - let src = Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); - let mut files = vec![]; - walk(&src, &mut files); - assert!(files.iter().any(|f| f.ends_with("print.rs")) && files.iter().any(|f| f.ends_with("cmd/file.rs")), "{files:?}"); - let mut offenders = vec![]; - for path in files { - let name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); - // `io` and this module are the checked gateway; `tests*.rs` files and `tests/` - // directories are test-only modules. - if path.parent() == Some(src.as_path()) && (name == "io.rs" || name == "file_access.rs") - || name.starts_with("tests") - || path.strip_prefix(&src).is_ok_and(|rel| rel.components().any(|c| c.as_os_str() == "tests")) - || path.starts_with(src.join("io")) - || path == src.join("recovery/desktop.rs") - { - continue; - } - let text = std::fs::read_to_string(&path).unwrap(); - // Inline test modules (which build their own fixtures) come last in each file. A - // `#[cfg(test)]` on a `use` line, a `mod tests;` declaration or another item doesn't end - // the code. - let end = text - .match_indices("#[cfg(test)]") - .find(|&(i, a)| { - let first = text[i + a.len()..].trim_start().lines().next().unwrap_or(""); - first.starts_with("mod ") && first.trim_end().ends_with('{') - }) - .map_or(text.len(), |(i, _)| i); - let code = &text[..end]; - for (n, line) in code.lines().enumerate() { - let line = line.trim_start(); - let touches = ["std::fs", "File::open", "File::create", "OpenOptions"].iter().any(|w| line.contains(w)); - if !line.starts_with("//") && touches { - offenders.push(format!("{}:{}: {line}", path.display(), n + 1)); - } - } - } - assert!(offenders.is_empty(), "use crate::io::{{read_file, write_file, open_path_with, save_path_with}}: {offenders:#?}"); - } -} +//! The folders automation may read and write (#769). +//! +//! `wordcraft-cli mcp --automation-read-root --automation-write-root ` confines the +//! files an agent's commands reach. [`AutomationRoots`] holds the two folders. While they are in force, every path a command +//! reads goes through [`check_read`] and every path it writes through [`check_write`]: +//! +//! - The path is made absolute against the working directory, as the file system reads it. Its +//! links are followed: for a file that doesn't exist yet, the links of its nearest existing +//! folder, the rest of the path being plain names (`..` below a missing folder is refused, and so +//! is a link that leads nowhere). +//! - The result must lie inside the root, compared by whole components (`/work2` isn't inside +//! `/work`), without regard to case on Windows. +//! - Writing replaces or removes a directory entry, so for write access the entry itself (its +//! folder's links followed, its own name as given) must lie inside the root as well: a link +//! outside the root that leads into it is not a way to write outside (#3735). +//! - Read and write access are separate: a root given alone grants only its own kind of access. +//! +//! Roots are in force on a thread inside [`confine`], and in the whole process after +//! [`confine_process`] (`wordcraft-cli mcp`), except inside [`unconfined`]. Without roots +//! nothing is checked. +//! +//! A path is checked, then opened: a process that can change the folders in between (swapping in +//! a link) can still win that race. The roots keep an agent to the files it was given; they don't +//! guard against other programs on the computer. + +use std::cell::RefCell; +use std::ffi::OsString; +use std::path::{Component, Path, PathBuf}; +use std::sync::{Arc, OnceLock}; + +/// Read access or write access. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Access { + Read, + Write, +} + +/// The folders automation may read and write; a root left out grants no access of its kind. +#[derive(Debug)] +pub struct AutomationRoots { + read: Option, + write: Option, +} + +#[derive(Debug)] +struct Root { + /// The folder made absolute, links followed: what paths are compared with. + resolved: PathBuf, + /// The same without Windows' `\\?\` prefix, for people. + shown: PathBuf, +} + +impl Root { + /// The folder at `path`, which must exist. + fn open(path: &Path, access: Access) -> Result { + let kind = match access { + Access::Read => "read", + Access::Write => "write", + }; + let resolved = std::fs::canonicalize(path) + .and_then(|p| if p.is_dir() { Ok(p) } else { Err(std::io::ErrorKind::NotADirectory.into()) }) + .map_err(|e| format!("cannot open the automation {kind} root `{}`: {e}", path.display()))?; + Ok(Self { shown: plain(resolved.clone()), resolved }) + } +} + +impl AutomationRoots { + /// The roots `read` and `write`, which must be existing folders; `None` when neither is given + /// (nothing is confined then). + pub fn new(read: Option<&Path>, write: Option<&Path>) -> Result>, String> { + if read.is_none() && write.is_none() { + return Ok(None); + } + let read = read.map(|p| Root::open(p, Access::Read)).transpose()?; + let write = write.map(|p| Root::open(p, Access::Write)).transpose()?; + Ok(Some(Arc::new(Self { read, write }))) + } + + /// The read root, absolute with its links followed. + pub fn read_root(&self) -> Option<&Path> { + self.read.as_ref().map(|r| r.shown.as_path()) + } + + /// The write root, absolute with its links followed. + pub fn write_root(&self) -> Option<&Path> { + self.write.as_ref().map(|r| r.shown.as_path()) + } + + /// May the file or folder at `path` be read (or written)? `Err` says why not. + pub fn check(&self, path: &str, access: Access) -> Result<(), String> { + let root = match access { + Access::Read => self.read.as_ref(), + Access::Write => self.write.as_ref(), + }; + let Some(Root { resolved: folder, shown }) = root else { + return match access { + Access::Read => Err(format!("automation filesystem access is not granted: read authority is absent: {path}")), + Access::Write => Err(format!("automation filesystem access is not granted: write authority is absent: {path}")), + }; + }; + // Writing replaces or removes the directory entry `path` names (a safe save renames its + // temporary file over it, a rename removes it): that entry must be inside the root too, + // not only what a link there leads to (#3735). + let entry_inside = || match access { + Access::Read => Ok(true), + Access::Write => resolve_entry(path).map(|entry| inside(&entry, folder)), + }; + if inside(&resolve(path)?, folder) && entry_inside()? { + return Ok(()); + } + let root = shown.display(); + match access { + Access::Read => Err(format!("automation path rejected: outside the read root {root}: {path}")), + Access::Write => Err(format!("automation path rejected: outside the write root {root}: {path}")), + } + } +} + +/// Where the roots are in force on a thread ([`confine`], [`unconfined`]). +#[derive(Clone, Debug)] +enum Scope { + Confined(Arc), + Unconfined, +} + +thread_local! { + /// This thread's scope; `None`: the process's roots ([`confine_process`]). + static SCOPE: RefCell> = const { RefCell::new(None) }; +} + +/// The roots in force in every thread outside [`confine`] and [`unconfined`]. +static PROCESS: OnceLock> = OnceLock::new(); + +/// Put `roots` in force in the whole process, for good. `false` when roots were in force already +/// (they stay as they were). +pub fn confine_process(roots: Arc) -> bool { + PROCESS.set(roots).is_ok() +} + +/// Puts the scope it replaced back when dropped (also when `f` panics). +struct Restore(Option); + +impl Drop for Restore { + fn drop(&mut self) { + let previous = self.0.take(); + SCOPE.with(|s| *s.borrow_mut() = previous); + } +} + +fn with_scope(scope: Option, f: impl FnOnce() -> T) -> T { + let _restore = Restore(SCOPE.with(|s| s.replace(scope))); + f() +} + +/// Run `f` with `roots` in force on this thread (`None`: as things stand). +pub fn confine(roots: Option<&Arc>, f: impl FnOnce() -> T) -> T { + match roots { + Some(r) => with_scope(Some(Scope::Confined(r.clone())), f), + None => f(), + } +} + +/// Run `f` with no roots in force on this thread: what the person at the keyboard does, and the +/// app's own folders. +pub fn unconfined(f: impl FnOnce() -> T) -> T { + with_scope(Some(Scope::Unconfined), f) +} + +/// The roots in force on this thread, if any. +pub fn current() -> Option> { + match SCOPE.with(|s| s.borrow().clone()) { + Some(Scope::Confined(r)) => Some(r), + Some(Scope::Unconfined) => None, + None => PROCESS.get().cloned(), + } +} + +/// `path` without Windows' `\\?\` prefix (which `canonicalize` adds), for people to read. +fn plain(path: PathBuf) -> PathBuf { + if !cfg!(windows) { + return path; + } + let text = path.to_string_lossy(); + if let Some(rest) = text.strip_prefix(r"\\?\UNC\") { + return PathBuf::from(format!(r"\\{rest}")); + } + match text.strip_prefix(r"\\?\") { + Some(rest) => PathBuf::from(rest), + None => path, + } +} + +/// May `path` be read here? Always, without roots in force. +pub fn check_read(path: &str) -> Result<(), String> { + current().map_or(Ok(()), |r| r.check(path, Access::Read)) +} + +/// May `path` be written here? Always, without roots in force. +pub fn check_write(path: &str) -> Result<(), String> { + current().map_or(Ok(()), |r| r.check(path, Access::Write)) +} + +/// `path` made absolute with its links followed; for a path that doesn't exist (yet), its nearest +/// existing folder's links followed and the rest appended. +fn resolve(path: &str) -> Result { + if path.is_empty() { + return Err("automation path rejected: path is empty".into()); + } + // Checked as given: making `…\NUL` absolute gives the device path `\\.\NUL` on Windows. + if cfg!(windows) && Path::new(path).components().any(|c| matches!(c, Component::Normal(n) if is_device_name(&n.to_string_lossy()))) { + return Err(format!("automation path rejected: reserved device names are not allowed: {path}")); + } + let absolute = std::path::absolute(path).map_err(|e| format!("automation path rejected: cannot resolve {path}: {e}"))?; + resolve_absolute(path, &absolute) +} + +/// The directory entry `path` names: its folder resolved as [`resolve`] resolves it, then its own +/// name as given, so a link there is the entry itself, not what it leads to. That entry is what +/// replacing or removing the file changes. +fn resolve_entry(path: &str) -> Result { + let absolute = std::path::absolute(path).map_err(|e| format!("automation path rejected: cannot resolve {path}: {e}"))?; + let (Some(folder), Some(name)) = (absolute.parent(), absolute.file_name()) else { + return Err(format!("automation path rejected: not a file name: {path}")); + }; + let mut entry = resolve_absolute(path, folder)?; + entry.push(name); + Ok(entry) +} + +/// [`resolve`] for `absolute`, `path` made absolute (`path` as given, for messages). +fn resolve_absolute(path: &str, absolute: &Path) -> Result { + // The names below the nearest existing folder, last first. + let mut missing: Vec = vec![]; + let mut at = absolute; + loop { + match std::fs::canonicalize(at) { + Ok(mut found) => { + found.extend(missing.iter().rev()); + return Ok(found); + } + Err(e) => match std::fs::symlink_metadata(at) { + Ok(m) if m.file_type().is_symlink() => return Err(format!("automation path rejected: a link that leads nowhere: {path}")), + // There, yet its real path can't be had (no permission): nothing to compare. + Ok(_) => return Err(format!("automation path rejected: cannot resolve {path}: {e}")), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(format!("automation path rejected: cannot resolve {path}: {e}")), + }, + } + let parent = at.parent(); + match (at.components().next_back(), parent) { + (Some(Component::Normal(name)), Some(parent)) => { + missing.push(name.to_os_string()); + at = parent; + } + (Some(Component::ParentDir), _) => { + return Err(format!("automation path rejected: parent traversal below a folder that doesn't exist: {path}")); + } + // Down to the root (or drive) and it doesn't exist either. + _ => { + let e = std::io::Error::from(std::io::ErrorKind::NotFound); + return Err(format!("automation path rejected: cannot resolve {path}: {e}")); + } + } + } +} + +/// Is `path` (resolved) `root` or inside it, component by component (without regard to case on +/// Windows, whose file systems ignore it)? +fn inside(path: &Path, root: &Path) -> bool { + let mut path = path.components(); + root.components().all(|r| path.next().is_some_and(|p| same_component(p, r))) +} + +fn same_component(a: Component, b: Component) -> bool { + if a == b { + return true; + } + if !cfg!(windows) { + return false; + } + match (a.as_os_str().to_str(), b.as_os_str().to_str()) { + (Some(a), Some(b)) => a.to_lowercase() == b.to_lowercase(), + _ => false, + } +} + +/// A Windows device name (`CON`, `NUL`, `COM1`… also with an extension), which names no file. +fn is_device_name(component: &str) -> bool { + let stem = component.split('.').next().unwrap_or(component).trim_end_matches(' ').to_ascii_uppercase(); + let numbered = |prefix: &str| stem.strip_prefix(prefix).is_some_and(|n| matches!(n, "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9")); + matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL" | "CLOCK$" | "CONIN$" | "CONOUT$") || numbered("COM") || numbered("LPT") +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A fresh folder holding `in` (read root), `out` (write root) and `outside`. + fn folders(name: &str) -> (PathBuf, Arc) { + let base = std::env::temp_dir().join(format!("wc-file-access-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + for d in ["in/sub", "out", "outside", "in2"] { + std::fs::create_dir_all(base.join(d)).unwrap(); + } + std::fs::write(base.join("in/a.docx"), "x").unwrap(); + std::fs::write(base.join("outside/secret.docx"), "x").unwrap(); + let roots = AutomationRoots::new(Some(&base.join("in")), Some(&base.join("out"))).unwrap().unwrap(); + (base, roots) + } + + fn s(p: PathBuf) -> String { + p.to_string_lossy().into_owned() + } + + #[test] + fn paths_inside_the_roots_pass_and_others_dont() { + let (base, roots) = folders("inside"); + assert_eq!(roots.check(&s(base.join("in/a.docx")), Access::Read), Ok(())); + assert_eq!(roots.check(&s(base.join("in")), Access::Read), Ok(()), "the root itself"); + assert_eq!(roots.check(&s(base.join("in/sub/new.docx")), Access::Read), Ok(()), "missing files are checked by their folder"); + assert_eq!(roots.check(&s(base.join("out/new/deeper/x.png")), Access::Write), Ok(()), "and by their nearest folder"); + let e = roots.check(&s(base.join("outside/secret.docx")), Access::Read).unwrap_err(); + assert!(e.starts_with("automation path rejected: outside the read root"), "{e}"); + // Read and write access are separate. + let e = roots.check(&s(base.join("in/a.docx")), Access::Write).unwrap_err(); + assert!(e.contains("outside the write root"), "{e}"); + assert!(roots.check(&s(base.join("out/x.png")), Access::Read).unwrap_err().contains("outside the read root")); + let _ = std::fs::remove_dir_all(base); + } + + #[test] + fn a_sibling_with_the_same_prefix_is_outside() { + let (base, roots) = folders("prefix"); + std::fs::write(base.join("in2/b.docx"), "x").unwrap(); + assert!(roots.check(&s(base.join("in2/b.docx")), Access::Read).is_err(), "in2 is not inside in"); + assert!(roots.check(&format!("{}2/c.docx", s(base.join("in"))), Access::Read).is_err()); + let _ = std::fs::remove_dir_all(base); + } + + #[test] + fn parent_traversal_is_resolved_or_refused() { + let (base, roots) = folders("dotdot"); + let sep = std::path::MAIN_SEPARATOR; + let up = format!("{}{sep}..{sep}outside{sep}secret.docx", s(base.join("in"))); + assert!(roots.check(&up, Access::Read).unwrap_err().contains("outside the read root"), "`..` out of the root"); + let back = format!("{}{sep}..{sep}a.docx", s(base.join("in/sub"))); + assert_eq!(roots.check(&back, Access::Read), Ok(()), "`..` that stays inside"); + let missing = format!("{}{sep}nope{sep}..{sep}..{sep}outside{sep}x.docx", s(base.join("out"))); + let e = roots.check(&missing, Access::Write).unwrap_err(); + // Windows resolves `..` by name first (so does opening the file there): then it's outside. + assert!(e.contains("parent traversal below a folder that doesn't exist") || e.contains("outside the write root"), "{e}"); + let _ = std::fs::remove_dir_all(base); + } + + #[test] + fn relative_paths_resolve_against_the_working_directory() { + let (base, roots) = folders("relative"); + // The tests' working directory is the crate's folder: not inside the roots. + assert!(roots.check("a.docx", Access::Read).unwrap_err().contains("outside the read root")); + assert!(roots.check("", Access::Read).unwrap_err().contains("path is empty")); + let _ = std::fs::remove_dir_all(base); + } + + #[test] + fn a_missing_root_is_an_error_and_none_is_no_confinement() { + let (base, _) = folders("missing"); + let e = AutomationRoots::new(Some(&base.join("nope")), None).unwrap_err(); + assert!(e.starts_with("cannot open the automation read root"), "{e}"); + let e = AutomationRoots::new(None, Some(&base.join("in/a.docx"))).unwrap_err(); + assert!( + e.starts_with("cannot open the automation write root") + && e.ends_with(&std::io::Error::from(std::io::ErrorKind::NotADirectory).to_string()), + "{e}" + ); + assert!(AutomationRoots::new(Some(Path::new("")), None).unwrap_err().starts_with("cannot open the automation read root ``")); + assert!(AutomationRoots::new(None, None).unwrap().is_none()); + // One root alone grants only its own access. + let read_only = AutomationRoots::new(Some(&base.join("in")), None).unwrap().unwrap(); + let e = read_only.check(&s(base.join("in/x.docx")), Access::Write).unwrap_err(); + assert!(e.contains("write authority is absent"), "{e}"); + let _ = std::fs::remove_dir_all(base); + } + + #[test] + fn links_are_followed_before_comparing() { + let (base, roots) = folders("links"); + #[cfg(unix)] + let made = |target: &Path, link: &Path| std::os::unix::fs::symlink(target, link).is_ok(); + #[cfg(windows)] + let made = |target: &Path, link: &Path| { + let made = + if target.is_dir() { std::os::windows::fs::symlink_dir(target, link) } else { std::os::windows::fs::symlink_file(target, link) }; + made.is_ok() + }; + #[cfg(not(any(unix, windows)))] + let made = |_: &Path, _: &Path| false; + // Windows needs Developer Mode or elevation to make links: nothing to test without them. + if !made(&base.join("outside/secret.docx"), &base.join("in/leak.docx")) { + eprintln!("skipped: symbolic links can't be made here"); + let _ = std::fs::remove_dir_all(base); + return; + } + assert!(roots.check(&s(base.join("in/leak.docx")), Access::Read).unwrap_err().contains("outside the read root"), "a file link out"); + assert!(made(&base.join("outside"), &base.join("out/away"))); + let e = roots.check(&s(base.join("out/away/new.png")), Access::Write).unwrap_err(); + assert!(e.contains("outside the write root"), "a folder link out: {e}"); + assert!(made(&base.join("outside/gone.docx"), &base.join("out/dangling.png"))); + let e = roots.check(&s(base.join("out/dangling.png")), Access::Write).unwrap_err(); + assert!(e.contains("a link that leads nowhere"), "writing would create the file outside: {e}"); + // A link to a folder inside the root stays inside. + assert!(made(&base.join("in/sub"), &base.join("in/alias"))); + assert_eq!(roots.check(&s(base.join("in/alias/x.docx")), Access::Read), Ok(())); + // A link outside the write root that leads into it: writing would replace the link, + // outside (#3735). One inside that leads to a file inside is fine. + std::fs::write(base.join("out/doc.docx"), "x").unwrap(); + assert!(made(&base.join("out/doc.docx"), &base.join("outside/alias.docx"))); + let e = roots.check(&s(base.join("outside/alias.docx")), Access::Write).unwrap_err(); + assert!(e.contains("outside the write root"), "the link itself is outside: {e}"); + assert!(made(&base.join("out/doc.docx"), &base.join("out/alias.docx"))); + assert_eq!(roots.check(&s(base.join("out/alias.docx")), Access::Write), Ok(())); + // A folder link outside that leads into the root: the entry is inside, as the system opens it. + assert!(made(&base.join("out"), &base.join("outside/to-out"))); + assert_eq!(roots.check(&s(base.join("outside/to-out/new.docx")), Access::Write), Ok(())); + // A root given through a link is the folder it leads to. + assert!(made(&base.join("in"), &base.join("in-link"))); + let linked = AutomationRoots::new(Some(&base.join("in-link")), None).unwrap().unwrap(); + assert_eq!(linked.check(&s(base.join("in/a.docx")), Access::Read), Ok(())); + let _ = std::fs::remove_dir_all(base); + } + + #[cfg(windows)] + #[test] + fn windows_ignores_case_and_refuses_devices() { + let (base, roots) = folders("case"); + let upper = s(base.join("IN/A.SVG")); + assert_eq!(roots.check(&upper, Access::Read), Ok(()), "{upper}"); + assert_eq!(roots.check(&s(base.join("In/Sub/New.docx")), Access::Read), Ok(())); + let e = roots.check(&s(base.join("out/NUL")), Access::Write).unwrap_err(); + assert!(e.contains("reserved device names"), "{e}"); + assert!(roots.check(&s(base.join("out/com1.txt")), Access::Write).is_err()); + assert_eq!(roots.check(&s(base.join("out/console.txt")), Access::Write), Ok(())); + let _ = std::fs::remove_dir_all(base); + } + + #[test] + fn scopes_nest_and_stay_on_their_thread() { + let (base, roots) = folders("scopes"); + let outside = s(base.join("outside/secret.docx")); + assert_eq!(check_read(&outside), Ok(()), "nothing is confined by default"); + confine(Some(&roots), || { + assert!(check_read(&outside).is_err()); + assert_eq!(unconfined(|| check_read(&outside)), Ok(())); + assert!(check_read(&outside).is_err(), "restored after unconfined"); + let o = outside.clone(); + let there = std::thread::spawn(move || check_read(&o).is_ok()).join().unwrap(); + assert!(there, "another thread is not confined by this thread's scope"); + }); + assert_eq!(check_read(&outside), Ok(()), "restored after confine"); + let r = std::panic::catch_unwind(|| confine(Some(&roots), || panic!("boom"))); + assert!(r.is_err()); + assert_eq!(check_read(&outside), Ok(()), "restored after a panic"); + assert_eq!(confine(None, || check_read(&outside)), Ok(())); + let _ = std::fs::remove_dir_all(base); + } + + #[test] + fn device_names() { + for d in ["CON", "nul", "Com1", "LPT9.txt", "aux.docx", "NUL "] { + assert!(is_device_name(d), "{d}"); + } + for f in ["console", "com10", "COM0", "lpt", "a.docx", "nul-x"] { + assert!(!is_device_name(f), "{f}"); + } + } + + /// Every file a command reads or writes by path goes through `io` (`open_path_with`, + /// `save_path_with`, `read_file`, `write_file`), which check the roots. Engine code that + /// reaches for `std::fs` itself would bypass them, so none may: every file under `src/` + /// (commands, `print.rs`, `session.rs`…) except `io.rs` with its `io/` helpers (`safe_save`, + /// reached only after `io` checked the roots), this module, and the test files. + /// The host-enabled recovery store owns its profile files; commands address known snapshot + /// IDs, never paths. Only that storage module is exempt, not recovery commands in general. + #[test] + fn commands_do_not_touch_the_file_system_themselves() { + fn walk(dir: &Path, files: &mut Vec) { + for entry in std::fs::read_dir(dir).unwrap() { + let path = entry.unwrap().path(); + if path.is_dir() { + walk(&path, files); + } else if path.extension().is_some_and(|e| e == "rs") { + files.push(path); + } + } + } + let src = Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + let mut files = vec![]; + walk(&src, &mut files); + assert!(files.iter().any(|f| f.ends_with("print.rs")) && files.iter().any(|f| f.ends_with("cmd/file.rs")), "{files:?}"); + let mut offenders = vec![]; + for path in files { + let name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); + // `io` and this module are the checked gateway; `tests*.rs` files and `tests/` + // directories are test-only modules. + if path.parent() == Some(src.as_path()) && (name == "io.rs" || name == "file_access.rs") + || name.starts_with("tests") + || path.strip_prefix(&src).is_ok_and(|rel| rel.components().any(|c| c.as_os_str() == "tests")) + || path.starts_with(src.join("io")) + || path == src.join("recovery/desktop.rs") + { + continue; + } + let text = std::fs::read_to_string(&path).unwrap(); + // Inline test modules (which build their own fixtures) come last in each file. A + // `#[cfg(test)]` on a `use` line, a `mod tests;` declaration or another item doesn't end + // the code. + let end = text + .match_indices("#[cfg(test)]") + .find(|&(i, a)| { + let first = text[i + a.len()..].trim_start().lines().next().unwrap_or(""); + first.starts_with("mod ") && first.trim_end().ends_with('{') + }) + .map_or(text.len(), |(i, _)| i); + let code = &text[..end]; + for (n, line) in code.lines().enumerate() { + let line = line.trim_start(); + let touches = ["std::fs", "File::open", "File::create", "OpenOptions"].iter().any(|w| line.contains(w)); + if !line.starts_with("//") && touches { + offenders.push(format!("{}:{}: {line}", path.display(), n + 1)); + } + } + } + assert!(offenders.is_empty(), "use crate::io::{{read_file, write_file, open_path_with, save_path_with}}: {offenders:#?}"); + } +} diff --git a/crates/engine/src/ime.rs b/crates/engine/src/ime.rs index f5f86c7885..b1a47980e9 100644 --- a/crates/engine/src/ime.rs +++ b/crates/engine/src/ime.rs @@ -1,117 +1,117 @@ -use crate::doc_state::Selection; -#[cfg(test)] -use crate::session::*; -#[cfg(test)] -use wordcraft_doc::{Document, Pos}; - -/// The caret identity used for the process-monotonic IME state revision. -#[derive(Clone, Debug, PartialEq, Eq)] -pub(super) enum ImeCaretState { - Body(Selection), - Equation { session: u64, pos: wordcraft_doc::math_edit::MathPos }, -} - -/// One checked allocation from a process-monotonic IME revision counter. -fn allocate_ime_state_revision(counter: &std::sync::atomic::AtomicU64) -> Option { - counter.try_update(std::sync::atomic::Ordering::Relaxed, std::sync::atomic::Ordering::Relaxed, |n| n.checked_add(1)).ok() -} - -static IME_STATE_REVISION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); - -/// Allocates the next process-monotonic IME state revision, returning `None` when the counter is -/// exhausted. -pub(super) fn next_ime_state_revision() -> Option { - allocate_ime_state_revision(&IME_STATE_REVISION) -} - -#[cfg(test)] -mod ime_revision_tests { - use super::*; - use serde_json::json; - - /// Checked allocations stop at `u64::MAX` and report exhaustion instead of wrapping. - #[test] - fn checked_revision_allocation_refuses_to_wrap() { - let counter = std::sync::atomic::AtomicU64::new(u64::MAX - 1); - assert_eq!(allocate_ime_state_revision(&counter), Some(u64::MAX - 1)); - assert_eq!(allocate_ime_state_revision(&counter), None, "exhaustion fails closed"); - assert_eq!(counter.load(std::sync::atomic::Ordering::Relaxed), u64::MAX); - } - - /// An unchanged caret reuses its revision, while a direct selection assignment is observed and - /// advances the revision. - #[test] - fn selection_and_caret_motion_advance_the_unavailable_revision() { - let mut s = Session::new(Document::from_text("hello")); - let first = s.ime_state_revision().expect("a revision"); - assert_eq!(s.ime_state_revision(), Some(first), "an unchanged caret reuses its revision"); - s.sel = Selection::caret(Pos::body(0, 2)); - let moved = s.ime_state_revision().expect("a revision"); - assert!(moved > first, "a direct selection assignment is caught"); - } - - /// A selection-only command advances the revision on the next read, while a committed state - /// change advances it immediately. - #[test] - fn successful_state_changes_allocate_but_selection_only_commands_do_not() { - let mut s = Session::new(Document::from_text("hello")); - let first = s.ime_state_revision().expect("a revision"); - s.run("select.all", &json!({})).unwrap(); - // `run` does not allocate for selection-only work; the next stamp sees the new caret. - assert!(s.ime_state_revision() > Some(first)); - let selected = s.ime_state_revision().expect("a revision"); - s.run("para.alignCenter", &json!({})).unwrap(); - assert!(s.ime_state_revision() > Some(selected), "a committed state change advances"); - } - - /// After the revision allocator is exhausted, a real equation edit still reports its classified - /// change but cannot mint a revision. - #[test] - fn exhausted_allocator_keeps_a_real_equation_commit_uncertifiable() { - let mut s = Session::new(Document::new()); - s.run("insert.equation", &json!({})).unwrap(); - s.exhaust_ime_state_revision(); - assert_eq!(s.ime_state_revision(), None, "exhaustion fails closed"); - s.run_ime("equation.type", &json!({"text": "x"})).unwrap(); - let outcome = s.ime_edit_outcome(); - assert!(outcome.classified && outcome.content_changed && outcome.state_changed, "{outcome:?}"); - assert_eq!(s.ime_state_revision(), None, "a real producer command cannot mint a revision"); - } - - /// The application-change signal is consumed once; commands run through `run_ime` do not set it, - /// while a normal command does. - #[test] - fn application_origin_signal_is_taken_once_and_ime_commands_do_not_set_it() { - let mut s = Session::new(Document::from_text("hello")); - s.run("para.alignCenter", &json!({})).unwrap(); - assert!(s.take_pending_ime_application_change(), "a normal command signals"); - assert!(!s.take_pending_ime_application_change(), "taking clears it"); - s.run_ime("text.insert", &json!({"text": "x"})).unwrap(); - assert!(!s.take_pending_ime_application_change(), "an IME transaction is not an application change"); - s.run("text.insert", &json!({"text": "y"})).unwrap(); - assert!(s.take_pending_ime_application_change()); - } - - /// Unknown and refused IME commands restore the normal origin without signalling an application - /// change, and a nested command inside a macro inherits the IME origin. - #[test] - fn ime_origin_is_restored_and_nested_commands_inherit_it() { - let mut s = Session::new(Document::from_text("hello")); - s.run("tools.macros", &json!({"define": {"name": "centre", "steps": [{"command": "para.alignCenter", "params": {}}]}})).unwrap(); - s.take_pending_ime_application_change(); - - // Unknown and refused commands restore the normal application origin; the failed run - // itself leaves no application-change signal. - assert!(s.run_ime("no.such.command", &json!({})).is_err()); - assert!(!s.take_pending_ime_application_change(), "an unknown IME command is not an application change"); - assert!(s.run_ime("equation.backspace", &json!({})).is_err()); - assert!(!s.take_pending_ime_application_change(), "a refused IME command is not an application change"); - - // A nested `run` inherits the IME origin from `run_ime`; the same macro run normally is - // an application change. - s.run_ime("tools.macros", &json!({"run": "centre"})).unwrap(); - assert!(!s.take_pending_ime_application_change(), "the nested command inherits the IME origin"); - s.run("tools.macros", &json!({"run": "centre"})).unwrap(); - assert!(s.take_pending_ime_application_change(), "a normal macro run is application-origin"); - } -} +use crate::doc_state::Selection; +#[cfg(test)] +use crate::session::*; +#[cfg(test)] +use wordcraft_doc::{Document, Pos}; + +/// The caret identity used for the process-monotonic IME state revision. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) enum ImeCaretState { + Body(Selection), + Equation { session: u64, pos: wordcraft_doc::math_edit::MathPos }, +} + +/// One checked allocation from a process-monotonic IME revision counter. +fn allocate_ime_state_revision(counter: &std::sync::atomic::AtomicU64) -> Option { + counter.try_update(std::sync::atomic::Ordering::Relaxed, std::sync::atomic::Ordering::Relaxed, |n| n.checked_add(1)).ok() +} + +static IME_STATE_REVISION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); + +/// Allocates the next process-monotonic IME state revision, returning `None` when the counter is +/// exhausted. +pub(super) fn next_ime_state_revision() -> Option { + allocate_ime_state_revision(&IME_STATE_REVISION) +} + +#[cfg(test)] +mod ime_revision_tests { + use super::*; + use serde_json::json; + + /// Checked allocations stop at `u64::MAX` and report exhaustion instead of wrapping. + #[test] + fn checked_revision_allocation_refuses_to_wrap() { + let counter = std::sync::atomic::AtomicU64::new(u64::MAX - 1); + assert_eq!(allocate_ime_state_revision(&counter), Some(u64::MAX - 1)); + assert_eq!(allocate_ime_state_revision(&counter), None, "exhaustion fails closed"); + assert_eq!(counter.load(std::sync::atomic::Ordering::Relaxed), u64::MAX); + } + + /// An unchanged caret reuses its revision, while a direct selection assignment is observed and + /// advances the revision. + #[test] + fn selection_and_caret_motion_advance_the_unavailable_revision() { + let mut s = Session::new(Document::from_text("hello")); + let first = s.ime_state_revision().expect("a revision"); + assert_eq!(s.ime_state_revision(), Some(first), "an unchanged caret reuses its revision"); + s.sel = Selection::caret(Pos::body(0, 2)); + let moved = s.ime_state_revision().expect("a revision"); + assert!(moved > first, "a direct selection assignment is caught"); + } + + /// A selection-only command advances the revision on the next read, while a committed state + /// change advances it immediately. + #[test] + fn successful_state_changes_allocate_but_selection_only_commands_do_not() { + let mut s = Session::new(Document::from_text("hello")); + let first = s.ime_state_revision().expect("a revision"); + s.run("select.all", &json!({})).unwrap(); + // `run` does not allocate for selection-only work; the next stamp sees the new caret. + assert!(s.ime_state_revision() > Some(first)); + let selected = s.ime_state_revision().expect("a revision"); + s.run("para.alignCenter", &json!({})).unwrap(); + assert!(s.ime_state_revision() > Some(selected), "a committed state change advances"); + } + + /// After the revision allocator is exhausted, a real equation edit still reports its classified + /// change but cannot mint a revision. + #[test] + fn exhausted_allocator_keeps_a_real_equation_commit_uncertifiable() { + let mut s = Session::new(Document::new()); + s.run("insert.equation", &json!({})).unwrap(); + s.exhaust_ime_state_revision(); + assert_eq!(s.ime_state_revision(), None, "exhaustion fails closed"); + s.run_ime("equation.type", &json!({"text": "x"})).unwrap(); + let outcome = s.ime_edit_outcome(); + assert!(outcome.classified && outcome.content_changed && outcome.state_changed, "{outcome:?}"); + assert_eq!(s.ime_state_revision(), None, "a real producer command cannot mint a revision"); + } + + /// The application-change signal is consumed once; commands run through `run_ime` do not set it, + /// while a normal command does. + #[test] + fn application_origin_signal_is_taken_once_and_ime_commands_do_not_set_it() { + let mut s = Session::new(Document::from_text("hello")); + s.run("para.alignCenter", &json!({})).unwrap(); + assert!(s.take_pending_ime_application_change(), "a normal command signals"); + assert!(!s.take_pending_ime_application_change(), "taking clears it"); + s.run_ime("text.insert", &json!({"text": "x"})).unwrap(); + assert!(!s.take_pending_ime_application_change(), "an IME transaction is not an application change"); + s.run("text.insert", &json!({"text": "y"})).unwrap(); + assert!(s.take_pending_ime_application_change()); + } + + /// Unknown and refused IME commands restore the normal origin without signalling an application + /// change, and a nested command inside a macro inherits the IME origin. + #[test] + fn ime_origin_is_restored_and_nested_commands_inherit_it() { + let mut s = Session::new(Document::from_text("hello")); + s.run("tools.macros", &json!({"define": {"name": "centre", "steps": [{"command": "para.alignCenter", "params": {}}]}})).unwrap(); + s.take_pending_ime_application_change(); + + // Unknown and refused commands restore the normal application origin; the failed run + // itself leaves no application-change signal. + assert!(s.run_ime("no.such.command", &json!({})).is_err()); + assert!(!s.take_pending_ime_application_change(), "an unknown IME command is not an application change"); + assert!(s.run_ime("equation.backspace", &json!({})).is_err()); + assert!(!s.take_pending_ime_application_change(), "a refused IME command is not an application change"); + + // A nested `run` inherits the IME origin from `run_ime`; the same macro run normally is + // an application change. + s.run_ime("tools.macros", &json!({"run": "centre"})).unwrap(); + assert!(!s.take_pending_ime_application_change(), "the nested command inherits the IME origin"); + s.run("tools.macros", &json!({"run": "centre"})).unwrap(); + assert!(s.take_pending_ime_application_change(), "a normal macro run is application-origin"); + } +} diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index b758ec3e77..c77152c2ec 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -1,878 +1,878 @@ -//! File format dispatch by extension. Format crates plug in here. - -use wordcraft_doc::Document; - -#[cfg(not(target_arch = "wasm32"))] -mod safe_save; - -/// Formats WordCraft opens. -pub const OPEN_EXTS: &[&str] = &[ - "docx", - "docm", - "dotx", - "dotm", - "doc", - "dot", - "txt", - "md", - "markdown", - "html", - "htm", - "mht", - "mhtml", - "rtf", - "odt", - "ott", - "fodt", - "tex", - "latex", - "ltx", - "xml", - "wcraft.json", - "json", -]; -/// Formats WordCraft saves (Save As). -pub const SAVE_EXTS: &[&str] = - &["docx", "docm", "dotx", "dotm", "pdf", "txt", "md", "html", "rtf", "odt", "ott", "fodt", "tex", "png", "json", "xml"]; - -/// Magic number for ZIP package signatures (used by .docx/.docm/.dotx/.dotm). -const ZIP_SIG: &[u8] = b"PK\x03\x04"; -/// Magic number for OLE compound document signatures (used by .doc/.dot). -const OLE_SIG: &[u8] = &[0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1]; - -/// The extension of a file name or path, lower-case; empty when the file name has none (a dot -/// in a folder name doesn't count). -fn ext_of(name: &str) -> String { - let file = name.rsplit(['/', '\\']).next().unwrap_or(name); - file.rsplit_once('.').map(|(_, ext)| ext.to_ascii_lowercase()).unwrap_or_default() -} - -/// Is `ext` one of Word's package formats (.docx/.docm/.dotx/.dotm), which save without loss? -pub fn is_word_package(ext: &str) -> bool { - wordcraft_docx::Flavor::from_ext(ext).is_some() -} - -/// How a copy in another format is written. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub struct ExportOptions { - /// Print Markup: PDFs show tracked changes marked up and, with `comments`, each page scaled - /// down beside a markup area with the comments in balloons (page images get the area too); - /// HTML, Markdown and text copies end with the comments. - pub markup: bool, - /// With `markup`, show the comments (Track Changes Options › Comments). - pub comments: bool, - /// An `.xml` copy is a Word 2003 XML Document (WordprocessingML 2003) instead of a Word XML - /// Document (Flat OPC): `format: "wordml2003"` on the save commands. - pub word2003: bool, - /// How digits look in PDFs and page images (the Numeral setting, `System` resolved). - pub numeral: wordcraft_layout::NumeralMode, - /// A PDF or page image is written only if nothing in it is silently missing (a character no - /// face can draw, a picture that is missing or can't be decoded); else the save fails with - /// `strict: …` (#6005). Ignored for other formats. - pub strict: bool, -} - -/// A document password: wiped from memory when dropped, never printed (`Debug` shows `***`). -#[derive(Clone, Default, PartialEq, Eq)] -pub struct Password(zeroize::Zeroizing); - -impl Password { - pub fn new(s: &str) -> Password { - Password(zeroize::Zeroizing::new(s.to_string())) - } - pub fn as_str(&self) -> &str { - &self.0 - } - /// The text, for a password box to edit in place. - pub fn as_mut_string(&mut self) -> &mut String { - &mut self.0 - } - pub fn is_empty(&self) -> bool { - self.0.is_empty() - } -} - -impl std::fmt::Debug for Password { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str("Password(***)") - } -} - -/// Does this open error mean the file is password-protected and no password was given? -pub fn needs_password(err: &str) -> bool { - err.contains(wordcraft_docx::PASSWORD_REQUIRED) -} - -/// Does this open error mean the password given was wrong? -pub fn wrong_password(err: &str) -> bool { - err.contains(wordcraft_docx::WRONG_PASSWORD) -} - -/// Is the file at `path` empty, zero bytes long? File Explorer's New › Microsoft Word Document -/// makes one, and `file.open` opens it as a new blank document (#1384). False for a file that -/// can't be read: opening it says why. -#[cfg(not(target_arch = "wasm32"))] -pub fn is_empty_file(path: &std::path::Path) -> bool { - crate::file_access::check_read(&path.to_string_lossy()).is_ok() && std::fs::metadata(path).is_ok_and(|m| m.is_file() && m.len() == 0) -} - -/// [`is_empty_file`]: on the web files come through the browser, never by path. -#[cfg(target_arch = "wasm32")] -pub fn is_empty_file(_path: &std::path::Path) -> bool { - false -} - -/// The canonical form of `path` (symbolic links and `.`/`..` resolved), for telling whether two -/// spellings name one file; `None` when it can't be resolved or is outside the readable roots. -#[cfg(not(target_arch = "wasm32"))] -pub(crate) fn canonical(path: &std::path::Path) -> Option { - crate::file_access::check_read(&path.to_string_lossy()).ok()?; - std::fs::canonicalize(path).ok() -} - -/// [`canonical`]: on the web files come through the browser, never by path. -#[cfg(target_arch = "wasm32")] -pub(crate) fn canonical(_path: &std::path::Path) -> Option { - None -} - -/// Can files with this extension be saved with a password (Word's packages, ODT)? -pub fn can_encrypt(name: &str) -> bool { - let ext = ext_of(name); - is_word_package(&ext) || ext == "odt" || ext == "ott" -} - -/// Open a password-protected ODT package (ODF 1.3 Part 2 §3.4). The errors for a missing or -/// wrong password are the `.docx` reader's texts, so [`needs_password`] and [`wrong_password`] -/// recognise them. -fn open_encrypted_odt(bytes: &[u8], password: Option<&str>, page: &wordcraft_doc::SectionProps) -> Result { - let pw = password.ok_or_else(|| format!("ODT: {}", wordcraft_docx::PASSWORD_REQUIRED))?; - let plain = wordcraft_formats::odt::decrypt(bytes, pw).map_err(|e| match e { - wordcraft_formats::odt::CryptError::WrongPassword => format!("ODT: {}", wordcraft_docx::WRONG_PASSWORD), - other => other.to_string(), - })?; - match crate::io_ext::open("odt", &plain, page) { - Some(r) => r, - None => Err("ODT: no reader".into()), - } -} - -/// Finish a document just read from a file: never leave it empty, and bring its unlocked -/// `DATE`/`TIME` fields up to now the way Word does on open (see -/// [`crate::cmd::references::refresh_date_fields`]). -fn opened(doc: &mut Document) { - doc.ensure_nonempty(); - crate::cmd::references::refresh_date_fields(doc); -} - -/// Parse a document from bytes; `name` gives the format by extension. -pub fn open_bytes(name: &str, bytes: &[u8]) -> Result { - open_bytes_with(name, bytes, None).map(|(doc, _)| doc) -} - -/// Open a Word 97-2003 binary file, decrypting it with `password` when it is password-protected -/// (XOR, RC4, RC4 CryptoAPI). The errors for a missing or wrong password are the same texts the -/// `.docx` reader gives, so [`needs_password`] and [`wrong_password`] recognise them. -fn open_binary_doc(bytes: &[u8], password: Option<&str>) -> Result<(Document, bool), String> { - use wordcraft_docbin::DocbinError; - let encrypted = wordcraft_docbin::is_encrypted(bytes); - match wordcraft_docbin::read_with_password(bytes, password) { - Ok(doc) => Ok((doc, encrypted)), - Err(DocbinError::PasswordRequired) => Err(wordcraft_docx::PASSWORD_REQUIRED.to_string()), - Err(DocbinError::WrongPassword) => Err(wordcraft_docx::WRONG_PASSWORD.to_string()), - Err(e) => Err(e.to_string()), - } -} - -/// [`open_bytes`] for a file that may be password-protected: a Word document encrypted with a -/// password ([MS-OFFCRYPTO] agile or Standard encryption, under any Word extension, or the -/// Word 97-2003 schemes) is decrypted with -/// `password` first. Also says -/// whether the file was encrypted. Without the password needed the error satisfies -/// [`needs_password`]; with a wrong one, [`wrong_password`]. -pub fn open_bytes_with(name: &str, bytes: &[u8], password: Option<&str>) -> Result<(Document, bool), String> { - open_bytes_in(name, bytes, password, &wordcraft_geom::Region::default()) -} - -/// The page a file that gives no page size (plain text, Markdown, HTML) opens on: `region`'s -/// paper, as a new blank document (#838, #1381), with the default margins. -pub fn region_page(region: &wordcraft_geom::Region) -> wordcraft_doc::SectionProps { - let (_, w, h) = region.paper(); - wordcraft_doc::SectionProps { page_w: w, page_h: h, ..Default::default() } -} - -/// [`open_bytes_with`] in `region`: a file that gives no page size opens on the region's paper -/// (see [`region_page`]); a document with its own page size keeps it. -pub fn open_bytes_in(name: &str, bytes: &[u8], password: Option<&str>, region: &wordcraft_geom::Region) -> Result<(Document, bool), String> { - let page = region_page(region); - let (doc, encrypted) = read_bytes(name, bytes, password, &page)?; - load_embedded_fonts(&doc); - Ok((doc, encrypted)) -} - -/// Load the fonts `doc` embeds (#1171) for this run, in memory only: text set in a font that isn't -/// installed then lays out and exports with it instead of a fallback. Faces already available -/// stay as they are. -fn load_embedded_fonts(doc: &Document) { - let files: Vec<(Vec, String)> = wordcraft_docx::embedded_fonts(doc) - .into_iter() - .filter_map(|f| wordcraft_fonts::decode_font_file(&f.data).ok().map(|data| (data, f.name))) - .collect(); - if !files.is_empty() { - wordcraft_fonts::FontDb::global().add_document_fonts(files); - } -} - -fn read_bytes(name: &str, bytes: &[u8], password: Option<&str>, page: &wordcraft_doc::SectionProps) -> Result<(Document, bool), String> { - let ext = ext_of(name); - if matches!(ext.as_str(), "docx" | "docm" | "dotx" | "dotm" | "doc" | "dot") && wordcraft_docx::is_encrypted(bytes) { - let mut doc = read_docx(bytes, password).map_err(|e| e.to_string())?; - opened(&mut doc); - return Ok((doc, true)); - } - if (ext == "odt" || ext == "ott") && wordcraft_formats::odt::is_encrypted(bytes) { - let mut doc = open_encrypted_odt(bytes, password, page)?; - opened(&mut doc); - return Ok((doc, true)); - } - if ext.is_empty() || signature_contradicts(&ext, bytes) { - return open_by_content(name, &ext, bytes, password, page); - } - let mut doc = match ext.as_str() { - "doc" | "dot" => { - let (mut doc, encrypted) = open_binary_doc(bytes, password)?; - opened(&mut doc); - return Ok((doc, encrypted)); - } - // Plain text goes through the formats importer: BOMs, unmarked UTF-16, Windows-1252. - "txt" | "text" => wordcraft_formats::txt::import_on(bytes, page), - "json" => { - let mut d = serde_json::from_slice::(bytes).map_err(|e| format!("{name}: {e}"))?; - d.normalize_runs(); - d - } - other => match crate::io_ext::open(other, bytes, page) { - Some(r) => r?, - None => return open_by_content(name, other, bytes, password, page), - }, - }; - opened(&mut doc); - Ok((doc, false)) -} - -/// Read a Word package (decrypted with `password` if it is encrypted), bringing in its -/// alternative format chunks (`w:altChunk`) as Word does when it opens one: see [`import_chunk`]. -pub(crate) fn read_docx(bytes: &[u8], password: Option<&str>) -> Result { - wordcraft_docx::read_with(bytes, &wordcraft_docx::ReadOptions { password, chunks: Some(&import_chunk) }) -} - -/// An alternative format chunk in a format the docx reader leaves to the format importers, as a -/// document: an MHT web archive, HTML, RTF or plain text, told by its content type, its part's -/// extension or what it holds. Its pictures fit the text width, as when the file is opened. -fn import_chunk(c: &wordcraft_docx::AltChunk) -> Option { - use wordcraft_formats::{html, mht, rtf, txt}; - let full = c.content_type.to_ascii_lowercase(); - let ct = full.split(';').next().unwrap_or("").trim(); - let charset = full.split(';').skip(1).find_map(|p| p.trim().strip_prefix("charset=")).map(|v| v.trim_matches('"')); - let ext = ext_of(c.part); - let b = c.bytes; - let mut doc = if matches!(ct, "message/rfc822" | "application/x-mimearchive") - || ct.starts_with("multipart/") - || matches!(ext.as_str(), "mht" | "mhtml") - || mht::is_mht(b) - { - mht::import(b) - } else if ct.contains("rtf") || ext == "rtf" || b.starts_with(b"{\\rtf") { - rtf::import(b).ok()? - } else if ct.contains("html") || matches!(ext.as_str(), "html" | "htm" | "xhtml") { - html::import_page(&html::decode_as(b, charset), &|_| None) - } else if ct == "text/plain" || ext == "txt" { - txt::import(b) - } else { - return None; - }; - // shortcut: pictures fit the chunk's default page, not the section it lands in; fit them there if wide pictures in chunks overflow narrow sections. - fit_pictures(&mut doc); - Some(doc) -} - -/// The narrowest text width of the document's sections, points: what a picture must fit. -pub(crate) fn text_width(doc: &Document) -> f32 { - doc.sections().iter().map(|(_, sp)| sp.text_width()).fold(f32::INFINITY, f32::min).clamp(36.0, 4000.0) -} - -/// Shrink inline pictures wider than `max_w` to it, keeping their proportions. Layout keeps a -/// picture's size (as Word does), so formats whose pictures come at their pixel size (HTML, -/// Markdown, ODT, RTF) are fitted when they are read, as Insert › Pictures does. -pub(crate) fn fit_block_pictures(b: &mut wordcraft_doc::Block, max_w: f32) { - crate::cmd::paste::each_para(b, 0, &mut |p| { - let mut changed = false; - for o in &mut p.objects { - if let wordcraft_doc::InlineObject::Image { w, h, float, .. } = o - && float.wrap == wordcraft_doc::para::Wrap::Inline - && w.is_finite() - && *w > max_w - { - let k = max_w / *w; - *w = max_w; - *h = (*h * k).max(1.0); - changed = true; - } - } - if changed { - p.touch(); - } - }); -} - -/// [`fit_block_pictures`] for a whole document read from a file, against its own text width. -pub(crate) fn fit_pictures(doc: &mut Document) { - let max_w = text_width(doc); - for b in &mut doc.body { - fit_block_pictures(std::sync::Arc::make_mut(b), max_w); - } -} - -/// Does the file hold a different document format than its Word or RTF extension says? An RTF -/// file saved as `.doc` (as accounting systems and report generators often save their -/// documents), a `.docx` package renamed `.doc`, a Word 97-2003 file renamed `.docx`, a Word XML -/// Document (2003 or Flat OPC) or a web page (HTML or MHT) saved as `.doc` (#2853): Word opens -/// each by what it holds, so they go through [`open_by_content`] instead of failing. -fn signature_contradicts(ext: &str, bytes: &[u8]) -> bool { - let expected = match ext { - "docx" | "docm" | "dotx" | "dotm" => "zip", - "doc" | "dot" => "ole", - "rtf" => "rtf", - _ => return false, - }; - let held = if bytes.starts_with(ZIP_SIG) { - "zip" - } else if bytes.starts_with(OLE_SIG) { - "ole" - } else if bytes.starts_with(b"{\\rtf") { - "rtf" - } else if is_word_xml(bytes) { - "xml" - } else if looks_like_web_page(bytes) { - "html" - } else if wordcraft_formats::mht::is_mht(bytes) { - "mht" - } else { - return false; - }; - held != expected -} - -/// The bytes after a UTF-8 byte-order mark and leading white space. -fn after_bom(bytes: &[u8]) -> &[u8] { - let b = bytes.strip_prefix(&[0xEF, 0xBB, 0xBF][..]).unwrap_or(bytes); - let start = b.iter().position(|c| !c.is_ascii_whitespace()).unwrap_or(b.len()); - b.get(start..).unwrap_or_default() -} - -/// Is this a Word XML Document: Word 2003 XML (`w:wordDocument`) or Flat OPC (`pkg:package`)? -/// Only text that starts with markup is parsed (UTF-16 has its byte-order mark). -fn is_word_xml(bytes: &[u8]) -> bool { - let utf16 = bytes.starts_with(&[0xFF, 0xFE]) || bytes.starts_with(&[0xFE, 0xFF]); - (utf16 || after_bom(bytes).starts_with(b"<")) && !matches!(wordcraft_docx::sniff_xml(bytes), wordcraft_docx::XmlKind::Other) -} - -/// Does the file start like a web page (`` or ``, after an XML -/// declaration and comments)? Word's Save as Web Page and many report generators write HTML -/// under a `.doc` name, and Word opens it as a web page. -fn looks_like_web_page(bytes: &[u8]) -> bool { - let head = wordcraft_formats::txt::decode(bytes.get(..bytes.len().min(4096)).unwrap_or(bytes)).to_ascii_lowercase(); - let mut t = head.trim_start_matches(['\u{FEFF}', ' ', '\t', '\r', '\n']); - for _ in 0..64 { - let skip = if t.starts_with("").map(|i| i + 3) - } else if t.starts_with("").map(|i| i + 2) - } else { - break; - }; - let Some(rest) = skip.and_then(|i| t.get(i..)) else { return false }; - t = rest.trim_start(); - } - // The tag name ends there: `` is some other XML, not a web page. - let tag = |name: &str| { - t.strip_prefix(name).is_some_and(|rest| rest.is_empty() || rest.starts_with(|c: char| c.is_ascii_whitespace() || c == '>' || c == '/')) - }; - tag(", - page: &wordcraft_doc::SectionProps, -) -> Result<(Document, bool), String> { - let (mut doc, encrypted) = if bytes.starts_with(OLE_SIG) && wordcraft_docx::is_encrypted(bytes) { - (read_docx(bytes, password).map_err(|e| e.to_string())?, true) - } else if bytes.starts_with(ZIP_SIG) { - (read_docx(bytes, None).map_err(|e| format!("{name}: not a document WordCraft can open ({e})"))?, false) - } else if bytes.starts_with(OLE_SIG) { - open_binary_doc(bytes, password) - .map_err(|e| if needs_password(&e) || wrong_password(&e) { e } else { format!("{name}: not a document WordCraft can open ({e})") })? - } else if bytes.starts_with(b"{\\rtf") { - (wordcraft_formats::rtf::import(bytes)?, false) - } else if is_word_xml(bytes) { - (wordcraft_docx::read_xml(bytes).map_err(|e| format!("{name}: {e}"))?, false) - } else if matches!(ext, "doc" | "dot" | "docx" | "docm" | "dotx" | "dotm" | "rtf") && looks_like_web_page(bytes) { - let opened = crate::io_ext::open("html", bytes, page).unwrap_or_else(|| Err(format!("{name}: can't open web pages"))); - (opened?, false) - } else if matches!(ext, "doc" | "dot" | "docx" | "docm" | "dotx" | "dotm" | "rtf") && wordcraft_formats::mht::is_mht(bytes) { - let opened = crate::io_ext::open("mht", bytes, page).unwrap_or_else(|| Err(format!("{name}: can't open web archives"))); - (opened?, false) - } else if wordcraft_formats::txt::is_text(bytes) { - (wordcraft_formats::txt::import_on(bytes, page), false) - } else if ext.is_empty() { - return Err(format!("{name}: not a document or text file WordCraft can open")); - } else { - return Err(format!("{name}: unsupported format `.{ext}` (not a document or text file)")); - }; - opened(&mut doc); - Ok((doc, encrypted)) -} - -/// Serialise a document; `name` gives the format by extension. -pub fn save_bytes(name: &str, doc: &Document) -> Result, String> { - save_bytes_opts(name, doc, &ExportOptions::default()) -} - -/// [`save_bytes`] with export options (Print Markup). -pub fn save_bytes_opts(name: &str, doc: &Document, opts: &ExportOptions) -> Result, String> { - let ext = ext_of(name); - match ext.as_str() { - // List paragraphs get their label and a tab, as in Word's plain-text save. - "txt" | "text" => { - let bytes = wordcraft_formats::txt::export(doc); - Ok(if opts.markup && opts.comments { wordcraft_formats::comments::append("txt", doc, bytes) } else { bytes }) - } - "json" => serde_json::to_vec_pretty(doc).map_err(|e| e.to_string()), - other => match crate::io_ext::save(other, doc, opts) { - Some(r) => r, - None => Err(format!("{name}: can't save as `.{other}`")), - }, - } -} - -/// [`save_bytes`], encrypted with `password` when there is one and the format is a Word package -/// (other formats are copies and are written as they are). -pub fn save_bytes_with(name: &str, doc: &Document, password: Option<&str>, opts: &ExportOptions) -> Result, String> { - let bytes = save_bytes_opts(name, doc, opts)?; - match password { - Some(pw) if matches!(ext_of(name).as_str(), "odt" | "ott") => wordcraft_formats::odt::encrypt(&bytes, pw), - Some(pw) if can_encrypt(name) => wordcraft_docx::encrypt(&bytes, pw).map_err(|e| e.to_string()), - _ => Ok(bytes), - } -} - -pub fn open_path(path: &std::path::Path) -> Result { - open_path_with(path, None).map(|(doc, _)| doc) -} - -/// [`open_path`] for a file that may be password-protected (see [`open_bytes_with`]). -#[cfg(not(target_arch = "wasm32"))] -pub fn open_path_with(path: &std::path::Path, password: Option<&str>) -> Result<(Document, bool), String> { - open_path_in(path, password, &wordcraft_geom::Region::default()) -} - -/// [`open_path_with`] in `region` (see [`open_bytes_in`]). -#[cfg(not(target_arch = "wasm32"))] -pub fn open_path_in(path: &std::path::Path, password: Option<&str>, region: &wordcraft_geom::Region) -> Result<(Document, bool), String> { - let bytes = read_document(path)?; - let name = path.to_string_lossy(); - if matches!(ext_of(&name).as_str(), "html" | "htm" | "xhtml") { - // Pictures referenced by a relative path load from beside the HTML file. - let images = LocalImages::new(path.parent().unwrap_or(std::path::Path::new(""))); - let html = wordcraft_formats::html::decode(&bytes); - let mut doc = wordcraft_formats::html::import_on(&html, &|src| images.load(src), ®ion_page(region)); - fit_pictures(&mut doc); - opened(&mut doc); - return Ok((doc, false)); - } - open_bytes_in(&name, &bytes, password, region) -} - -/// Largest document file opened by path. -#[cfg(not(target_arch = "wasm32"))] -const MAX_DOCUMENT: u64 = 2 << 30; - -/// The bytes of the document at `path`, read as `file.open` reads a path: inside the automation -/// read root, and only a regular file of at most 2 GB. A named pipe would block the open and a -/// device such as `/dev/zero` would never end ([`read_regular_file`]). Public for the app, which -/// opens a file whose name isn't valid Unicode through `file.open`'s `data` (#1440). -#[cfg(not(target_arch = "wasm32"))] -pub fn read_document(path: &std::path::Path) -> Result, String> { - crate::file_access::check_read(&path.to_string_lossy())?; - let meta = std::fs::metadata(path).map_err(|e| format!("{}: {e}", path.display()))?; - if !meta.is_file() { - return Err(format!("{}: not a file", path.display())); - } - if meta.len() > MAX_DOCUMENT { - return Err(format!("{}: file is larger than 2 GB", path.display())); - } - // Checked again on the opened file, which may not be the one just looked at. - match read_regular_file(path, MAX_DOCUMENT) { - Some(bytes) if bytes.len() as u64 <= MAX_DOCUMENT => Ok(bytes), - Some(_) => Err(format!("{}: file is larger than 2 GB", path.display())), - None => Err(format!("{}: could not be read as a file", path.display())), - } -} - -/// The pictures an HTML file names by a path relative to its folder. -/// -/// Only regular files inside the folder (after resolving `..` and symlinks) are read. The folder is -/// the HTML file's own, which `open_path_with` has already checked against the automation read root. URLs -/// (`http:`, `file:`…) and absolute paths are not followed. Each file is read at most once and -/// at most `max_file` bytes of it; every reference, cached or not, counts against a budget for -/// the whole document, so one large picture can't be multiplied by referencing it many times. -#[cfg(not(target_arch = "wasm32"))] -pub(crate) struct LocalImages { - /// The folder, canonical; `None` when it can't be resolved (nothing loads then). - dir: Option, - max_file: u64, - /// Bytes the document may still load. - left: std::cell::Cell, - cache: std::cell::RefCell>)>>, -} - -#[cfg(not(target_arch = "wasm32"))] -impl LocalImages { - /// Largest picture file read. - const MAX_FILE: u64 = 256 << 20; - /// Picture bytes one document may load, counting every reference. - const BUDGET: u64 = 1 << 30; - /// Files whose bytes are kept for further references. - const CACHE: usize = 64; - - pub(crate) fn new(dir: &std::path::Path) -> LocalImages { - LocalImages::with_limits(dir, LocalImages::MAX_FILE, LocalImages::BUDGET) - } - - pub(crate) fn with_limits(dir: &std::path::Path, max_file: u64, budget: u64) -> LocalImages { - let dir = if dir.as_os_str().is_empty() { std::path::Path::new(".") } else { dir }; - LocalImages { dir: std::fs::canonicalize(dir).ok(), max_file, left: std::cell::Cell::new(budget), cache: Default::default() } - } - - /// Bytes of the file `src` names, or `None` when it isn't a regular file inside the folder, - /// is too large, or the budget is spent. - pub(crate) fn load(&self, src: &str) -> Option>> { - let path = self.resolve(src)?; - let cached = self.cache.borrow().iter().find(|(p, _)| *p == path).map(|(_, d)| d.clone()); - if let Some(data) = cached { - self.charge(data.len())?; - return Some(data); - } - let limit = self.max_file.min(self.left.get()); - let bytes = read_regular_file(&path, limit)?; - // What was read is spent even when it's over the limit. - self.charge(bytes.len())?; - if bytes.len() as u64 > limit { - return None; - } - let data = std::sync::Arc::new(bytes); - let mut cache = self.cache.borrow_mut(); - if cache.len() < LocalImages::CACHE { - cache.push((path, data.clone())); - } - Some(data) - } - - /// Take `n` bytes from the budget; `None` (and the budget emptied) when there aren't enough. - fn charge(&self, n: usize) -> Option<()> { - let left = self.left.get().checked_sub(n as u64); - self.left.set(left.unwrap_or(0)); - left.map(|_| ()) - } - - /// The canonical path `src` names, if it's inside the folder and may be read. - fn resolve(&self, src: &str) -> Option { - let src = src.split(['#', '?']).next().unwrap_or(""); - if src.is_empty() || src.starts_with("//") || src.split_once(':').is_some_and(|(scheme, _)| !scheme.contains('/')) { - return None; - } - let rel = String::from_utf8(wordcraft_formats::model::percent_decode(src)).ok()?; - let rel = std::path::Path::new(&rel); - if rel.components().any(|c| matches!(c, std::path::Component::Prefix(_) | std::path::Component::RootDir)) { - return None; - } - let dir = self.dir.as_ref()?; - // Canonical paths have `..` and symlinks resolved, so this keeps both inside the folder. - let path = std::fs::canonicalize(dir.join(rel)).ok()?; - // The folder is the one the HTML path names, which may be a link from outside the - // automation read root; the picture itself must be inside it (#3736). - crate::file_access::check_read(&path.to_string_lossy()).ok()?; - path.starts_with(dir).then_some(path) - } -} - -/// Up to `limit + 1` bytes of the regular file at `path` (more than `limit` means too large), or -/// `None` for anything else. A named pipe blocks when opened and a device reports a length of 0 -/// and may never end, so the path is checked before opening, the opened handle is checked again, -/// and the read is bounded whatever length the file reports. Public for the app (a copied picture -/// file, Paste Special). -#[cfg(not(target_arch = "wasm32"))] -pub fn read_regular_file(path: &std::path::Path, limit: u64) -> Option> { - let (file, meta) = open_regular_file(path).ok()?; - read_bounded(file, &meta, limit).ok() -} - -#[cfg(target_arch = "wasm32")] -pub fn read_regular_file(_path: &std::path::Path, _limit: u64) -> Option> { - None -} - -/// The bytes of the regular file at `path` when it holds at most `limit` of them; `None` for -/// anything else. A file whose opened handle reports more than `limit` bytes is refused before -/// anything is read; the read stays bounded for a file that grows after the check. Public for the -/// app (a copied picture file, Paste Special). -#[cfg(not(target_arch = "wasm32"))] -pub fn read_regular_file_within(path: &std::path::Path, limit: u64) -> Option> { - let (file, meta) = open_regular_file(path).ok()?; - if meta.len() > limit { - return None; - } - read_bounded(file, &meta, limit).ok().filter(|b| b.len() as u64 <= limit) -} - -#[cfg(target_arch = "wasm32")] -pub fn read_regular_file_within(_path: &std::path::Path, _limit: u64) -> Option> { - None -} - -/// The regular file at `path`, opened once, with the opened handle's metadata. The path is -/// checked before opening (a named pipe blocks in `open` until a writer appears) and the handle -/// after (the path may have been swapped in between); anything but a regular file is "not a file". -#[cfg(not(target_arch = "wasm32"))] -fn open_regular_file(path: &std::path::Path) -> Result<(std::fs::File, std::fs::Metadata), String> { - let meta = std::fs::metadata(path).map_err(|e| format!("{}: {e}", path.display()))?; - if !meta.is_file() { - return Err(format!("{}: not a file", path.display())); - } - let mut open = std::fs::OpenOptions::new(); - open.read(true); - // A path swapped for a named pipe since the check above would block `open`; non-blocking - // opens it at once (no effect on a regular file) and the handle check below refuses it. - #[cfg(unix)] - std::os::unix::fs::OpenOptionsExt::custom_flags(&mut open, libc::O_NONBLOCK); - let file = open.open(path).map_err(|e| format!("{}: {e}", path.display()))?; - let meta = file.metadata().map_err(|e| format!("{}: {e}", path.display()))?; - if !meta.is_file() { - return Err(format!("{}: not a file", path.display())); - } - Ok((file, meta)) -} - -/// Up to `limit + 1` bytes of `file` (more than `limit` means too large), however long it says it is. -#[cfg(not(target_arch = "wasm32"))] -fn read_bounded(file: std::fs::File, meta: &std::fs::Metadata, limit: u64) -> std::io::Result> { - use std::io::Read; - let mut out = Vec::with_capacity(usize::try_from(meta.len().min(limit)).unwrap_or(0)); - file.take(limit.saturating_add(1)).read_to_end(&mut out)?; - Ok(out) -} - -/// Largest file [`read_file`] reads whole — the same 2 GB ceiling [`open_path_with`] gives a -/// document. -pub const MAX_READ_FILE: u64 = 2 << 30; - -/// The bytes of the file at `path`, for commands that take a path (pictures, mail-merge data). -/// Every such read goes through here so the automation read root applies ([`crate::file_access`]). -/// Only a regular file is read, and at most [`MAX_READ_FILE`] bytes of it: a named pipe blocks -/// when opened and a device like `/dev/zero` never reaches end of file. -#[cfg(not(target_arch = "wasm32"))] -pub fn read_file(path: &str) -> Result, String> { - read_file_capped(path, MAX_READ_FILE, "file") -} - -#[cfg(target_arch = "wasm32")] -pub fn read_file(path: &str) -> Result, String> { - Err(format!("{path}: files are opened through the browser on the web")) -} - -/// [`read_file`] for a file that must be a regular file of at most `max` bytes, checked before -/// anything is read (a font named by the user, which could be any size). The file is opened once -/// and the read stops past `max`, so a file swapped or grown after the check is refused too (#3666). -#[cfg(not(target_arch = "wasm32"))] -pub fn read_file_capped(path: &str, max: u64, what: &str) -> Result, String> { - crate::file_access::check_read(path)?; - let too_large = || format!("{path}: the {what} is larger than {} MB", max >> 20); - let (file, meta) = open_regular_file(std::path::Path::new(path))?; - if meta.len() > max { - return Err(too_large()); - } - let bytes = read_bounded(file, &meta, max).map_err(|e| format!("{path}: {e}"))?; - if bytes.len() as u64 > max { - return Err(too_large()); - } - Ok(bytes) -} - -/// The first `max` bytes of the file at `path` (fewer when it is shorter), inside the automation -/// read root when there is one: enough to tell a file's format by its start. Only a regular file -/// is opened: a named pipe would block the open until a writer appeared (#3666). -#[cfg(not(target_arch = "wasm32"))] -pub fn read_file_head(path: &str, max: u64) -> Result, String> { - use std::io::Read; - crate::file_access::check_read(path)?; - let (file, _) = open_regular_file(std::path::Path::new(path))?; - let mut head = Vec::new(); - file.take(max).read_to_end(&mut head).map_err(|e| format!("{path}: {e}"))?; - Ok(head) -} - -/// An entry of [`list_dir`]: a file or folder (links followed). -#[cfg(not(target_arch = "wasm32"))] -#[derive(Clone, Debug)] -pub struct DirEntry { - pub name: String, - pub path: std::path::PathBuf, - pub dir: bool, - /// The file's size in bytes (0 for a folder). - pub len: u64, - /// When it last changed, in seconds since the Unix epoch, if the system says. - pub modified: Option, -} - -/// The files and folders in the folder at `path`, inside the automation read root (entries -/// leading outside it, and ones that can't be read, are left out); at most `max` entries are -/// looked at, and `true` says the listing was cut short there. -#[cfg(not(target_arch = "wasm32"))] -pub fn list_dir(path: &std::path::Path, max: usize) -> Result<(Vec, bool), String> { - crate::file_access::check_read(&path.to_string_lossy())?; - let read = std::fs::read_dir(path).map_err(|e| format!("{}: {e}", path.display()))?; - let mut out = Vec::new(); - for (seen, entry) in read.enumerate() { - if seen >= max { - return Ok((out, true)); - } - let Ok(entry) = entry else { continue }; - let path = entry.path(); - if crate::file_access::check_read(&path.to_string_lossy()).is_err() { - continue; - } - let Ok(meta) = std::fs::metadata(&path) else { continue }; - if !meta.is_dir() && !meta.is_file() { - continue; - } - let modified = meta.modified().ok().and_then(|m| m.duration_since(std::time::UNIX_EPOCH).ok()).map(|d| d.as_secs()); - let name = entry.file_name().to_string_lossy().into_owned(); - out.push(DirEntry { name, path, dir: meta.is_dir(), len: if meta.is_file() { meta.len() } else { 0 }, modified }); - } - Ok((out, false)) -} - -/// The title in the core properties of the Word package at `path` (a file of at most `max_file` -/// bytes; at most `max_part` bytes of the part are read), inside the automation read root. -#[cfg(not(target_arch = "wasm32"))] -pub fn package_title(path: &std::path::Path, max_file: u64, max_part: u64) -> Option { - crate::file_access::check_read(&path.to_string_lossy()).ok()?; - let (file, meta) = open_regular_file(path).ok()?; - if meta.len() > max_file { - return None; - } - wordcraft_docx::package_title(std::io::BufReader::new(file), max_part) -} - -/// Rename without replacing another file, respecting both automation access roots. -#[cfg(not(target_arch = "wasm32"))] -pub fn rename_file(source: &std::path::Path, destination: &std::path::Path) -> Result<(), String> { - crate::file_access::check_read(&source.to_string_lossy())?; - crate::file_access::check_write(&source.to_string_lossy())?; - crate::file_access::check_write(&destination.to_string_lossy())?; - // Creating a sibling hard link refuses collisions atomically and keeps the original bytes. - if let Err(e) = std::fs::hard_link(source, destination) { - if e.kind() == std::io::ErrorKind::AlreadyExists && only_case_differs(source, destination) { - return std::fs::rename(source, destination).map_err(|e| format!("Could not rename document: {e}")); - } - return Err(format!("Could not rename document: {e}")); - } - if let Err(e) = std::fs::remove_file(source) { - let _ = std::fs::remove_file(destination); - return Err(format!("Could not rename document: {e}")); - } - Ok(()) -} - -/// Whether `destination` names `source` in the same directory spelled with different case, and -/// the directory holds no entry spelled exactly as `destination`. -#[cfg(not(target_arch = "wasm32"))] -fn only_case_differs(source: &std::path::Path, destination: &std::path::Path) -> bool { - let (Some(dir), Some(from), Some(to)) = (source.parent(), source.file_name(), destination.file_name()) else { - return false; - }; - if destination.parent() != Some(dir) || from == to || from.to_string_lossy().to_lowercase() != to.to_string_lossy().to_lowercase() { - return false; - } - let dir = if dir.as_os_str().is_empty() { std::path::Path::new(".") } else { dir }; - std::fs::read_dir(dir).is_ok_and(|entries| entries.filter_map(Result::ok).all(|entry| entry.file_name() != to)) -} - -/// Write `bytes` to the file at `path`, inside the automation write root when there is one. -#[cfg(not(target_arch = "wasm32"))] -pub fn write_file(path: &str, bytes: &[u8]) -> Result<(), String> { - crate::file_access::check_write(path)?; - // Like a document save: the old file is replaced only once the new bytes are fully written. - safe_save::write(std::path::Path::new(path), bytes).map_err(|e| format!("{path}: {e}")) -} - -#[cfg(target_arch = "wasm32")] -pub fn write_file(path: &str, _bytes: &[u8]) -> Result<(), String> { - Err(format!("{path}: files are saved through the browser on the web")) -} - -#[cfg(target_arch = "wasm32")] -pub fn open_path_with(path: &std::path::Path, _password: Option<&str>) -> Result<(Document, bool), String> { - Err(format!("{}: files are opened through the browser on the web", path.display())) -} - -#[cfg(target_arch = "wasm32")] -pub fn open_path_in(path: &std::path::Path, password: Option<&str>, _region: &wordcraft_geom::Region) -> Result<(Document, bool), String> { - open_path_with(path, password) -} - -pub fn save_path(path: &std::path::Path, doc: &Document) -> Result<(), String> { - save_path_with(path, doc, None, &ExportOptions::default()) -} - -/// [`save_path`], encrypted with `password` as [`save_bytes_with`] does. -#[cfg(not(target_arch = "wasm32"))] -pub fn save_path_with(path: &std::path::Path, doc: &Document, password: Option<&str>, opts: &ExportOptions) -> Result<(), String> { - crate::file_access::check_write(&path.to_string_lossy())?; - let bytes = save_bytes_with(&path.to_string_lossy(), doc, password, opts)?; - safe_save::write(path, &bytes).map_err(|e| format!("{}: {e}", path.display())) -} - -/// Saves a document to `path` with optional password and export options on web targets. -#[cfg(target_arch = "wasm32")] -pub fn save_path_with(path: &std::path::Path, _doc: &Document, _password: Option<&str>, _opts: &ExportOptions) -> Result<(), String> { - Err(format!("{}: files are saved through the browser on the web", path.display())) -} - -/// Write `text` to `path` as it is (a Markdown file's own text), as safely as [`save_path_with`]. -#[cfg(not(target_arch = "wasm32"))] -pub fn save_text_path(path: &std::path::Path, text: &str) -> Result<(), String> { - crate::file_access::check_write(&path.to_string_lossy())?; - safe_save::write(path, text.as_bytes()).map_err(|e| format!("{}: {e}", path.display())) -} - -/// Write `text` to `path` as it is (a Markdown file's own text), as safely as [`save_path_with`]. -#[cfg(target_arch = "wasm32")] -pub fn save_text_path(path: &std::path::Path, _text: &str) -> Result<(), String> { - Err(format!("{}: files are saved through the browser on the web", path.display())) -} - -/// Decode text bytes: UTF-8 (with or without BOM), UTF-16 LE/BE (BOM or sniffed), else the -/// legacy encoding they look like — GBK, Big5, Shift_JIS, Windows-1252… (see -/// [`wordcraft_formats::txt::decode`]). -pub fn decode_text(b: &[u8]) -> String { - wordcraft_formats::txt::decode(b) -} +//! File format dispatch by extension. Format crates plug in here. + +use wordcraft_doc::Document; + +#[cfg(not(target_arch = "wasm32"))] +mod safe_save; + +/// Formats WordCraft opens. +pub const OPEN_EXTS: &[&str] = &[ + "docx", + "docm", + "dotx", + "dotm", + "doc", + "dot", + "txt", + "md", + "markdown", + "html", + "htm", + "mht", + "mhtml", + "rtf", + "odt", + "ott", + "fodt", + "tex", + "latex", + "ltx", + "xml", + "wcraft.json", + "json", +]; +/// Formats WordCraft saves (Save As). +pub const SAVE_EXTS: &[&str] = + &["docx", "docm", "dotx", "dotm", "pdf", "txt", "md", "html", "rtf", "odt", "ott", "fodt", "tex", "png", "json", "xml"]; + +/// Magic number for ZIP package signatures (used by .docx/.docm/.dotx/.dotm). +const ZIP_SIG: &[u8] = b"PK\x03\x04"; +/// Magic number for OLE compound document signatures (used by .doc/.dot). +const OLE_SIG: &[u8] = &[0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1]; + +/// The extension of a file name or path, lower-case; empty when the file name has none (a dot +/// in a folder name doesn't count). +fn ext_of(name: &str) -> String { + let file = name.rsplit(['/', '\\']).next().unwrap_or(name); + file.rsplit_once('.').map(|(_, ext)| ext.to_ascii_lowercase()).unwrap_or_default() +} + +/// Is `ext` one of Word's package formats (.docx/.docm/.dotx/.dotm), which save without loss? +pub fn is_word_package(ext: &str) -> bool { + wordcraft_docx::Flavor::from_ext(ext).is_some() +} + +/// How a copy in another format is written. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct ExportOptions { + /// Print Markup: PDFs show tracked changes marked up and, with `comments`, each page scaled + /// down beside a markup area with the comments in balloons (page images get the area too); + /// HTML, Markdown and text copies end with the comments. + pub markup: bool, + /// With `markup`, show the comments (Track Changes Options › Comments). + pub comments: bool, + /// An `.xml` copy is a Word 2003 XML Document (WordprocessingML 2003) instead of a Word XML + /// Document (Flat OPC): `format: "wordml2003"` on the save commands. + pub word2003: bool, + /// How digits look in PDFs and page images (the Numeral setting, `System` resolved). + pub numeral: wordcraft_layout::NumeralMode, + /// A PDF or page image is written only if nothing in it is silently missing (a character no + /// face can draw, a picture that is missing or can't be decoded); else the save fails with + /// `strict: …` (#6005). Ignored for other formats. + pub strict: bool, +} + +/// A document password: wiped from memory when dropped, never printed (`Debug` shows `***`). +#[derive(Clone, Default, PartialEq, Eq)] +pub struct Password(zeroize::Zeroizing); + +impl Password { + pub fn new(s: &str) -> Password { + Password(zeroize::Zeroizing::new(s.to_string())) + } + pub fn as_str(&self) -> &str { + &self.0 + } + /// The text, for a password box to edit in place. + pub fn as_mut_string(&mut self) -> &mut String { + &mut self.0 + } + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } +} + +impl std::fmt::Debug for Password { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("Password(***)") + } +} + +/// Does this open error mean the file is password-protected and no password was given? +pub fn needs_password(err: &str) -> bool { + err.contains(wordcraft_docx::PASSWORD_REQUIRED) +} + +/// Does this open error mean the password given was wrong? +pub fn wrong_password(err: &str) -> bool { + err.contains(wordcraft_docx::WRONG_PASSWORD) +} + +/// Is the file at `path` empty, zero bytes long? File Explorer's New › Microsoft Word Document +/// makes one, and `file.open` opens it as a new blank document (#1384). False for a file that +/// can't be read: opening it says why. +#[cfg(not(target_arch = "wasm32"))] +pub fn is_empty_file(path: &std::path::Path) -> bool { + crate::file_access::check_read(&path.to_string_lossy()).is_ok() && std::fs::metadata(path).is_ok_and(|m| m.is_file() && m.len() == 0) +} + +/// [`is_empty_file`]: on the web files come through the browser, never by path. +#[cfg(target_arch = "wasm32")] +pub fn is_empty_file(_path: &std::path::Path) -> bool { + false +} + +/// The canonical form of `path` (symbolic links and `.`/`..` resolved), for telling whether two +/// spellings name one file; `None` when it can't be resolved or is outside the readable roots. +#[cfg(not(target_arch = "wasm32"))] +pub(crate) fn canonical(path: &std::path::Path) -> Option { + crate::file_access::check_read(&path.to_string_lossy()).ok()?; + std::fs::canonicalize(path).ok() +} + +/// [`canonical`]: on the web files come through the browser, never by path. +#[cfg(target_arch = "wasm32")] +pub(crate) fn canonical(_path: &std::path::Path) -> Option { + None +} + +/// Can files with this extension be saved with a password (Word's packages, ODT)? +pub fn can_encrypt(name: &str) -> bool { + let ext = ext_of(name); + is_word_package(&ext) || ext == "odt" || ext == "ott" +} + +/// Open a password-protected ODT package (ODF 1.3 Part 2 §3.4). The errors for a missing or +/// wrong password are the `.docx` reader's texts, so [`needs_password`] and [`wrong_password`] +/// recognise them. +fn open_encrypted_odt(bytes: &[u8], password: Option<&str>, page: &wordcraft_doc::SectionProps) -> Result { + let pw = password.ok_or_else(|| format!("ODT: {}", wordcraft_docx::PASSWORD_REQUIRED))?; + let plain = wordcraft_formats::odt::decrypt(bytes, pw).map_err(|e| match e { + wordcraft_formats::odt::CryptError::WrongPassword => format!("ODT: {}", wordcraft_docx::WRONG_PASSWORD), + other => other.to_string(), + })?; + match crate::io_ext::open("odt", &plain, page) { + Some(r) => r, + None => Err("ODT: no reader".into()), + } +} + +/// Finish a document just read from a file: never leave it empty, and bring its unlocked +/// `DATE`/`TIME` fields up to now the way Word does on open (see +/// [`crate::cmd::references::refresh_date_fields`]). +fn opened(doc: &mut Document) { + doc.ensure_nonempty(); + crate::cmd::references::refresh_date_fields(doc); +} + +/// Parse a document from bytes; `name` gives the format by extension. +pub fn open_bytes(name: &str, bytes: &[u8]) -> Result { + open_bytes_with(name, bytes, None).map(|(doc, _)| doc) +} + +/// Open a Word 97-2003 binary file, decrypting it with `password` when it is password-protected +/// (XOR, RC4, RC4 CryptoAPI). The errors for a missing or wrong password are the same texts the +/// `.docx` reader gives, so [`needs_password`] and [`wrong_password`] recognise them. +fn open_binary_doc(bytes: &[u8], password: Option<&str>) -> Result<(Document, bool), String> { + use wordcraft_docbin::DocbinError; + let encrypted = wordcraft_docbin::is_encrypted(bytes); + match wordcraft_docbin::read_with_password(bytes, password) { + Ok(doc) => Ok((doc, encrypted)), + Err(DocbinError::PasswordRequired) => Err(wordcraft_docx::PASSWORD_REQUIRED.to_string()), + Err(DocbinError::WrongPassword) => Err(wordcraft_docx::WRONG_PASSWORD.to_string()), + Err(e) => Err(e.to_string()), + } +} + +/// [`open_bytes`] for a file that may be password-protected: a Word document encrypted with a +/// password ([MS-OFFCRYPTO] agile or Standard encryption, under any Word extension, or the +/// Word 97-2003 schemes) is decrypted with +/// `password` first. Also says +/// whether the file was encrypted. Without the password needed the error satisfies +/// [`needs_password`]; with a wrong one, [`wrong_password`]. +pub fn open_bytes_with(name: &str, bytes: &[u8], password: Option<&str>) -> Result<(Document, bool), String> { + open_bytes_in(name, bytes, password, &wordcraft_geom::Region::default()) +} + +/// The page a file that gives no page size (plain text, Markdown, HTML) opens on: `region`'s +/// paper, as a new blank document (#838, #1381), with the default margins. +pub fn region_page(region: &wordcraft_geom::Region) -> wordcraft_doc::SectionProps { + let (_, w, h) = region.paper(); + wordcraft_doc::SectionProps { page_w: w, page_h: h, ..Default::default() } +} + +/// [`open_bytes_with`] in `region`: a file that gives no page size opens on the region's paper +/// (see [`region_page`]); a document with its own page size keeps it. +pub fn open_bytes_in(name: &str, bytes: &[u8], password: Option<&str>, region: &wordcraft_geom::Region) -> Result<(Document, bool), String> { + let page = region_page(region); + let (doc, encrypted) = read_bytes(name, bytes, password, &page)?; + load_embedded_fonts(&doc); + Ok((doc, encrypted)) +} + +/// Load the fonts `doc` embeds (#1171) for this run, in memory only: text set in a font that isn't +/// installed then lays out and exports with it instead of a fallback. Faces already available +/// stay as they are. +fn load_embedded_fonts(doc: &Document) { + let files: Vec<(Vec, String)> = wordcraft_docx::embedded_fonts(doc) + .into_iter() + .filter_map(|f| wordcraft_fonts::decode_font_file(&f.data).ok().map(|data| (data, f.name))) + .collect(); + if !files.is_empty() { + wordcraft_fonts::FontDb::global().add_document_fonts(files); + } +} + +fn read_bytes(name: &str, bytes: &[u8], password: Option<&str>, page: &wordcraft_doc::SectionProps) -> Result<(Document, bool), String> { + let ext = ext_of(name); + if matches!(ext.as_str(), "docx" | "docm" | "dotx" | "dotm" | "doc" | "dot") && wordcraft_docx::is_encrypted(bytes) { + let mut doc = read_docx(bytes, password).map_err(|e| e.to_string())?; + opened(&mut doc); + return Ok((doc, true)); + } + if (ext == "odt" || ext == "ott") && wordcraft_formats::odt::is_encrypted(bytes) { + let mut doc = open_encrypted_odt(bytes, password, page)?; + opened(&mut doc); + return Ok((doc, true)); + } + if ext.is_empty() || signature_contradicts(&ext, bytes) { + return open_by_content(name, &ext, bytes, password, page); + } + let mut doc = match ext.as_str() { + "doc" | "dot" => { + let (mut doc, encrypted) = open_binary_doc(bytes, password)?; + opened(&mut doc); + return Ok((doc, encrypted)); + } + // Plain text goes through the formats importer: BOMs, unmarked UTF-16, Windows-1252. + "txt" | "text" => wordcraft_formats::txt::import_on(bytes, page), + "json" => { + let mut d = serde_json::from_slice::(bytes).map_err(|e| format!("{name}: {e}"))?; + d.normalize_runs(); + d + } + other => match crate::io_ext::open(other, bytes, page) { + Some(r) => r?, + None => return open_by_content(name, other, bytes, password, page), + }, + }; + opened(&mut doc); + Ok((doc, false)) +} + +/// Read a Word package (decrypted with `password` if it is encrypted), bringing in its +/// alternative format chunks (`w:altChunk`) as Word does when it opens one: see [`import_chunk`]. +pub(crate) fn read_docx(bytes: &[u8], password: Option<&str>) -> Result { + wordcraft_docx::read_with(bytes, &wordcraft_docx::ReadOptions { password, chunks: Some(&import_chunk) }) +} + +/// An alternative format chunk in a format the docx reader leaves to the format importers, as a +/// document: an MHT web archive, HTML, RTF or plain text, told by its content type, its part's +/// extension or what it holds. Its pictures fit the text width, as when the file is opened. +fn import_chunk(c: &wordcraft_docx::AltChunk) -> Option { + use wordcraft_formats::{html, mht, rtf, txt}; + let full = c.content_type.to_ascii_lowercase(); + let ct = full.split(';').next().unwrap_or("").trim(); + let charset = full.split(';').skip(1).find_map(|p| p.trim().strip_prefix("charset=")).map(|v| v.trim_matches('"')); + let ext = ext_of(c.part); + let b = c.bytes; + let mut doc = if matches!(ct, "message/rfc822" | "application/x-mimearchive") + || ct.starts_with("multipart/") + || matches!(ext.as_str(), "mht" | "mhtml") + || mht::is_mht(b) + { + mht::import(b) + } else if ct.contains("rtf") || ext == "rtf" || b.starts_with(b"{\\rtf") { + rtf::import(b).ok()? + } else if ct.contains("html") || matches!(ext.as_str(), "html" | "htm" | "xhtml") { + html::import_page(&html::decode_as(b, charset), &|_| None) + } else if ct == "text/plain" || ext == "txt" { + txt::import(b) + } else { + return None; + }; + // shortcut: pictures fit the chunk's default page, not the section it lands in; fit them there if wide pictures in chunks overflow narrow sections. + fit_pictures(&mut doc); + Some(doc) +} + +/// The narrowest text width of the document's sections, points: what a picture must fit. +pub(crate) fn text_width(doc: &Document) -> f32 { + doc.sections().iter().map(|(_, sp)| sp.text_width()).fold(f32::INFINITY, f32::min).clamp(36.0, 4000.0) +} + +/// Shrink inline pictures wider than `max_w` to it, keeping their proportions. Layout keeps a +/// picture's size (as Word does), so formats whose pictures come at their pixel size (HTML, +/// Markdown, ODT, RTF) are fitted when they are read, as Insert › Pictures does. +pub(crate) fn fit_block_pictures(b: &mut wordcraft_doc::Block, max_w: f32) { + crate::cmd::paste::each_para(b, 0, &mut |p| { + let mut changed = false; + for o in &mut p.objects { + if let wordcraft_doc::InlineObject::Image { w, h, float, .. } = o + && float.wrap == wordcraft_doc::para::Wrap::Inline + && w.is_finite() + && *w > max_w + { + let k = max_w / *w; + *w = max_w; + *h = (*h * k).max(1.0); + changed = true; + } + } + if changed { + p.touch(); + } + }); +} + +/// [`fit_block_pictures`] for a whole document read from a file, against its own text width. +pub(crate) fn fit_pictures(doc: &mut Document) { + let max_w = text_width(doc); + for b in &mut doc.body { + fit_block_pictures(std::sync::Arc::make_mut(b), max_w); + } +} + +/// Does the file hold a different document format than its Word or RTF extension says? An RTF +/// file saved as `.doc` (as accounting systems and report generators often save their +/// documents), a `.docx` package renamed `.doc`, a Word 97-2003 file renamed `.docx`, a Word XML +/// Document (2003 or Flat OPC) or a web page (HTML or MHT) saved as `.doc` (#2853): Word opens +/// each by what it holds, so they go through [`open_by_content`] instead of failing. +fn signature_contradicts(ext: &str, bytes: &[u8]) -> bool { + let expected = match ext { + "docx" | "docm" | "dotx" | "dotm" => "zip", + "doc" | "dot" => "ole", + "rtf" => "rtf", + _ => return false, + }; + let held = if bytes.starts_with(ZIP_SIG) { + "zip" + } else if bytes.starts_with(OLE_SIG) { + "ole" + } else if bytes.starts_with(b"{\\rtf") { + "rtf" + } else if is_word_xml(bytes) { + "xml" + } else if looks_like_web_page(bytes) { + "html" + } else if wordcraft_formats::mht::is_mht(bytes) { + "mht" + } else { + return false; + }; + held != expected +} + +/// The bytes after a UTF-8 byte-order mark and leading white space. +fn after_bom(bytes: &[u8]) -> &[u8] { + let b = bytes.strip_prefix(&[0xEF, 0xBB, 0xBF][..]).unwrap_or(bytes); + let start = b.iter().position(|c| !c.is_ascii_whitespace()).unwrap_or(b.len()); + b.get(start..).unwrap_or_default() +} + +/// Is this a Word XML Document: Word 2003 XML (`w:wordDocument`) or Flat OPC (`pkg:package`)? +/// Only text that starts with markup is parsed (UTF-16 has its byte-order mark). +fn is_word_xml(bytes: &[u8]) -> bool { + let utf16 = bytes.starts_with(&[0xFF, 0xFE]) || bytes.starts_with(&[0xFE, 0xFF]); + (utf16 || after_bom(bytes).starts_with(b"<")) && !matches!(wordcraft_docx::sniff_xml(bytes), wordcraft_docx::XmlKind::Other) +} + +/// Does the file start like a web page (`` or ``, after an XML +/// declaration and comments)? Word's Save as Web Page and many report generators write HTML +/// under a `.doc` name, and Word opens it as a web page. +fn looks_like_web_page(bytes: &[u8]) -> bool { + let head = wordcraft_formats::txt::decode(bytes.get(..bytes.len().min(4096)).unwrap_or(bytes)).to_ascii_lowercase(); + let mut t = head.trim_start_matches(['\u{FEFF}', ' ', '\t', '\r', '\n']); + for _ in 0..64 { + let skip = if t.starts_with("").map(|i| i + 3) + } else if t.starts_with("").map(|i| i + 2) + } else { + break; + }; + let Some(rest) = skip.and_then(|i| t.get(i..)) else { return false }; + t = rest.trim_start(); + } + // The tag name ends there: `` is some other XML, not a web page. + let tag = |name: &str| { + t.strip_prefix(name).is_some_and(|rest| rest.is_empty() || rest.starts_with(|c: char| c.is_ascii_whitespace() || c == '>' || c == '/')) + }; + tag(", + page: &wordcraft_doc::SectionProps, +) -> Result<(Document, bool), String> { + let (mut doc, encrypted) = if bytes.starts_with(OLE_SIG) && wordcraft_docx::is_encrypted(bytes) { + (read_docx(bytes, password).map_err(|e| e.to_string())?, true) + } else if bytes.starts_with(ZIP_SIG) { + (read_docx(bytes, None).map_err(|e| format!("{name}: not a document WordCraft can open ({e})"))?, false) + } else if bytes.starts_with(OLE_SIG) { + open_binary_doc(bytes, password) + .map_err(|e| if needs_password(&e) || wrong_password(&e) { e } else { format!("{name}: not a document WordCraft can open ({e})") })? + } else if bytes.starts_with(b"{\\rtf") { + (wordcraft_formats::rtf::import(bytes)?, false) + } else if is_word_xml(bytes) { + (wordcraft_docx::read_xml(bytes).map_err(|e| format!("{name}: {e}"))?, false) + } else if matches!(ext, "doc" | "dot" | "docx" | "docm" | "dotx" | "dotm" | "rtf") && looks_like_web_page(bytes) { + let opened = crate::io_ext::open("html", bytes, page).unwrap_or_else(|| Err(format!("{name}: can't open web pages"))); + (opened?, false) + } else if matches!(ext, "doc" | "dot" | "docx" | "docm" | "dotx" | "dotm" | "rtf") && wordcraft_formats::mht::is_mht(bytes) { + let opened = crate::io_ext::open("mht", bytes, page).unwrap_or_else(|| Err(format!("{name}: can't open web archives"))); + (opened?, false) + } else if wordcraft_formats::txt::is_text(bytes) { + (wordcraft_formats::txt::import_on(bytes, page), false) + } else if ext.is_empty() { + return Err(format!("{name}: not a document or text file WordCraft can open")); + } else { + return Err(format!("{name}: unsupported format `.{ext}` (not a document or text file)")); + }; + opened(&mut doc); + Ok((doc, encrypted)) +} + +/// Serialise a document; `name` gives the format by extension. +pub fn save_bytes(name: &str, doc: &Document) -> Result, String> { + save_bytes_opts(name, doc, &ExportOptions::default()) +} + +/// [`save_bytes`] with export options (Print Markup). +pub fn save_bytes_opts(name: &str, doc: &Document, opts: &ExportOptions) -> Result, String> { + let ext = ext_of(name); + match ext.as_str() { + // List paragraphs get their label and a tab, as in Word's plain-text save. + "txt" | "text" => { + let bytes = wordcraft_formats::txt::export(doc); + Ok(if opts.markup && opts.comments { wordcraft_formats::comments::append("txt", doc, bytes) } else { bytes }) + } + "json" => serde_json::to_vec_pretty(doc).map_err(|e| e.to_string()), + other => match crate::io_ext::save(other, doc, opts) { + Some(r) => r, + None => Err(format!("{name}: can't save as `.{other}`")), + }, + } +} + +/// [`save_bytes`], encrypted with `password` when there is one and the format is a Word package +/// (other formats are copies and are written as they are). +pub fn save_bytes_with(name: &str, doc: &Document, password: Option<&str>, opts: &ExportOptions) -> Result, String> { + let bytes = save_bytes_opts(name, doc, opts)?; + match password { + Some(pw) if matches!(ext_of(name).as_str(), "odt" | "ott") => wordcraft_formats::odt::encrypt(&bytes, pw), + Some(pw) if can_encrypt(name) => wordcraft_docx::encrypt(&bytes, pw).map_err(|e| e.to_string()), + _ => Ok(bytes), + } +} + +pub fn open_path(path: &std::path::Path) -> Result { + open_path_with(path, None).map(|(doc, _)| doc) +} + +/// [`open_path`] for a file that may be password-protected (see [`open_bytes_with`]). +#[cfg(not(target_arch = "wasm32"))] +pub fn open_path_with(path: &std::path::Path, password: Option<&str>) -> Result<(Document, bool), String> { + open_path_in(path, password, &wordcraft_geom::Region::default()) +} + +/// [`open_path_with`] in `region` (see [`open_bytes_in`]). +#[cfg(not(target_arch = "wasm32"))] +pub fn open_path_in(path: &std::path::Path, password: Option<&str>, region: &wordcraft_geom::Region) -> Result<(Document, bool), String> { + let bytes = read_document(path)?; + let name = path.to_string_lossy(); + if matches!(ext_of(&name).as_str(), "html" | "htm" | "xhtml") { + // Pictures referenced by a relative path load from beside the HTML file. + let images = LocalImages::new(path.parent().unwrap_or(std::path::Path::new(""))); + let html = wordcraft_formats::html::decode(&bytes); + let mut doc = wordcraft_formats::html::import_on(&html, &|src| images.load(src), ®ion_page(region)); + fit_pictures(&mut doc); + opened(&mut doc); + return Ok((doc, false)); + } + open_bytes_in(&name, &bytes, password, region) +} + +/// Largest document file opened by path. +#[cfg(not(target_arch = "wasm32"))] +const MAX_DOCUMENT: u64 = 2 << 30; + +/// The bytes of the document at `path`, read as `file.open` reads a path: inside the automation +/// read root, and only a regular file of at most 2 GB. A named pipe would block the open and a +/// device such as `/dev/zero` would never end ([`read_regular_file`]). Public for the app, which +/// opens a file whose name isn't valid Unicode through `file.open`'s `data` (#1440). +#[cfg(not(target_arch = "wasm32"))] +pub fn read_document(path: &std::path::Path) -> Result, String> { + crate::file_access::check_read(&path.to_string_lossy())?; + let meta = std::fs::metadata(path).map_err(|e| format!("{}: {e}", path.display()))?; + if !meta.is_file() { + return Err(format!("{}: not a file", path.display())); + } + if meta.len() > MAX_DOCUMENT { + return Err(format!("{}: file is larger than 2 GB", path.display())); + } + // Checked again on the opened file, which may not be the one just looked at. + match read_regular_file(path, MAX_DOCUMENT) { + Some(bytes) if bytes.len() as u64 <= MAX_DOCUMENT => Ok(bytes), + Some(_) => Err(format!("{}: file is larger than 2 GB", path.display())), + None => Err(format!("{}: could not be read as a file", path.display())), + } +} + +/// The pictures an HTML file names by a path relative to its folder. +/// +/// Only regular files inside the folder (after resolving `..` and symlinks) are read. The folder is +/// the HTML file's own, which `open_path_with` has already checked against the automation read root. URLs +/// (`http:`, `file:`…) and absolute paths are not followed. Each file is read at most once and +/// at most `max_file` bytes of it; every reference, cached or not, counts against a budget for +/// the whole document, so one large picture can't be multiplied by referencing it many times. +#[cfg(not(target_arch = "wasm32"))] +pub(crate) struct LocalImages { + /// The folder, canonical; `None` when it can't be resolved (nothing loads then). + dir: Option, + max_file: u64, + /// Bytes the document may still load. + left: std::cell::Cell, + cache: std::cell::RefCell>)>>, +} + +#[cfg(not(target_arch = "wasm32"))] +impl LocalImages { + /// Largest picture file read. + const MAX_FILE: u64 = 256 << 20; + /// Picture bytes one document may load, counting every reference. + const BUDGET: u64 = 1 << 30; + /// Files whose bytes are kept for further references. + const CACHE: usize = 64; + + pub(crate) fn new(dir: &std::path::Path) -> LocalImages { + LocalImages::with_limits(dir, LocalImages::MAX_FILE, LocalImages::BUDGET) + } + + pub(crate) fn with_limits(dir: &std::path::Path, max_file: u64, budget: u64) -> LocalImages { + let dir = if dir.as_os_str().is_empty() { std::path::Path::new(".") } else { dir }; + LocalImages { dir: std::fs::canonicalize(dir).ok(), max_file, left: std::cell::Cell::new(budget), cache: Default::default() } + } + + /// Bytes of the file `src` names, or `None` when it isn't a regular file inside the folder, + /// is too large, or the budget is spent. + pub(crate) fn load(&self, src: &str) -> Option>> { + let path = self.resolve(src)?; + let cached = self.cache.borrow().iter().find(|(p, _)| *p == path).map(|(_, d)| d.clone()); + if let Some(data) = cached { + self.charge(data.len())?; + return Some(data); + } + let limit = self.max_file.min(self.left.get()); + let bytes = read_regular_file(&path, limit)?; + // What was read is spent even when it's over the limit. + self.charge(bytes.len())?; + if bytes.len() as u64 > limit { + return None; + } + let data = std::sync::Arc::new(bytes); + let mut cache = self.cache.borrow_mut(); + if cache.len() < LocalImages::CACHE { + cache.push((path, data.clone())); + } + Some(data) + } + + /// Take `n` bytes from the budget; `None` (and the budget emptied) when there aren't enough. + fn charge(&self, n: usize) -> Option<()> { + let left = self.left.get().checked_sub(n as u64); + self.left.set(left.unwrap_or(0)); + left.map(|_| ()) + } + + /// The canonical path `src` names, if it's inside the folder and may be read. + fn resolve(&self, src: &str) -> Option { + let src = src.split(['#', '?']).next().unwrap_or(""); + if src.is_empty() || src.starts_with("//") || src.split_once(':').is_some_and(|(scheme, _)| !scheme.contains('/')) { + return None; + } + let rel = String::from_utf8(wordcraft_formats::model::percent_decode(src)).ok()?; + let rel = std::path::Path::new(&rel); + if rel.components().any(|c| matches!(c, std::path::Component::Prefix(_) | std::path::Component::RootDir)) { + return None; + } + let dir = self.dir.as_ref()?; + // Canonical paths have `..` and symlinks resolved, so this keeps both inside the folder. + let path = std::fs::canonicalize(dir.join(rel)).ok()?; + // The folder is the one the HTML path names, which may be a link from outside the + // automation read root; the picture itself must be inside it (#3736). + crate::file_access::check_read(&path.to_string_lossy()).ok()?; + path.starts_with(dir).then_some(path) + } +} + +/// Up to `limit + 1` bytes of the regular file at `path` (more than `limit` means too large), or +/// `None` for anything else. A named pipe blocks when opened and a device reports a length of 0 +/// and may never end, so the path is checked before opening, the opened handle is checked again, +/// and the read is bounded whatever length the file reports. Public for the app (a copied picture +/// file, Paste Special). +#[cfg(not(target_arch = "wasm32"))] +pub fn read_regular_file(path: &std::path::Path, limit: u64) -> Option> { + let (file, meta) = open_regular_file(path).ok()?; + read_bounded(file, &meta, limit).ok() +} + +#[cfg(target_arch = "wasm32")] +pub fn read_regular_file(_path: &std::path::Path, _limit: u64) -> Option> { + None +} + +/// The bytes of the regular file at `path` when it holds at most `limit` of them; `None` for +/// anything else. A file whose opened handle reports more than `limit` bytes is refused before +/// anything is read; the read stays bounded for a file that grows after the check. Public for the +/// app (a copied picture file, Paste Special). +#[cfg(not(target_arch = "wasm32"))] +pub fn read_regular_file_within(path: &std::path::Path, limit: u64) -> Option> { + let (file, meta) = open_regular_file(path).ok()?; + if meta.len() > limit { + return None; + } + read_bounded(file, &meta, limit).ok().filter(|b| b.len() as u64 <= limit) +} + +#[cfg(target_arch = "wasm32")] +pub fn read_regular_file_within(_path: &std::path::Path, _limit: u64) -> Option> { + None +} + +/// The regular file at `path`, opened once, with the opened handle's metadata. The path is +/// checked before opening (a named pipe blocks in `open` until a writer appears) and the handle +/// after (the path may have been swapped in between); anything but a regular file is "not a file". +#[cfg(not(target_arch = "wasm32"))] +fn open_regular_file(path: &std::path::Path) -> Result<(std::fs::File, std::fs::Metadata), String> { + let meta = std::fs::metadata(path).map_err(|e| format!("{}: {e}", path.display()))?; + if !meta.is_file() { + return Err(format!("{}: not a file", path.display())); + } + let mut open = std::fs::OpenOptions::new(); + open.read(true); + // A path swapped for a named pipe since the check above would block `open`; non-blocking + // opens it at once (no effect on a regular file) and the handle check below refuses it. + #[cfg(unix)] + std::os::unix::fs::OpenOptionsExt::custom_flags(&mut open, libc::O_NONBLOCK); + let file = open.open(path).map_err(|e| format!("{}: {e}", path.display()))?; + let meta = file.metadata().map_err(|e| format!("{}: {e}", path.display()))?; + if !meta.is_file() { + return Err(format!("{}: not a file", path.display())); + } + Ok((file, meta)) +} + +/// Up to `limit + 1` bytes of `file` (more than `limit` means too large), however long it says it is. +#[cfg(not(target_arch = "wasm32"))] +fn read_bounded(file: std::fs::File, meta: &std::fs::Metadata, limit: u64) -> std::io::Result> { + use std::io::Read; + let mut out = Vec::with_capacity(usize::try_from(meta.len().min(limit)).unwrap_or(0)); + file.take(limit.saturating_add(1)).read_to_end(&mut out)?; + Ok(out) +} + +/// Largest file [`read_file`] reads whole — the same 2 GB ceiling [`open_path_with`] gives a +/// document. +pub const MAX_READ_FILE: u64 = 2 << 30; + +/// The bytes of the file at `path`, for commands that take a path (pictures, mail-merge data). +/// Every such read goes through here so the automation read root applies ([`crate::file_access`]). +/// Only a regular file is read, and at most [`MAX_READ_FILE`] bytes of it: a named pipe blocks +/// when opened and a device like `/dev/zero` never reaches end of file. +#[cfg(not(target_arch = "wasm32"))] +pub fn read_file(path: &str) -> Result, String> { + read_file_capped(path, MAX_READ_FILE, "file") +} + +#[cfg(target_arch = "wasm32")] +pub fn read_file(path: &str) -> Result, String> { + Err(format!("{path}: files are opened through the browser on the web")) +} + +/// [`read_file`] for a file that must be a regular file of at most `max` bytes, checked before +/// anything is read (a font named by the user, which could be any size). The file is opened once +/// and the read stops past `max`, so a file swapped or grown after the check is refused too (#3666). +#[cfg(not(target_arch = "wasm32"))] +pub fn read_file_capped(path: &str, max: u64, what: &str) -> Result, String> { + crate::file_access::check_read(path)?; + let too_large = || format!("{path}: the {what} is larger than {} MB", max >> 20); + let (file, meta) = open_regular_file(std::path::Path::new(path))?; + if meta.len() > max { + return Err(too_large()); + } + let bytes = read_bounded(file, &meta, max).map_err(|e| format!("{path}: {e}"))?; + if bytes.len() as u64 > max { + return Err(too_large()); + } + Ok(bytes) +} + +/// The first `max` bytes of the file at `path` (fewer when it is shorter), inside the automation +/// read root when there is one: enough to tell a file's format by its start. Only a regular file +/// is opened: a named pipe would block the open until a writer appeared (#3666). +#[cfg(not(target_arch = "wasm32"))] +pub fn read_file_head(path: &str, max: u64) -> Result, String> { + use std::io::Read; + crate::file_access::check_read(path)?; + let (file, _) = open_regular_file(std::path::Path::new(path))?; + let mut head = Vec::new(); + file.take(max).read_to_end(&mut head).map_err(|e| format!("{path}: {e}"))?; + Ok(head) +} + +/// An entry of [`list_dir`]: a file or folder (links followed). +#[cfg(not(target_arch = "wasm32"))] +#[derive(Clone, Debug)] +pub struct DirEntry { + pub name: String, + pub path: std::path::PathBuf, + pub dir: bool, + /// The file's size in bytes (0 for a folder). + pub len: u64, + /// When it last changed, in seconds since the Unix epoch, if the system says. + pub modified: Option, +} + +/// The files and folders in the folder at `path`, inside the automation read root (entries +/// leading outside it, and ones that can't be read, are left out); at most `max` entries are +/// looked at, and `true` says the listing was cut short there. +#[cfg(not(target_arch = "wasm32"))] +pub fn list_dir(path: &std::path::Path, max: usize) -> Result<(Vec, bool), String> { + crate::file_access::check_read(&path.to_string_lossy())?; + let read = std::fs::read_dir(path).map_err(|e| format!("{}: {e}", path.display()))?; + let mut out = Vec::new(); + for (seen, entry) in read.enumerate() { + if seen >= max { + return Ok((out, true)); + } + let Ok(entry) = entry else { continue }; + let path = entry.path(); + if crate::file_access::check_read(&path.to_string_lossy()).is_err() { + continue; + } + let Ok(meta) = std::fs::metadata(&path) else { continue }; + if !meta.is_dir() && !meta.is_file() { + continue; + } + let modified = meta.modified().ok().and_then(|m| m.duration_since(std::time::UNIX_EPOCH).ok()).map(|d| d.as_secs()); + let name = entry.file_name().to_string_lossy().into_owned(); + out.push(DirEntry { name, path, dir: meta.is_dir(), len: if meta.is_file() { meta.len() } else { 0 }, modified }); + } + Ok((out, false)) +} + +/// The title in the core properties of the Word package at `path` (a file of at most `max_file` +/// bytes; at most `max_part` bytes of the part are read), inside the automation read root. +#[cfg(not(target_arch = "wasm32"))] +pub fn package_title(path: &std::path::Path, max_file: u64, max_part: u64) -> Option { + crate::file_access::check_read(&path.to_string_lossy()).ok()?; + let (file, meta) = open_regular_file(path).ok()?; + if meta.len() > max_file { + return None; + } + wordcraft_docx::package_title(std::io::BufReader::new(file), max_part) +} + +/// Rename without replacing another file, respecting both automation access roots. +#[cfg(not(target_arch = "wasm32"))] +pub fn rename_file(source: &std::path::Path, destination: &std::path::Path) -> Result<(), String> { + crate::file_access::check_read(&source.to_string_lossy())?; + crate::file_access::check_write(&source.to_string_lossy())?; + crate::file_access::check_write(&destination.to_string_lossy())?; + // Creating a sibling hard link refuses collisions atomically and keeps the original bytes. + if let Err(e) = std::fs::hard_link(source, destination) { + if e.kind() == std::io::ErrorKind::AlreadyExists && only_case_differs(source, destination) { + return std::fs::rename(source, destination).map_err(|e| format!("Could not rename document: {e}")); + } + return Err(format!("Could not rename document: {e}")); + } + if let Err(e) = std::fs::remove_file(source) { + let _ = std::fs::remove_file(destination); + return Err(format!("Could not rename document: {e}")); + } + Ok(()) +} + +/// Whether `destination` names `source` in the same directory spelled with different case, and +/// the directory holds no entry spelled exactly as `destination`. +#[cfg(not(target_arch = "wasm32"))] +fn only_case_differs(source: &std::path::Path, destination: &std::path::Path) -> bool { + let (Some(dir), Some(from), Some(to)) = (source.parent(), source.file_name(), destination.file_name()) else { + return false; + }; + if destination.parent() != Some(dir) || from == to || from.to_string_lossy().to_lowercase() != to.to_string_lossy().to_lowercase() { + return false; + } + let dir = if dir.as_os_str().is_empty() { std::path::Path::new(".") } else { dir }; + std::fs::read_dir(dir).is_ok_and(|entries| entries.filter_map(Result::ok).all(|entry| entry.file_name() != to)) +} + +/// Write `bytes` to the file at `path`, inside the automation write root when there is one. +#[cfg(not(target_arch = "wasm32"))] +pub fn write_file(path: &str, bytes: &[u8]) -> Result<(), String> { + crate::file_access::check_write(path)?; + // Like a document save: the old file is replaced only once the new bytes are fully written. + safe_save::write(std::path::Path::new(path), bytes).map_err(|e| format!("{path}: {e}")) +} + +#[cfg(target_arch = "wasm32")] +pub fn write_file(path: &str, _bytes: &[u8]) -> Result<(), String> { + Err(format!("{path}: files are saved through the browser on the web")) +} + +#[cfg(target_arch = "wasm32")] +pub fn open_path_with(path: &std::path::Path, _password: Option<&str>) -> Result<(Document, bool), String> { + Err(format!("{}: files are opened through the browser on the web", path.display())) +} + +#[cfg(target_arch = "wasm32")] +pub fn open_path_in(path: &std::path::Path, password: Option<&str>, _region: &wordcraft_geom::Region) -> Result<(Document, bool), String> { + open_path_with(path, password) +} + +pub fn save_path(path: &std::path::Path, doc: &Document) -> Result<(), String> { + save_path_with(path, doc, None, &ExportOptions::default()) +} + +/// [`save_path`], encrypted with `password` as [`save_bytes_with`] does. +#[cfg(not(target_arch = "wasm32"))] +pub fn save_path_with(path: &std::path::Path, doc: &Document, password: Option<&str>, opts: &ExportOptions) -> Result<(), String> { + crate::file_access::check_write(&path.to_string_lossy())?; + let bytes = save_bytes_with(&path.to_string_lossy(), doc, password, opts)?; + safe_save::write(path, &bytes).map_err(|e| format!("{}: {e}", path.display())) +} + +/// Saves a document to `path` with optional password and export options on web targets. +#[cfg(target_arch = "wasm32")] +pub fn save_path_with(path: &std::path::Path, _doc: &Document, _password: Option<&str>, _opts: &ExportOptions) -> Result<(), String> { + Err(format!("{}: files are saved through the browser on the web", path.display())) +} + +/// Write `text` to `path` as it is (a Markdown file's own text), as safely as [`save_path_with`]. +#[cfg(not(target_arch = "wasm32"))] +pub fn save_text_path(path: &std::path::Path, text: &str) -> Result<(), String> { + crate::file_access::check_write(&path.to_string_lossy())?; + safe_save::write(path, text.as_bytes()).map_err(|e| format!("{}: {e}", path.display())) +} + +/// Write `text` to `path` as it is (a Markdown file's own text), as safely as [`save_path_with`]. +#[cfg(target_arch = "wasm32")] +pub fn save_text_path(path: &std::path::Path, _text: &str) -> Result<(), String> { + Err(format!("{}: files are saved through the browser on the web", path.display())) +} + +/// Decode text bytes: UTF-8 (with or without BOM), UTF-16 LE/BE (BOM or sniffed), else the +/// legacy encoding they look like — GBK, Big5, Shift_JIS, Windows-1252… (see +/// [`wordcraft_formats::txt::decode`]). +pub fn decode_text(b: &[u8]) -> String { + wordcraft_formats::txt::decode(b) +} diff --git a/crates/engine/src/io/safe_save.rs b/crates/engine/src/io/safe_save.rs index e5c3b161b3..7f79cf0e21 100644 --- a/crates/engine/src/io/safe_save.rs +++ b/crates/engine/src/io/safe_save.rs @@ -1,521 +1,521 @@ -//! Sibling temporary files owned by one save. The destination changes only after preparation -//! succeeds; concurrent saves have separate files and the last successful replacement wins. -//! -//! When the folder refuses the temporary file (macOS privacy protection, #2138, or a read-only -//! folder), an existing destination is never written in place: it is left exactly as it was and -//! the error says to use Save As to another folder or to give WordCraft access to this one. Only -//! a destination that doesn't exist yet, so has no previous version to lose, is created directly; -//! a failed direct write removes it again. -use std::fs::{self, File, OpenOptions}; -use std::io::{self, Write}; -use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicU64, Ordering}; - -/// Writes `bytes` safely to `path`, replacing an existing destination atomically via a -/// temporary file. If the destination's folder refuses the temporary file, a new file -/// is created directly and removed on failure; an existing file is left untouched. -pub(super) fn write(path: &Path, bytes: &[u8]) -> io::Result<()> { - let permissions = match fs::metadata(path) { - Ok(meta) if meta.is_file() => Some(meta.permissions()), - Ok(_) => None, - Err(e) if e.kind() == io::ErrorKind::NotFound => None, - Err(e) => return Err(e), - }; - // A new document gets the usual mode (0666 less the umask); a replaced one keeps its own, - // and its temporary file stays private until that mode is applied. - let private = permissions.is_some(); - let written = replace_with(path, private, |file| { - file.write_all(bytes)?; - if let Some(permissions) = permissions { - keep_permissions(file.set_permissions(permissions))?; - } - // Synchronize the prepared file before replacing the destination. Directory durability - // and network-filesystem/power-loss guarantees are outside this operation's contract. - sync_file(file) - }); - written.map_err(explain) -} - -/// Flushes `file` to stable storage (`sync_all`). On Apple platforms that is a full flush -/// (`F_FULLFSYNC`); where the file system can't do one (SMB shares and virtual file systems -/// refuse it with `ENOTSUP` #6155, `EINVAL`, or `ENOTTY`), this falls back to a plain `fsync`, -/// as Apple's own `removefile` and SQLite do. Plain `fsync` flushes to the drive controller but -/// does not guarantee disk cache durability; any other error is returned as it is. -fn sync_file(file: &File) -> io::Result<()> { - #[cfg(target_vendor = "apple")] - let plain = || rustix::fs::fsync(file).map_err(io::Error::from); - #[cfg(not(target_vendor = "apple"))] - let plain = || Ok(()); - or_plain_fsync(file.sync_all(), plain) -} - -/// Returns `full`, or calls `fsync()` when `full` failed only because the file system can't do a full flush. -fn or_plain_fsync(full: io::Result<()>, fsync: impl FnOnce() -> io::Result<()>) -> io::Result<()> { - match full { - Err(e) if full_flush_unsupported(&e) => fsync(), - other => other, - } -} - -/// Whether `e` is a file system refusing a full flush (`F_FULLFSYNC`): `ENOTSUP` (45, SMB), -/// `EINVAL` (22), or `ENOTTY` (25) on Apple platforms. Never elsewhere: there `sync_all` is a plain `fsync`. -fn full_flush_unsupported(e: &io::Error) -> bool { - const EINVAL: i32 = 22; - const ENOTTY: i32 = 25; - const ENOTSUP: i32 = 45; - cfg!(target_vendor = "apple") && matches!(e.raw_os_error(), Some(ENOTSUP | EINVAL | ENOTTY)) -} - -/// A folder that keeps no Unix modes refuses to set one with "Operation not supported": a NAS -/// share mounted by GVFS (`smb://` in the Linux file manager) refuses every mode change (#3003). -/// There is no mode to keep there, so the save goes on; any other failure still stops it. -fn keep_permissions(set: io::Result<()>) -> io::Result<()> { - match set { - Err(e) if not_supported(&e) => Ok(()), - set => set, - } -} - -/// ENOTSUP and EOPNOTSUPP, which std doesn't count as `Unsupported` (only ENOSYS), plus that kind. -fn not_supported(e: &io::Error) -> bool { - const CODES: &[i32] = if cfg!(any(target_os = "linux", target_os = "android")) { - &[95] - } else if cfg!(any(target_os = "macos", target_os = "ios")) { - &[45, 102] - } else if cfg!(target_os = "openbsd") { - &[45, 91] - } else if cfg!(target_os = "netbsd") { - &[45, 86] - } else if cfg!(any(target_os = "freebsd", target_os = "dragonfly")) { - &[45] - } else { - &[] - }; - e.kind() == io::ErrorKind::Unsupported || e.raw_os_error().is_some_and(|code| CODES.contains(&code)) -} - -fn replace_with(path: &Path, private: bool, prepare: impl FnOnce(&mut File) -> io::Result<()>) -> io::Result<()> { - let mut temp = match Temporary::create(path, private) { - Ok(temp) => temp, - // macOS only lets an app write the file picked in the Save panel inside a protected - // folder (Desktop, Documents, Downloads, iCloud Drive), not a sibling temporary file - // (#2138); a read-only folder refuses it too. - Err(e) if e.kind() == io::ErrorKind::PermissionDenied => return create_directly(path, e, prepare), - Err(e) => return Err(e), - }; - prepare(temp.file()?)?; - temp.commit(path) -} - -/// No temporary file may be created beside `path` (`refused` says why). A file that doesn't exist -/// yet is created directly (there is no previous version to lose) and removed again if the save -/// fails. Anything already at `path` is left untouched: overwriting it in place could destroy the -/// only copy, so the error says how to save safely instead. -fn create_directly(path: &Path, refused: io::Error, prepare: impl FnOnce(&mut File) -> io::Result<()>) -> io::Result<()> { - // `create_new` never opens an existing file or follows a link, so it can't touch one. - let mut file = match OpenOptions::new().write(true).create_new(true).open(path) { - Ok(file) => file, - Err(e) if e.kind() == io::ErrorKind::AlreadyExists => { - return Err(guided( - refused, - "This folder doesn't let WordCraft create the temporary file a safe save needs, so the existing file was left unchanged.", - )); - } - Err(e) if e.kind() == io::ErrorKind::PermissionDenied => return Err(guided(e, "WordCraft can't create files in this folder.")), - Err(e) => return Err(e), - }; - let Err(failed) = prepare(&mut file) else { - return Ok(()); - }; - drop(file); - match fs::remove_file(path) { - Ok(()) => Err(failed), - Err(e) => Err(io::Error::new(failed.kind(), format!("{failed}; removing the incomplete new file also failed ({e})"))), - } -} - -/// A save refused for want of folder access, already worded with what to do about it. -#[derive(Debug)] -struct Guided(String); - -impl std::fmt::Display for Guided { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(&self.0) - } -} - -impl std::error::Error for Guided {} - -/// `e`, then `why`, then how to save anyway: Save As to another folder, or access to this one. -fn guided(e: io::Error, why: &str) -> io::Error { - let access = if cfg!(target_os = "macos") { - "allow WordCraft to use this folder in System Settings › Privacy & Security › Files and Folders" - } else { - "get write access to this folder" - }; - io::Error::new(e.kind(), Guided(format!("{e}. {why} Use Save As to save the document in another folder, or {access}, then save again."))) -} - -/// A permission error on macOS usually means its privacy protection keeps WordCraft out of the -/// folder; say where to allow it (errors from [`guided`] already do). -fn explain(e: io::Error) -> io::Error { - let worded = e.get_ref().is_some_and(|inner| inner.is::()); - if cfg!(target_os = "macos") && e.kind() == io::ErrorKind::PermissionDenied && !worded { - return io::Error::new( - e.kind(), - format!( - "{e}. macOS may be blocking WordCraft from this folder: allow it in System Settings › Privacy & Security › Files and Folders, or save somewhere else" - ), - ); - } - e -} - -struct Temporary { - path: PathBuf, - file: Option, - committed: bool, -} - -impl Temporary { - /// `private`: create the file readable by its owner only (0600) rather than with the default - /// mode (0666 less the umask). - fn create(destination: &Path, private: bool) -> io::Result { - static NEXT: AtomicU64 = AtomicU64::new(0); - if destination.file_name().is_none() { - return Err(io::Error::new(io::ErrorKind::InvalidInput, "save destination needs a file name")); - } - // A short name also works beside a destination already at the filename-length limit. - for _ in 0..128 { - let id = NEXT.fetch_add(1, Ordering::Relaxed); - let path = destination.with_file_name(format!(".wordcraft-save-{}-{id}.tmp", std::process::id())); - let mut options = OpenOptions::new(); - options.write(true).create_new(true); - #[cfg(unix)] - if private { - use std::os::unix::fs::OpenOptionsExt; - options.mode(0o600); - } - #[cfg(not(unix))] - let _ = private; - match options.open(&path) { - Ok(file) => return Ok(Self { path, file: Some(file), committed: false }), - Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue, - Err(e) => return Err(e), - } - } - Err(io::Error::new(io::ErrorKind::AlreadyExists, "could not create a save temporary file")) - } - - fn file(&mut self) -> io::Result<&mut File> { - self.file.as_mut().ok_or_else(|| io::Error::other("save temporary file is closed")) - } - - fn commit(mut self, destination: &Path) -> io::Result<()> { - // Close before replacement, including on Windows. - drop(self.file.take()); - fs::rename(&self.path, destination)?; - self.committed = true; - Ok(()) - } -} - -impl Drop for Temporary { - fn drop(&mut self) { - drop(self.file.take()); - if !self.committed { - // If filesystem permissions change, cleanup can fail too. Never remove a neighbour - // or the destination to compensate for a failed save. - let _ = fs::remove_file(&self.path); - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - struct Scratch(PathBuf); - impl Scratch { - fn new() -> Self { - static NEXT: AtomicU64 = AtomicU64::new(0); - let path = - std::env::temp_dir().join(format!("wordcraft-save-owner-test-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed))); - fs::create_dir(&path).unwrap(); - Self(path) - } - fn target(&self) -> PathBuf { - self.0.join("original.docx") - } - } - impl Drop for Scratch { - fn drop(&mut self) { - let _ = fs::remove_dir_all(&self.0); - } - } - - #[test] - fn partial_and_complete_preparation_failures_leave_original_and_no_owned_temp() { - for partial in [true, false] { - let d = Scratch::new(); - let path = d.target(); - fs::write(&path, b"Original bytes.").unwrap(); - let err = replace_with(&path, true, |file| { - file.write_all(if partial { b"Partial" } else { b"Complete replacement bytes." })?; - Err(io::Error::other("injected preparation failure")) - }) - .unwrap_err(); - assert!(err.to_string().contains("injected")); - assert_eq!(fs::read(&path).unwrap(), b"Original bytes."); - assert_eq!(fs::read_dir(&d.0).unwrap().count(), 1); - } - } - - #[test] - fn overlapping_prepared_saves_keep_separate_bytes_and_commit_complete_files() { - let d = Scratch::new(); - let path = d.target(); - fs::write(&path, b"Original bytes.").unwrap(); - let mut first = Temporary::create(&path, true).unwrap(); - let mut second = Temporary::create(&path, true).unwrap(); - assert_ne!(first.path, second.path); - first.file().unwrap().write_all(b"First complete save.").unwrap(); - second.file().unwrap().write_all(b"Second complete save.").unwrap(); - first.file().unwrap().sync_all().unwrap(); - second.file().unwrap().sync_all().unwrap(); - assert_eq!(fs::read(&path).unwrap(), b"Original bytes."); - first.commit(&path).unwrap(); - assert_eq!(fs::read(&path).unwrap(), b"First complete save."); - second.commit(&path).unwrap(); - assert_eq!(fs::read(&path).unwrap(), b"Second complete save."); - assert_eq!(fs::read_dir(&d.0).unwrap().count(), 1); - } - - /// Exports (`write_file`) replace the destination instead of truncating it in place: another - /// hard link to the old file keeps the old bytes, and no temporary file is left behind. - #[test] - fn write_file_replaces_the_destination_instead_of_truncating_it() { - let d = Scratch::new(); - let path = d.target(); - let alias = d.0.join("alias.docx"); - fs::write(&path, b"Original bytes.").unwrap(); - fs::hard_link(&path, &alias).unwrap(); - crate::io::write_file(&path.to_string_lossy(), b"Exported bytes.").unwrap(); - assert_eq!(fs::read(&path).unwrap(), b"Exported bytes."); - assert_eq!(fs::read(&alias).unwrap(), b"Original bytes."); - assert_eq!(fs::read_dir(&d.0).unwrap().count(), 2); - } - - /// #3003: a folder that refuses mode changes (a GVFS-mounted NAS share) still takes the - /// save; a refused mode change for any other reason still stops it and keeps the original. - #[test] - fn a_folder_without_unix_modes_still_takes_the_save() { - let d = Scratch::new(); - let path = d.target(); - fs::write(&path, b"Original bytes.").unwrap(); - replace_with(&path, true, |file| { - file.write_all(b"Saved on the share.")?; - keep_permissions(Err(io::Error::from(io::ErrorKind::Unsupported)))?; - file.sync_all() - }) - .unwrap(); - assert_eq!(fs::read(&path).unwrap(), b"Saved on the share."); - assert_eq!(fs::read_dir(&d.0).unwrap().count(), 1); - - let err = replace_with(&path, true, |file| { - file.write_all(b"Refused.")?; - keep_permissions(Err(io::Error::from(io::ErrorKind::PermissionDenied))) - }) - .unwrap_err(); - assert_eq!(err.kind(), io::ErrorKind::PermissionDenied); - assert_eq!(fs::read(&path).unwrap(), b"Saved on the share."); - assert_eq!(fs::read_dir(&d.0).unwrap().count(), 1); - } - - /// GVFS reports a refused mode change as ENOTSUP, which std leaves uncategorized; other OS - /// errors still stop the save. - #[cfg(any(target_os = "linux", target_os = "macos"))] - #[test] - fn enotsup_from_the_os_is_an_unsupported_mode_change() { - let enotsup = if cfg!(target_os = "linux") { 95 } else { 45 }; - assert!(keep_permissions(Err(io::Error::from_raw_os_error(enotsup))).is_ok()); - let eacces = 13; - assert!(keep_permissions(Err(io::Error::from_raw_os_error(eacces))).is_err()); - } - - /// Makes `dir` read-only while `f` runs. `None` when running as root, where the folder isn't - /// read-only to us. - #[cfg(unix)] - fn in_read_only(dir: &Path, f: impl FnOnce() -> T) -> Option { - use std::os::unix::fs::PermissionsExt; - fs::set_permissions(dir, fs::Permissions::from_mode(0o555)).unwrap(); - let probe = fs::write(dir.join("probe"), b""); - let out = f(); - fs::set_permissions(dir, fs::Permissions::from_mode(0o755)).unwrap(); - probe.is_err().then_some(out) - } - - /// #2138 review: where no sibling temporary file may be created (macOS privacy protection, - /// or a read-only folder), an existing document is left byte for byte as it was, and the - /// error says to use Save As or get access to the folder. - #[cfg(unix)] - #[test] - fn an_existing_file_in_a_folder_refusing_the_temporary_file_is_left_untouched() { - let d = Scratch::new(); - let path = d.target(); - fs::write(&path, b"Original bytes.").unwrap(); - let Some(saved) = in_read_only(&d.0, || write(&path, b"Saved bytes.")) else { return }; - let err = saved.unwrap_err(); - assert_eq!(err.kind(), io::ErrorKind::PermissionDenied); - let message = err.to_string(); - assert!(message.contains("left unchanged") && message.contains("Save As") && message.contains("this folder"), "{message}"); - assert_eq!(message.matches("Privacy & Security").count(), usize::from(cfg!(target_os = "macos")), "{message}"); - assert_eq!(fs::read(&path).unwrap(), b"Original bytes."); - assert_eq!(fs::read_dir(&d.0).unwrap().count(), 1); - } - - /// FredApps' #2138 regression: a save that would fail partway through still leaves the - /// previous document byte-identical when the folder refuses the temporary file. - #[cfg(unix)] - #[test] - fn a_failed_save_in_a_folder_refusing_the_temporary_file_keeps_the_original() { - let d = Scratch::new(); - let path = d.target(); - fs::write(&path, b"Original bytes.").unwrap(); - let Some(result) = in_read_only(&d.0, || { - replace_with(&path, true, |file| { - file.write_all(b"Partial")?; - Err(io::Error::other("injected partial write failure")) - }) - }) else { - return; - }; - assert!(result.is_err()); - assert_eq!(fs::read(&path).unwrap(), b"Original bytes.", "failed save must preserve the previous document"); - assert_eq!(fs::read_dir(&d.0).unwrap().count(), 1); - } - - /// A folder that refuses new files altogether refuses a new document too, with the same - /// guidance, and leaves nothing behind. - #[cfg(unix)] - #[test] - fn a_new_file_in_a_read_only_folder_is_refused_with_guidance() { - let d = Scratch::new(); - let path = d.target(); - let Some(saved) = in_read_only(&d.0, || write(&path, b"New document.")) else { return }; - let message = saved.unwrap_err().to_string(); - assert!(message.contains("Save As"), "{message}"); - assert!(!path.exists()); - } - - /// When only the temporary file is refused (macOS lets the app write the file picked in the - /// Save panel), a file that doesn't exist yet is created directly; nothing is replaced. - #[test] - fn a_new_file_is_created_directly_when_only_the_temporary_file_is_refused() { - let d = Scratch::new(); - let path = d.target(); - let refused = io::Error::from(io::ErrorKind::PermissionDenied); - create_directly(&path, refused, |file| file.write_all(b"New document.")).unwrap(); - assert_eq!(fs::read(&path).unwrap(), b"New document."); - assert_eq!(fs::read_dir(&d.0).unwrap().count(), 1); - } - - #[test] - fn a_failed_direct_save_of_a_new_file_leaves_nothing() { - let d = Scratch::new(); - let path = d.target(); - let refused = io::Error::from(io::ErrorKind::PermissionDenied); - let err = create_directly(&path, refused, |file| { - file.write_all(b"Partial")?; - Err(io::Error::other("injected")) - }) - .unwrap_err(); - assert!(err.to_string().contains("injected")); - assert!(!path.exists()); - } - - /// The direct path never opens an existing file and never runs the save: the original is - /// untouched and the error carries the Save As guidance. - #[test] - fn an_existing_file_is_never_written_directly() { - let d = Scratch::new(); - let path = d.target(); - fs::write(&path, b"Original bytes.").unwrap(); - let refused = io::Error::from(io::ErrorKind::PermissionDenied); - let mut ran = false; - let err = create_directly(&path, refused, |file| { - ran = true; - file.write_all(b"New") - }) - .unwrap_err(); - assert!(!ran); - assert_eq!(err.kind(), io::ErrorKind::PermissionDenied); - assert!(err.to_string().contains("Save As") && err.to_string().contains("left unchanged"), "{err}"); - assert_eq!(fs::read(&path).unwrap(), b"Original bytes."); - } - - #[cfg(unix)] - #[test] - fn new_temporary_files_are_private_until_prepared() { - use std::os::unix::fs::PermissionsExt; - let d = Scratch::new(); - let temp = Temporary::create(&d.target(), true).unwrap(); - assert_eq!(fs::metadata(&temp.path).unwrap().permissions().mode() & 0o777, 0o600); - } - - /// A new document gets the default mode, as `fs::write` would give it — not 0600; a replaced - /// document keeps its own mode. - #[cfg(unix)] - #[test] - fn new_documents_get_the_default_mode_and_replaced_ones_keep_theirs() { - use std::os::unix::fs::PermissionsExt; - let d = Scratch::new(); - let reference = d.0.join("reference.docx"); - fs::write(&reference, b"x").unwrap(); - let default_mode = fs::metadata(&reference).unwrap().permissions().mode() & 0o777; - let path = d.target(); - write(&path, b"New document.").unwrap(); - assert_eq!(fs::metadata(&path).unwrap().permissions().mode() & 0o777, default_mode); - fs::set_permissions(&path, fs::Permissions::from_mode(0o640)).unwrap(); - write(&path, b"Saved again.").unwrap(); - assert_eq!(fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o640); - assert_eq!(fs::read(&path).unwrap(), b"Saved again."); - } - - /// A share that refuses the full flush (`F_FULLFSYNC`, #6155) still saves through a plain fsync - /// on Apple platforms; every other sync error still fails the save. - #[test] - fn a_refused_full_flush_falls_back_to_a_plain_fsync_on_apple_only() { - use std::cell::Cell; - let refused = |errno| Err(io::Error::from_raw_os_error(errno)); - for (errno, name) in [(45, "ENOTSUP, SMB"), (22, "EINVAL"), (25, "ENOTTY")] { - let fsynced = Cell::new(false); - let r = or_plain_fsync(refused(errno), || { - fsynced.set(true); - Ok(()) - }); - let apple = cfg!(target_vendor = "apple"); - assert_eq!(r.is_ok(), apple, "{name}"); - assert_eq!(fsynced.get(), apple, "{name}: the plain fsync runs only on Apple platforms"); - } - // The plain fsync's own failure is reported. - if cfg!(target_vendor = "apple") { - let r = or_plain_fsync(refused(45), || Err(io::Error::from_raw_os_error(5))); - assert_eq!(r.map_err(|e| e.raw_os_error()), Err(Some(5))); - } - // Real errors (EIO, ENOSPC, EACCES) never fall back, and success needs no fallback. - for errno in [5, 28, 13] { - let r = or_plain_fsync(refused(errno), || panic!("no fallback for errno {errno}")); - assert_eq!(r.map_err(|e| e.raw_os_error()), Err(Some(errno))); - } - assert!(or_plain_fsync(Ok(()), || panic!("no fallback after a full flush")).is_ok()); - } - - #[test] - fn sync_file_flushes_a_local_file() { - let d = Scratch::new(); - let path = d.0.join("f.bin"); - let file = OpenOptions::new().write(true).create_new(true).open(&path).unwrap(); - sync_file(&file).unwrap(); - } -} +//! Sibling temporary files owned by one save. The destination changes only after preparation +//! succeeds; concurrent saves have separate files and the last successful replacement wins. +//! +//! When the folder refuses the temporary file (macOS privacy protection, #2138, or a read-only +//! folder), an existing destination is never written in place: it is left exactly as it was and +//! the error says to use Save As to another folder or to give WordCraft access to this one. Only +//! a destination that doesn't exist yet, so has no previous version to lose, is created directly; +//! a failed direct write removes it again. +use std::fs::{self, File, OpenOptions}; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + +/// Writes `bytes` safely to `path`, replacing an existing destination atomically via a +/// temporary file. If the destination's folder refuses the temporary file, a new file +/// is created directly and removed on failure; an existing file is left untouched. +pub(super) fn write(path: &Path, bytes: &[u8]) -> io::Result<()> { + let permissions = match fs::metadata(path) { + Ok(meta) if meta.is_file() => Some(meta.permissions()), + Ok(_) => None, + Err(e) if e.kind() == io::ErrorKind::NotFound => None, + Err(e) => return Err(e), + }; + // A new document gets the usual mode (0666 less the umask); a replaced one keeps its own, + // and its temporary file stays private until that mode is applied. + let private = permissions.is_some(); + let written = replace_with(path, private, |file| { + file.write_all(bytes)?; + if let Some(permissions) = permissions { + keep_permissions(file.set_permissions(permissions))?; + } + // Synchronize the prepared file before replacing the destination. Directory durability + // and network-filesystem/power-loss guarantees are outside this operation's contract. + sync_file(file) + }); + written.map_err(explain) +} + +/// Flushes `file` to stable storage (`sync_all`). On Apple platforms that is a full flush +/// (`F_FULLFSYNC`); where the file system can't do one (SMB shares and virtual file systems +/// refuse it with `ENOTSUP` #6155, `EINVAL`, or `ENOTTY`), this falls back to a plain `fsync`, +/// as Apple's own `removefile` and SQLite do. Plain `fsync` flushes to the drive controller but +/// does not guarantee disk cache durability; any other error is returned as it is. +fn sync_file(file: &File) -> io::Result<()> { + #[cfg(target_vendor = "apple")] + let plain = || rustix::fs::fsync(file).map_err(io::Error::from); + #[cfg(not(target_vendor = "apple"))] + let plain = || Ok(()); + or_plain_fsync(file.sync_all(), plain) +} + +/// Returns `full`, or calls `fsync()` when `full` failed only because the file system can't do a full flush. +fn or_plain_fsync(full: io::Result<()>, fsync: impl FnOnce() -> io::Result<()>) -> io::Result<()> { + match full { + Err(e) if full_flush_unsupported(&e) => fsync(), + other => other, + } +} + +/// Whether `e` is a file system refusing a full flush (`F_FULLFSYNC`): `ENOTSUP` (45, SMB), +/// `EINVAL` (22), or `ENOTTY` (25) on Apple platforms. Never elsewhere: there `sync_all` is a plain `fsync`. +fn full_flush_unsupported(e: &io::Error) -> bool { + const EINVAL: i32 = 22; + const ENOTTY: i32 = 25; + const ENOTSUP: i32 = 45; + cfg!(target_vendor = "apple") && matches!(e.raw_os_error(), Some(ENOTSUP | EINVAL | ENOTTY)) +} + +/// A folder that keeps no Unix modes refuses to set one with "Operation not supported": a NAS +/// share mounted by GVFS (`smb://` in the Linux file manager) refuses every mode change (#3003). +/// There is no mode to keep there, so the save goes on; any other failure still stops it. +fn keep_permissions(set: io::Result<()>) -> io::Result<()> { + match set { + Err(e) if not_supported(&e) => Ok(()), + set => set, + } +} + +/// ENOTSUP and EOPNOTSUPP, which std doesn't count as `Unsupported` (only ENOSYS), plus that kind. +fn not_supported(e: &io::Error) -> bool { + const CODES: &[i32] = if cfg!(any(target_os = "linux", target_os = "android")) { + &[95] + } else if cfg!(any(target_os = "macos", target_os = "ios")) { + &[45, 102] + } else if cfg!(target_os = "openbsd") { + &[45, 91] + } else if cfg!(target_os = "netbsd") { + &[45, 86] + } else if cfg!(any(target_os = "freebsd", target_os = "dragonfly")) { + &[45] + } else { + &[] + }; + e.kind() == io::ErrorKind::Unsupported || e.raw_os_error().is_some_and(|code| CODES.contains(&code)) +} + +fn replace_with(path: &Path, private: bool, prepare: impl FnOnce(&mut File) -> io::Result<()>) -> io::Result<()> { + let mut temp = match Temporary::create(path, private) { + Ok(temp) => temp, + // macOS only lets an app write the file picked in the Save panel inside a protected + // folder (Desktop, Documents, Downloads, iCloud Drive), not a sibling temporary file + // (#2138); a read-only folder refuses it too. + Err(e) if e.kind() == io::ErrorKind::PermissionDenied => return create_directly(path, e, prepare), + Err(e) => return Err(e), + }; + prepare(temp.file()?)?; + temp.commit(path) +} + +/// No temporary file may be created beside `path` (`refused` says why). A file that doesn't exist +/// yet is created directly (there is no previous version to lose) and removed again if the save +/// fails. Anything already at `path` is left untouched: overwriting it in place could destroy the +/// only copy, so the error says how to save safely instead. +fn create_directly(path: &Path, refused: io::Error, prepare: impl FnOnce(&mut File) -> io::Result<()>) -> io::Result<()> { + // `create_new` never opens an existing file or follows a link, so it can't touch one. + let mut file = match OpenOptions::new().write(true).create_new(true).open(path) { + Ok(file) => file, + Err(e) if e.kind() == io::ErrorKind::AlreadyExists => { + return Err(guided( + refused, + "This folder doesn't let WordCraft create the temporary file a safe save needs, so the existing file was left unchanged.", + )); + } + Err(e) if e.kind() == io::ErrorKind::PermissionDenied => return Err(guided(e, "WordCraft can't create files in this folder.")), + Err(e) => return Err(e), + }; + let Err(failed) = prepare(&mut file) else { + return Ok(()); + }; + drop(file); + match fs::remove_file(path) { + Ok(()) => Err(failed), + Err(e) => Err(io::Error::new(failed.kind(), format!("{failed}; removing the incomplete new file also failed ({e})"))), + } +} + +/// A save refused for want of folder access, already worded with what to do about it. +#[derive(Debug)] +struct Guided(String); + +impl std::fmt::Display for Guided { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +impl std::error::Error for Guided {} + +/// `e`, then `why`, then how to save anyway: Save As to another folder, or access to this one. +fn guided(e: io::Error, why: &str) -> io::Error { + let access = if cfg!(target_os = "macos") { + "allow WordCraft to use this folder in System Settings › Privacy & Security › Files and Folders" + } else { + "get write access to this folder" + }; + io::Error::new(e.kind(), Guided(format!("{e}. {why} Use Save As to save the document in another folder, or {access}, then save again."))) +} + +/// A permission error on macOS usually means its privacy protection keeps WordCraft out of the +/// folder; say where to allow it (errors from [`guided`] already do). +fn explain(e: io::Error) -> io::Error { + let worded = e.get_ref().is_some_and(|inner| inner.is::()); + if cfg!(target_os = "macos") && e.kind() == io::ErrorKind::PermissionDenied && !worded { + return io::Error::new( + e.kind(), + format!( + "{e}. macOS may be blocking WordCraft from this folder: allow it in System Settings › Privacy & Security › Files and Folders, or save somewhere else" + ), + ); + } + e +} + +struct Temporary { + path: PathBuf, + file: Option, + committed: bool, +} + +impl Temporary { + /// `private`: create the file readable by its owner only (0600) rather than with the default + /// mode (0666 less the umask). + fn create(destination: &Path, private: bool) -> io::Result { + static NEXT: AtomicU64 = AtomicU64::new(0); + if destination.file_name().is_none() { + return Err(io::Error::new(io::ErrorKind::InvalidInput, "save destination needs a file name")); + } + // A short name also works beside a destination already at the filename-length limit. + for _ in 0..128 { + let id = NEXT.fetch_add(1, Ordering::Relaxed); + let path = destination.with_file_name(format!(".wordcraft-save-{}-{id}.tmp", std::process::id())); + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + if private { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + #[cfg(not(unix))] + let _ = private; + match options.open(&path) { + Ok(file) => return Ok(Self { path, file: Some(file), committed: false }), + Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue, + Err(e) => return Err(e), + } + } + Err(io::Error::new(io::ErrorKind::AlreadyExists, "could not create a save temporary file")) + } + + fn file(&mut self) -> io::Result<&mut File> { + self.file.as_mut().ok_or_else(|| io::Error::other("save temporary file is closed")) + } + + fn commit(mut self, destination: &Path) -> io::Result<()> { + // Close before replacement, including on Windows. + drop(self.file.take()); + fs::rename(&self.path, destination)?; + self.committed = true; + Ok(()) + } +} + +impl Drop for Temporary { + fn drop(&mut self) { + drop(self.file.take()); + if !self.committed { + // If filesystem permissions change, cleanup can fail too. Never remove a neighbour + // or the destination to compensate for a failed save. + let _ = fs::remove_file(&self.path); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + struct Scratch(PathBuf); + impl Scratch { + fn new() -> Self { + static NEXT: AtomicU64 = AtomicU64::new(0); + let path = + std::env::temp_dir().join(format!("wordcraft-save-owner-test-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed))); + fs::create_dir(&path).unwrap(); + Self(path) + } + fn target(&self) -> PathBuf { + self.0.join("original.docx") + } + } + impl Drop for Scratch { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + + #[test] + fn partial_and_complete_preparation_failures_leave_original_and_no_owned_temp() { + for partial in [true, false] { + let d = Scratch::new(); + let path = d.target(); + fs::write(&path, b"Original bytes.").unwrap(); + let err = replace_with(&path, true, |file| { + file.write_all(if partial { b"Partial" } else { b"Complete replacement bytes." })?; + Err(io::Error::other("injected preparation failure")) + }) + .unwrap_err(); + assert!(err.to_string().contains("injected")); + assert_eq!(fs::read(&path).unwrap(), b"Original bytes."); + assert_eq!(fs::read_dir(&d.0).unwrap().count(), 1); + } + } + + #[test] + fn overlapping_prepared_saves_keep_separate_bytes_and_commit_complete_files() { + let d = Scratch::new(); + let path = d.target(); + fs::write(&path, b"Original bytes.").unwrap(); + let mut first = Temporary::create(&path, true).unwrap(); + let mut second = Temporary::create(&path, true).unwrap(); + assert_ne!(first.path, second.path); + first.file().unwrap().write_all(b"First complete save.").unwrap(); + second.file().unwrap().write_all(b"Second complete save.").unwrap(); + first.file().unwrap().sync_all().unwrap(); + second.file().unwrap().sync_all().unwrap(); + assert_eq!(fs::read(&path).unwrap(), b"Original bytes."); + first.commit(&path).unwrap(); + assert_eq!(fs::read(&path).unwrap(), b"First complete save."); + second.commit(&path).unwrap(); + assert_eq!(fs::read(&path).unwrap(), b"Second complete save."); + assert_eq!(fs::read_dir(&d.0).unwrap().count(), 1); + } + + /// Exports (`write_file`) replace the destination instead of truncating it in place: another + /// hard link to the old file keeps the old bytes, and no temporary file is left behind. + #[test] + fn write_file_replaces_the_destination_instead_of_truncating_it() { + let d = Scratch::new(); + let path = d.target(); + let alias = d.0.join("alias.docx"); + fs::write(&path, b"Original bytes.").unwrap(); + fs::hard_link(&path, &alias).unwrap(); + crate::io::write_file(&path.to_string_lossy(), b"Exported bytes.").unwrap(); + assert_eq!(fs::read(&path).unwrap(), b"Exported bytes."); + assert_eq!(fs::read(&alias).unwrap(), b"Original bytes."); + assert_eq!(fs::read_dir(&d.0).unwrap().count(), 2); + } + + /// #3003: a folder that refuses mode changes (a GVFS-mounted NAS share) still takes the + /// save; a refused mode change for any other reason still stops it and keeps the original. + #[test] + fn a_folder_without_unix_modes_still_takes_the_save() { + let d = Scratch::new(); + let path = d.target(); + fs::write(&path, b"Original bytes.").unwrap(); + replace_with(&path, true, |file| { + file.write_all(b"Saved on the share.")?; + keep_permissions(Err(io::Error::from(io::ErrorKind::Unsupported)))?; + file.sync_all() + }) + .unwrap(); + assert_eq!(fs::read(&path).unwrap(), b"Saved on the share."); + assert_eq!(fs::read_dir(&d.0).unwrap().count(), 1); + + let err = replace_with(&path, true, |file| { + file.write_all(b"Refused.")?; + keep_permissions(Err(io::Error::from(io::ErrorKind::PermissionDenied))) + }) + .unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::PermissionDenied); + assert_eq!(fs::read(&path).unwrap(), b"Saved on the share."); + assert_eq!(fs::read_dir(&d.0).unwrap().count(), 1); + } + + /// GVFS reports a refused mode change as ENOTSUP, which std leaves uncategorized; other OS + /// errors still stop the save. + #[cfg(any(target_os = "linux", target_os = "macos"))] + #[test] + fn enotsup_from_the_os_is_an_unsupported_mode_change() { + let enotsup = if cfg!(target_os = "linux") { 95 } else { 45 }; + assert!(keep_permissions(Err(io::Error::from_raw_os_error(enotsup))).is_ok()); + let eacces = 13; + assert!(keep_permissions(Err(io::Error::from_raw_os_error(eacces))).is_err()); + } + + /// Makes `dir` read-only while `f` runs. `None` when running as root, where the folder isn't + /// read-only to us. + #[cfg(unix)] + fn in_read_only(dir: &Path, f: impl FnOnce() -> T) -> Option { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(dir, fs::Permissions::from_mode(0o555)).unwrap(); + let probe = fs::write(dir.join("probe"), b""); + let out = f(); + fs::set_permissions(dir, fs::Permissions::from_mode(0o755)).unwrap(); + probe.is_err().then_some(out) + } + + /// #2138 review: where no sibling temporary file may be created (macOS privacy protection, + /// or a read-only folder), an existing document is left byte for byte as it was, and the + /// error says to use Save As or get access to the folder. + #[cfg(unix)] + #[test] + fn an_existing_file_in_a_folder_refusing_the_temporary_file_is_left_untouched() { + let d = Scratch::new(); + let path = d.target(); + fs::write(&path, b"Original bytes.").unwrap(); + let Some(saved) = in_read_only(&d.0, || write(&path, b"Saved bytes.")) else { return }; + let err = saved.unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::PermissionDenied); + let message = err.to_string(); + assert!(message.contains("left unchanged") && message.contains("Save As") && message.contains("this folder"), "{message}"); + assert_eq!(message.matches("Privacy & Security").count(), usize::from(cfg!(target_os = "macos")), "{message}"); + assert_eq!(fs::read(&path).unwrap(), b"Original bytes."); + assert_eq!(fs::read_dir(&d.0).unwrap().count(), 1); + } + + /// FredApps' #2138 regression: a save that would fail partway through still leaves the + /// previous document byte-identical when the folder refuses the temporary file. + #[cfg(unix)] + #[test] + fn a_failed_save_in_a_folder_refusing_the_temporary_file_keeps_the_original() { + let d = Scratch::new(); + let path = d.target(); + fs::write(&path, b"Original bytes.").unwrap(); + let Some(result) = in_read_only(&d.0, || { + replace_with(&path, true, |file| { + file.write_all(b"Partial")?; + Err(io::Error::other("injected partial write failure")) + }) + }) else { + return; + }; + assert!(result.is_err()); + assert_eq!(fs::read(&path).unwrap(), b"Original bytes.", "failed save must preserve the previous document"); + assert_eq!(fs::read_dir(&d.0).unwrap().count(), 1); + } + + /// A folder that refuses new files altogether refuses a new document too, with the same + /// guidance, and leaves nothing behind. + #[cfg(unix)] + #[test] + fn a_new_file_in_a_read_only_folder_is_refused_with_guidance() { + let d = Scratch::new(); + let path = d.target(); + let Some(saved) = in_read_only(&d.0, || write(&path, b"New document.")) else { return }; + let message = saved.unwrap_err().to_string(); + assert!(message.contains("Save As"), "{message}"); + assert!(!path.exists()); + } + + /// When only the temporary file is refused (macOS lets the app write the file picked in the + /// Save panel), a file that doesn't exist yet is created directly; nothing is replaced. + #[test] + fn a_new_file_is_created_directly_when_only_the_temporary_file_is_refused() { + let d = Scratch::new(); + let path = d.target(); + let refused = io::Error::from(io::ErrorKind::PermissionDenied); + create_directly(&path, refused, |file| file.write_all(b"New document.")).unwrap(); + assert_eq!(fs::read(&path).unwrap(), b"New document."); + assert_eq!(fs::read_dir(&d.0).unwrap().count(), 1); + } + + #[test] + fn a_failed_direct_save_of_a_new_file_leaves_nothing() { + let d = Scratch::new(); + let path = d.target(); + let refused = io::Error::from(io::ErrorKind::PermissionDenied); + let err = create_directly(&path, refused, |file| { + file.write_all(b"Partial")?; + Err(io::Error::other("injected")) + }) + .unwrap_err(); + assert!(err.to_string().contains("injected")); + assert!(!path.exists()); + } + + /// The direct path never opens an existing file and never runs the save: the original is + /// untouched and the error carries the Save As guidance. + #[test] + fn an_existing_file_is_never_written_directly() { + let d = Scratch::new(); + let path = d.target(); + fs::write(&path, b"Original bytes.").unwrap(); + let refused = io::Error::from(io::ErrorKind::PermissionDenied); + let mut ran = false; + let err = create_directly(&path, refused, |file| { + ran = true; + file.write_all(b"New") + }) + .unwrap_err(); + assert!(!ran); + assert_eq!(err.kind(), io::ErrorKind::PermissionDenied); + assert!(err.to_string().contains("Save As") && err.to_string().contains("left unchanged"), "{err}"); + assert_eq!(fs::read(&path).unwrap(), b"Original bytes."); + } + + #[cfg(unix)] + #[test] + fn new_temporary_files_are_private_until_prepared() { + use std::os::unix::fs::PermissionsExt; + let d = Scratch::new(); + let temp = Temporary::create(&d.target(), true).unwrap(); + assert_eq!(fs::metadata(&temp.path).unwrap().permissions().mode() & 0o777, 0o600); + } + + /// A new document gets the default mode, as `fs::write` would give it — not 0600; a replaced + /// document keeps its own mode. + #[cfg(unix)] + #[test] + fn new_documents_get_the_default_mode_and_replaced_ones_keep_theirs() { + use std::os::unix::fs::PermissionsExt; + let d = Scratch::new(); + let reference = d.0.join("reference.docx"); + fs::write(&reference, b"x").unwrap(); + let default_mode = fs::metadata(&reference).unwrap().permissions().mode() & 0o777; + let path = d.target(); + write(&path, b"New document.").unwrap(); + assert_eq!(fs::metadata(&path).unwrap().permissions().mode() & 0o777, default_mode); + fs::set_permissions(&path, fs::Permissions::from_mode(0o640)).unwrap(); + write(&path, b"Saved again.").unwrap(); + assert_eq!(fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o640); + assert_eq!(fs::read(&path).unwrap(), b"Saved again."); + } + + /// A share that refuses the full flush (`F_FULLFSYNC`, #6155) still saves through a plain fsync + /// on Apple platforms; every other sync error still fails the save. + #[test] + fn a_refused_full_flush_falls_back_to_a_plain_fsync_on_apple_only() { + use std::cell::Cell; + let refused = |errno| Err(io::Error::from_raw_os_error(errno)); + for (errno, name) in [(45, "ENOTSUP, SMB"), (22, "EINVAL"), (25, "ENOTTY")] { + let fsynced = Cell::new(false); + let r = or_plain_fsync(refused(errno), || { + fsynced.set(true); + Ok(()) + }); + let apple = cfg!(target_vendor = "apple"); + assert_eq!(r.is_ok(), apple, "{name}"); + assert_eq!(fsynced.get(), apple, "{name}: the plain fsync runs only on Apple platforms"); + } + // The plain fsync's own failure is reported. + if cfg!(target_vendor = "apple") { + let r = or_plain_fsync(refused(45), || Err(io::Error::from_raw_os_error(5))); + assert_eq!(r.map_err(|e| e.raw_os_error()), Err(Some(5))); + } + // Real errors (EIO, ENOSPC, EACCES) never fall back, and success needs no fallback. + for errno in [5, 28, 13] { + let r = or_plain_fsync(refused(errno), || panic!("no fallback for errno {errno}")); + assert_eq!(r.map_err(|e| e.raw_os_error()), Err(Some(errno))); + } + assert!(or_plain_fsync(Ok(()), || panic!("no fallback after a full flush")).is_ok()); + } + + #[test] + fn sync_file_flushes_a_local_file() { + let d = Scratch::new(); + let path = d.0.join("f.bin"); + let file = OpenOptions::new().write(true).create_new(true).open(&path).unwrap(); + sync_file(&file).unwrap(); + } +} diff --git a/crates/engine/src/io_ext.rs b/crates/engine/src/io_ext.rs index 16c8136f8f..2fde16034e 100644 --- a/crates/engine/src/io_ext.rs +++ b/crates/engine/src/io_ext.rs @@ -1,90 +1,90 @@ -//! Format crates registered with the I/O dispatcher. - -use wordcraft_doc::Document; - -/// Open a format other than plain text / JSON; files that give no page size (Markdown, HTML) -/// start on `page`. -pub fn open(ext: &str, bytes: &[u8], page: &wordcraft_doc::SectionProps) -> Option> { - match ext { - "docx" | "docm" | "dotx" | "dotm" => Some(crate::io::read_docx(bytes, None).map_err(|e| e.to_string())), - "doc" | "dot" => Some(wordcraft_docbin::read(bytes).map_err(|e| e.to_string())), - // Word XML Document (Flat OPC) or Word 2003 XML, told apart by the root element. - "xml" => Some(wordcraft_docx::read_xml(bytes).map_err(|e| e.to_string())), - // HTML, Markdown, RTF…: pictures fit the text width (see `io::fit_pictures`). An - // OpenDocument file says how big they are, and one wider than the text is meant to be; - // changing it would take its paragraph off being written as read. Its reader fits those - // that state no size. - other => wordcraft_formats::import_on(other, bytes, page).map(|r| { - r.map(|mut doc| { - if !matches!(other, "odt" | "ott" | "fodt") { - crate::io::fit_pictures(&mut doc); - } - draw_charts(&mut doc); - doc - }) - }), - } -} - -/// Draw the charts a reader left undrawn (ODT charts come as their chart model alone): in the -/// document's theme colours, at their size. -fn draw_charts(doc: &mut Document) { - use wordcraft_doc::para::InlineObject; - /// Groups nest at most this deep here (the readers flatten deeper ones). - const DEPTH: usize = 16; - fn undrawn(o: &InlineObject, depth: usize) -> bool { - match o { - InlineObject::Graphic { graphic, .. } => graphic.chart.is_some() && graphic.items.is_empty(), - InlineObject::Group { children, .. } if depth < DEPTH => children.iter().any(|c| undrawn(&c.obj, depth + 1)), - _ => false, - } - } - fn draw(o: &mut InlineObject, theme: &[wordcraft_doc::Rgb], depth: usize) { - match o { - InlineObject::Group { children, .. } if depth < DEPTH => children.iter_mut().for_each(|c| draw(&mut c.obj, theme, depth + 1)), - o if undrawn(o, depth) => crate::cmd::chart::redraw(o, theme), - _ => {} - } - } - let theme = doc.settings.theme_colors.clone(); - // (Drawing them doesn't change the document: markup kept as read for them still is.) - doc.fill_objects(&|o| undrawn(o, 0), &mut |o| draw(o, &theme, 0)); -} - -/// Save to a format other than plain text / JSON. -pub fn save(ext: &str, doc: &Document, opts: &crate::io::ExportOptions) -> Option, String>> { - // .docx/.docm/.dotx/.dotm: the extension picks the package flavour Word expects. - if let Some(flavor) = wordcraft_docx::Flavor::from_ext(ext) { - return Some(wordcraft_docx::write_as(doc, flavor).map_err(|e| e.to_string())); - } - match ext { - "xml" if opts.word2003 => Some(wordcraft_docx::write_wordml2003(doc).map_err(|e| e.to_string())), - "xml" => Some(wordcraft_docx::write_flat(doc).map_err(|e| e.to_string())), - "png" => Some(render_png(doc, 0, 2.0, opts)), - "pdf" => { - // Output shows the fields Word fills in as it prints (see `output_fields`). - let out = crate::cmd::references::output_fields(doc); - let doc = out.as_ref().unwrap_or(doc); - Some(crate::strict::export_pdf(doc, opts)) - } - other => wordcraft_formats::export_with(other, doc, &wordcraft_formats::ExportOptions { comments: opts.markup && opts.comments }), - } -} - -/// Render page `page` as PNG at `scale` px/pt. With Print Markup's comments the page has its -/// markup area with the comments in balloons (page images always show tracked changes). -pub fn render_png(doc: &Document, page: usize, scale: f32, opts: &crate::io::ExportOptions) -> Result, String> { - let out = crate::cmd::references::output_fields(doc); - let doc = out.as_ref().unwrap_or(doc); - let lo = wordcraft_layout::LayoutOptions { comment_balloons: opts.markup && opts.comments, numeral: opts.numeral, ..Default::default() }; - let l = wordcraft_layout::layout(doc, &mut wordcraft_layout::LayoutCache::new(), &lo); - let p = l.pages.get(page).ok_or_else(|| format!("no page {}", page + 1))?; - if opts.strict { - let report = crate::strict::strict_report(doc, &l, &crate::strict::png_display(), Some(page..=page), wordcraft_layout::StrictOutput::Png); - if !report.passed() { - return Err(report.refusal()); - } - } - let img = wordcraft_render::render_page(doc, p, scale, &Default::default()); - Ok(img.to_png()) -} +//! Format crates registered with the I/O dispatcher. + +use wordcraft_doc::Document; + +/// Open a format other than plain text / JSON; files that give no page size (Markdown, HTML) +/// start on `page`. +pub fn open(ext: &str, bytes: &[u8], page: &wordcraft_doc::SectionProps) -> Option> { + match ext { + "docx" | "docm" | "dotx" | "dotm" => Some(crate::io::read_docx(bytes, None).map_err(|e| e.to_string())), + "doc" | "dot" => Some(wordcraft_docbin::read(bytes).map_err(|e| e.to_string())), + // Word XML Document (Flat OPC) or Word 2003 XML, told apart by the root element. + "xml" => Some(wordcraft_docx::read_xml(bytes).map_err(|e| e.to_string())), + // HTML, Markdown, RTF…: pictures fit the text width (see `io::fit_pictures`). An + // OpenDocument file says how big they are, and one wider than the text is meant to be; + // changing it would take its paragraph off being written as read. Its reader fits those + // that state no size. + other => wordcraft_formats::import_on(other, bytes, page).map(|r| { + r.map(|mut doc| { + if !matches!(other, "odt" | "ott" | "fodt") { + crate::io::fit_pictures(&mut doc); + } + draw_charts(&mut doc); + doc + }) + }), + } +} + +/// Draw the charts a reader left undrawn (ODT charts come as their chart model alone): in the +/// document's theme colours, at their size. +fn draw_charts(doc: &mut Document) { + use wordcraft_doc::para::InlineObject; + /// Groups nest at most this deep here (the readers flatten deeper ones). + const DEPTH: usize = 16; + fn undrawn(o: &InlineObject, depth: usize) -> bool { + match o { + InlineObject::Graphic { graphic, .. } => graphic.chart.is_some() && graphic.items.is_empty(), + InlineObject::Group { children, .. } if depth < DEPTH => children.iter().any(|c| undrawn(&c.obj, depth + 1)), + _ => false, + } + } + fn draw(o: &mut InlineObject, theme: &[wordcraft_doc::Rgb], depth: usize) { + match o { + InlineObject::Group { children, .. } if depth < DEPTH => children.iter_mut().for_each(|c| draw(&mut c.obj, theme, depth + 1)), + o if undrawn(o, depth) => crate::cmd::chart::redraw(o, theme), + _ => {} + } + } + let theme = doc.settings.theme_colors.clone(); + // (Drawing them doesn't change the document: markup kept as read for them still is.) + doc.fill_objects(&|o| undrawn(o, 0), &mut |o| draw(o, &theme, 0)); +} + +/// Save to a format other than plain text / JSON. +pub fn save(ext: &str, doc: &Document, opts: &crate::io::ExportOptions) -> Option, String>> { + // .docx/.docm/.dotx/.dotm: the extension picks the package flavour Word expects. + if let Some(flavor) = wordcraft_docx::Flavor::from_ext(ext) { + return Some(wordcraft_docx::write_as(doc, flavor).map_err(|e| e.to_string())); + } + match ext { + "xml" if opts.word2003 => Some(wordcraft_docx::write_wordml2003(doc).map_err(|e| e.to_string())), + "xml" => Some(wordcraft_docx::write_flat(doc).map_err(|e| e.to_string())), + "png" => Some(render_png(doc, 0, 2.0, opts)), + "pdf" => { + // Output shows the fields Word fills in as it prints (see `output_fields`). + let out = crate::cmd::references::output_fields(doc); + let doc = out.as_ref().unwrap_or(doc); + Some(crate::strict::export_pdf(doc, opts)) + } + other => wordcraft_formats::export_with(other, doc, &wordcraft_formats::ExportOptions { comments: opts.markup && opts.comments }), + } +} + +/// Render page `page` as PNG at `scale` px/pt. With Print Markup's comments the page has its +/// markup area with the comments in balloons (page images always show tracked changes). +pub fn render_png(doc: &Document, page: usize, scale: f32, opts: &crate::io::ExportOptions) -> Result, String> { + let out = crate::cmd::references::output_fields(doc); + let doc = out.as_ref().unwrap_or(doc); + let lo = wordcraft_layout::LayoutOptions { comment_balloons: opts.markup && opts.comments, numeral: opts.numeral, ..Default::default() }; + let l = wordcraft_layout::layout(doc, &mut wordcraft_layout::LayoutCache::new(), &lo); + let p = l.pages.get(page).ok_or_else(|| format!("no page {}", page + 1))?; + if opts.strict { + let report = crate::strict::strict_report(doc, &l, &crate::strict::png_display(), Some(page..=page), wordcraft_layout::StrictOutput::Png); + if !report.passed() { + return Err(report.refusal()); + } + } + let img = wordcraft_render::render_page(doc, p, scale, &Default::default()); + Ok(img.to_png()) +} diff --git a/crates/engine/src/keymap.rs b/crates/engine/src/keymap.rs index 54be04f0db..0cd6173b69 100644 --- a/crates/engine/src/keymap.rs +++ b/crates/engine/src/keymap.rs @@ -1,275 +1,275 @@ -//! Custom keyboard shortcuts (Tools › Customize Keyboard, `tools.customizeKeyboard`): keys the -//! user assigned to commands and built-in keys the user removed. The user's map is consulted -//! before the registry's built-in `shortcut`s, so an assignment overrides a built-in key and a -//! removed built-in key does nothing. -//! -//! Keys are written like the registry's shortcuts (`Mod+Shift+K`, `Alt+F7`, `F2`; `Mod` is Ctrl, -//! or ⌘ on macOS) and compared in one canonical form ([`normalize`]). The map is saved with the -//! front end's preferences; reading a saved map never fails: entries that aren't a valid key or -//! command are dropped. - -use std::collections::{BTreeMap, BTreeSet}; - -use serde::{Deserialize, Serialize}; -use serde_json::Value; - -use crate::{CommandSpec, Registry}; - -/// At most this many saved entries are read (a hand-edited settings file can't balloon memory). -const MAX_ENTRIES: usize = 2000; -/// Longest key text accepted. -const MAX_KEY_LEN: usize = 48; - -/// Named (non-character) keys, in their canonical spelling. -const NAMED: &[&str] = - &["Left", "Right", "Up", "Down", "Home", "End", "PageUp", "PageDown", "Enter", "Tab", "Backspace", "Delete", "Escape", "Space", "Insert"]; - -/// Custom keyboard shortcuts. `assigned` maps a key to the command it runs; `removed` holds -/// built-in keys that were taken away (removed, or reassigned to another command). -#[derive(Clone, Debug, Default, PartialEq, Serialize)] -pub struct KeyMap { - pub assigned: BTreeMap, - pub removed: BTreeSet, -} - -/// A key in canonical form: modifiers in the order `Mod+Ctrl+Alt+Shift+`, then the key (a single -/// character upper-cased, a named key, or `F1`–`F24`). `None` when it isn't a key. -pub fn normalize(key: &str) -> Option { - let key = key.trim(); - if key.is_empty() || key.len() > MAX_KEY_LEN { - return None; - } - // `Mod++` is the plus key with Mod. - let (mods, name) = match key.strip_suffix("++") { - Some(m) => (m, "+"), - None => match key.rsplit_once('+') { - Some((m, n)) => (m, n), - None => ("", key), - }, - }; - let (mut cmd, mut ctrl, mut alt, mut shift) = (false, false, false, false); - for m in mods.split('+').filter(|m| !m.is_empty()) { - match m.to_ascii_lowercase().as_str() { - "mod" => cmd = true, - "ctrl" => ctrl = true, - "alt" => alt = true, - "shift" => shift = true, - _ => return None, - } - } - let name = canonical_name(name)?; - let mut out = String::new(); - for (on, m) in [(cmd, "Mod+"), (ctrl, "Ctrl+"), (alt, "Alt+"), (shift, "Shift+")] { - if on { - out.push_str(m); - } - } - out.push_str(&name); - Some(out) -} - -fn canonical_name(name: &str) -> Option { - let mut chars = name.chars(); - if let (Some(c), None) = (chars.next(), chars.next()) { - return (!c.is_whitespace() && !c.is_control()).then(|| c.to_uppercase().collect()); - } - if let Some(n) = NAMED.iter().find(|n| n.eq_ignore_ascii_case(name)) { - return Some((*n).to_string()); - } - let f = name.strip_prefix('F').or_else(|| name.strip_prefix('f'))?; - let n: u8 = f.parse().ok()?; - (1..=24).contains(&n).then(|| format!("F{n}")) -} - -/// A command's built-in keys, normalized. -fn builtin_keys(spec: &CommandSpec) -> impl Iterator + '_ { - spec.shortcut.split(" / ").filter_map(normalize) -} - -/// The command a key runs without customization (the first command listing it). -fn builtin<'a>(reg: &'a Registry, key: &str) -> Option<&'a CommandSpec> { - reg.all().iter().find(|s| !s.shortcut.is_empty() && builtin_keys(s).any(|k| k == key)) -} - -impl KeyMap { - /// Read a saved map, keeping only well-formed entries (never fails). - pub fn from_value(v: &Value) -> KeyMap { - let mut map = KeyMap::default(); - if let Some(a) = v.get("assigned").and_then(Value::as_object) { - for (k, id) in a.iter().take(MAX_ENTRIES) { - if let (Some(k), Some(id)) = (normalize(k), id.as_str().filter(|id| !id.is_empty() && id.len() <= 128)) { - map.assigned.insert(k, id.to_string()); - } - } - } - if let Some(r) = v.get("removed").and_then(Value::as_array) { - map.removed.extend(r.iter().take(MAX_ENTRIES).filter_map(Value::as_str).filter_map(normalize)); - } - map - } - - /// Whether nothing is customized. - pub fn is_empty(&self) -> bool { - self.assigned.is_empty() && self.removed.is_empty() - } - - /// Drop entries for commands that don't exist and removals of keys that aren't built in. - pub fn retain_known(&mut self, reg: &Registry) { - self.assigned.retain(|_, id| reg.get(id).is_some()); - self.removed.retain(|k| builtin(reg, k).is_some()); - } - - /// The command a key runs: the user's assignment, else the built-in one unless removed. - pub fn resolve<'a>(&self, reg: &'a Registry, key: &str) -> Option<&'a CommandSpec> { - let key = normalize(key)?; - if let Some(spec) = self.assigned.get(&key).and_then(|id| reg.get(id)) { - return Some(spec); - } - if self.removed.contains(&key) { - return None; - } - builtin(reg, &key) - } - - /// The keys that run a command now: its built-in keys still in effect, then the user's. - pub fn keys_for(&self, reg: &Registry, id: &str) -> Vec { - let mut keys: Vec = Vec::new(); - if let Some(spec) = reg.get(id) { - for k in builtin_keys(spec) { - // Uncustomized (the usual case, every frame for tooltips): no registry scan. - if !keys.contains(&k) && (self.is_empty() || self.resolve(reg, &k).is_some_and(|s| s.id == id)) { - keys.push(k); - } - } - } - for (k, owner) in &self.assigned { - if owner == id && !keys.contains(k) { - keys.push(k.clone()); - } - } - keys - } - - /// Assign `key` to command `id`; the key leaves whatever command had it. Returns that command. - pub fn assign(&mut self, reg: &Registry, id: &str, key: &str) -> Result, String> { - if reg.get(id).is_none() { - return Err(format!("unknown command `{id}`")); - } - let key = normalize(key).ok_or_else(|| format!("`{key}` isn't a key (e.g. Mod+Shift+K, Alt+F7, F2)"))?; - let previous = self.resolve(reg, &key).map(|s| s.id.to_string()).filter(|p| p != id); - match builtin(reg, &key) { - // Back to its own built-in key: no custom entry needed. - Some(b) if b.id == id => { - self.assigned.remove(&key); - self.removed.remove(&key); - } - // Taken from a built-in command: it loses the key for good. - Some(_) => { - self.assigned.insert(key.clone(), id.to_string()); - self.removed.insert(key); - } - None => { - self.assigned.insert(key, id.to_string()); - } - } - Ok(previous) - } - - /// Take `key` away from command `id`. Returns whether it had the key. - pub fn remove(&mut self, reg: &Registry, id: &str, key: &str) -> Result { - let key = normalize(key).ok_or_else(|| format!("`{key}` isn't a key"))?; - if self.resolve(reg, &key).is_none_or(|s| s.id != id) { - return Ok(false); - } - self.assigned.remove(&key); - if builtin(reg, &key).is_some() { - self.removed.insert(key); - } - Ok(true) - } - - /// Back to the built-in keys only. - pub fn reset(&mut self) { - *self = KeyMap::default(); - } -} - -impl<'de> Deserialize<'de> for KeyMap { - /// Lenient: any JSON reads (junk entries dropped), so a damaged map never loses the other - /// preferences saved next to it. - fn deserialize>(d: D) -> Result { - Ok(KeyMap::from_value(&Value::deserialize(d)?)) - } -} - -#[cfg(test)] -mod tests { - use serde_json::json; - - use super::*; - - #[test] - fn keys_normalize_to_one_spelling() { - assert_eq!(normalize("mod+shift+alt+v").as_deref(), Some("Mod+Alt+Shift+V")); - assert_eq!(normalize("Mod+=").as_deref(), Some("Mod+=")); - assert_eq!(normalize("Mod++").as_deref(), Some("Mod++")); - assert_eq!(normalize("f12").as_deref(), Some("F12")); - assert_eq!(normalize("Ctrl+pagedown").as_deref(), Some("Ctrl+PageDown")); - for junk in ["", "Mod+", "Hyper+K", "F99", "Mod+Shift+NotAKey", "Mod+\u{7}", &"x".repeat(100)] { - assert_eq!(normalize(junk), None, "{junk:?}"); - } - } - - /// #368: an assignment overrides a built-in key, a removed built-in key runs nothing, and - /// Reset All brings the built-ins back. - #[test] - fn user_keys_override_and_remove_built_ins() { - let reg = crate::cmd::registry(); - let mut m = KeyMap::default(); - assert_eq!(m.resolve(®, "Mod+B").map(|s| s.id), Some("format.bold")); - // Mod+B to Italic: Bold loses it. - assert_eq!(m.assign(®, "format.italic", "mod+b").unwrap().as_deref(), Some("format.bold")); - assert_eq!(m.resolve(®, "Mod+B").map(|s| s.id), Some("format.italic")); - assert!(m.keys_for(®, "format.bold").is_empty()); - assert_eq!(m.keys_for(®, "format.italic"), ["Mod+I", "Mod+B"]); - // Removing it from Italic leaves Mod+B doing nothing (Bold doesn't get it back). - assert!(m.remove(®, "format.italic", "Mod+B").unwrap()); - assert!(m.resolve(®, "Mod+B").is_none()); - // Removing a built-in key disables it; a new key works. - assert!(m.remove(®, "format.underline", "Mod+U").unwrap()); - assert!(m.resolve(®, "Mod+U").is_none()); - assert_eq!(m.assign(®, "format.underline", "Mod+Alt+Shift+U").unwrap(), None); - assert_eq!(m.resolve(®, "Mod+Shift+Alt+U").map(|s| s.id), Some("format.underline")); - assert!(m.assign(®, "no.such.command", "Mod+Q").is_err()); - assert!(m.assign(®, "format.bold", "Hyper+Q").is_err()); - m.reset(); - assert_eq!(m.resolve(®, "Mod+B").map(|s| s.id), Some("format.bold")); - assert_eq!(m.resolve(®, "Mod+U").map(|s| s.id), Some("format.underline")); - - // The command does the same for agents, without opening the dialog. - let mut s = crate::Session::new(wordcraft_doc::Document::new()); - let r = s.run("tools.customizeKeyboard", &json!({"assign": {"command": "format.italic", "key": "Mod+B"}, "key": "Mod+B"})).unwrap(); - assert_eq!((r["replaced"].as_str(), r["assignedTo"].as_str()), (Some("format.bold"), Some("format.italic"))); - assert!(s.ui_requests.is_empty(), "programmatic calls never open dialogs"); - s.run("tools.customizeKeyboard", &json!({})).unwrap(); - assert_eq!(s.ui_requests.last(), Some(&json!({"open": "customizeKeyboard"}))); - } - - #[test] - fn saved_maps_round_trip_and_junk_is_ignored() { - let reg = crate::cmd::registry(); - let mut m = KeyMap::default(); - m.assign(®, "format.italic", "Mod+B").unwrap(); - let back: KeyMap = serde_json::from_str(&serde_json::to_string(&m).unwrap()).unwrap(); - assert_eq!(back, m); - let junk = json!({"assigned": {"Hyper+X": "format.bold", "Mod+Q": 7, "Mod+J": "no.such", "Mod+Alt+K": "format.bold"}, "removed": ["Mod+U", 3, "F99", "Mod+Shift+Q"]}); - let mut j: KeyMap = serde_json::from_value(junk).unwrap(); - j.retain_known(®); - assert_eq!(j.assigned.len(), 1, "{j:?}"); - assert_eq!(j.resolve(®, "Mod+Alt+K").map(|s| s.id), Some("format.bold")); - assert_eq!(j.removed.iter().collect::>(), ["Mod+U"]); - for v in [json!(null), json!(5), json!("x"), json!({"assigned": [1], "removed": {"a": 1}})] { - assert!(serde_json::from_value::(v).unwrap().is_empty()); - } - } -} +//! Custom keyboard shortcuts (Tools › Customize Keyboard, `tools.customizeKeyboard`): keys the +//! user assigned to commands and built-in keys the user removed. The user's map is consulted +//! before the registry's built-in `shortcut`s, so an assignment overrides a built-in key and a +//! removed built-in key does nothing. +//! +//! Keys are written like the registry's shortcuts (`Mod+Shift+K`, `Alt+F7`, `F2`; `Mod` is Ctrl, +//! or ⌘ on macOS) and compared in one canonical form ([`normalize`]). The map is saved with the +//! front end's preferences; reading a saved map never fails: entries that aren't a valid key or +//! command are dropped. + +use std::collections::{BTreeMap, BTreeSet}; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::{CommandSpec, Registry}; + +/// At most this many saved entries are read (a hand-edited settings file can't balloon memory). +const MAX_ENTRIES: usize = 2000; +/// Longest key text accepted. +const MAX_KEY_LEN: usize = 48; + +/// Named (non-character) keys, in their canonical spelling. +const NAMED: &[&str] = + &["Left", "Right", "Up", "Down", "Home", "End", "PageUp", "PageDown", "Enter", "Tab", "Backspace", "Delete", "Escape", "Space", "Insert"]; + +/// Custom keyboard shortcuts. `assigned` maps a key to the command it runs; `removed` holds +/// built-in keys that were taken away (removed, or reassigned to another command). +#[derive(Clone, Debug, Default, PartialEq, Serialize)] +pub struct KeyMap { + pub assigned: BTreeMap, + pub removed: BTreeSet, +} + +/// A key in canonical form: modifiers in the order `Mod+Ctrl+Alt+Shift+`, then the key (a single +/// character upper-cased, a named key, or `F1`–`F24`). `None` when it isn't a key. +pub fn normalize(key: &str) -> Option { + let key = key.trim(); + if key.is_empty() || key.len() > MAX_KEY_LEN { + return None; + } + // `Mod++` is the plus key with Mod. + let (mods, name) = match key.strip_suffix("++") { + Some(m) => (m, "+"), + None => match key.rsplit_once('+') { + Some((m, n)) => (m, n), + None => ("", key), + }, + }; + let (mut cmd, mut ctrl, mut alt, mut shift) = (false, false, false, false); + for m in mods.split('+').filter(|m| !m.is_empty()) { + match m.to_ascii_lowercase().as_str() { + "mod" => cmd = true, + "ctrl" => ctrl = true, + "alt" => alt = true, + "shift" => shift = true, + _ => return None, + } + } + let name = canonical_name(name)?; + let mut out = String::new(); + for (on, m) in [(cmd, "Mod+"), (ctrl, "Ctrl+"), (alt, "Alt+"), (shift, "Shift+")] { + if on { + out.push_str(m); + } + } + out.push_str(&name); + Some(out) +} + +fn canonical_name(name: &str) -> Option { + let mut chars = name.chars(); + if let (Some(c), None) = (chars.next(), chars.next()) { + return (!c.is_whitespace() && !c.is_control()).then(|| c.to_uppercase().collect()); + } + if let Some(n) = NAMED.iter().find(|n| n.eq_ignore_ascii_case(name)) { + return Some((*n).to_string()); + } + let f = name.strip_prefix('F').or_else(|| name.strip_prefix('f'))?; + let n: u8 = f.parse().ok()?; + (1..=24).contains(&n).then(|| format!("F{n}")) +} + +/// A command's built-in keys, normalized. +fn builtin_keys(spec: &CommandSpec) -> impl Iterator + '_ { + spec.shortcut.split(" / ").filter_map(normalize) +} + +/// The command a key runs without customization (the first command listing it). +fn builtin<'a>(reg: &'a Registry, key: &str) -> Option<&'a CommandSpec> { + reg.all().iter().find(|s| !s.shortcut.is_empty() && builtin_keys(s).any(|k| k == key)) +} + +impl KeyMap { + /// Read a saved map, keeping only well-formed entries (never fails). + pub fn from_value(v: &Value) -> KeyMap { + let mut map = KeyMap::default(); + if let Some(a) = v.get("assigned").and_then(Value::as_object) { + for (k, id) in a.iter().take(MAX_ENTRIES) { + if let (Some(k), Some(id)) = (normalize(k), id.as_str().filter(|id| !id.is_empty() && id.len() <= 128)) { + map.assigned.insert(k, id.to_string()); + } + } + } + if let Some(r) = v.get("removed").and_then(Value::as_array) { + map.removed.extend(r.iter().take(MAX_ENTRIES).filter_map(Value::as_str).filter_map(normalize)); + } + map + } + + /// Whether nothing is customized. + pub fn is_empty(&self) -> bool { + self.assigned.is_empty() && self.removed.is_empty() + } + + /// Drop entries for commands that don't exist and removals of keys that aren't built in. + pub fn retain_known(&mut self, reg: &Registry) { + self.assigned.retain(|_, id| reg.get(id).is_some()); + self.removed.retain(|k| builtin(reg, k).is_some()); + } + + /// The command a key runs: the user's assignment, else the built-in one unless removed. + pub fn resolve<'a>(&self, reg: &'a Registry, key: &str) -> Option<&'a CommandSpec> { + let key = normalize(key)?; + if let Some(spec) = self.assigned.get(&key).and_then(|id| reg.get(id)) { + return Some(spec); + } + if self.removed.contains(&key) { + return None; + } + builtin(reg, &key) + } + + /// The keys that run a command now: its built-in keys still in effect, then the user's. + pub fn keys_for(&self, reg: &Registry, id: &str) -> Vec { + let mut keys: Vec = Vec::new(); + if let Some(spec) = reg.get(id) { + for k in builtin_keys(spec) { + // Uncustomized (the usual case, every frame for tooltips): no registry scan. + if !keys.contains(&k) && (self.is_empty() || self.resolve(reg, &k).is_some_and(|s| s.id == id)) { + keys.push(k); + } + } + } + for (k, owner) in &self.assigned { + if owner == id && !keys.contains(k) { + keys.push(k.clone()); + } + } + keys + } + + /// Assign `key` to command `id`; the key leaves whatever command had it. Returns that command. + pub fn assign(&mut self, reg: &Registry, id: &str, key: &str) -> Result, String> { + if reg.get(id).is_none() { + return Err(format!("unknown command `{id}`")); + } + let key = normalize(key).ok_or_else(|| format!("`{key}` isn't a key (e.g. Mod+Shift+K, Alt+F7, F2)"))?; + let previous = self.resolve(reg, &key).map(|s| s.id.to_string()).filter(|p| p != id); + match builtin(reg, &key) { + // Back to its own built-in key: no custom entry needed. + Some(b) if b.id == id => { + self.assigned.remove(&key); + self.removed.remove(&key); + } + // Taken from a built-in command: it loses the key for good. + Some(_) => { + self.assigned.insert(key.clone(), id.to_string()); + self.removed.insert(key); + } + None => { + self.assigned.insert(key, id.to_string()); + } + } + Ok(previous) + } + + /// Take `key` away from command `id`. Returns whether it had the key. + pub fn remove(&mut self, reg: &Registry, id: &str, key: &str) -> Result { + let key = normalize(key).ok_or_else(|| format!("`{key}` isn't a key"))?; + if self.resolve(reg, &key).is_none_or(|s| s.id != id) { + return Ok(false); + } + self.assigned.remove(&key); + if builtin(reg, &key).is_some() { + self.removed.insert(key); + } + Ok(true) + } + + /// Back to the built-in keys only. + pub fn reset(&mut self) { + *self = KeyMap::default(); + } +} + +impl<'de> Deserialize<'de> for KeyMap { + /// Lenient: any JSON reads (junk entries dropped), so a damaged map never loses the other + /// preferences saved next to it. + fn deserialize>(d: D) -> Result { + Ok(KeyMap::from_value(&Value::deserialize(d)?)) + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + #[test] + fn keys_normalize_to_one_spelling() { + assert_eq!(normalize("mod+shift+alt+v").as_deref(), Some("Mod+Alt+Shift+V")); + assert_eq!(normalize("Mod+=").as_deref(), Some("Mod+=")); + assert_eq!(normalize("Mod++").as_deref(), Some("Mod++")); + assert_eq!(normalize("f12").as_deref(), Some("F12")); + assert_eq!(normalize("Ctrl+pagedown").as_deref(), Some("Ctrl+PageDown")); + for junk in ["", "Mod+", "Hyper+K", "F99", "Mod+Shift+NotAKey", "Mod+\u{7}", &"x".repeat(100)] { + assert_eq!(normalize(junk), None, "{junk:?}"); + } + } + + /// #368: an assignment overrides a built-in key, a removed built-in key runs nothing, and + /// Reset All brings the built-ins back. + #[test] + fn user_keys_override_and_remove_built_ins() { + let reg = crate::cmd::registry(); + let mut m = KeyMap::default(); + assert_eq!(m.resolve(®, "Mod+B").map(|s| s.id), Some("format.bold")); + // Mod+B to Italic: Bold loses it. + assert_eq!(m.assign(®, "format.italic", "mod+b").unwrap().as_deref(), Some("format.bold")); + assert_eq!(m.resolve(®, "Mod+B").map(|s| s.id), Some("format.italic")); + assert!(m.keys_for(®, "format.bold").is_empty()); + assert_eq!(m.keys_for(®, "format.italic"), ["Mod+I", "Mod+B"]); + // Removing it from Italic leaves Mod+B doing nothing (Bold doesn't get it back). + assert!(m.remove(®, "format.italic", "Mod+B").unwrap()); + assert!(m.resolve(®, "Mod+B").is_none()); + // Removing a built-in key disables it; a new key works. + assert!(m.remove(®, "format.underline", "Mod+U").unwrap()); + assert!(m.resolve(®, "Mod+U").is_none()); + assert_eq!(m.assign(®, "format.underline", "Mod+Alt+Shift+U").unwrap(), None); + assert_eq!(m.resolve(®, "Mod+Shift+Alt+U").map(|s| s.id), Some("format.underline")); + assert!(m.assign(®, "no.such.command", "Mod+Q").is_err()); + assert!(m.assign(®, "format.bold", "Hyper+Q").is_err()); + m.reset(); + assert_eq!(m.resolve(®, "Mod+B").map(|s| s.id), Some("format.bold")); + assert_eq!(m.resolve(®, "Mod+U").map(|s| s.id), Some("format.underline")); + + // The command does the same for agents, without opening the dialog. + let mut s = crate::Session::new(wordcraft_doc::Document::new()); + let r = s.run("tools.customizeKeyboard", &json!({"assign": {"command": "format.italic", "key": "Mod+B"}, "key": "Mod+B"})).unwrap(); + assert_eq!((r["replaced"].as_str(), r["assignedTo"].as_str()), (Some("format.bold"), Some("format.italic"))); + assert!(s.ui_requests.is_empty(), "programmatic calls never open dialogs"); + s.run("tools.customizeKeyboard", &json!({})).unwrap(); + assert_eq!(s.ui_requests.last(), Some(&json!({"open": "customizeKeyboard"}))); + } + + #[test] + fn saved_maps_round_trip_and_junk_is_ignored() { + let reg = crate::cmd::registry(); + let mut m = KeyMap::default(); + m.assign(®, "format.italic", "Mod+B").unwrap(); + let back: KeyMap = serde_json::from_str(&serde_json::to_string(&m).unwrap()).unwrap(); + assert_eq!(back, m); + let junk = json!({"assigned": {"Hyper+X": "format.bold", "Mod+Q": 7, "Mod+J": "no.such", "Mod+Alt+K": "format.bold"}, "removed": ["Mod+U", 3, "F99", "Mod+Shift+Q"]}); + let mut j: KeyMap = serde_json::from_value(junk).unwrap(); + j.retain_known(®); + assert_eq!(j.assigned.len(), 1, "{j:?}"); + assert_eq!(j.resolve(®, "Mod+Alt+K").map(|s| s.id), Some("format.bold")); + assert_eq!(j.removed.iter().collect::>(), ["Mod+U"]); + for v in [json!(null), json!(5), json!("x"), json!({"assigned": [1], "removed": {"a": 1}})] { + assert!(serde_json::from_value::(v).unwrap().is_empty()); + } + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index e5795e534e..875b818c26 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1,212 +1,212 @@ -//! WordCraft engine: the editing session and its commands. -//! -//! **Everything is a command.** Each user-visible action is a [`CommandSpec`] with a stable id -//! (`format.bold`, `insert.table`), a label, the ribbon/menu location, a default shortcut, a -//! params description and a `run` function. Menus, ribbon, shortcuts, the command palette, the -//! CLI, the JSON control channel and the MCP server all call [`Session::run`]. -#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] - -pub mod catalog; -pub mod cmd; -pub(crate) mod doc_state; -pub mod file_access; -pub(crate) mod ime; -pub mod io; -mod io_ext; -pub mod keymap; -pub(crate) mod locale; -pub mod math_gallery; -pub mod packs; -pub(crate) mod prefs; -pub mod print; -pub mod recovery; -pub mod ribbon; -pub mod sample; -mod session; -pub(crate) mod snapshot; -pub mod speech; -pub mod strict; -pub(crate) mod view; - -use std::collections::HashMap; - -use serde_json::Value; - -pub use io::Password; -pub use keymap::KeyMap; -pub use ribbon::RibbonLayout; -pub use session::{ - CellRange, ColumnBlock, DocInfo, DocState, EditSnapshot, FindState, MathEdit, Prefs, Selection, Session, StylesPaneDock, UNDO_LIST_MAX, - ViewState, WindowView, blank_document, next_math_session, set_system_locale, -}; -/// The outcome a command reports for IME receipt classification. -/// -/// A command that can be called by an input method must classify its committed effects. Only a -/// classified content change without an automatic rewrite may mint an IME receipt; any other -/// committed state change still advances the IME state revision and therefore makes a mixed -/// frame `Other` rather than certifying it. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub struct ImeEditOutcome { - /// The handler explicitly classified the committed effects. - pub classified: bool, - /// IME-visible content changed. Only this gates an IME receipt. - pub content_changed: bool, - /// Any committed document state changed, including style or representation changes. - pub state_changed: bool, - /// An automatic engine rewrite was applied (AutoCorrect, build-up, AutoFormat). - pub transformed: bool, - /// The caret or selection changed. Filled in by [`Session::run`]. - pub selection_changed: bool, -} - -/// The largest text one `equation.type` call inserts, in characters. The engine truncates to this -/// in both linear and built-up modes; the UI refuses an IME commit above it instead of inserting a -/// prefix. -pub const MAX_EQUATION_TYPE_CHARS: usize = 10_000; - -/// The largest number of unit steps one `equation.deleteSurrounding` request may take on either -/// side of the caret. A hostile IME count must not become an unbounded command. -pub const MAX_EQUATION_DELETE_UNITS: usize = 64; - -pub use wordcraft_doc as doc; -pub use wordcraft_geom::Region; -pub use wordcraft_layout as layout; -pub use wordcraft_proof as proof; -pub use wordcraft_render as render; - -#[derive(Debug, thiserror::Error, Clone, PartialEq)] -pub enum CmdError { - #[error("unknown command `{0}`")] - Unknown(String), - #[error("bad parameters: {0}")] - Params(String), - #[error("not available: {0}")] - Disabled(String), - #[error("{0}")] - Failed(String), -} - -impl From for CmdError { - fn from(e: wordcraft_doc::DocError) -> Self { - CmdError::Failed(e.to_string()) - } -} - -pub type CmdResult = Result; - -/// A registered command. -#[derive(Clone)] -pub struct CommandSpec { - pub id: &'static str, - pub label: &'static str, - /// Where it lives in the UI: `Home › Font`, `Insert › Tables`, `File`… - pub location: &'static str, - /// Default shortcut in a portable form (`Mod+B`, `Mod+Shift+>`, `F7`). `Mod` = Ctrl, or ⌘ on macOS. - pub shortcut: &'static str, - /// Parameter description for agents (`{"size": number}`). - pub params: &'static str, - pub mutates: bool, - /// `None` = enabled; `Some(reason)` = disabled. - pub enabled: fn(&Session) -> Option<&'static str>, - pub run: fn(&mut Session, &Value) -> CmdResult, -} - -fn always(_: &Session) -> Option<&'static str> { - None -} - -impl CommandSpec { - pub const fn new(id: &'static str, label: &'static str, location: &'static str, run: fn(&mut Session, &Value) -> CmdResult) -> Self { - CommandSpec { id, label, location, shortcut: "", params: "{}", mutates: true, enabled: always, run } - } - pub const fn key(mut self, s: &'static str) -> Self { - self.shortcut = s; - self - } - pub const fn params(mut self, p: &'static str) -> Self { - self.params = p; - self - } - /// Doesn't change the document (navigation, view, export). - pub const fn pure(mut self) -> Self { - self.mutates = false; - self - } - pub const fn when(mut self, f: fn(&Session) -> Option<&'static str>) -> Self { - self.enabled = f; - self - } -} - -/// All commands by id. -pub struct Registry { - specs: Vec, - by_id: HashMap<&'static str, usize>, -} - -impl Registry { - pub fn new(specs: Vec) -> Self { - let mut by_id = HashMap::new(); - for (i, s) in specs.iter().enumerate() { - by_id.insert(s.id, i); - } - Registry { specs, by_id } - } - pub fn get(&self, id: &str) -> Option<&CommandSpec> { - self.by_id.get(id).and_then(|i| self.specs.get(*i)) - } - pub fn all(&self) -> &[CommandSpec] { - &self.specs - } - /// Command list as JSON (for agents: `commands` / `list_commands`). - pub fn describe(&self) -> Value { - Value::Array( - self.specs - .iter() - .map(|s| serde_json::json!({"id": s.id, "label": s.label, "location": s.location, "shortcut": s.shortcut, "params": s.params, "mutates": s.mutates})) - .collect(), - ) - } - /// The command bound to a shortcut, if any. - pub fn by_shortcut(&self, key: &str) -> Option<&CommandSpec> { - self.specs.iter().find(|s| !s.shortcut.is_empty() && s.shortcut.split(" / ").any(|k| k.eq_ignore_ascii_case(key))) - } -} - -/// Parameter helpers. -pub mod p { - use serde_json::Value; - - use crate::CmdError; - - pub fn str<'a>(v: &'a Value, k: &str) -> Option<&'a str> { - v.get(k).and_then(Value::as_str) - } - pub fn req_str<'a>(v: &'a Value, k: &str) -> Result<&'a str, CmdError> { - str(v, k).ok_or_else(|| CmdError::Params(format!("`{k}` (string) is required"))) - } - pub fn f32(v: &Value, k: &str) -> Option { - v.get(k).and_then(Value::as_f64).map(|x| x as f32).filter(|x| x.is_finite()) - } - pub fn req_f32(v: &Value, k: &str) -> Result { - f32(v, k).ok_or_else(|| CmdError::Params(format!("`{k}` (number) is required"))) - } - pub fn u64(v: &Value, k: &str) -> Option { - v.get(k).and_then(|x| x.as_u64().or_else(|| x.as_f64().filter(|f| *f >= 0.0 && f.is_finite()).map(|f| f as u64))) - } - /// A 1-based page parameter as a 0-based index. A value that cannot fit in - /// `usize` (32-bit and wasm) saturates instead of wrapping to page 1. - pub fn page_index(page: u64) -> usize { - usize::try_from(page.max(1)).unwrap_or(usize::MAX).saturating_sub(1) - } - pub fn bool(v: &Value, k: &str) -> Option { - v.get(k).and_then(Value::as_bool) - } - /// The `value` param, or the first positional-ish string. - pub fn value(v: &Value) -> Option<&Value> { - v.get("value") - } -} - -#[cfg(test)] -mod tests; +//! WordCraft engine: the editing session and its commands. +//! +//! **Everything is a command.** Each user-visible action is a [`CommandSpec`] with a stable id +//! (`format.bold`, `insert.table`), a label, the ribbon/menu location, a default shortcut, a +//! params description and a `run` function. Menus, ribbon, shortcuts, the command palette, the +//! CLI, the JSON control channel and the MCP server all call [`Session::run`]. +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] + +pub mod catalog; +pub mod cmd; +pub(crate) mod doc_state; +pub mod file_access; +pub(crate) mod ime; +pub mod io; +mod io_ext; +pub mod keymap; +pub(crate) mod locale; +pub mod math_gallery; +pub mod packs; +pub(crate) mod prefs; +pub mod print; +pub mod recovery; +pub mod ribbon; +pub mod sample; +mod session; +pub(crate) mod snapshot; +pub mod speech; +pub mod strict; +pub(crate) mod view; + +use std::collections::HashMap; + +use serde_json::Value; + +pub use io::Password; +pub use keymap::KeyMap; +pub use ribbon::RibbonLayout; +pub use session::{ + CellRange, ColumnBlock, DocInfo, DocState, EditSnapshot, FindState, MathEdit, Prefs, Selection, Session, StylesPaneDock, UNDO_LIST_MAX, + ViewState, WindowView, blank_document, next_math_session, set_system_locale, +}; +/// The outcome a command reports for IME receipt classification. +/// +/// A command that can be called by an input method must classify its committed effects. Only a +/// classified content change without an automatic rewrite may mint an IME receipt; any other +/// committed state change still advances the IME state revision and therefore makes a mixed +/// frame `Other` rather than certifying it. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct ImeEditOutcome { + /// The handler explicitly classified the committed effects. + pub classified: bool, + /// IME-visible content changed. Only this gates an IME receipt. + pub content_changed: bool, + /// Any committed document state changed, including style or representation changes. + pub state_changed: bool, + /// An automatic engine rewrite was applied (AutoCorrect, build-up, AutoFormat). + pub transformed: bool, + /// The caret or selection changed. Filled in by [`Session::run`]. + pub selection_changed: bool, +} + +/// The largest text one `equation.type` call inserts, in characters. The engine truncates to this +/// in both linear and built-up modes; the UI refuses an IME commit above it instead of inserting a +/// prefix. +pub const MAX_EQUATION_TYPE_CHARS: usize = 10_000; + +/// The largest number of unit steps one `equation.deleteSurrounding` request may take on either +/// side of the caret. A hostile IME count must not become an unbounded command. +pub const MAX_EQUATION_DELETE_UNITS: usize = 64; + +pub use wordcraft_doc as doc; +pub use wordcraft_geom::Region; +pub use wordcraft_layout as layout; +pub use wordcraft_proof as proof; +pub use wordcraft_render as render; + +#[derive(Debug, thiserror::Error, Clone, PartialEq)] +pub enum CmdError { + #[error("unknown command `{0}`")] + Unknown(String), + #[error("bad parameters: {0}")] + Params(String), + #[error("not available: {0}")] + Disabled(String), + #[error("{0}")] + Failed(String), +} + +impl From for CmdError { + fn from(e: wordcraft_doc::DocError) -> Self { + CmdError::Failed(e.to_string()) + } +} + +pub type CmdResult = Result; + +/// A registered command. +#[derive(Clone)] +pub struct CommandSpec { + pub id: &'static str, + pub label: &'static str, + /// Where it lives in the UI: `Home › Font`, `Insert › Tables`, `File`… + pub location: &'static str, + /// Default shortcut in a portable form (`Mod+B`, `Mod+Shift+>`, `F7`). `Mod` = Ctrl, or ⌘ on macOS. + pub shortcut: &'static str, + /// Parameter description for agents (`{"size": number}`). + pub params: &'static str, + pub mutates: bool, + /// `None` = enabled; `Some(reason)` = disabled. + pub enabled: fn(&Session) -> Option<&'static str>, + pub run: fn(&mut Session, &Value) -> CmdResult, +} + +fn always(_: &Session) -> Option<&'static str> { + None +} + +impl CommandSpec { + pub const fn new(id: &'static str, label: &'static str, location: &'static str, run: fn(&mut Session, &Value) -> CmdResult) -> Self { + CommandSpec { id, label, location, shortcut: "", params: "{}", mutates: true, enabled: always, run } + } + pub const fn key(mut self, s: &'static str) -> Self { + self.shortcut = s; + self + } + pub const fn params(mut self, p: &'static str) -> Self { + self.params = p; + self + } + /// Doesn't change the document (navigation, view, export). + pub const fn pure(mut self) -> Self { + self.mutates = false; + self + } + pub const fn when(mut self, f: fn(&Session) -> Option<&'static str>) -> Self { + self.enabled = f; + self + } +} + +/// All commands by id. +pub struct Registry { + specs: Vec, + by_id: HashMap<&'static str, usize>, +} + +impl Registry { + pub fn new(specs: Vec) -> Self { + let mut by_id = HashMap::new(); + for (i, s) in specs.iter().enumerate() { + by_id.insert(s.id, i); + } + Registry { specs, by_id } + } + pub fn get(&self, id: &str) -> Option<&CommandSpec> { + self.by_id.get(id).and_then(|i| self.specs.get(*i)) + } + pub fn all(&self) -> &[CommandSpec] { + &self.specs + } + /// Command list as JSON (for agents: `commands` / `list_commands`). + pub fn describe(&self) -> Value { + Value::Array( + self.specs + .iter() + .map(|s| serde_json::json!({"id": s.id, "label": s.label, "location": s.location, "shortcut": s.shortcut, "params": s.params, "mutates": s.mutates})) + .collect(), + ) + } + /// The command bound to a shortcut, if any. + pub fn by_shortcut(&self, key: &str) -> Option<&CommandSpec> { + self.specs.iter().find(|s| !s.shortcut.is_empty() && s.shortcut.split(" / ").any(|k| k.eq_ignore_ascii_case(key))) + } +} + +/// Parameter helpers. +pub mod p { + use serde_json::Value; + + use crate::CmdError; + + pub fn str<'a>(v: &'a Value, k: &str) -> Option<&'a str> { + v.get(k).and_then(Value::as_str) + } + pub fn req_str<'a>(v: &'a Value, k: &str) -> Result<&'a str, CmdError> { + str(v, k).ok_or_else(|| CmdError::Params(format!("`{k}` (string) is required"))) + } + pub fn f32(v: &Value, k: &str) -> Option { + v.get(k).and_then(Value::as_f64).map(|x| x as f32).filter(|x| x.is_finite()) + } + pub fn req_f32(v: &Value, k: &str) -> Result { + f32(v, k).ok_or_else(|| CmdError::Params(format!("`{k}` (number) is required"))) + } + pub fn u64(v: &Value, k: &str) -> Option { + v.get(k).and_then(|x| x.as_u64().or_else(|| x.as_f64().filter(|f| *f >= 0.0 && f.is_finite()).map(|f| f as u64))) + } + /// A 1-based page parameter as a 0-based index. A value that cannot fit in + /// `usize` (32-bit and wasm) saturates instead of wrapping to page 1. + pub fn page_index(page: u64) -> usize { + usize::try_from(page.max(1)).unwrap_or(usize::MAX).saturating_sub(1) + } + pub fn bool(v: &Value, k: &str) -> Option { + v.get(k).and_then(Value::as_bool) + } + /// The `value` param, or the first positional-ish string. + pub fn value(v: &Value) -> Option<&Value> { + v.get("value") + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/engine/src/locale.rs b/crates/engine/src/locale.rs index 5d76e2eb87..b712648a4e 100644 --- a/crates/engine/src/locale.rs +++ b/crates/engine/src/locale.rs @@ -1,15 +1,15 @@ -use std::sync::OnceLock; - -/// The system locale (BCP 47 tag) the shell found at startup, for Numeral › System. -pub(super) static SYSTEM_LOCALE: OnceLock = OnceLock::new(); - -/// Tell the engine the system locale (the shell calls this once at startup; layout never reads -/// the environment). Headless sessions without it show Western digits for Numeral › System. -pub fn set_system_locale(tag: &str) { - let _ = SYSTEM_LOCALE.set(tag.to_string()); -} - -/// The system locale [`set_system_locale`] recorded, if the shell set one. -pub(crate) fn system_locale() -> Option<&'static str> { - SYSTEM_LOCALE.get().map(String::as_str) -} +use std::sync::OnceLock; + +/// The system locale (BCP 47 tag) the shell found at startup, for Numeral › System. +pub(super) static SYSTEM_LOCALE: OnceLock = OnceLock::new(); + +/// Tell the engine the system locale (the shell calls this once at startup; layout never reads +/// the environment). Headless sessions without it show Western digits for Numeral › System. +pub fn set_system_locale(tag: &str) { + let _ = SYSTEM_LOCALE.set(tag.to_string()); +} + +/// The system locale [`set_system_locale`] recorded, if the shell set one. +pub(crate) fn system_locale() -> Option<&'static str> { + SYSTEM_LOCALE.get().map(String::as_str) +} diff --git a/crates/engine/src/math_gallery.rs b/crates/engine/src/math_gallery.rs index 6c101592d7..c4375a1fa8 100644 --- a/crates/engine/src/math_gallery.rs +++ b/crates/engine/src/math_gallery.rs @@ -1,591 +1,591 @@ -//! The Equation tab's galleries: structure templates (fractions, scripts, radicals, integrals, -//! large operators, brackets, functions, accents, limits and logs, operators, matrices), symbol -//! sets and ready-made equations. Every template is linear-format text (`⬚` = empty slot) that -//! [`wordcraft_doc::math::parse_linear`] builds into structures. - -use wordcraft_doc::math::{Arg, LimLoc, parse_linear}; - -/// One template. -#[derive(Clone, Copy, Debug)] -pub struct Template { - pub id: &'static str, - pub label: &'static str, - /// Linear format; `⬚` marks an empty argument. - pub linear: &'static str, - /// Limit placement for n-ary operators (`None`: the operator's default). - pub limits: Option, -} - -const fn t(id: &'static str, label: &'static str, linear: &'static str) -> Template { - Template { id, label, linear, limits: None } -} -const fn stacked(id: &'static str, label: &'static str, linear: &'static str) -> Template { - Template { id, label, linear, limits: Some(LimLoc::UndOvr) } -} -const fn beside(id: &'static str, label: &'static str, linear: &'static str) -> Template { - Template { id, label, linear, limits: Some(LimLoc::SubSup) } -} - -impl Template { - /// The template as nodes. - pub fn nodes(&self) -> Arg { - let mut n = parse_linear(self.linear); - if self.limits.is_some() { - wordcraft_doc::math_edit::set_limits(&mut n, self.limits); - } - n - } -} - -/// A structure gallery (one Equation tab button) with its sections. -pub struct Gallery { - pub id: &'static str, - pub label: &'static str, - pub sections: &'static [(&'static str, &'static [Template])], -} - -pub const STRUCTURES: &[Gallery] = &[ - Gallery { - id: "fraction", - label: "Fraction", - sections: &[ - ( - "Fraction", - &[ - t("frac.stacked", "Stacked Fraction", "⬚/⬚"), - t("frac.skewed", "Skewed Fraction", "⬚⁄⬚"), - t("frac.linear", "Linear Fraction", "⬚∕⬚"), - t("frac.nobar", "Stacked, no bar", "⬚¦⬚"), - ], - ), - ( - "Common Fractions", - &[ - t("frac.dydx", "Differential", "dy/dx"), - t("frac.DyDx", "Change in y over change in x", "Δy/Δx"), - t("frac.partial", "Partial Differential", "∂y/∂x"), - t("frac.delta", "Small change", "δy/δx"), - t("frac.pi2", "Pi Over 2", "π/2"), - t("frac.half", "One half", "1/2"), - ], - ), - ], - }, - Gallery { - id: "script", - label: "Script", - sections: &[ - ( - "Subscripts and Superscripts", - &[ - t("script.sup", "Superscript", "⬚^⬚"), - t("script.sub", "Subscript", "⬚_⬚"), - t("script.subsup", "Subscript-Superscript", "⬚_⬚^⬚"), - t("script.pre", "Left Subscript-Superscript", "_⬚^⬚▒⬚"), - ], - ), - ( - "Common Subscripts and Superscripts", - &[ - t("script.xy2", "Subscript-Superscript", "x_y^2"), - t("script.eiwt", "Exponential", "e^(−iωt)"), - t("script.x2", "Square", "x^2"), - t("script.pre1n", "Left Subscript-Superscript", "_1^n▒Y"), - ], - ), - ], - }, - Gallery { - id: "radical", - label: "Radical", - sections: &[ - ( - "Radicals", - &[ - t("rad.sqrt", "Square Root", "√⬚"), - t("rad.nth", "Radical with Degree", "√(⬚&⬚)"), - t("rad.sqrt2", "Square Root with Degree", "√(2&⬚)"), - t("rad.cbrt", "Cube Root", "√(3&⬚)"), - ], - ), - ("Common Radicals", &[t("rad.quadratic", "Quadratic Root", "(−b±√(b^2−4ac))/2a"), t("rad.hyp", "Hypotenuse", "√(a^2+b^2)")]), - ], - }, - Gallery { - id: "integral", - label: "Integral", - sections: &[ - ( - "Integrals", - &[ - t("int.plain", "Integral", "∫▒⬚"), - beside("int.limits", "Integral with Limits", "∫_⬚^⬚▒⬚"), - stacked("int.stacked", "Integral with Stacked Limits", "∫_⬚^⬚▒⬚"), - t("int.double", "Double Integral", "∬▒⬚"), - beside("int.doubleLimits", "Double Integral with Limits", "∬_⬚^⬚▒⬚"), - stacked("int.doubleStacked", "Double Integral with Stacked Limits", "∬_⬚^⬚▒⬚"), - t("int.triple", "Triple Integral", "∭▒⬚"), - beside("int.tripleLimits", "Triple Integral with Limits", "∭_⬚^⬚▒⬚"), - stacked("int.tripleStacked", "Triple Integral with Stacked Limits", "∭_⬚^⬚▒⬚"), - ], - ), - ( - "Contour Integrals", - &[ - t("int.contour", "Contour Integral", "∮▒⬚"), - beside("int.contourLimits", "Contour Integral with Limits", "∮_⬚^⬚▒⬚"), - stacked("int.contourStacked", "Contour Integral with Stacked Limits", "∮_⬚^⬚▒⬚"), - t("int.surface", "Surface Integral", "∯▒⬚"), - beside("int.surfaceLimits", "Surface Integral with Limits", "∯_⬚^⬚▒⬚"), - stacked("int.surfaceStacked", "Surface Integral with Stacked Limits", "∯_⬚^⬚▒⬚"), - t("int.volume", "Volume Integral", "∰▒⬚"), - beside("int.volumeLimits", "Volume Integral with Limits", "∰_⬚^⬚▒⬚"), - stacked("int.volumeStacked", "Volume Integral with Stacked Limits", "∰_⬚^⬚▒⬚"), - ], - ), - ( - "Differentials", - &[t("int.dx", "Differential x", "dx"), t("int.dy", "Differential y", "dy"), t("int.dtheta", "Differential theta", "dθ")], - ), - ], - }, - Gallery { - id: "largeOperator", - label: "Large\nOperator", - sections: &[ - ( - "Summations", - &[ - t("nary.sum", "Summation", "∑▒⬚"), - stacked("nary.sumLimits", "Summation with Limits", "∑_⬚^⬚▒⬚"), - beside("nary.sumScripts", "Summation with Subscript-Superscript Limits", "∑_⬚^⬚▒⬚"), - stacked("nary.sumLower", "Summation with Lower Limit", "∑_⬚▒⬚"), - beside("nary.sumSub", "Summation with Subscript Limit", "∑_⬚▒⬚"), - ], - ), - ( - "Products and Coproducts", - &[ - t("nary.prod", "Product", "∏▒⬚"), - stacked("nary.prodLimits", "Product with Limits", "∏_⬚^⬚▒⬚"), - beside("nary.prodScripts", "Product with Subscript-Superscript Limits", "∏_⬚^⬚▒⬚"), - stacked("nary.prodLower", "Product with Lower Limit", "∏_⬚▒⬚"), - t("nary.coprod", "Coproduct", "∐▒⬚"), - stacked("nary.coprodLimits", "Coproduct with Limits", "∐_⬚^⬚▒⬚"), - beside("nary.coprodScripts", "Coproduct with Subscript-Superscript Limits", "∐_⬚^⬚▒⬚"), - stacked("nary.coprodLower", "Coproduct with Lower Limit", "∐_⬚▒⬚"), - ], - ), - ( - "Unions and Intersections", - &[ - t("nary.union", "Union", "⋃▒⬚"), - stacked("nary.unionLimits", "Union with Limits", "⋃_⬚^⬚▒⬚"), - beside("nary.unionScripts", "Union with Subscript-Superscript Limits", "⋃_⬚^⬚▒⬚"), - stacked("nary.unionLower", "Union with Lower Limit", "⋃_⬚▒⬚"), - t("nary.intersection", "Intersection", "⋂▒⬚"), - stacked("nary.intersectionLimits", "Intersection with Limits", "⋂_⬚^⬚▒⬚"), - beside("nary.intersectionScripts", "Intersection with Subscript-Superscript Limits", "⋂_⬚^⬚▒⬚"), - stacked("nary.intersectionLower", "Intersection with Lower Limit", "⋂_⬚▒⬚"), - ], - ), - ( - "Other Large Operators", - &[ - t("nary.or", "Logical OR", "⋁▒⬚"), - stacked("nary.orLimits", "Logical OR with Limits", "⋁_⬚^⬚▒⬚"), - t("nary.and", "Logical AND", "⋀▒⬚"), - stacked("nary.andLimits", "Logical AND with Limits", "⋀_⬚^⬚▒⬚"), - t("nary.oplus", "Circled Plus", "⨁▒⬚"), - t("nary.otimes", "Circled Times", "⨂▒⬚"), - t("nary.odot", "Circled Dot", "⨀▒⬚"), - t("nary.uplus", "Union with Plus", "⨄▒⬚"), - t("nary.sqcup", "Square Union", "⨆▒⬚"), - ], - ), - ( - "Common Large Operators", - &[ - stacked("nary.binomial", "Summation over binomials", "∑_k▒〖(n¦k)〗"), - stacked("nary.sum0n", "Summation from 0 to n", "∑_(i=0)^n▒⬚"), - stacked("nary.double", "Double summation", "∑_(0≤i≤m,0 impl Iterator { - STRUCTURES.iter().flat_map(|g| g.sections.iter().flat_map(|(_, ts)| ts.iter())) -} - -pub fn template(id: &str) -> Option<&'static Template> { - templates().find(|t| t.id == id) -} - -/// Symbol sets (the Symbols gallery's categories). -pub const SYMBOL_SETS: &[(&str, &str)] = &[ - ("Basic Math", "±∞=≠~×÷!∝<≪>≫≤≥∓≅≈≡∀∁∂√∛∜∪∩∅%°℉℃∆∇∃∄∈∋←↑→↓↔∴+−¬αβγδεϵθϑμπρστφωω∗∙⋮⋯⋰⋱ℵℶ∎"), - ("Greek Letters", "αβγδεϵζηθϑικλμνξοπϖρϱσςτυφϕχψωΑΒΓΔΕΖΗΘΙΚΛΜΝΞΟΠΡΣΤΥΦΧΨΩ"), - ("Letter-Like Symbols", "∀∁ℂ∂ðℇϝℱℊℋℌℍℎℏıℑȷℒℓℕ℘ℙℚℛℜℝℤ℧Åℬℭℰℯℳℴℵℶℷℸⅅⅆⅇⅈⅉ"), - ("Operators", "+−÷×±∓∝∕∗∘∙√∛∜∩∪⊎⊓⊔∧∨⊕⊖⊗⊘⊙⊚⊛⊞⊟⊠⊡⋄⋅⋆⋉⋊∔∸∖≀=≠<>≤≥≮≰≯≱≡∼≃≈≅≢≄≉≇∝≪≫∈∋∉⊂⊃⊆⊇≺≻≼≽⊏⊐⊑⊒∥⊥⊢⊣⋈≍∴∵∷≐≑≒≓≔≕≖≗≜≝≞≟∑∫∬∭∮∯∰∏∐⋂⋃⋀⋁⨀⨁⨂⨄⨆"), - ("Arrows", "←→↑↓↔↕⇐⇒⇑⇓⇔⇕⟵⟶⟷⟸⟹⟺↦⟼↩↪↼↽⇀⇁↿↾⇃⇂⇋⇌⇄⇆⇇⇉⇈⇊↖↗↘↙↶↷↺↻⊸↜↝↞↠↢↣↫↬↭⇚⇛↰↱⇝"), - ("Negated Relations", "≠≮≯≰≱≢≁≄≉≇≭≨≩⊀⊁⋠⋡∉∌⊄⊅⊈⊉⊊⊋⋢⋣⋦⋧⋨⋩⋪⋫⋬⋭∤∦⊬⊭⊮⊯∄"), - ("Scripts", "𝒜ℬ𝒞𝒟ℰℱ𝒢ℋℐ𝒥𝒦ℒℳ𝒩𝒪𝒫𝒬ℛ𝒮𝒯𝒰𝒱𝒲𝒳𝒴𝒵𝒶𝒷𝒸𝒹ℯ𝒻ℊ𝒽𝒾𝒿𝓀𝓁𝓂𝓃ℴ𝓅𝓆𝓇𝓈𝓉𝓊𝓋𝓌𝓍𝓎𝓏𝔄𝔅ℭ𝔇𝔈𝔉𝔊ℌℑ𝔍𝔎𝔏𝔐𝔑𝔒𝔓𝔔ℜ𝔖𝔗𝔘𝔙𝔚𝔛𝔜ℨ𝔸𝔹ℂ𝔻𝔼𝔽𝔾ℍ𝕀𝕁𝕂𝕃𝕄ℕ𝕆ℙℚℝ𝕊𝕋𝕌𝕍𝕎𝕏𝕐ℤ"), - ("Geometry", "∟∠∡∢⊾⊿⋕⊥∤∥∦∶∷∴∵∎△▭▱○◊□◇⊡⊙"), -]; - -/// Ready-made equations (Insert › Equation), grouped by subject: geometry, algebra, calculus, -/// trigonometry, probability. The labels are our own descriptions of the formulas. -pub const BUILT_INS: &[(&str, &str, &str)] = &[ - ("pythagoras", "Sides of a right triangle", "a^2+b^2=c^2"), - ("area", "Circle area from its radius", "A=πr^2"), - ("quadratic", "Roots of ax²+bx+c", "x=(−b±√(b^2−4ac))/2a"), - ("binomial", "Binomial expansion of (x+a)ⁿ", "(x+a)^n=∑_(k=0)^n▒〖(n¦k) x^k a^(n−k)〗"), - ("expansion", "Power series of (1+x)ⁿ", "(1+x)^n=1+nx/1!+(n(n−1)x^2)/2!+⋯"), - ("euler", "Euler's identity", "e^(iπ)+1=0"), - ("derivative", "Derivative as a limit", "f'(x)=lim┬(h→0)⁡〖(f(x+h)−f(x))/h〗"), - ("taylor", "Exponential series", "e^x=1+x/1!+x^2/2!+x^3/3!+⋯, −∞ bool { - a.iter().any(|n| match n { - MNode::Run(r) => r.text.contains('⬚') || r.text.contains('▒') || r.text.contains('┬') || r.text.contains('┴'), - other => { - (0..wordcraft_doc::math_edit::child_count(other)).any(|c| wordcraft_doc::math_edit::child(other, c).is_some_and(|x| has_leftover(x))) - } - }) - } - - /// A script or above/below operand takes one letter unless it is grouped (`a_(mn)`), as in - /// Word; letters right after one mean a template split a word (`⟶┴yields` put only `y` above - /// the arrow). - fn split_operand(a: &[MNode]) -> bool { - a.windows(2).any(|w| { - matches!(w[0], MNode::Lim { .. } | MNode::Script { .. }) - && matches!(&w[1], MNode::Run(r) if r.text.starts_with(|c: char| c.is_alphabetic())) - }) || a - .iter() - .any(|n| (0..wordcraft_doc::math_edit::child_count(n)).any(|c| wordcraft_doc::math_edit::child(n, c).is_some_and(|x| split_operand(x)))) - } - - #[test] - fn multi_letter_operands_are_grouped() { - for tpl in templates() { - assert!(!split_operand(&tpl.nodes()), "{} splits an operand: {}", tpl.id, tpl.linear); - } - for (id, _, lin) in BUILT_INS { - assert!(!split_operand(&parse_linear(lin)), "{id} splits an operand: {lin}"); - } - } - - #[test] - fn every_template_builds() { - let mut ids = std::collections::HashSet::new(); - for tpl in templates() { - assert!(ids.insert(tpl.id), "duplicate id {}", tpl.id); - let n = tpl.nodes(); - assert!(!n.is_empty(), "{} builds nothing", tpl.id); - assert!(!has_leftover(&n), "{} left linear operators: {}", tpl.id, to_linear(&n)); - // Templates with placeholders are structures, not text. - if tpl.linear.contains('⬚') { - assert!(n.iter().any(|x| !matches!(x, MNode::Run(_))), "{} → {n:?}", tpl.id); - } - } - for (id, _, lin) in BUILT_INS { - let n = parse_linear(lin); - assert!(!has_leftover(&n), "{id}: {}", to_linear(&n)); - } - } -} +//! The Equation tab's galleries: structure templates (fractions, scripts, radicals, integrals, +//! large operators, brackets, functions, accents, limits and logs, operators, matrices), symbol +//! sets and ready-made equations. Every template is linear-format text (`⬚` = empty slot) that +//! [`wordcraft_doc::math::parse_linear`] builds into structures. + +use wordcraft_doc::math::{Arg, LimLoc, parse_linear}; + +/// One template. +#[derive(Clone, Copy, Debug)] +pub struct Template { + pub id: &'static str, + pub label: &'static str, + /// Linear format; `⬚` marks an empty argument. + pub linear: &'static str, + /// Limit placement for n-ary operators (`None`: the operator's default). + pub limits: Option, +} + +const fn t(id: &'static str, label: &'static str, linear: &'static str) -> Template { + Template { id, label, linear, limits: None } +} +const fn stacked(id: &'static str, label: &'static str, linear: &'static str) -> Template { + Template { id, label, linear, limits: Some(LimLoc::UndOvr) } +} +const fn beside(id: &'static str, label: &'static str, linear: &'static str) -> Template { + Template { id, label, linear, limits: Some(LimLoc::SubSup) } +} + +impl Template { + /// The template as nodes. + pub fn nodes(&self) -> Arg { + let mut n = parse_linear(self.linear); + if self.limits.is_some() { + wordcraft_doc::math_edit::set_limits(&mut n, self.limits); + } + n + } +} + +/// A structure gallery (one Equation tab button) with its sections. +pub struct Gallery { + pub id: &'static str, + pub label: &'static str, + pub sections: &'static [(&'static str, &'static [Template])], +} + +pub const STRUCTURES: &[Gallery] = &[ + Gallery { + id: "fraction", + label: "Fraction", + sections: &[ + ( + "Fraction", + &[ + t("frac.stacked", "Stacked Fraction", "⬚/⬚"), + t("frac.skewed", "Skewed Fraction", "⬚⁄⬚"), + t("frac.linear", "Linear Fraction", "⬚∕⬚"), + t("frac.nobar", "Stacked, no bar", "⬚¦⬚"), + ], + ), + ( + "Common Fractions", + &[ + t("frac.dydx", "Differential", "dy/dx"), + t("frac.DyDx", "Change in y over change in x", "Δy/Δx"), + t("frac.partial", "Partial Differential", "∂y/∂x"), + t("frac.delta", "Small change", "δy/δx"), + t("frac.pi2", "Pi Over 2", "π/2"), + t("frac.half", "One half", "1/2"), + ], + ), + ], + }, + Gallery { + id: "script", + label: "Script", + sections: &[ + ( + "Subscripts and Superscripts", + &[ + t("script.sup", "Superscript", "⬚^⬚"), + t("script.sub", "Subscript", "⬚_⬚"), + t("script.subsup", "Subscript-Superscript", "⬚_⬚^⬚"), + t("script.pre", "Left Subscript-Superscript", "_⬚^⬚▒⬚"), + ], + ), + ( + "Common Subscripts and Superscripts", + &[ + t("script.xy2", "Subscript-Superscript", "x_y^2"), + t("script.eiwt", "Exponential", "e^(−iωt)"), + t("script.x2", "Square", "x^2"), + t("script.pre1n", "Left Subscript-Superscript", "_1^n▒Y"), + ], + ), + ], + }, + Gallery { + id: "radical", + label: "Radical", + sections: &[ + ( + "Radicals", + &[ + t("rad.sqrt", "Square Root", "√⬚"), + t("rad.nth", "Radical with Degree", "√(⬚&⬚)"), + t("rad.sqrt2", "Square Root with Degree", "√(2&⬚)"), + t("rad.cbrt", "Cube Root", "√(3&⬚)"), + ], + ), + ("Common Radicals", &[t("rad.quadratic", "Quadratic Root", "(−b±√(b^2−4ac))/2a"), t("rad.hyp", "Hypotenuse", "√(a^2+b^2)")]), + ], + }, + Gallery { + id: "integral", + label: "Integral", + sections: &[ + ( + "Integrals", + &[ + t("int.plain", "Integral", "∫▒⬚"), + beside("int.limits", "Integral with Limits", "∫_⬚^⬚▒⬚"), + stacked("int.stacked", "Integral with Stacked Limits", "∫_⬚^⬚▒⬚"), + t("int.double", "Double Integral", "∬▒⬚"), + beside("int.doubleLimits", "Double Integral with Limits", "∬_⬚^⬚▒⬚"), + stacked("int.doubleStacked", "Double Integral with Stacked Limits", "∬_⬚^⬚▒⬚"), + t("int.triple", "Triple Integral", "∭▒⬚"), + beside("int.tripleLimits", "Triple Integral with Limits", "∭_⬚^⬚▒⬚"), + stacked("int.tripleStacked", "Triple Integral with Stacked Limits", "∭_⬚^⬚▒⬚"), + ], + ), + ( + "Contour Integrals", + &[ + t("int.contour", "Contour Integral", "∮▒⬚"), + beside("int.contourLimits", "Contour Integral with Limits", "∮_⬚^⬚▒⬚"), + stacked("int.contourStacked", "Contour Integral with Stacked Limits", "∮_⬚^⬚▒⬚"), + t("int.surface", "Surface Integral", "∯▒⬚"), + beside("int.surfaceLimits", "Surface Integral with Limits", "∯_⬚^⬚▒⬚"), + stacked("int.surfaceStacked", "Surface Integral with Stacked Limits", "∯_⬚^⬚▒⬚"), + t("int.volume", "Volume Integral", "∰▒⬚"), + beside("int.volumeLimits", "Volume Integral with Limits", "∰_⬚^⬚▒⬚"), + stacked("int.volumeStacked", "Volume Integral with Stacked Limits", "∰_⬚^⬚▒⬚"), + ], + ), + ( + "Differentials", + &[t("int.dx", "Differential x", "dx"), t("int.dy", "Differential y", "dy"), t("int.dtheta", "Differential theta", "dθ")], + ), + ], + }, + Gallery { + id: "largeOperator", + label: "Large\nOperator", + sections: &[ + ( + "Summations", + &[ + t("nary.sum", "Summation", "∑▒⬚"), + stacked("nary.sumLimits", "Summation with Limits", "∑_⬚^⬚▒⬚"), + beside("nary.sumScripts", "Summation with Subscript-Superscript Limits", "∑_⬚^⬚▒⬚"), + stacked("nary.sumLower", "Summation with Lower Limit", "∑_⬚▒⬚"), + beside("nary.sumSub", "Summation with Subscript Limit", "∑_⬚▒⬚"), + ], + ), + ( + "Products and Coproducts", + &[ + t("nary.prod", "Product", "∏▒⬚"), + stacked("nary.prodLimits", "Product with Limits", "∏_⬚^⬚▒⬚"), + beside("nary.prodScripts", "Product with Subscript-Superscript Limits", "∏_⬚^⬚▒⬚"), + stacked("nary.prodLower", "Product with Lower Limit", "∏_⬚▒⬚"), + t("nary.coprod", "Coproduct", "∐▒⬚"), + stacked("nary.coprodLimits", "Coproduct with Limits", "∐_⬚^⬚▒⬚"), + beside("nary.coprodScripts", "Coproduct with Subscript-Superscript Limits", "∐_⬚^⬚▒⬚"), + stacked("nary.coprodLower", "Coproduct with Lower Limit", "∐_⬚▒⬚"), + ], + ), + ( + "Unions and Intersections", + &[ + t("nary.union", "Union", "⋃▒⬚"), + stacked("nary.unionLimits", "Union with Limits", "⋃_⬚^⬚▒⬚"), + beside("nary.unionScripts", "Union with Subscript-Superscript Limits", "⋃_⬚^⬚▒⬚"), + stacked("nary.unionLower", "Union with Lower Limit", "⋃_⬚▒⬚"), + t("nary.intersection", "Intersection", "⋂▒⬚"), + stacked("nary.intersectionLimits", "Intersection with Limits", "⋂_⬚^⬚▒⬚"), + beside("nary.intersectionScripts", "Intersection with Subscript-Superscript Limits", "⋂_⬚^⬚▒⬚"), + stacked("nary.intersectionLower", "Intersection with Lower Limit", "⋂_⬚▒⬚"), + ], + ), + ( + "Other Large Operators", + &[ + t("nary.or", "Logical OR", "⋁▒⬚"), + stacked("nary.orLimits", "Logical OR with Limits", "⋁_⬚^⬚▒⬚"), + t("nary.and", "Logical AND", "⋀▒⬚"), + stacked("nary.andLimits", "Logical AND with Limits", "⋀_⬚^⬚▒⬚"), + t("nary.oplus", "Circled Plus", "⨁▒⬚"), + t("nary.otimes", "Circled Times", "⨂▒⬚"), + t("nary.odot", "Circled Dot", "⨀▒⬚"), + t("nary.uplus", "Union with Plus", "⨄▒⬚"), + t("nary.sqcup", "Square Union", "⨆▒⬚"), + ], + ), + ( + "Common Large Operators", + &[ + stacked("nary.binomial", "Summation over binomials", "∑_k▒〖(n¦k)〗"), + stacked("nary.sum0n", "Summation from 0 to n", "∑_(i=0)^n▒⬚"), + stacked("nary.double", "Double summation", "∑_(0≤i≤m,0 impl Iterator { + STRUCTURES.iter().flat_map(|g| g.sections.iter().flat_map(|(_, ts)| ts.iter())) +} + +pub fn template(id: &str) -> Option<&'static Template> { + templates().find(|t| t.id == id) +} + +/// Symbol sets (the Symbols gallery's categories). +pub const SYMBOL_SETS: &[(&str, &str)] = &[ + ("Basic Math", "±∞=≠~×÷!∝<≪>≫≤≥∓≅≈≡∀∁∂√∛∜∪∩∅%°℉℃∆∇∃∄∈∋←↑→↓↔∴+−¬αβγδεϵθϑμπρστφωω∗∙⋮⋯⋰⋱ℵℶ∎"), + ("Greek Letters", "αβγδεϵζηθϑικλμνξοπϖρϱσςτυφϕχψωΑΒΓΔΕΖΗΘΙΚΛΜΝΞΟΠΡΣΤΥΦΧΨΩ"), + ("Letter-Like Symbols", "∀∁ℂ∂ðℇϝℱℊℋℌℍℎℏıℑȷℒℓℕ℘ℙℚℛℜℝℤ℧Åℬℭℰℯℳℴℵℶℷℸⅅⅆⅇⅈⅉ"), + ("Operators", "+−÷×±∓∝∕∗∘∙√∛∜∩∪⊎⊓⊔∧∨⊕⊖⊗⊘⊙⊚⊛⊞⊟⊠⊡⋄⋅⋆⋉⋊∔∸∖≀=≠<>≤≥≮≰≯≱≡∼≃≈≅≢≄≉≇∝≪≫∈∋∉⊂⊃⊆⊇≺≻≼≽⊏⊐⊑⊒∥⊥⊢⊣⋈≍∴∵∷≐≑≒≓≔≕≖≗≜≝≞≟∑∫∬∭∮∯∰∏∐⋂⋃⋀⋁⨀⨁⨂⨄⨆"), + ("Arrows", "←→↑↓↔↕⇐⇒⇑⇓⇔⇕⟵⟶⟷⟸⟹⟺↦⟼↩↪↼↽⇀⇁↿↾⇃⇂⇋⇌⇄⇆⇇⇉⇈⇊↖↗↘↙↶↷↺↻⊸↜↝↞↠↢↣↫↬↭⇚⇛↰↱⇝"), + ("Negated Relations", "≠≮≯≰≱≢≁≄≉≇≭≨≩⊀⊁⋠⋡∉∌⊄⊅⊈⊉⊊⊋⋢⋣⋦⋧⋨⋩⋪⋫⋬⋭∤∦⊬⊭⊮⊯∄"), + ("Scripts", "𝒜ℬ𝒞𝒟ℰℱ𝒢ℋℐ𝒥𝒦ℒℳ𝒩𝒪𝒫𝒬ℛ𝒮𝒯𝒰𝒱𝒲𝒳𝒴𝒵𝒶𝒷𝒸𝒹ℯ𝒻ℊ𝒽𝒾𝒿𝓀𝓁𝓂𝓃ℴ𝓅𝓆𝓇𝓈𝓉𝓊𝓋𝓌𝓍𝓎𝓏𝔄𝔅ℭ𝔇𝔈𝔉𝔊ℌℑ𝔍𝔎𝔏𝔐𝔑𝔒𝔓𝔔ℜ𝔖𝔗𝔘𝔙𝔚𝔛𝔜ℨ𝔸𝔹ℂ𝔻𝔼𝔽𝔾ℍ𝕀𝕁𝕂𝕃𝕄ℕ𝕆ℙℚℝ𝕊𝕋𝕌𝕍𝕎𝕏𝕐ℤ"), + ("Geometry", "∟∠∡∢⊾⊿⋕⊥∤∥∦∶∷∴∵∎△▭▱○◊□◇⊡⊙"), +]; + +/// Ready-made equations (Insert › Equation), grouped by subject: geometry, algebra, calculus, +/// trigonometry, probability. The labels are our own descriptions of the formulas. +pub const BUILT_INS: &[(&str, &str, &str)] = &[ + ("pythagoras", "Sides of a right triangle", "a^2+b^2=c^2"), + ("area", "Circle area from its radius", "A=πr^2"), + ("quadratic", "Roots of ax²+bx+c", "x=(−b±√(b^2−4ac))/2a"), + ("binomial", "Binomial expansion of (x+a)ⁿ", "(x+a)^n=∑_(k=0)^n▒〖(n¦k) x^k a^(n−k)〗"), + ("expansion", "Power series of (1+x)ⁿ", "(1+x)^n=1+nx/1!+(n(n−1)x^2)/2!+⋯"), + ("euler", "Euler's identity", "e^(iπ)+1=0"), + ("derivative", "Derivative as a limit", "f'(x)=lim┬(h→0)⁡〖(f(x+h)−f(x))/h〗"), + ("taylor", "Exponential series", "e^x=1+x/1!+x^2/2!+x^3/3!+⋯, −∞ bool { + a.iter().any(|n| match n { + MNode::Run(r) => r.text.contains('⬚') || r.text.contains('▒') || r.text.contains('┬') || r.text.contains('┴'), + other => { + (0..wordcraft_doc::math_edit::child_count(other)).any(|c| wordcraft_doc::math_edit::child(other, c).is_some_and(|x| has_leftover(x))) + } + }) + } + + /// A script or above/below operand takes one letter unless it is grouped (`a_(mn)`), as in + /// Word; letters right after one mean a template split a word (`⟶┴yields` put only `y` above + /// the arrow). + fn split_operand(a: &[MNode]) -> bool { + a.windows(2).any(|w| { + matches!(w[0], MNode::Lim { .. } | MNode::Script { .. }) + && matches!(&w[1], MNode::Run(r) if r.text.starts_with(|c: char| c.is_alphabetic())) + }) || a + .iter() + .any(|n| (0..wordcraft_doc::math_edit::child_count(n)).any(|c| wordcraft_doc::math_edit::child(n, c).is_some_and(|x| split_operand(x)))) + } + + #[test] + fn multi_letter_operands_are_grouped() { + for tpl in templates() { + assert!(!split_operand(&tpl.nodes()), "{} splits an operand: {}", tpl.id, tpl.linear); + } + for (id, _, lin) in BUILT_INS { + assert!(!split_operand(&parse_linear(lin)), "{id} splits an operand: {lin}"); + } + } + + #[test] + fn every_template_builds() { + let mut ids = std::collections::HashSet::new(); + for tpl in templates() { + assert!(ids.insert(tpl.id), "duplicate id {}", tpl.id); + let n = tpl.nodes(); + assert!(!n.is_empty(), "{} builds nothing", tpl.id); + assert!(!has_leftover(&n), "{} left linear operators: {}", tpl.id, to_linear(&n)); + // Templates with placeholders are structures, not text. + if tpl.linear.contains('⬚') { + assert!(n.iter().any(|x| !matches!(x, MNode::Run(_))), "{} → {n:?}", tpl.id); + } + } + for (id, _, lin) in BUILT_INS { + let n = parse_linear(lin); + assert!(!has_leftover(&n), "{id}: {}", to_linear(&n)); + } + } +} diff --git a/crates/engine/src/packs.rs b/crates/engine/src/packs.rs index 5619114bb9..7320c78663 100644 --- a/crates/engine/src/packs.rs +++ b/crates/engine/src/packs.rs @@ -1,1163 +1,1163 @@ -//! Language packs (CP4.3): spelling dictionaries and hyphenation patterns for languages beyond -//! the embedded English, one pack per locale, downloaded and verified by the font store -//! (craft-libs `craft-font-store`, item kind `language-pack`) and read by `craft-proof` from the -//! store's `language-packs/` folder. -//! -//! The commands (`proof.packs`, `proof.installPack`, `proof.removePack`), Options › Language, -//! the missing-pack bar and About › Language data all go through this module. It never asks -//! anything: an install needs the font store's single consent setting (downloading on), which only -//! a person in the app can turn on ([`Backend::enable_network`], called from an Install click). -//! Agents (MCP, the CLI, the control channel) install only while it is on. -//! -//! The [`Backend`] is set once at start-up by the desktop app and the CLI ([`use_font_store`]). -//! Without one (tests, the web build, `ui_shot`) nothing is available and nothing is downloaded. - -use std::collections::BTreeSet; -use std::path::PathBuf; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::{Arc, Mutex, PoisonError, RwLock}; - -use serde::Serialize; - -use crate::{CmdError, Session}; - -#[cfg(not(target_arch = "wasm32"))] -mod store; - -/// Longest locale tag taken, in bytes (BCP 47 tags in practice stay under 20). -pub const MAX_LOCALE_LEN: usize = 35; -/// Most packs an index may list that are read. -const MAX_PACKS: usize = 1000; -/// Paragraphs of a document looked at for its languages (the missing-pack bar). -const MAX_PARAGRAPHS_SCANNED: usize = 20_000; -/// Distinct languages of a document collected. -const MAX_DOC_LANGUAGES: usize = 64; - -/// A pack the pinned index lists. -#[derive(Clone, Debug, PartialEq, Eq, Serialize)] -pub struct Pack { - /// Lowercase BCP 47 (`nl`, `pt-pt`). - pub locale: String, - /// The language's own name (`Nederlands`). - pub name: String, - /// The craft-i18n commit the pack was built from. - pub version: String, - /// The archive. - pub url: String, - /// The upstream folder the data comes from, at its pinned commit (the index's optional - /// `source`), shown before install; the archive when the index gives none. - pub source: Option, - /// Bytes of the archive. - pub size: u64, - /// SPDX expression. - pub licence: String, - /// `Embeddable` or `Download-only`. - pub class: String, - /// Has a spelling dictionary. - pub spelling: bool, - /// Has hyphenation patterns. - pub hyphenation: bool, -} - -/// An installed pack. -#[derive(Clone, Debug, PartialEq, Eq, Serialize)] -pub struct Installed { - pub locale: String, - pub name: String, - pub licence: String, - pub path: PathBuf, -} - -/// Stops a download. On the desktop it is the font store's own flag, which a download watches -/// even while the server sends nothing. -#[derive(Clone, Debug, Default)] -pub struct Cancel(#[cfg(not(target_arch = "wasm32"))] pub(crate) craft_font_store::Cancel, #[cfg(target_arch = "wasm32")] Arc); - -impl Cancel { - pub fn new() -> Self { - Self::default() - } - #[cfg(not(target_arch = "wasm32"))] - pub fn cancel(&self) { - self.0.cancel(); - } - #[cfg(not(target_arch = "wasm32"))] - pub fn is_cancelled(&self) -> bool { - self.0.is_cancelled() - } - #[cfg(target_arch = "wasm32")] - pub fn cancel(&self) { - self.0.store(true, Ordering::Relaxed); - } - #[cfg(target_arch = "wasm32")] - pub fn is_cancelled(&self) -> bool { - self.0.load(Ordering::Relaxed) - } -} - -/// Where packs come from: the font store on the desktop, a fixed list in tests and `ui_shot`. -pub trait Backend: Send + Sync { - /// The pinned index as last fetched (no request); empty when it never was. - fn index(&self) -> Result, String>; - /// Fetch the pinned index (a request; only while downloading is on). - fn fetch_index(&self, cancel: &Cancel) -> Result, String>; - /// Whether the font store may download. - fn network_on(&self) -> bool; - /// Turn downloading on. Only from the person's own action in the app (an Install click): - /// the commands never call it, and a backend for an agent refuses. - fn enable_network(&self) -> Result<(), String>; - /// Download, verify and unpack `pack` into the packs folder. `progress` gets (done, total) bytes. - fn install(&self, pack: &Pack, cancel: &Cancel, progress: &mut dyn FnMut(u64, u64)) -> Result<(), String>; - /// Remove the installed pack for `locale`. - fn remove(&self, locale: &str) -> Result<(), String>; - /// The installed-packs folder (`/language-packs`). - fn packs_dir(&self) -> Option; - /// The packs installed now. - fn installed(&self) -> Vec { - installed_from_proof() - } - /// The test index in use instead of the pinned one (`CRAFT_LANGUAGE_PACK_INDEX`), if any. - fn index_override(&self) -> Option { - None - } -} - -static BACKEND: RwLock>> = RwLock::new(None); -/// The index last read, so the bar and the list don't read the store's cache every frame. -static INDEX: Mutex>> = Mutex::new(None); -/// Bumped whenever [`INDEX`] changes. -static INDEX_GENERATION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); - -fn set_index(index: Option>) { - *INDEX.lock().unwrap_or_else(PoisonError::into_inner) = index; - INDEX_GENERATION.fetch_add(1, Ordering::Relaxed); -} - -/// Changes whenever the list of available packs does (a fetch, another backend). -pub fn index_generation() -> u64 { - INDEX_GENERATION.load(Ordering::Relaxed) -} - -/// Use `backend` for packs from now on (`None`: no packs), and point proofing at its folder. -pub fn set_backend(backend: Option>) { - let dir = backend.as_ref().and_then(|b| b.packs_dir()); - *BACKEND.write().unwrap_or_else(PoisonError::into_inner) = backend; - set_index(None); - FETCHED.store(false, Ordering::Relaxed); - AUTO_REFRESHED.store(false, Ordering::Relaxed); - WANTED.lock().unwrap_or_else(PoisonError::into_inner).clear(); - jobs().errors.clear(); - wordcraft_proof::packs::set_packs_dir(dir); -} - -/// Use the suite's font store for packs (`/language-packs`), as the person in the app -/// (`user`: an Install click may turn downloading on) or as an agent (the CLI, headless MCP: it -/// never can). The pack index is the pinned one, or `CRAFT_LANGUAGE_PACK_INDEX` (a URL or a path, -/// for testing packs before they are published), which the font store reads itself. Returns -/// whether there is a store folder. -#[cfg(not(target_arch = "wasm32"))] -pub fn use_font_store(user: bool) -> bool { - match store::FontStoreBackend::new(user) { - Some(b) => { - set_backend(Some(Arc::new(b))); - true - } - None => false, - } -} - -/// [`use_font_store`] with the store in `dir` instead of the person's (`ui_shot`, tests). -#[cfg(not(target_arch = "wasm32"))] -pub fn use_font_store_in(dir: PathBuf, user: bool) { - set_backend(Some(Arc::new(store::FontStoreBackend::in_dir(dir, user)))); -} - -/// The backend in use. -pub fn backend() -> Option> { - BACKEND.read().unwrap_or_else(PoisonError::into_inner).clone() -} - -fn installed_from_proof() -> Vec { - wordcraft_proof::packs::installed().into_iter().map(|p| Installed { locale: p.locale, name: p.name, licence: p.licence, path: p.path }).collect() -} - -/// The test source packs come from instead of the pinned index (`CRAFT_LANGUAGE_PACK_INDEX`): -/// Options › Language says so. -pub fn index_override() -> Option { - backend().and_then(|b| b.index_override()) -} - -/// The packs installed now (none without a backend). -pub fn installed() -> Vec { - backend().map(|b| b.installed()).unwrap_or_default() -} - -/// The available packs: the index as last read, read from the store's cache the first time. -pub fn available() -> Vec { - let Some(b) = backend() else { return Vec::new() }; - let mut cached = INDEX.lock().unwrap_or_else(PoisonError::into_inner); - if let Some(i) = cached.as_ref() { - return i.clone(); - } - let index = clean_index(b.index().unwrap_or_default()); - *cached = Some(index.clone()); - drop(cached); - INDEX_GENERATION.fetch_add(1, Ordering::Relaxed); - index -} - -/// How many packs [`available`] lists, without copying them (the window asks every frame). -pub fn available_len() -> usize { - if let Some(i) = INDEX.lock().unwrap_or_else(PoisonError::into_inner).as_ref() { - return i.len(); - } - available().len() -} - -/// Fetch the index again (downloading must be on). -pub fn refresh(cancel: &Cancel) -> Result, String> { - let b = backend().ok_or_else(no_store)?; - if !b.network_on() { - return Err(network_off()); - } - let index = clean_index(b.fetch_index(cancel)?); - set_index(Some(index.clone())); - FETCHED.store(true, Ordering::Relaxed); - install_wanted(&index); - Ok(index) -} - -/// Drop packs with a malformed locale, duplicates and English (embedded), and cap the list. -fn clean_index(mut packs: Vec) -> Vec { - packs.truncate(MAX_PACKS); - let mut seen = BTreeSet::new(); - packs.retain(|p| valid_locale(&p.locale) && !is_embedded(&p.locale) && seen.insert(p.locale.clone())); - packs.sort_by(|a, b| a.locale.cmp(&b.locale)); - packs -} - -/// English is embedded in every build; it never comes as a pack. -fn is_embedded(locale: &str) -> bool { - locale == "en" || locale.starts_with("en-") -} - -/// A lowercase BCP 47-shaped tag: letters, digits and `-`, starting with a letter. -pub fn valid_locale(locale: &str) -> bool { - !locale.is_empty() - && locale.len() <= MAX_LOCALE_LEN - && locale.starts_with(|c: char| c.is_ascii_lowercase()) - && locale.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') - && !locale.contains("--") - && !locale.ends_with('-') -} - -/// A locale from a command's params: trimmed, lowercased, `_` as `-`. -pub fn parse_locale(raw: &str) -> Result { - let l = raw.trim().replace('_', "-").to_ascii_lowercase(); - if valid_locale(&l) { Ok(l) } else { Err(CmdError::Params(format!("`locale` must be a language tag like nl or pt-PT, not {raw:?}"))) } -} - -fn no_store() -> String { - if cfg!(target_arch = "wasm32") { - "Language packs aren't available in the browser".into() - } else { - "there is no font store folder for language packs".into() - } -} - -fn network_off() -> String { - "downloading is turned off: turn it on in WordCraft (File › Options › Language, or an Install button there)".into() -} - -/// The pack for `locale` in the index. -pub fn find(locale: &str) -> Option { - available().into_iter().find(|p| p.locale == locale) -} - -/// Whether a pack is installed for `locale`. -pub fn is_installed(locale: &str) -> bool { - installed().iter().any(|p| p.locale == locale) -} - -/// Install the pack for `locale` (downloading must be on): the commands and the Install button. -/// Proofing picks it up at once. -pub fn install(locale: &str, cancel: &Cancel, progress: &mut dyn FnMut(u64, u64)) -> Result { - let b = backend().ok_or_else(no_store)?; - let pack = find(locale).ok_or_else(|| format!("there is no language pack for {locale}"))?; - if !b.network_on() { - return Err(network_off()); - } - if let Some(dir) = b.packs_dir() { - crate::file_access::check_write(&dir.to_string_lossy())?; - } - b.install(&pack, cancel, progress)?; - reload(&b); - b.installed().into_iter().find(|p| p.locale == locale).ok_or_else(|| format!("the {locale} pack didn't install")) -} - -/// Remove the installed pack for `locale`. -pub fn remove(locale: &str) -> Result<(), String> { - let b = backend().ok_or_else(no_store)?; - if let Some(job) = jobs().installs.iter().find(|j| j.locale == locale) { - return Err(if job.stopping() { - format!("the {locale} pack's cancelled install is still stopping: try again in a moment") - } else { - format!("the {locale} pack is being installed: cancel it first") - }); - } - if !b.installed().iter().any(|p| p.locale == locale) { - return Err(format!("no language pack is installed for {locale}")); - } - if let Some(dir) = b.packs_dir() { - crate::file_access::check_write(&dir.to_string_lossy())?; - } - b.remove(locale)?; - reload(&b); - Ok(()) -} - -/// What the app does with a long pack request (an install or a fetch of the list): `None` runs it -/// in the caller (the CLI, headless MCP, tests); `Some(notify)` runs it on a thread, so the window -/// keeps drawing whoever asked (the person, the control channel, MCP), and calls `notify` on each -/// change (the window repaints). Set once by the desktop app. -pub type Notify = Arc; - -static BACKGROUND: RwLock> = RwLock::new(None); - -/// An install running on a thread. -struct InstallJob { - locale: String, - /// Bytes done. - done: u64, - /// Bytes in all. - total: u64, - /// Stops this run. A cancelled job stays listed (hidden from [`installing`]) until its thread - /// notices and ends, so two downloads of a locale never write its folder at once. - cancel: Cancel, - /// Install was asked again after a cancel, before the cancelled run ended: the thread starts - /// over (with the fresh `cancel`) instead of ending. - restart: bool, -} - -impl InstallJob { - /// Cancelled, and its thread hasn't ended yet. - fn stopping(&self) -> bool { - self.cancel.is_cancelled() - } -} - -/// Installs and fetches running on a thread, and how the finished ones ended. -#[derive(Default)] -struct Jobs { - /// Running installs, one per locale. - installs: Vec, - fetching: bool, - /// The last failure of each locale's install ("" for the list), until it is tried again. - errors: Vec<(String, String)>, - /// Bumped as each job ends: the window relays out and shows the result. - finished: u64, -} - -static JOBS: Mutex = Mutex::new(Jobs { installs: Vec::new(), fetching: false, errors: Vec::new(), finished: 0 }); - -fn jobs() -> std::sync::MutexGuard<'static, Jobs> { - JOBS.lock().unwrap_or_else(PoisonError::into_inner) -} - -/// Run long pack requests on a thread (`Some`: the desktop app), or in the caller (`None`). -pub fn set_background(notify: Option) { - *BACKGROUND.write().unwrap_or_else(PoisonError::into_inner) = notify; -} - -fn background() -> Option { - if cfg!(target_arch = "wasm32") { - return None; - } - BACKGROUND.read().unwrap_or_else(PoisonError::into_inner).clone() -} - -/// Whether installs and fetches run on a thread ([`set_background`]). -pub fn runs_in_background() -> bool { - background().is_some() -} - -/// A running install: (locale, bytes done, bytes in all). -#[derive(Clone, Debug, PartialEq, Eq, Serialize)] -pub struct Progress { - pub locale: String, - pub done: u64, - pub total: u64, -} - -/// The installs running now (not the cancelled ones still stopping). -pub fn installing() -> Vec { - jobs().installs.iter().filter(|j| !j.stopping()).map(|j| Progress { locale: j.locale.clone(), done: j.done, total: j.total }).collect() -} - -/// Whether the list is being fetched. -pub fn fetching() -> bool { - jobs().fetching -} - -/// The last failure of `locale`'s install ("" for the list), until it is tried again. -pub fn error(locale: &str) -> Option { - jobs().errors.iter().find(|(l, _)| l == locale).map(|(_, e)| e.clone()) -} - -/// Every recorded failure, (locale or "" for the list, message). -pub fn errors() -> Vec<(String, String)> { - jobs().errors.clone() -} - -/// Counts finished jobs: when it changes, the window lays the documents out again. -pub fn finished() -> u64 { - jobs().finished -} - -/// Stop `locale`'s running install. The person's own choice: it ends without an error, and -/// [`installing`] stops listing it at once (Install shows again). -pub fn cancel(locale: &str) { - if let Some(job) = jobs().installs.iter_mut().find(|j| j.locale == locale) { - job.restart = false; - job.cancel.cancel(); - } -} - -fn set_error(locale: &str, e: Option) { - let mut j = jobs(); - j.errors.retain(|(l, _)| l != locale); - if let Some(e) = e { - j.errors.push((locale.to_string(), e)); - } -} - -/// Start installing `locale` ([`install`]): on a thread when [`runs_in_background`], else now. -/// What's wrong up front (no pack, downloading off, outside the write root) is an error at once. -/// Returns whether it is still running (`false`: done). Asked again while it runs, it carries on; -/// right after a cancel, before the cancelled download has stopped, it starts over once that -/// one ends (never two downloads of a pack at once). -pub fn start_install(locale: &str) -> Result { - let b = backend().ok_or_else(no_store)?; - let pack = find(locale).ok_or_else(|| format!("there is no language pack for {locale}"))?; - if !b.network_on() { - return Err(network_off()); - } - if let Some(dir) = b.packs_dir() { - crate::file_access::check_write(&dir.to_string_lossy())?; - } - let Some(notify) = background() else { - let r = install(locale, &Cancel::new(), &mut |_, _| {}); - set_error(locale, r.as_ref().err().cloned()); - return r.map(|_| false); - }; - let cancel = Cancel::new(); - { - let mut j = jobs(); - j.errors.retain(|(l, _)| l != locale); - if let Some(job) = j.installs.iter_mut().find(|j| j.locale == locale) { - if job.stopping() { - // Cancelled and not ended yet: its thread starts over when it does. - job.done = 0; - job.total = pack.size; - job.cancel = cancel; - job.restart = true; - } - return Ok(true); - } - j.installs.push(InstallJob { locale: locale.to_string(), done: 0, total: pack.size, cancel: cancel.clone(), restart: false }); - } - let key = locale.to_string(); - let locale = locale.to_string(); - let started = spawn(move || { - let mut cancel = cancel; - loop { - let progress_notify = notify.clone(); - let r = install(&locale, &cancel, &mut |done, total| { - if let Some(job) = jobs().installs.iter_mut().find(|j| j.locale == locale) { - job.done = done; - job.total = total; - } - progress_notify(); - }); - let mut j = jobs(); - if let Some(job) = j.installs.iter_mut().find(|j| j.locale == locale && j.restart) { - job.restart = false; - cancel = job.cancel.clone(); - drop(j); - notify(); - continue; - } - j.installs.retain(|j| j.locale != locale); - // The person's own cancel isn't a failure: nothing to show. - if let Err(e) = r - && !cancel.is_cancelled() - { - j.errors.push((locale, e)); - } - j.finished = j.finished.wrapping_add(1); - drop(j); - notify(); - break; - } - }); - if let Err(e) = started { - jobs().installs.retain(|j| j.locale != key); - return Err(e); - } - Ok(true) -} - -/// Start fetching the list again ([`refresh`]): on a thread when [`runs_in_background`], else now. -/// Returns whether it is still running. -pub fn start_refresh() -> Result { - let b = backend().ok_or_else(no_store)?; - if !b.network_on() { - return Err(network_off()); - } - let Some(notify) = background() else { - let r = refresh(&Cancel::new()); - set_error("", r.as_ref().err().cloned()); - return r.map(|_| false); - }; - { - let mut j = jobs(); - if j.fetching { - return Ok(true); - } - j.fetching = true; - j.errors.retain(|(l, _)| !l.is_empty()); - } - let started = spawn(move || { - let r = refresh(&Cancel::new()); - let mut j = jobs(); - j.fetching = false; - if let Err(e) = r { - j.errors.push((String::new(), e)); - } - j.finished = j.finished.wrapping_add(1); - drop(j); - notify(); - }); - if let Err(e) = started { - jobs().fetching = false; - return Err(e); - } - Ok(true) -} - -#[cfg(not(target_arch = "wasm32"))] -fn spawn(f: impl FnOnce() + Send + 'static) -> Result<(), String> { - std::thread::Builder::new().name("language-pack".into()).spawn(f).map(|_| ()).map_err(|e| format!("couldn't start the download: {e}")) -} - -#[cfg(target_arch = "wasm32")] -fn spawn(_: impl FnOnce() + Send + 'static) -> Result<(), String> { - Err(no_store()) -} - -/// Have proofing look at the packs folder again (its dictionaries are reloaded). -fn reload(b: &Arc) { - if wordcraft_proof::packs::packs_dir() == b.packs_dir() { - wordcraft_proof::packs::reload(); - } else { - wordcraft_proof::packs::set_packs_dir(b.packs_dir()); - } -} - -/// The full licence texts for About › Language data: the embedded English data, then each -/// installed pack. -pub fn licence_texts() -> Vec<(String, String)> { - let mut texts = wordcraft_proof::packs::licence_texts(); - // SCOWL's notices (with the Ispell BSD licence of its affix files), when English spells from - // the embedded SCOWL data; after the Moby and supplement entries. - if let Some(notice) = wordcraft_proof::scowl::notice() { - let at = texts.len().min(2); - texts.insert(at, (SCOWL_TITLE.into(), notice.into())); - } - texts -} - -/// About › Language data's title for SCOWL's notices. -pub const SCOWL_TITLE: &str = "English spelling dictionaries (SCOWL)"; - -/// The pack locale for a document language tag: the whole tag (`pt-pt`), else each shorter -/// prefix of it (`sr-latn` for `sr-Latn-RS`, `nl` for `nl-NL`), the first the index has. A -/// variant after the region is also tried without the region, before the region alone -/// (`ca-ES-valencia` is the `ca-valencia` pack's), as craft-proof looks its dictionary up. `None` -/// for English and tags with no pack. -pub fn pack_for_tag<'a>(tag: &str, packs: &'a [Pack]) -> Option<&'a Pack> { - pack_candidates(tag).iter().find_map(|c| packs.iter().find(|p| &p.locale == c)) -} - -/// Is `subtag` a region, as it follows a tag's language (and script): two letters (`NL`) or -/// three digits (`419`)? The one test of what a region is; the lists, the menus and -/// [`pack_for_tag`] all use it. -pub fn is_region(subtag: &str) -> bool { - (subtag.len() == 2 && subtag.bytes().all(|b| b.is_ascii_alphabetic())) || (subtag.len() == 3 && subtag.bytes().all(|b| b.is_ascii_digit())) -} - -/// `tag` without its region subtags (`nl-NL` is `nl`, `sr-Latn-RS` is `sr-Latn`, `ca-ES-valencia` -/// is `ca-valencia`), joined with `-` whichever of `-` and `_` separated them; the case is kept. -/// The language itself (the first subtag) is never a region. -pub fn without_region(tag: &str) -> String { - tag.split(['-', '_']).enumerate().filter(|(i, p)| *i == 0 || !is_region(p)).map(|(_, p)| p).collect::>().join("-") -} - -/// The pack locales [`pack_for_tag`] tries for `tag`, best first. -fn pack_candidates(tag: &str) -> Vec { - // A tag is a handful of short subtags; anything longer is junk and only its start is read. - let tag: String = tag.trim().chars().take(64).collect::().replace('_', "-").to_ascii_lowercase(); - let parts: Vec<&str> = tag.split('-').collect(); - let base = parts.first().copied().unwrap_or(""); - if base.is_empty() || base == "en" { - return Vec::new(); - } - let mut out: Vec = Vec::new(); - for n in (1..=parts.len()).rev() { - let prefix = parts.get(..n).unwrap_or_default().join("-"); - // The prefix, then without its region: the variant's pack before the region alone. - let stripped = without_region(&prefix); - for c in [prefix, stripped] { - if !out.contains(&c) { - out.push(c); - } - } - } - out -} - -/// The languages (`w:lang`, resolved through the styles) of the body text, in first-seen order. -pub fn document_languages(s: &Session) -> Vec { - let mut out: Vec = Vec::new(); - for path in s.doc.para_paths(wordcraft_doc::StoryRef::Body).into_iter().take(MAX_PARAGRAPHS_SCANNED) { - let Some(p) = s.doc.para(wordcraft_doc::StoryRef::Body, &path) else { continue }; - if p.text.trim().is_empty() { - continue; - } - let style = p.props.style.as_deref(); - for (range, c) in p.run_ranges() { - let Some(text) = p.text.get(range) else { continue }; - if !text.chars().any(char::is_alphabetic) { - continue; - } - let rc = s.doc.styles.resolve_char_in(style, None, c); - if rc.no_proof { - continue; - } - let tag = rc.lang.unwrap_or_else(|| "en-US".into()); - if !out.contains(&tag) { - out.push(tag); - if out.len() >= MAX_DOC_LANGUAGES { - return out; - } - } - } - } - out -} - -/// Whether proofing reads a pack for text in `tag`: every language craft-proof can name, its own -/// (Polish…) and any other (`ProofLang::Pack`, read from the pack alone); not `und`, `zxx` or a -/// malformed tag. The one place the bar's language rule lives. -pub fn proofable(tag: &str) -> bool { - wordcraft_proof::ProofLang::from_tag(Some(tag)) != wordcraft_proof::ProofLang::Other -} - -/// The Hunspell dictionary `tag`'s language needs and doesn't have, for the status bar's -/// "(no dictionary)": craft-proof's [`wordcraft_proof::missing_dictionary`], except for a -/// language craft-proof has no rules of its own for (`ProofLang::Pack`: Japanese, Chinese, -/// Thai…) when no pack for it is available either, as no Hunspell dictionary may exist at all. -pub fn missing_dictionary(tag: Option<&str>) -> Option<&'static str> { - let name = wordcraft_proof::missing_dictionary(tag)?; - let lang = wordcraft_proof::ProofLang::from_tag(tag); - if lang.pack_locale().is_some() && !has_pack_for_tag(tag.unwrap_or("")) { - return None; - } - Some(name) -} - -/// The last answer of whether the index has a pack for a tag, for the index generation it was -/// worked out for: the status bar asks every frame while the caret is in a pack language, and -/// working it out copies the whole index. -#[derive(Default)] -struct PackLookup { - /// (index generation, tag, has a pack). - entry: Option<(u64, String, bool)>, -} - -impl PackLookup { - /// The remembered answer for `tag` at `generation`, else `compute`'s (then remembered). - fn get(&mut self, generation: u64, tag: &str, compute: impl FnOnce() -> bool) -> bool { - if let Some((g, t, has)) = &self.entry - && *g == generation - && t == tag - { - return *has; - } - let has = compute(); - self.entry = Some((generation, tag.to_string(), has)); - has - } -} - -static PACK_LOOKUP: Mutex = Mutex::new(PackLookup { entry: None }); - -/// Whether the index has a pack for `tag` ([`pack_for_tag`]), worked out again only when the -/// index changes ([`index_generation`]) or the tag does. -fn has_pack_for_tag(tag: &str) -> bool { - // Read before [`available`], which may fill the index and bump it: the answer is then worked - // out once more, never kept for a list it wasn't worked out from. - let generation = index_generation(); - PACK_LOOKUP.lock().unwrap_or_else(PoisonError::into_inner).get(generation, tag, || pack_for_tag(tag, &available()).is_some()) -} - -/// The language new text at the caret gets (its typing language, resolved through the styles -/// and the document default), unless it is marked Do not check spelling or grammar: choosing a -/// language with nothing selected asks for its pack before anything is typed in it. -pub fn caret_language(s: &Session) -> Option { - let style = s.doc.para_at(&s.sel.focus).and_then(|p| p.props.style.as_deref()); - let rc = s.doc.styles.resolve_char(style, &s.typing_props()); - if rc.no_proof { - return None; - } - Some(rc.lang.unwrap_or_else(|| "en-US".into())) -} - -/// The languages the missing-pack bar looks at: the body text's ([`document_languages`]), then -/// the caret's typing language ([`caret_language`]). -pub fn wanted_languages(s: &Session) -> Vec { - let mut out = document_languages(s); - if let Some(tag) = caret_language(s) - && !out.contains(&tag) - { - out.push(tag); - } - out -} - -/// The packs the document's text (or the caret's typing language) asks for that are available -/// and not installed: what the missing-pack bar offers. Never a language without a pack, nor one -/// proofing can't use. -pub fn missing(s: &Session) -> Vec { - let packs = available(); - if packs.is_empty() { - return Vec::new(); - } - let installed = installed(); - let mut out: Vec = Vec::new(); - for tag in wanted_languages(s) { - if proofable(&tag) - && let Some(p) = pack_for_tag(&tag, &packs) - && !installed.iter().any(|i| i.locale == p.locale) - && !out.iter().any(|o| o.locale == p.locale) - { - out.push(p.clone()); - } - } - out -} - -/// Where a language's proofing comes from (Options › Language › Your languages). -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] -#[serde(rename_all = "snake_case")] -pub enum Proofing { - /// English: the embedded dictionaries. - BuiltIn, - /// An installed Language pack. - Installed, - /// A Hunspell dictionary in the person's own dictionaries folder. - OwnDictionary, - /// A Hunspell dictionary the system has. - SystemDictionary, - /// No dictionary yet; a Language pack can be installed. - Install, - /// Grammar and hyphenation rules built in; a Language pack adds spelling. - GrammarBuiltIn, - /// No dictionary and no pack for it. - NotAvailable, -} - -/// A row of Options › Language › Your languages. -#[derive(Clone, Debug, PartialEq, Eq, Serialize)] -pub struct LanguageRow { - /// The tags the default proofing language or the open document use for this language (the - /// first is what Set as default sets); empty for a language listed for its installed pack. - pub tags: Vec, - /// The pack locale: the installed pack, else the one the index offers. - pub pack: Option, - /// Is the pack installed? - pub installed: bool, - /// Bytes of the offered pack (0 when the index has none). - pub size: u64, - pub proofing: Proofing, - /// Is this the default proofing language? - pub default: bool, -} - -impl LanguageRow { - /// Is `tag` (the insertion point's language) one of this row's languages? - pub fn is_tag(&self, tag: &str) -> bool { - self.tags.iter().any(|t| t.eq_ignore_ascii_case(tag)) - } -} - -/// The first locale `has` accepts for `tag`, in [`pack_for_tag`]'s order. `None` for English and junk. -fn locale_for_tag(tag: &str, has: &dyn Fn(&str) -> bool) -> Option { - pack_candidates(tag).into_iter().find(|c| has(c)) -} - -/// [`your_languages_with`] against the dictionaries on this computer. -pub fn your_languages(s: &Session) -> Vec { - let folder = s.prefs.dictionaries_folder.clone(); - your_languages_with(s, folder.as_deref(), &dictionary_file) -} - -/// The Hunspell `.dic` file proofing reads for `tag`'s language now (a built-in-rules language's -/// own, or a pack language's), if any. -fn dictionary_file(tag: &str) -> Option { - let lang = wordcraft_proof::ProofLang::from_tag(Some(tag)); - if let Some((_, f)) = wordcraft_proof::installed_dictionaries().into_iter().find(|(l, _)| *l == lang) { - return f; - } - wordcraft_proof::pack_dictionary_file(lang) -} - -/// Options › Language › Your languages (unsorted; the window names and sorts them): each English -/// variant in use (the default proofing language, American English and any in the open document), every language -/// with an installed Language pack, and every proofing language of the open document. One row per -/// language: tags one pack proofs share a row. `folder` is the person's own dictionaries folder; -/// `dictionary` finds the `.dic` file proofing reads for a tag. -pub fn your_languages_with(s: &Session, folder: Option<&str>, dictionary: &dyn Fn(&str) -> Option) -> Vec { - let index = available(); - let installed = installed(); - let default_tag = s - .prefs - .default_language - .clone() - .or_else(|| s.editing_lang.clone()) - .or_else(|| s.doc.styles.default_chr.lang.clone()) - .unwrap_or_else(|| "en-US".to_string()); - // American English stays listed whatever the default is, so it can always be set back. - let mut tags = vec![default_tag.clone(), "en-US".to_string()]; - tags.dedup_by(|a, b| a.eq_ignore_ascii_case(b)); - for t in document_languages(s) { - if !tags.iter().any(|x| x.eq_ignore_ascii_case(&t)) { - tags.push(t); - } - } - let mut rows: Vec = Vec::new(); - let mut add = |tag: Option<&str>, locale: Option| { - let english = tag.is_some_and(|t| wordcraft_proof::ProofLang::from_tag(Some(t)).is_english()); - // English variants stay apart (en-GB and en-US are two rows); a pack's tags share one. - let existing = rows.iter().position(|r| match (&locale, &r.pack) { - (Some(l), Some(p)) if !english => l == p, - _ => tag.is_some_and(|t| r.is_tag(t)), - }); - let i = existing.unwrap_or_else(|| { - rows.push(LanguageRow { - tags: Vec::new(), - pack: locale.clone(), - installed: false, - size: 0, - proofing: Proofing::NotAvailable, - default: false, - }); - rows.len() - 1 - }); - if let (Some(t), Some(row)) = (tag, rows.get_mut(i)) - && !row.is_tag(t) - { - row.tags.push(t.to_string()); - } - }; - for t in &tags { - if !proofable(t) { - continue; - } - // The most specific pack, installed or offered, as the bar picks it ([`pack_for_tag`]): with - // `ca` installed, Valencian text is still the `ca-valencia` pack's row. - let locale = locale_for_tag(t, &|l| installed.iter().any(|i| i.locale == l) || index.iter().any(|p| p.locale == l)); - add(Some(t), locale); - } - for i in &installed { - add(None, Some(i.locale.clone())); - } - for row in &mut rows { - let installed_here = row.pack.as_ref().is_some_and(|l| installed.iter().any(|i| &i.locale == l)); - let offered = row.pack.as_ref().and_then(|l| index.iter().find(|p| &p.locale == l)); - row.installed = installed_here; - row.size = offered.map_or(0, |p| p.size); - row.default = row.is_tag(&default_tag); - let tag = row.tags.first().cloned().or_else(|| row.pack.clone()).unwrap_or_default(); - let lang = wordcraft_proof::ProofLang::from_tag(Some(&tag)); - let file = if lang.is_english() { None } else { dictionary(&tag) }; - let own = file.as_ref().is_some_and(|f| folder.is_some_and(|d| !d.trim().is_empty() && f.starts_with(d.trim()))); - row.proofing = if lang.is_english() { - Proofing::BuiltIn - } else if own { - Proofing::OwnDictionary - } else if installed_here { - Proofing::Installed - } else if file.is_some() { - Proofing::SystemDictionary - } else if offered.is_some() { - if matches!(lang, wordcraft_proof::ProofLang::Pack(_)) { Proofing::Install } else { Proofing::GrammarBuiltIn } - } else { - Proofing::NotAvailable - }; - } - rows -} - -/// The index was fetched this session (since the backend was set): what it lists is then the -/// whole answer, even when it lists nothing. -static FETCHED: AtomicBool = AtomicBool::new(false); -/// The automatic fetch of a missing or stale list ([`refresh_if_stale`]) was tried. -static AUTO_REFRESHED: AtomicBool = AtomicBool::new(false); -/// Languages (document tags) whose pack the person asked for before the list was known -/// ([`start_get`]): installed once a fetch lists one. -static WANTED: Mutex> = Mutex::new(Vec::new()); - -/// Whether the index was fetched since the backend was set. -pub fn fetched() -> bool { - FETCHED.load(Ordering::Relaxed) -} - -/// The languages the document (or the caret) asks for while there is no list of packs yet (a -/// fresh install, or a list cached for an older pin): proofable, not English, with no dictionary -/// and no pack installed, one per proofing language, as the document tags them (`tr-TR`). The -/// bar offers to get them: the list is fetched first, and the language then installed if it has -/// a pack. Nothing once a list is known or was fetched this session ([`missing`] then answers). -pub fn unlisted(s: &Session) -> Vec { - if backend().is_none() || fetched() || available_len() > 0 { - return Vec::new(); - } - let installed = installed(); - let mut seen: Vec = Vec::new(); - let mut out = Vec::new(); - for tag in wanted_languages(s) { - let base = tag.split(['-', '_']).next().unwrap_or("").to_ascii_lowercase(); - let lang = wordcraft_proof::ProofLang::from_tag(Some(&tag)); - if base.is_empty() - || base == "en" - || !proofable(&tag) - || wordcraft_proof::missing_dictionary(Some(&tag)).is_none() - || seen.contains(&lang) - || installed.iter().any(|i| i.locale == base || tag.eq_ignore_ascii_case(&i.locale)) - { - continue; - } - seen.push(lang); - out.push(tag); - } - out -} - -/// The person asked for `tag`'s pack before the list was known (the bar's Install, after their -/// consent turned downloading on): fetch the list ([`start_refresh`]), then install the pack it -/// lists for `tag`, if any; with none, the bar stops offering it. Downloading must be on. -/// Returns whether it is still running. -pub fn start_get(tag: &str) -> Result { - let tag = tag.trim(); - if tag.is_empty() || tag.len() > MAX_LOCALE_LEN { - return Err(format!("`{tag}` isn't a language tag")); - } - if let Some(p) = pack_for_tag(tag, &available()) { - return start_install(&p.locale); - } - let b = backend().ok_or_else(no_store)?; - if !b.network_on() { - // Never queued: a later fetch mustn't install what was refused. - return Err(network_off()); - } - let queued = { - let mut w = WANTED.lock().unwrap_or_else(PoisonError::into_inner); - let new = !w.iter().any(|t| t == tag) && w.len() < MAX_DOC_LANGUAGES; - if new { - w.push(tag.to_string()); - } - new - }; - let r = start_refresh(); - if r.is_err() && queued { - WANTED.lock().unwrap_or_else(PoisonError::into_inner).retain(|t| t != tag); - } - r -} - -/// After a fetch: install the packs asked for before the list was known ([`start_get`]). -fn install_wanted(index: &[Pack]) { - let wanted = std::mem::take(&mut *WANTED.lock().unwrap_or_else(PoisonError::into_inner)); - for tag in wanted { - let Some(p) = pack_for_tag(&tag, index) else { continue }; - if is_installed(&p.locale) { - continue; - } - if let Err(e) = start_install(&p.locale) { - set_error(&p.locale, Some(e)); - } - } -} - -/// Once a session (the first time it is asked once requests run on a thread, [`runs_in_background`]: -/// the window), with downloading already on and no list (none cached, or one cached for an older -/// pin): fetch it in the background, so the bar can offer packs. The person's consent is the -/// downloading setting itself; nothing is asked. Its failure is recorded for Options (""). The -/// window asks every frame: after the first time this reads nothing (the downloading setting is -/// a file). Returns whether a fetch was started. -pub fn refresh_if_stale() -> bool { - let Some(b) = backend() else { return false }; - if !runs_in_background() || AUTO_REFRESHED.swap(true, Ordering::Relaxed) { - return false; - } - if !b.network_on() || fetched() || fetching() || available_len() > 0 { - return false; - } - start_refresh().is_ok() -} - -/// A backend with a fixed list and installs that only record themselves: tests and `ui_shot`. -#[derive(Default)] -pub struct FixedBackend { - pub packs: Vec, - pub network: AtomicBool, - /// Agents can't turn the network on. - pub agent: bool, - pub installed: Mutex>, -} - -impl Pack { - /// Where the data comes from: the upstream source when the index names it, else the archive. - pub fn source_link(&self) -> &str { - self.source.as_deref().filter(|s| !s.is_empty()).unwrap_or(&self.url) - } -} - -impl FixedBackend { - /// A few packs shaped like the published ones, for screenshots and tests. - pub fn sample() -> Self { - let pack = |locale: &str, name: &str, size: u64, licence: &str, class: &str| Pack { - locale: locale.into(), - name: name.into(), - version: "0000000".into(), - url: format!("https://github.com/storytold/craft-i18n/releases/download/language-packs-2/{locale}.tar.zst"), - source: Some("https://github.com/LibreOffice/dictionaries".into()), - size, - licence: licence.into(), - class: class.into(), - spelling: true, - hyphenation: true, - }; - FixedBackend { - packs: vec![ - pack("ca", "Català", 1_480_000, "GPL-2.0-or-later OR LGPL-2.1-or-later", "Download-only"), - pack("el", "Ελληνικά", 2_310_000, "MPL-1.1 OR GPL-2.0-or-later OR LGPL-2.1-or-later", "Embeddable"), - pack("fr", "Français", 1_120_000, "MPL-2.0", "Embeddable"), - pack("nl", "Nederlands", 1_950_000, "BSD-3-Clause", "Embeddable"), - pack("pl", "Polski", 4_620_000, "GPL-2.0-or-later OR LGPL-2.1-or-later OR MPL-1.1 OR Apache-2.0 OR CC-BY-4.0", "Embeddable"), - pack("sk", "Slovenčina", 2_040_000, "GPL-2.0-only OR LGPL-2.1-only OR MPL-1.1", "Download-only"), - ], - ..Default::default() - } - } -} - -impl Backend for FixedBackend { - fn index(&self) -> Result, String> { - Ok(self.packs.clone()) - } - fn fetch_index(&self, _: &Cancel) -> Result, String> { - Ok(self.packs.clone()) - } - fn network_on(&self) -> bool { - self.network.load(Ordering::Relaxed) - } - fn enable_network(&self) -> Result<(), String> { - if self.agent { - return Err("only the user can turn on downloading".into()); - } - self.network.store(true, Ordering::Relaxed); - Ok(()) - } - fn install(&self, pack: &Pack, cancel: &Cancel, progress: &mut dyn FnMut(u64, u64)) -> Result<(), String> { - if cancel.is_cancelled() { - return Err("cancelled".into()); - } - progress(pack.size, pack.size); - let mut i = self.installed.lock().unwrap_or_else(PoisonError::into_inner); - i.retain(|p| p.locale != pack.locale); - i.push(Installed { locale: pack.locale.clone(), name: pack.name.clone(), licence: pack.licence.clone(), path: PathBuf::new() }); - Ok(()) - } - fn remove(&self, locale: &str) -> Result<(), String> { - self.installed.lock().unwrap_or_else(PoisonError::into_inner).retain(|p| p.locale != locale); - Ok(()) - } - fn packs_dir(&self) -> Option { - None - } - fn installed(&self) -> Vec { - self.installed.lock().unwrap_or_else(PoisonError::into_inner).clone() - } -} - -#[cfg(test)] -mod tests { - /// CP4.4 (`cargo xtask language`): a build embeds English Language data and nothing it - /// wasn't asked to. A Language build names the locales it embeds in `CRAFT_PROOF_EMBED` - /// (`en` alone: English only); the default embeds the carry-over locales too, until Language - /// packs are published and pinned (craft-proof's `DEFAULT_EMBED`; TODO(CP4.4): English only - /// then). - /// About › Language data shows SCOWL's notices (the Ispell BSD licence among them) whenever - /// English spells from the embedded SCOWL data. - #[test] - fn about_shows_the_scowl_notice_when_scowl_is_embedded() { - let texts = super::licence_texts(); - match wordcraft_proof::scowl::notice() { - Some(notice) => { - let entry = texts.iter().find(|(t, _)| t == super::SCOWL_TITLE).map(|(_, x)| x.as_str()); - assert_eq!(entry, Some(notice)); - assert!(notice.contains("Ispell") || notice.contains("ispell"), "the Ispell notice is part of it"); - } - None => assert!(texts.iter().all(|(t, _)| t != super::SCOWL_TITLE), "no SCOWL data: no SCOWL entry"), - } - } - - /// FU.3: the status bar's "(no dictionary)" asks every frame whether the index has a pack - /// for the caret's language; the answer is reused until the index changes (a new - /// generation) or the tag does, so the index isn't copied every frame. - #[test] - fn the_pack_lookup_is_reused_until_the_index_changes() { - let mut lookup = super::PackLookup::default(); - let mut computed = 0; - let mut ask = |lookup: &mut super::PackLookup, generation: u64, tag: &str, answer: bool| { - lookup.get(generation, tag, || { - computed += 1; - answer - }) - }; - assert!(ask(&mut lookup, 1, "ja-JP", true)); - // Same generation and tag: the remembered answer, even where the list would now say no. - for _ in 0..10 { - assert!(ask(&mut lookup, 1, "ja-JP", false)); - } - assert!(!ask(&mut lookup, 2, "ja-JP", false), "a new generation works it out again"); - assert!(ask(&mut lookup, 2, "th-TH", true), "another tag too"); - assert_eq!(computed, 3); - } - - #[test] - fn the_default_build_embeds_only_english() { - const CARRY_OVER: [&str; 5] = ["de", "pl", "pt", "it", "ru"]; - let named: Vec = std::env::var("CRAFT_PROOF_EMBED") - .unwrap_or_default() - .split(',') - .map(|l| l.trim().to_ascii_lowercase()) - .filter(|l| !l.is_empty()) - .collect(); - let allowed = |base: &str| base == "en" || if named.is_empty() { CARRY_OVER.contains(&base) } else { named.iter().any(|n| n == base) }; - let embedded = wordcraft_proof::packs::embedded_locales(); - let extra: Vec<&&str> = embedded.iter().filter(|l| !allowed(l.split('-').next().unwrap_or(l))).collect(); - assert!(extra.is_empty(), "embedded beyond English and {named:?} (or the carry-over by default): {extra:?}"); - } -} +//! Language packs (CP4.3): spelling dictionaries and hyphenation patterns for languages beyond +//! the embedded English, one pack per locale, downloaded and verified by the font store +//! (craft-libs `craft-font-store`, item kind `language-pack`) and read by `craft-proof` from the +//! store's `language-packs/` folder. +//! +//! The commands (`proof.packs`, `proof.installPack`, `proof.removePack`), Options › Language, +//! the missing-pack bar and About › Language data all go through this module. It never asks +//! anything: an install needs the font store's single consent setting (downloading on), which only +//! a person in the app can turn on ([`Backend::enable_network`], called from an Install click). +//! Agents (MCP, the CLI, the control channel) install only while it is on. +//! +//! The [`Backend`] is set once at start-up by the desktop app and the CLI ([`use_font_store`]). +//! Without one (tests, the web build, `ui_shot`) nothing is available and nothing is downloaded. + +use std::collections::BTreeSet; +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex, PoisonError, RwLock}; + +use serde::Serialize; + +use crate::{CmdError, Session}; + +#[cfg(not(target_arch = "wasm32"))] +mod store; + +/// Longest locale tag taken, in bytes (BCP 47 tags in practice stay under 20). +pub const MAX_LOCALE_LEN: usize = 35; +/// Most packs an index may list that are read. +const MAX_PACKS: usize = 1000; +/// Paragraphs of a document looked at for its languages (the missing-pack bar). +const MAX_PARAGRAPHS_SCANNED: usize = 20_000; +/// Distinct languages of a document collected. +const MAX_DOC_LANGUAGES: usize = 64; + +/// A pack the pinned index lists. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct Pack { + /// Lowercase BCP 47 (`nl`, `pt-pt`). + pub locale: String, + /// The language's own name (`Nederlands`). + pub name: String, + /// The craft-i18n commit the pack was built from. + pub version: String, + /// The archive. + pub url: String, + /// The upstream folder the data comes from, at its pinned commit (the index's optional + /// `source`), shown before install; the archive when the index gives none. + pub source: Option, + /// Bytes of the archive. + pub size: u64, + /// SPDX expression. + pub licence: String, + /// `Embeddable` or `Download-only`. + pub class: String, + /// Has a spelling dictionary. + pub spelling: bool, + /// Has hyphenation patterns. + pub hyphenation: bool, +} + +/// An installed pack. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct Installed { + pub locale: String, + pub name: String, + pub licence: String, + pub path: PathBuf, +} + +/// Stops a download. On the desktop it is the font store's own flag, which a download watches +/// even while the server sends nothing. +#[derive(Clone, Debug, Default)] +pub struct Cancel(#[cfg(not(target_arch = "wasm32"))] pub(crate) craft_font_store::Cancel, #[cfg(target_arch = "wasm32")] Arc); + +impl Cancel { + pub fn new() -> Self { + Self::default() + } + #[cfg(not(target_arch = "wasm32"))] + pub fn cancel(&self) { + self.0.cancel(); + } + #[cfg(not(target_arch = "wasm32"))] + pub fn is_cancelled(&self) -> bool { + self.0.is_cancelled() + } + #[cfg(target_arch = "wasm32")] + pub fn cancel(&self) { + self.0.store(true, Ordering::Relaxed); + } + #[cfg(target_arch = "wasm32")] + pub fn is_cancelled(&self) -> bool { + self.0.load(Ordering::Relaxed) + } +} + +/// Where packs come from: the font store on the desktop, a fixed list in tests and `ui_shot`. +pub trait Backend: Send + Sync { + /// The pinned index as last fetched (no request); empty when it never was. + fn index(&self) -> Result, String>; + /// Fetch the pinned index (a request; only while downloading is on). + fn fetch_index(&self, cancel: &Cancel) -> Result, String>; + /// Whether the font store may download. + fn network_on(&self) -> bool; + /// Turn downloading on. Only from the person's own action in the app (an Install click): + /// the commands never call it, and a backend for an agent refuses. + fn enable_network(&self) -> Result<(), String>; + /// Download, verify and unpack `pack` into the packs folder. `progress` gets (done, total) bytes. + fn install(&self, pack: &Pack, cancel: &Cancel, progress: &mut dyn FnMut(u64, u64)) -> Result<(), String>; + /// Remove the installed pack for `locale`. + fn remove(&self, locale: &str) -> Result<(), String>; + /// The installed-packs folder (`/language-packs`). + fn packs_dir(&self) -> Option; + /// The packs installed now. + fn installed(&self) -> Vec { + installed_from_proof() + } + /// The test index in use instead of the pinned one (`CRAFT_LANGUAGE_PACK_INDEX`), if any. + fn index_override(&self) -> Option { + None + } +} + +static BACKEND: RwLock>> = RwLock::new(None); +/// The index last read, so the bar and the list don't read the store's cache every frame. +static INDEX: Mutex>> = Mutex::new(None); +/// Bumped whenever [`INDEX`] changes. +static INDEX_GENERATION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + +fn set_index(index: Option>) { + *INDEX.lock().unwrap_or_else(PoisonError::into_inner) = index; + INDEX_GENERATION.fetch_add(1, Ordering::Relaxed); +} + +/// Changes whenever the list of available packs does (a fetch, another backend). +pub fn index_generation() -> u64 { + INDEX_GENERATION.load(Ordering::Relaxed) +} + +/// Use `backend` for packs from now on (`None`: no packs), and point proofing at its folder. +pub fn set_backend(backend: Option>) { + let dir = backend.as_ref().and_then(|b| b.packs_dir()); + *BACKEND.write().unwrap_or_else(PoisonError::into_inner) = backend; + set_index(None); + FETCHED.store(false, Ordering::Relaxed); + AUTO_REFRESHED.store(false, Ordering::Relaxed); + WANTED.lock().unwrap_or_else(PoisonError::into_inner).clear(); + jobs().errors.clear(); + wordcraft_proof::packs::set_packs_dir(dir); +} + +/// Use the suite's font store for packs (`/language-packs`), as the person in the app +/// (`user`: an Install click may turn downloading on) or as an agent (the CLI, headless MCP: it +/// never can). The pack index is the pinned one, or `CRAFT_LANGUAGE_PACK_INDEX` (a URL or a path, +/// for testing packs before they are published), which the font store reads itself. Returns +/// whether there is a store folder. +#[cfg(not(target_arch = "wasm32"))] +pub fn use_font_store(user: bool) -> bool { + match store::FontStoreBackend::new(user) { + Some(b) => { + set_backend(Some(Arc::new(b))); + true + } + None => false, + } +} + +/// [`use_font_store`] with the store in `dir` instead of the person's (`ui_shot`, tests). +#[cfg(not(target_arch = "wasm32"))] +pub fn use_font_store_in(dir: PathBuf, user: bool) { + set_backend(Some(Arc::new(store::FontStoreBackend::in_dir(dir, user)))); +} + +/// The backend in use. +pub fn backend() -> Option> { + BACKEND.read().unwrap_or_else(PoisonError::into_inner).clone() +} + +fn installed_from_proof() -> Vec { + wordcraft_proof::packs::installed().into_iter().map(|p| Installed { locale: p.locale, name: p.name, licence: p.licence, path: p.path }).collect() +} + +/// The test source packs come from instead of the pinned index (`CRAFT_LANGUAGE_PACK_INDEX`): +/// Options › Language says so. +pub fn index_override() -> Option { + backend().and_then(|b| b.index_override()) +} + +/// The packs installed now (none without a backend). +pub fn installed() -> Vec { + backend().map(|b| b.installed()).unwrap_or_default() +} + +/// The available packs: the index as last read, read from the store's cache the first time. +pub fn available() -> Vec { + let Some(b) = backend() else { return Vec::new() }; + let mut cached = INDEX.lock().unwrap_or_else(PoisonError::into_inner); + if let Some(i) = cached.as_ref() { + return i.clone(); + } + let index = clean_index(b.index().unwrap_or_default()); + *cached = Some(index.clone()); + drop(cached); + INDEX_GENERATION.fetch_add(1, Ordering::Relaxed); + index +} + +/// How many packs [`available`] lists, without copying them (the window asks every frame). +pub fn available_len() -> usize { + if let Some(i) = INDEX.lock().unwrap_or_else(PoisonError::into_inner).as_ref() { + return i.len(); + } + available().len() +} + +/// Fetch the index again (downloading must be on). +pub fn refresh(cancel: &Cancel) -> Result, String> { + let b = backend().ok_or_else(no_store)?; + if !b.network_on() { + return Err(network_off()); + } + let index = clean_index(b.fetch_index(cancel)?); + set_index(Some(index.clone())); + FETCHED.store(true, Ordering::Relaxed); + install_wanted(&index); + Ok(index) +} + +/// Drop packs with a malformed locale, duplicates and English (embedded), and cap the list. +fn clean_index(mut packs: Vec) -> Vec { + packs.truncate(MAX_PACKS); + let mut seen = BTreeSet::new(); + packs.retain(|p| valid_locale(&p.locale) && !is_embedded(&p.locale) && seen.insert(p.locale.clone())); + packs.sort_by(|a, b| a.locale.cmp(&b.locale)); + packs +} + +/// English is embedded in every build; it never comes as a pack. +fn is_embedded(locale: &str) -> bool { + locale == "en" || locale.starts_with("en-") +} + +/// A lowercase BCP 47-shaped tag: letters, digits and `-`, starting with a letter. +pub fn valid_locale(locale: &str) -> bool { + !locale.is_empty() + && locale.len() <= MAX_LOCALE_LEN + && locale.starts_with(|c: char| c.is_ascii_lowercase()) + && locale.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') + && !locale.contains("--") + && !locale.ends_with('-') +} + +/// A locale from a command's params: trimmed, lowercased, `_` as `-`. +pub fn parse_locale(raw: &str) -> Result { + let l = raw.trim().replace('_', "-").to_ascii_lowercase(); + if valid_locale(&l) { Ok(l) } else { Err(CmdError::Params(format!("`locale` must be a language tag like nl or pt-PT, not {raw:?}"))) } +} + +fn no_store() -> String { + if cfg!(target_arch = "wasm32") { + "Language packs aren't available in the browser".into() + } else { + "there is no font store folder for language packs".into() + } +} + +fn network_off() -> String { + "downloading is turned off: turn it on in WordCraft (File › Options › Language, or an Install button there)".into() +} + +/// The pack for `locale` in the index. +pub fn find(locale: &str) -> Option { + available().into_iter().find(|p| p.locale == locale) +} + +/// Whether a pack is installed for `locale`. +pub fn is_installed(locale: &str) -> bool { + installed().iter().any(|p| p.locale == locale) +} + +/// Install the pack for `locale` (downloading must be on): the commands and the Install button. +/// Proofing picks it up at once. +pub fn install(locale: &str, cancel: &Cancel, progress: &mut dyn FnMut(u64, u64)) -> Result { + let b = backend().ok_or_else(no_store)?; + let pack = find(locale).ok_or_else(|| format!("there is no language pack for {locale}"))?; + if !b.network_on() { + return Err(network_off()); + } + if let Some(dir) = b.packs_dir() { + crate::file_access::check_write(&dir.to_string_lossy())?; + } + b.install(&pack, cancel, progress)?; + reload(&b); + b.installed().into_iter().find(|p| p.locale == locale).ok_or_else(|| format!("the {locale} pack didn't install")) +} + +/// Remove the installed pack for `locale`. +pub fn remove(locale: &str) -> Result<(), String> { + let b = backend().ok_or_else(no_store)?; + if let Some(job) = jobs().installs.iter().find(|j| j.locale == locale) { + return Err(if job.stopping() { + format!("the {locale} pack's cancelled install is still stopping: try again in a moment") + } else { + format!("the {locale} pack is being installed: cancel it first") + }); + } + if !b.installed().iter().any(|p| p.locale == locale) { + return Err(format!("no language pack is installed for {locale}")); + } + if let Some(dir) = b.packs_dir() { + crate::file_access::check_write(&dir.to_string_lossy())?; + } + b.remove(locale)?; + reload(&b); + Ok(()) +} + +/// What the app does with a long pack request (an install or a fetch of the list): `None` runs it +/// in the caller (the CLI, headless MCP, tests); `Some(notify)` runs it on a thread, so the window +/// keeps drawing whoever asked (the person, the control channel, MCP), and calls `notify` on each +/// change (the window repaints). Set once by the desktop app. +pub type Notify = Arc; + +static BACKGROUND: RwLock> = RwLock::new(None); + +/// An install running on a thread. +struct InstallJob { + locale: String, + /// Bytes done. + done: u64, + /// Bytes in all. + total: u64, + /// Stops this run. A cancelled job stays listed (hidden from [`installing`]) until its thread + /// notices and ends, so two downloads of a locale never write its folder at once. + cancel: Cancel, + /// Install was asked again after a cancel, before the cancelled run ended: the thread starts + /// over (with the fresh `cancel`) instead of ending. + restart: bool, +} + +impl InstallJob { + /// Cancelled, and its thread hasn't ended yet. + fn stopping(&self) -> bool { + self.cancel.is_cancelled() + } +} + +/// Installs and fetches running on a thread, and how the finished ones ended. +#[derive(Default)] +struct Jobs { + /// Running installs, one per locale. + installs: Vec, + fetching: bool, + /// The last failure of each locale's install ("" for the list), until it is tried again. + errors: Vec<(String, String)>, + /// Bumped as each job ends: the window relays out and shows the result. + finished: u64, +} + +static JOBS: Mutex = Mutex::new(Jobs { installs: Vec::new(), fetching: false, errors: Vec::new(), finished: 0 }); + +fn jobs() -> std::sync::MutexGuard<'static, Jobs> { + JOBS.lock().unwrap_or_else(PoisonError::into_inner) +} + +/// Run long pack requests on a thread (`Some`: the desktop app), or in the caller (`None`). +pub fn set_background(notify: Option) { + *BACKGROUND.write().unwrap_or_else(PoisonError::into_inner) = notify; +} + +fn background() -> Option { + if cfg!(target_arch = "wasm32") { + return None; + } + BACKGROUND.read().unwrap_or_else(PoisonError::into_inner).clone() +} + +/// Whether installs and fetches run on a thread ([`set_background`]). +pub fn runs_in_background() -> bool { + background().is_some() +} + +/// A running install: (locale, bytes done, bytes in all). +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct Progress { + pub locale: String, + pub done: u64, + pub total: u64, +} + +/// The installs running now (not the cancelled ones still stopping). +pub fn installing() -> Vec { + jobs().installs.iter().filter(|j| !j.stopping()).map(|j| Progress { locale: j.locale.clone(), done: j.done, total: j.total }).collect() +} + +/// Whether the list is being fetched. +pub fn fetching() -> bool { + jobs().fetching +} + +/// The last failure of `locale`'s install ("" for the list), until it is tried again. +pub fn error(locale: &str) -> Option { + jobs().errors.iter().find(|(l, _)| l == locale).map(|(_, e)| e.clone()) +} + +/// Every recorded failure, (locale or "" for the list, message). +pub fn errors() -> Vec<(String, String)> { + jobs().errors.clone() +} + +/// Counts finished jobs: when it changes, the window lays the documents out again. +pub fn finished() -> u64 { + jobs().finished +} + +/// Stop `locale`'s running install. The person's own choice: it ends without an error, and +/// [`installing`] stops listing it at once (Install shows again). +pub fn cancel(locale: &str) { + if let Some(job) = jobs().installs.iter_mut().find(|j| j.locale == locale) { + job.restart = false; + job.cancel.cancel(); + } +} + +fn set_error(locale: &str, e: Option) { + let mut j = jobs(); + j.errors.retain(|(l, _)| l != locale); + if let Some(e) = e { + j.errors.push((locale.to_string(), e)); + } +} + +/// Start installing `locale` ([`install`]): on a thread when [`runs_in_background`], else now. +/// What's wrong up front (no pack, downloading off, outside the write root) is an error at once. +/// Returns whether it is still running (`false`: done). Asked again while it runs, it carries on; +/// right after a cancel, before the cancelled download has stopped, it starts over once that +/// one ends (never two downloads of a pack at once). +pub fn start_install(locale: &str) -> Result { + let b = backend().ok_or_else(no_store)?; + let pack = find(locale).ok_or_else(|| format!("there is no language pack for {locale}"))?; + if !b.network_on() { + return Err(network_off()); + } + if let Some(dir) = b.packs_dir() { + crate::file_access::check_write(&dir.to_string_lossy())?; + } + let Some(notify) = background() else { + let r = install(locale, &Cancel::new(), &mut |_, _| {}); + set_error(locale, r.as_ref().err().cloned()); + return r.map(|_| false); + }; + let cancel = Cancel::new(); + { + let mut j = jobs(); + j.errors.retain(|(l, _)| l != locale); + if let Some(job) = j.installs.iter_mut().find(|j| j.locale == locale) { + if job.stopping() { + // Cancelled and not ended yet: its thread starts over when it does. + job.done = 0; + job.total = pack.size; + job.cancel = cancel; + job.restart = true; + } + return Ok(true); + } + j.installs.push(InstallJob { locale: locale.to_string(), done: 0, total: pack.size, cancel: cancel.clone(), restart: false }); + } + let key = locale.to_string(); + let locale = locale.to_string(); + let started = spawn(move || { + let mut cancel = cancel; + loop { + let progress_notify = notify.clone(); + let r = install(&locale, &cancel, &mut |done, total| { + if let Some(job) = jobs().installs.iter_mut().find(|j| j.locale == locale) { + job.done = done; + job.total = total; + } + progress_notify(); + }); + let mut j = jobs(); + if let Some(job) = j.installs.iter_mut().find(|j| j.locale == locale && j.restart) { + job.restart = false; + cancel = job.cancel.clone(); + drop(j); + notify(); + continue; + } + j.installs.retain(|j| j.locale != locale); + // The person's own cancel isn't a failure: nothing to show. + if let Err(e) = r + && !cancel.is_cancelled() + { + j.errors.push((locale, e)); + } + j.finished = j.finished.wrapping_add(1); + drop(j); + notify(); + break; + } + }); + if let Err(e) = started { + jobs().installs.retain(|j| j.locale != key); + return Err(e); + } + Ok(true) +} + +/// Start fetching the list again ([`refresh`]): on a thread when [`runs_in_background`], else now. +/// Returns whether it is still running. +pub fn start_refresh() -> Result { + let b = backend().ok_or_else(no_store)?; + if !b.network_on() { + return Err(network_off()); + } + let Some(notify) = background() else { + let r = refresh(&Cancel::new()); + set_error("", r.as_ref().err().cloned()); + return r.map(|_| false); + }; + { + let mut j = jobs(); + if j.fetching { + return Ok(true); + } + j.fetching = true; + j.errors.retain(|(l, _)| !l.is_empty()); + } + let started = spawn(move || { + let r = refresh(&Cancel::new()); + let mut j = jobs(); + j.fetching = false; + if let Err(e) = r { + j.errors.push((String::new(), e)); + } + j.finished = j.finished.wrapping_add(1); + drop(j); + notify(); + }); + if let Err(e) = started { + jobs().fetching = false; + return Err(e); + } + Ok(true) +} + +#[cfg(not(target_arch = "wasm32"))] +fn spawn(f: impl FnOnce() + Send + 'static) -> Result<(), String> { + std::thread::Builder::new().name("language-pack".into()).spawn(f).map(|_| ()).map_err(|e| format!("couldn't start the download: {e}")) +} + +#[cfg(target_arch = "wasm32")] +fn spawn(_: impl FnOnce() + Send + 'static) -> Result<(), String> { + Err(no_store()) +} + +/// Have proofing look at the packs folder again (its dictionaries are reloaded). +fn reload(b: &Arc) { + if wordcraft_proof::packs::packs_dir() == b.packs_dir() { + wordcraft_proof::packs::reload(); + } else { + wordcraft_proof::packs::set_packs_dir(b.packs_dir()); + } +} + +/// The full licence texts for About › Language data: the embedded English data, then each +/// installed pack. +pub fn licence_texts() -> Vec<(String, String)> { + let mut texts = wordcraft_proof::packs::licence_texts(); + // SCOWL's notices (with the Ispell BSD licence of its affix files), when English spells from + // the embedded SCOWL data; after the Moby and supplement entries. + if let Some(notice) = wordcraft_proof::scowl::notice() { + let at = texts.len().min(2); + texts.insert(at, (SCOWL_TITLE.into(), notice.into())); + } + texts +} + +/// About › Language data's title for SCOWL's notices. +pub const SCOWL_TITLE: &str = "English spelling dictionaries (SCOWL)"; + +/// The pack locale for a document language tag: the whole tag (`pt-pt`), else each shorter +/// prefix of it (`sr-latn` for `sr-Latn-RS`, `nl` for `nl-NL`), the first the index has. A +/// variant after the region is also tried without the region, before the region alone +/// (`ca-ES-valencia` is the `ca-valencia` pack's), as craft-proof looks its dictionary up. `None` +/// for English and tags with no pack. +pub fn pack_for_tag<'a>(tag: &str, packs: &'a [Pack]) -> Option<&'a Pack> { + pack_candidates(tag).iter().find_map(|c| packs.iter().find(|p| &p.locale == c)) +} + +/// Is `subtag` a region, as it follows a tag's language (and script): two letters (`NL`) or +/// three digits (`419`)? The one test of what a region is; the lists, the menus and +/// [`pack_for_tag`] all use it. +pub fn is_region(subtag: &str) -> bool { + (subtag.len() == 2 && subtag.bytes().all(|b| b.is_ascii_alphabetic())) || (subtag.len() == 3 && subtag.bytes().all(|b| b.is_ascii_digit())) +} + +/// `tag` without its region subtags (`nl-NL` is `nl`, `sr-Latn-RS` is `sr-Latn`, `ca-ES-valencia` +/// is `ca-valencia`), joined with `-` whichever of `-` and `_` separated them; the case is kept. +/// The language itself (the first subtag) is never a region. +pub fn without_region(tag: &str) -> String { + tag.split(['-', '_']).enumerate().filter(|(i, p)| *i == 0 || !is_region(p)).map(|(_, p)| p).collect::>().join("-") +} + +/// The pack locales [`pack_for_tag`] tries for `tag`, best first. +fn pack_candidates(tag: &str) -> Vec { + // A tag is a handful of short subtags; anything longer is junk and only its start is read. + let tag: String = tag.trim().chars().take(64).collect::().replace('_', "-").to_ascii_lowercase(); + let parts: Vec<&str> = tag.split('-').collect(); + let base = parts.first().copied().unwrap_or(""); + if base.is_empty() || base == "en" { + return Vec::new(); + } + let mut out: Vec = Vec::new(); + for n in (1..=parts.len()).rev() { + let prefix = parts.get(..n).unwrap_or_default().join("-"); + // The prefix, then without its region: the variant's pack before the region alone. + let stripped = without_region(&prefix); + for c in [prefix, stripped] { + if !out.contains(&c) { + out.push(c); + } + } + } + out +} + +/// The languages (`w:lang`, resolved through the styles) of the body text, in first-seen order. +pub fn document_languages(s: &Session) -> Vec { + let mut out: Vec = Vec::new(); + for path in s.doc.para_paths(wordcraft_doc::StoryRef::Body).into_iter().take(MAX_PARAGRAPHS_SCANNED) { + let Some(p) = s.doc.para(wordcraft_doc::StoryRef::Body, &path) else { continue }; + if p.text.trim().is_empty() { + continue; + } + let style = p.props.style.as_deref(); + for (range, c) in p.run_ranges() { + let Some(text) = p.text.get(range) else { continue }; + if !text.chars().any(char::is_alphabetic) { + continue; + } + let rc = s.doc.styles.resolve_char_in(style, None, c); + if rc.no_proof { + continue; + } + let tag = rc.lang.unwrap_or_else(|| "en-US".into()); + if !out.contains(&tag) { + out.push(tag); + if out.len() >= MAX_DOC_LANGUAGES { + return out; + } + } + } + } + out +} + +/// Whether proofing reads a pack for text in `tag`: every language craft-proof can name, its own +/// (Polish…) and any other (`ProofLang::Pack`, read from the pack alone); not `und`, `zxx` or a +/// malformed tag. The one place the bar's language rule lives. +pub fn proofable(tag: &str) -> bool { + wordcraft_proof::ProofLang::from_tag(Some(tag)) != wordcraft_proof::ProofLang::Other +} + +/// The Hunspell dictionary `tag`'s language needs and doesn't have, for the status bar's +/// "(no dictionary)": craft-proof's [`wordcraft_proof::missing_dictionary`], except for a +/// language craft-proof has no rules of its own for (`ProofLang::Pack`: Japanese, Chinese, +/// Thai…) when no pack for it is available either, as no Hunspell dictionary may exist at all. +pub fn missing_dictionary(tag: Option<&str>) -> Option<&'static str> { + let name = wordcraft_proof::missing_dictionary(tag)?; + let lang = wordcraft_proof::ProofLang::from_tag(tag); + if lang.pack_locale().is_some() && !has_pack_for_tag(tag.unwrap_or("")) { + return None; + } + Some(name) +} + +/// The last answer of whether the index has a pack for a tag, for the index generation it was +/// worked out for: the status bar asks every frame while the caret is in a pack language, and +/// working it out copies the whole index. +#[derive(Default)] +struct PackLookup { + /// (index generation, tag, has a pack). + entry: Option<(u64, String, bool)>, +} + +impl PackLookup { + /// The remembered answer for `tag` at `generation`, else `compute`'s (then remembered). + fn get(&mut self, generation: u64, tag: &str, compute: impl FnOnce() -> bool) -> bool { + if let Some((g, t, has)) = &self.entry + && *g == generation + && t == tag + { + return *has; + } + let has = compute(); + self.entry = Some((generation, tag.to_string(), has)); + has + } +} + +static PACK_LOOKUP: Mutex = Mutex::new(PackLookup { entry: None }); + +/// Whether the index has a pack for `tag` ([`pack_for_tag`]), worked out again only when the +/// index changes ([`index_generation`]) or the tag does. +fn has_pack_for_tag(tag: &str) -> bool { + // Read before [`available`], which may fill the index and bump it: the answer is then worked + // out once more, never kept for a list it wasn't worked out from. + let generation = index_generation(); + PACK_LOOKUP.lock().unwrap_or_else(PoisonError::into_inner).get(generation, tag, || pack_for_tag(tag, &available()).is_some()) +} + +/// The language new text at the caret gets (its typing language, resolved through the styles +/// and the document default), unless it is marked Do not check spelling or grammar: choosing a +/// language with nothing selected asks for its pack before anything is typed in it. +pub fn caret_language(s: &Session) -> Option { + let style = s.doc.para_at(&s.sel.focus).and_then(|p| p.props.style.as_deref()); + let rc = s.doc.styles.resolve_char(style, &s.typing_props()); + if rc.no_proof { + return None; + } + Some(rc.lang.unwrap_or_else(|| "en-US".into())) +} + +/// The languages the missing-pack bar looks at: the body text's ([`document_languages`]), then +/// the caret's typing language ([`caret_language`]). +pub fn wanted_languages(s: &Session) -> Vec { + let mut out = document_languages(s); + if let Some(tag) = caret_language(s) + && !out.contains(&tag) + { + out.push(tag); + } + out +} + +/// The packs the document's text (or the caret's typing language) asks for that are available +/// and not installed: what the missing-pack bar offers. Never a language without a pack, nor one +/// proofing can't use. +pub fn missing(s: &Session) -> Vec { + let packs = available(); + if packs.is_empty() { + return Vec::new(); + } + let installed = installed(); + let mut out: Vec = Vec::new(); + for tag in wanted_languages(s) { + if proofable(&tag) + && let Some(p) = pack_for_tag(&tag, &packs) + && !installed.iter().any(|i| i.locale == p.locale) + && !out.iter().any(|o| o.locale == p.locale) + { + out.push(p.clone()); + } + } + out +} + +/// Where a language's proofing comes from (Options › Language › Your languages). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum Proofing { + /// English: the embedded dictionaries. + BuiltIn, + /// An installed Language pack. + Installed, + /// A Hunspell dictionary in the person's own dictionaries folder. + OwnDictionary, + /// A Hunspell dictionary the system has. + SystemDictionary, + /// No dictionary yet; a Language pack can be installed. + Install, + /// Grammar and hyphenation rules built in; a Language pack adds spelling. + GrammarBuiltIn, + /// No dictionary and no pack for it. + NotAvailable, +} + +/// A row of Options › Language › Your languages. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct LanguageRow { + /// The tags the default proofing language or the open document use for this language (the + /// first is what Set as default sets); empty for a language listed for its installed pack. + pub tags: Vec, + /// The pack locale: the installed pack, else the one the index offers. + pub pack: Option, + /// Is the pack installed? + pub installed: bool, + /// Bytes of the offered pack (0 when the index has none). + pub size: u64, + pub proofing: Proofing, + /// Is this the default proofing language? + pub default: bool, +} + +impl LanguageRow { + /// Is `tag` (the insertion point's language) one of this row's languages? + pub fn is_tag(&self, tag: &str) -> bool { + self.tags.iter().any(|t| t.eq_ignore_ascii_case(tag)) + } +} + +/// The first locale `has` accepts for `tag`, in [`pack_for_tag`]'s order. `None` for English and junk. +fn locale_for_tag(tag: &str, has: &dyn Fn(&str) -> bool) -> Option { + pack_candidates(tag).into_iter().find(|c| has(c)) +} + +/// [`your_languages_with`] against the dictionaries on this computer. +pub fn your_languages(s: &Session) -> Vec { + let folder = s.prefs.dictionaries_folder.clone(); + your_languages_with(s, folder.as_deref(), &dictionary_file) +} + +/// The Hunspell `.dic` file proofing reads for `tag`'s language now (a built-in-rules language's +/// own, or a pack language's), if any. +fn dictionary_file(tag: &str) -> Option { + let lang = wordcraft_proof::ProofLang::from_tag(Some(tag)); + if let Some((_, f)) = wordcraft_proof::installed_dictionaries().into_iter().find(|(l, _)| *l == lang) { + return f; + } + wordcraft_proof::pack_dictionary_file(lang) +} + +/// Options › Language › Your languages (unsorted; the window names and sorts them): each English +/// variant in use (the default proofing language, American English and any in the open document), every language +/// with an installed Language pack, and every proofing language of the open document. One row per +/// language: tags one pack proofs share a row. `folder` is the person's own dictionaries folder; +/// `dictionary` finds the `.dic` file proofing reads for a tag. +pub fn your_languages_with(s: &Session, folder: Option<&str>, dictionary: &dyn Fn(&str) -> Option) -> Vec { + let index = available(); + let installed = installed(); + let default_tag = s + .prefs + .default_language + .clone() + .or_else(|| s.editing_lang.clone()) + .or_else(|| s.doc.styles.default_chr.lang.clone()) + .unwrap_or_else(|| "en-US".to_string()); + // American English stays listed whatever the default is, so it can always be set back. + let mut tags = vec![default_tag.clone(), "en-US".to_string()]; + tags.dedup_by(|a, b| a.eq_ignore_ascii_case(b)); + for t in document_languages(s) { + if !tags.iter().any(|x| x.eq_ignore_ascii_case(&t)) { + tags.push(t); + } + } + let mut rows: Vec = Vec::new(); + let mut add = |tag: Option<&str>, locale: Option| { + let english = tag.is_some_and(|t| wordcraft_proof::ProofLang::from_tag(Some(t)).is_english()); + // English variants stay apart (en-GB and en-US are two rows); a pack's tags share one. + let existing = rows.iter().position(|r| match (&locale, &r.pack) { + (Some(l), Some(p)) if !english => l == p, + _ => tag.is_some_and(|t| r.is_tag(t)), + }); + let i = existing.unwrap_or_else(|| { + rows.push(LanguageRow { + tags: Vec::new(), + pack: locale.clone(), + installed: false, + size: 0, + proofing: Proofing::NotAvailable, + default: false, + }); + rows.len() - 1 + }); + if let (Some(t), Some(row)) = (tag, rows.get_mut(i)) + && !row.is_tag(t) + { + row.tags.push(t.to_string()); + } + }; + for t in &tags { + if !proofable(t) { + continue; + } + // The most specific pack, installed or offered, as the bar picks it ([`pack_for_tag`]): with + // `ca` installed, Valencian text is still the `ca-valencia` pack's row. + let locale = locale_for_tag(t, &|l| installed.iter().any(|i| i.locale == l) || index.iter().any(|p| p.locale == l)); + add(Some(t), locale); + } + for i in &installed { + add(None, Some(i.locale.clone())); + } + for row in &mut rows { + let installed_here = row.pack.as_ref().is_some_and(|l| installed.iter().any(|i| &i.locale == l)); + let offered = row.pack.as_ref().and_then(|l| index.iter().find(|p| &p.locale == l)); + row.installed = installed_here; + row.size = offered.map_or(0, |p| p.size); + row.default = row.is_tag(&default_tag); + let tag = row.tags.first().cloned().or_else(|| row.pack.clone()).unwrap_or_default(); + let lang = wordcraft_proof::ProofLang::from_tag(Some(&tag)); + let file = if lang.is_english() { None } else { dictionary(&tag) }; + let own = file.as_ref().is_some_and(|f| folder.is_some_and(|d| !d.trim().is_empty() && f.starts_with(d.trim()))); + row.proofing = if lang.is_english() { + Proofing::BuiltIn + } else if own { + Proofing::OwnDictionary + } else if installed_here { + Proofing::Installed + } else if file.is_some() { + Proofing::SystemDictionary + } else if offered.is_some() { + if matches!(lang, wordcraft_proof::ProofLang::Pack(_)) { Proofing::Install } else { Proofing::GrammarBuiltIn } + } else { + Proofing::NotAvailable + }; + } + rows +} + +/// The index was fetched this session (since the backend was set): what it lists is then the +/// whole answer, even when it lists nothing. +static FETCHED: AtomicBool = AtomicBool::new(false); +/// The automatic fetch of a missing or stale list ([`refresh_if_stale`]) was tried. +static AUTO_REFRESHED: AtomicBool = AtomicBool::new(false); +/// Languages (document tags) whose pack the person asked for before the list was known +/// ([`start_get`]): installed once a fetch lists one. +static WANTED: Mutex> = Mutex::new(Vec::new()); + +/// Whether the index was fetched since the backend was set. +pub fn fetched() -> bool { + FETCHED.load(Ordering::Relaxed) +} + +/// The languages the document (or the caret) asks for while there is no list of packs yet (a +/// fresh install, or a list cached for an older pin): proofable, not English, with no dictionary +/// and no pack installed, one per proofing language, as the document tags them (`tr-TR`). The +/// bar offers to get them: the list is fetched first, and the language then installed if it has +/// a pack. Nothing once a list is known or was fetched this session ([`missing`] then answers). +pub fn unlisted(s: &Session) -> Vec { + if backend().is_none() || fetched() || available_len() > 0 { + return Vec::new(); + } + let installed = installed(); + let mut seen: Vec = Vec::new(); + let mut out = Vec::new(); + for tag in wanted_languages(s) { + let base = tag.split(['-', '_']).next().unwrap_or("").to_ascii_lowercase(); + let lang = wordcraft_proof::ProofLang::from_tag(Some(&tag)); + if base.is_empty() + || base == "en" + || !proofable(&tag) + || wordcraft_proof::missing_dictionary(Some(&tag)).is_none() + || seen.contains(&lang) + || installed.iter().any(|i| i.locale == base || tag.eq_ignore_ascii_case(&i.locale)) + { + continue; + } + seen.push(lang); + out.push(tag); + } + out +} + +/// The person asked for `tag`'s pack before the list was known (the bar's Install, after their +/// consent turned downloading on): fetch the list ([`start_refresh`]), then install the pack it +/// lists for `tag`, if any; with none, the bar stops offering it. Downloading must be on. +/// Returns whether it is still running. +pub fn start_get(tag: &str) -> Result { + let tag = tag.trim(); + if tag.is_empty() || tag.len() > MAX_LOCALE_LEN { + return Err(format!("`{tag}` isn't a language tag")); + } + if let Some(p) = pack_for_tag(tag, &available()) { + return start_install(&p.locale); + } + let b = backend().ok_or_else(no_store)?; + if !b.network_on() { + // Never queued: a later fetch mustn't install what was refused. + return Err(network_off()); + } + let queued = { + let mut w = WANTED.lock().unwrap_or_else(PoisonError::into_inner); + let new = !w.iter().any(|t| t == tag) && w.len() < MAX_DOC_LANGUAGES; + if new { + w.push(tag.to_string()); + } + new + }; + let r = start_refresh(); + if r.is_err() && queued { + WANTED.lock().unwrap_or_else(PoisonError::into_inner).retain(|t| t != tag); + } + r +} + +/// After a fetch: install the packs asked for before the list was known ([`start_get`]). +fn install_wanted(index: &[Pack]) { + let wanted = std::mem::take(&mut *WANTED.lock().unwrap_or_else(PoisonError::into_inner)); + for tag in wanted { + let Some(p) = pack_for_tag(&tag, index) else { continue }; + if is_installed(&p.locale) { + continue; + } + if let Err(e) = start_install(&p.locale) { + set_error(&p.locale, Some(e)); + } + } +} + +/// Once a session (the first time it is asked once requests run on a thread, [`runs_in_background`]: +/// the window), with downloading already on and no list (none cached, or one cached for an older +/// pin): fetch it in the background, so the bar can offer packs. The person's consent is the +/// downloading setting itself; nothing is asked. Its failure is recorded for Options (""). The +/// window asks every frame: after the first time this reads nothing (the downloading setting is +/// a file). Returns whether a fetch was started. +pub fn refresh_if_stale() -> bool { + let Some(b) = backend() else { return false }; + if !runs_in_background() || AUTO_REFRESHED.swap(true, Ordering::Relaxed) { + return false; + } + if !b.network_on() || fetched() || fetching() || available_len() > 0 { + return false; + } + start_refresh().is_ok() +} + +/// A backend with a fixed list and installs that only record themselves: tests and `ui_shot`. +#[derive(Default)] +pub struct FixedBackend { + pub packs: Vec, + pub network: AtomicBool, + /// Agents can't turn the network on. + pub agent: bool, + pub installed: Mutex>, +} + +impl Pack { + /// Where the data comes from: the upstream source when the index names it, else the archive. + pub fn source_link(&self) -> &str { + self.source.as_deref().filter(|s| !s.is_empty()).unwrap_or(&self.url) + } +} + +impl FixedBackend { + /// A few packs shaped like the published ones, for screenshots and tests. + pub fn sample() -> Self { + let pack = |locale: &str, name: &str, size: u64, licence: &str, class: &str| Pack { + locale: locale.into(), + name: name.into(), + version: "0000000".into(), + url: format!("https://github.com/storytold/craft-i18n/releases/download/language-packs-2/{locale}.tar.zst"), + source: Some("https://github.com/LibreOffice/dictionaries".into()), + size, + licence: licence.into(), + class: class.into(), + spelling: true, + hyphenation: true, + }; + FixedBackend { + packs: vec![ + pack("ca", "Català", 1_480_000, "GPL-2.0-or-later OR LGPL-2.1-or-later", "Download-only"), + pack("el", "Ελληνικά", 2_310_000, "MPL-1.1 OR GPL-2.0-or-later OR LGPL-2.1-or-later", "Embeddable"), + pack("fr", "Français", 1_120_000, "MPL-2.0", "Embeddable"), + pack("nl", "Nederlands", 1_950_000, "BSD-3-Clause", "Embeddable"), + pack("pl", "Polski", 4_620_000, "GPL-2.0-or-later OR LGPL-2.1-or-later OR MPL-1.1 OR Apache-2.0 OR CC-BY-4.0", "Embeddable"), + pack("sk", "Slovenčina", 2_040_000, "GPL-2.0-only OR LGPL-2.1-only OR MPL-1.1", "Download-only"), + ], + ..Default::default() + } + } +} + +impl Backend for FixedBackend { + fn index(&self) -> Result, String> { + Ok(self.packs.clone()) + } + fn fetch_index(&self, _: &Cancel) -> Result, String> { + Ok(self.packs.clone()) + } + fn network_on(&self) -> bool { + self.network.load(Ordering::Relaxed) + } + fn enable_network(&self) -> Result<(), String> { + if self.agent { + return Err("only the user can turn on downloading".into()); + } + self.network.store(true, Ordering::Relaxed); + Ok(()) + } + fn install(&self, pack: &Pack, cancel: &Cancel, progress: &mut dyn FnMut(u64, u64)) -> Result<(), String> { + if cancel.is_cancelled() { + return Err("cancelled".into()); + } + progress(pack.size, pack.size); + let mut i = self.installed.lock().unwrap_or_else(PoisonError::into_inner); + i.retain(|p| p.locale != pack.locale); + i.push(Installed { locale: pack.locale.clone(), name: pack.name.clone(), licence: pack.licence.clone(), path: PathBuf::new() }); + Ok(()) + } + fn remove(&self, locale: &str) -> Result<(), String> { + self.installed.lock().unwrap_or_else(PoisonError::into_inner).retain(|p| p.locale != locale); + Ok(()) + } + fn packs_dir(&self) -> Option { + None + } + fn installed(&self) -> Vec { + self.installed.lock().unwrap_or_else(PoisonError::into_inner).clone() + } +} + +#[cfg(test)] +mod tests { + /// CP4.4 (`cargo xtask language`): a build embeds English Language data and nothing it + /// wasn't asked to. A Language build names the locales it embeds in `CRAFT_PROOF_EMBED` + /// (`en` alone: English only); the default embeds the carry-over locales too, until Language + /// packs are published and pinned (craft-proof's `DEFAULT_EMBED`; TODO(CP4.4): English only + /// then). + /// About › Language data shows SCOWL's notices (the Ispell BSD licence among them) whenever + /// English spells from the embedded SCOWL data. + #[test] + fn about_shows_the_scowl_notice_when_scowl_is_embedded() { + let texts = super::licence_texts(); + match wordcraft_proof::scowl::notice() { + Some(notice) => { + let entry = texts.iter().find(|(t, _)| t == super::SCOWL_TITLE).map(|(_, x)| x.as_str()); + assert_eq!(entry, Some(notice)); + assert!(notice.contains("Ispell") || notice.contains("ispell"), "the Ispell notice is part of it"); + } + None => assert!(texts.iter().all(|(t, _)| t != super::SCOWL_TITLE), "no SCOWL data: no SCOWL entry"), + } + } + + /// FU.3: the status bar's "(no dictionary)" asks every frame whether the index has a pack + /// for the caret's language; the answer is reused until the index changes (a new + /// generation) or the tag does, so the index isn't copied every frame. + #[test] + fn the_pack_lookup_is_reused_until_the_index_changes() { + let mut lookup = super::PackLookup::default(); + let mut computed = 0; + let mut ask = |lookup: &mut super::PackLookup, generation: u64, tag: &str, answer: bool| { + lookup.get(generation, tag, || { + computed += 1; + answer + }) + }; + assert!(ask(&mut lookup, 1, "ja-JP", true)); + // Same generation and tag: the remembered answer, even where the list would now say no. + for _ in 0..10 { + assert!(ask(&mut lookup, 1, "ja-JP", false)); + } + assert!(!ask(&mut lookup, 2, "ja-JP", false), "a new generation works it out again"); + assert!(ask(&mut lookup, 2, "th-TH", true), "another tag too"); + assert_eq!(computed, 3); + } + + #[test] + fn the_default_build_embeds_only_english() { + const CARRY_OVER: [&str; 5] = ["de", "pl", "pt", "it", "ru"]; + let named: Vec = std::env::var("CRAFT_PROOF_EMBED") + .unwrap_or_default() + .split(',') + .map(|l| l.trim().to_ascii_lowercase()) + .filter(|l| !l.is_empty()) + .collect(); + let allowed = |base: &str| base == "en" || if named.is_empty() { CARRY_OVER.contains(&base) } else { named.iter().any(|n| n == base) }; + let embedded = wordcraft_proof::packs::embedded_locales(); + let extra: Vec<&&str> = embedded.iter().filter(|l| !allowed(l.split('-').next().unwrap_or(l))).collect(); + assert!(extra.is_empty(), "embedded beyond English and {named:?} (or the carry-over by default): {extra:?}"); + } +} diff --git a/crates/engine/src/packs/store.rs b/crates/engine/src/packs/store.rs index fbc89d13b9..abc3b53471 100644 --- a/crates/engine/src/packs/store.rs +++ b/crates/engine/src/packs/store.rs @@ -1,104 +1,104 @@ -//! The font store as the packs [`Backend`](super::Backend): its folder, its consent setting, the -//! pinned pack index and verified installs (craft-libs `craft-font-store`, kind `language-pack`). - -use std::path::PathBuf; -use std::sync::{Mutex, PoisonError}; - -use craft_font_catalog::FontEnv; -use craft_font_store::{FontStore, Network, PackEntry, PackIndex, PackIndexPin}; - -use super::{Cancel, Pack}; - -/// The pack index this build trusts: craft-i18n's second Language pack release, -/// language-packs-2 (75 packs, built from craft-i18n `20467b63`), pinned by the sha256 of its `index.json`. -/// `CRAFT_LANGUAGE_PACK_INDEX` (read by the font store itself) points WordCraft at a local or test -/// index instead, for trying packs before they are released. -pub const PACK_INDEX: PackIndexPin = PackIndexPin { - url: "https://github.com/storytold/craft-i18n/releases/download/language-packs-2/index.json", - sha256: "b35c7aef0591177f18233b551da16035dcd615549ff874e169ffd19f16fea74b", -}; - -pub(super) struct FontStoreBackend { - store: FontStore, - /// The index last read or fetched; installs take their entry (url, size, sha256) from it. - index: Mutex>, -} - -impl FontStoreBackend { - pub(super) fn new(user: bool) -> Option { - let env = FontEnv::from_process(); - let store = if user { FontStore::for_user(&env)? } else { FontStore::for_agent(&env)? }; - Some(Self { store, index: Mutex::new(None) }) - } - - /// A store in `dir`. - pub(super) fn in_dir(dir: PathBuf, user: bool) -> Self { - let caller = if user { craft_font_store::Caller::User } else { craft_font_store::Caller::Agent }; - Self { store: FontStore::new(dir, caller), index: Mutex::new(None) } - } - - fn keep(&self, index: PackIndex) -> Vec { - let packs = index.packs.iter().map(pack).collect(); - *self.index.lock().unwrap_or_else(PoisonError::into_inner) = Some(index); - packs - } -} - -fn pack(e: &PackEntry) -> Pack { - Pack { - locale: e.locale.clone(), - name: e.name.clone(), - version: e.version.clone(), - url: e.url.clone(), - // TODO(CP4): the index's optional `source` once craft-font-store's PackEntry carries it. - source: None, - size: e.size, - licence: e.licence.clone(), - class: e.class.clone(), - spelling: e.spelling, - hyphenation: e.hyphenation, - } -} - -impl super::Backend for FontStoreBackend { - fn index(&self) -> Result, String> { - Ok(self.store.cached_language_pack_index(&PACK_INDEX).map(|i| self.keep(i)).unwrap_or_default()) - } - - fn fetch_index(&self, cancel: &Cancel) -> Result, String> { - let index = self.store.fetch_language_pack_index(&PACK_INDEX, &cancel.0).map_err(|e| e.to_string())?; - Ok(self.keep(index)) - } - - fn network_on(&self) -> bool { - self.store.network() == Network::On - } - - fn enable_network(&self) -> Result<(), String> { - self.store.enable_network().map_err(|e| e.to_string()) - } - - fn install(&self, pack: &Pack, cancel: &Cancel, progress: &mut dyn FnMut(u64, u64)) -> Result<(), String> { - let index = { - let held = self.index.lock().unwrap_or_else(PoisonError::into_inner).clone(); - match held { - Some(i) => i, - None => self.store.cached_language_pack_index(&PACK_INDEX).ok_or("the list of Language packs hasn't been fetched")?, - } - }; - let entry = index.pack(&pack.locale).ok_or_else(|| format!("there is no language pack for {}", pack.locale))?.clone(); - self.store.install_language_pack(&index, &entry, &cancel.0, &mut |p| progress(p.done, p.total)).map(|_| ()).map_err(|e| e.to_string()) - } - - fn remove(&self, locale: &str) -> Result<(), String> { - self.store.remove_language_pack(locale).map_err(|e| e.to_string()) - } - - fn index_override(&self) -> Option { - craft_font_store::language_pack_index_overridden() - } - - fn packs_dir(&self) -> Option { - Some(self.store.language_packs_dir()) - } -} +//! The font store as the packs [`Backend`](super::Backend): its folder, its consent setting, the +//! pinned pack index and verified installs (craft-libs `craft-font-store`, kind `language-pack`). + +use std::path::PathBuf; +use std::sync::{Mutex, PoisonError}; + +use craft_font_catalog::FontEnv; +use craft_font_store::{FontStore, Network, PackEntry, PackIndex, PackIndexPin}; + +use super::{Cancel, Pack}; + +/// The pack index this build trusts: craft-i18n's second Language pack release, +/// language-packs-2 (75 packs, built from craft-i18n `20467b63`), pinned by the sha256 of its `index.json`. +/// `CRAFT_LANGUAGE_PACK_INDEX` (read by the font store itself) points WordCraft at a local or test +/// index instead, for trying packs before they are released. +pub const PACK_INDEX: PackIndexPin = PackIndexPin { + url: "https://github.com/storytold/craft-i18n/releases/download/language-packs-2/index.json", + sha256: "b35c7aef0591177f18233b551da16035dcd615549ff874e169ffd19f16fea74b", +}; + +pub(super) struct FontStoreBackend { + store: FontStore, + /// The index last read or fetched; installs take their entry (url, size, sha256) from it. + index: Mutex>, +} + +impl FontStoreBackend { + pub(super) fn new(user: bool) -> Option { + let env = FontEnv::from_process(); + let store = if user { FontStore::for_user(&env)? } else { FontStore::for_agent(&env)? }; + Some(Self { store, index: Mutex::new(None) }) + } + + /// A store in `dir`. + pub(super) fn in_dir(dir: PathBuf, user: bool) -> Self { + let caller = if user { craft_font_store::Caller::User } else { craft_font_store::Caller::Agent }; + Self { store: FontStore::new(dir, caller), index: Mutex::new(None) } + } + + fn keep(&self, index: PackIndex) -> Vec { + let packs = index.packs.iter().map(pack).collect(); + *self.index.lock().unwrap_or_else(PoisonError::into_inner) = Some(index); + packs + } +} + +fn pack(e: &PackEntry) -> Pack { + Pack { + locale: e.locale.clone(), + name: e.name.clone(), + version: e.version.clone(), + url: e.url.clone(), + // TODO(CP4): the index's optional `source` once craft-font-store's PackEntry carries it. + source: None, + size: e.size, + licence: e.licence.clone(), + class: e.class.clone(), + spelling: e.spelling, + hyphenation: e.hyphenation, + } +} + +impl super::Backend for FontStoreBackend { + fn index(&self) -> Result, String> { + Ok(self.store.cached_language_pack_index(&PACK_INDEX).map(|i| self.keep(i)).unwrap_or_default()) + } + + fn fetch_index(&self, cancel: &Cancel) -> Result, String> { + let index = self.store.fetch_language_pack_index(&PACK_INDEX, &cancel.0).map_err(|e| e.to_string())?; + Ok(self.keep(index)) + } + + fn network_on(&self) -> bool { + self.store.network() == Network::On + } + + fn enable_network(&self) -> Result<(), String> { + self.store.enable_network().map_err(|e| e.to_string()) + } + + fn install(&self, pack: &Pack, cancel: &Cancel, progress: &mut dyn FnMut(u64, u64)) -> Result<(), String> { + let index = { + let held = self.index.lock().unwrap_or_else(PoisonError::into_inner).clone(); + match held { + Some(i) => i, + None => self.store.cached_language_pack_index(&PACK_INDEX).ok_or("the list of Language packs hasn't been fetched")?, + } + }; + let entry = index.pack(&pack.locale).ok_or_else(|| format!("there is no language pack for {}", pack.locale))?.clone(); + self.store.install_language_pack(&index, &entry, &cancel.0, &mut |p| progress(p.done, p.total)).map(|_| ()).map_err(|e| e.to_string()) + } + + fn remove(&self, locale: &str) -> Result<(), String> { + self.store.remove_language_pack(locale).map_err(|e| e.to_string()) + } + + fn index_override(&self) -> Option { + craft_font_store::language_pack_index_overridden() + } + + fn packs_dir(&self) -> Option { + Some(self.store.language_packs_dir()) + } +} diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 735a0b6094..0dd6300279 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -1,142 +1,142 @@ -use serde::{Deserialize, Serialize}; - -/// Editing preferences that persist between runs (the front end saves and restores them). -/// File › Options sets most of them (`file.options`). -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(default, rename_all = "camelCase")] -pub struct Prefs { - /// Word Count includes text boxes, footnotes and endnotes (Word's default). - pub count_notes: bool, - /// On-screen paper for pages without a document background; excluded from saved documents. - #[serde(deserialize_with = "lenient_paper_color")] - pub paper_color: crate::cmd::view::PaperColor, - /// Track Changes Options: what markup shows and how revisions are drawn (per user, as in - /// Word). - pub markup: wordcraft_layout::display::MarkupOptions, - /// File › Print / Export: Print Markup. PDFs, prints and page images show comments in - /// balloons beside each page and tracked changes as marked up; HTML, Markdown and text - /// copies end with the comments (`file.printMarkup`). - pub print_markup: bool, - /// Language › Detect language automatically (per user, as in Word). - pub detect_language: bool, - /// Set Proofing Language › Set As Default: overrides the interface language for future - /// blank documents and plain text. Older preferences keep following the interface. - #[serde(deserialize_with = "lenient_default_language")] - pub default_language: Option, - /// Typing over a selection replaces it; off, the typed text goes in front of it. - pub typing_replaces_selection: bool, - /// The Insert key switches overtype on and off. - pub insert_key_overtype: bool, - /// Overtype: typed characters replace the ones after the caret. - pub overtype: bool, - /// The unit rulers and dialogs show lengths in, when the user chose one (File › Options › - /// Advanced › Measurement units); `None` follows the region ([`Session::units`]). Saved under - /// a new key; an older `units` value other than inches (the old default) carries over on load. - #[serde(rename = "measurementUnits")] - pub units: Option, - /// The region the user chose for new documents, units and the decimal separator (a locale - /// tag like `pl-PL`, File › Options › Advanced › Region); `None` follows the system. - pub region: Option, - /// A folder searched for Hunspell dictionaries before the system locations (File › Options › - /// Language, #1063): an app started from Finder or the Dock doesn't see - /// `WORDCRAFT_DICTIONARIES`. `None` searches the system locations only. - pub dictionaries_folder: Option, - /// The user templates folder File › New lists personal templates from and Save as Word - /// Template starts in (File › Options › Advanced › File locations, #2852). `None`: the app's - /// own `Templates` folder in its settings folder. - pub user_templates_folder: Option, - /// Formatting marks shown even while Show/Hide ¶ is off. - pub marks: wordcraft_layout::display::FormattingMarks, - /// Mark grammar errors as you type (with Check spelling as you type on). - pub mark_grammar: bool, - /// Check spelling as you type (File › Options › Proofing). The live value is - /// `ViewState::proofing`; this is the copy that is saved and restored (#1792). - pub check_spelling: bool, - /// Read Mode's Page Color. The live value is `ViewState::read_page_color`. - #[serde(deserialize_with = "lenient_read_page_color")] - pub read_page_color: crate::cmd::view::ReadPageColor, - pub ignore_uppercase: bool, - pub ignore_numbers: bool, - pub ignore_internet: bool, - /// AutoCorrect Options: replacements, capitalization and AutoFormat As You Type (per user, as - /// in Word). A damaged saved value reads as the defaults. - #[serde(deserialize_with = "crate::cmd::tools::lenient_autocorrect")] - pub autocorrect: crate::cmd::tools::AutoCorrectPrefs, -} - -impl Default for Prefs { - fn default() -> Self { - Prefs { - paper_color: Default::default(), - count_notes: true, - markup: Default::default(), - print_markup: false, - typing_replaces_selection: true, - insert_key_overtype: false, - overtype: false, - units: Default::default(), - region: None, - dictionaries_folder: None, - user_templates_folder: None, - marks: Default::default(), - mark_grammar: true, - check_spelling: true, - read_page_color: Default::default(), - ignore_uppercase: true, - ignore_numbers: true, - ignore_internet: true, - detect_language: true, - default_language: None, - autocorrect: Default::default(), - } - } -} - -impl Prefs { - /// What the spelling check skips. - pub fn spelling(&self) -> wordcraft_proof::Options { - wordcraft_proof::Options { - ignore_uppercase: self.ignore_uppercase, - ignore_numbers: self.ignore_numbers, - ignore_internet: self.ignore_internet, - } - } -} - -/// Ignore malformed saved language tags without discarding the other preferences. -fn lenient_default_language<'de, D: serde::Deserializer<'de>>(d: D) -> Result, D::Error> { - let value = serde_json::Value::deserialize(d)?; - Ok(value.as_str().and_then(wordcraft_proof::lang::normalize_tag)) -} - -fn lenient_paper_color<'de, D: serde::Deserializer<'de>>(d: D) -> Result { - let value = serde_json::Value::deserialize(d)?; - Ok(serde_json::from_value(value).unwrap_or_default()) -} - -/// Ignore an invalid saved Page Color without discarding other preferences. -fn lenient_read_page_color<'de, D: serde::Deserializer<'de>>(d: D) -> Result { - let value = serde_json::Value::deserialize(d)?; - Ok(serde_json::from_value(value).unwrap_or_default()) -} - -#[cfg(test)] -mod tests { - use super::Prefs; - use crate::cmd::view::ReadPageColor; - - #[test] - fn read_page_color_loads_from_old_and_damaged_prefs() { - let old: Prefs = serde_json::from_str(r#"{"checkSpelling":false}"#).unwrap(); - assert_eq!(old.read_page_color, ReadPageColor::None); - assert!(!old.check_spelling); - for bad in [r#"{"readPageColor":"unknown","checkSpelling":false}"#, r#"{"readPageColor":42,"checkSpelling":false}"#] { - let prefs: Prefs = serde_json::from_str(bad).unwrap(); - assert_eq!(prefs.read_page_color, ReadPageColor::None); - assert!(!prefs.check_spelling); - } - let saved: Prefs = serde_json::from_str(r#"{"readPageColor":"sepia"}"#).unwrap(); - assert_eq!(saved.read_page_color, ReadPageColor::Sepia); - assert_eq!(serde_json::to_value(saved).unwrap()["readPageColor"], "sepia"); - } -} +use serde::{Deserialize, Serialize}; + +/// Editing preferences that persist between runs (the front end saves and restores them). +/// File › Options sets most of them (`file.options`). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Prefs { + /// Word Count includes text boxes, footnotes and endnotes (Word's default). + pub count_notes: bool, + /// On-screen paper for pages without a document background; excluded from saved documents. + #[serde(deserialize_with = "lenient_paper_color")] + pub paper_color: crate::cmd::view::PaperColor, + /// Track Changes Options: what markup shows and how revisions are drawn (per user, as in + /// Word). + pub markup: wordcraft_layout::display::MarkupOptions, + /// File › Print / Export: Print Markup. PDFs, prints and page images show comments in + /// balloons beside each page and tracked changes as marked up; HTML, Markdown and text + /// copies end with the comments (`file.printMarkup`). + pub print_markup: bool, + /// Language › Detect language automatically (per user, as in Word). + pub detect_language: bool, + /// Set Proofing Language › Set As Default: overrides the interface language for future + /// blank documents and plain text. Older preferences keep following the interface. + #[serde(deserialize_with = "lenient_default_language")] + pub default_language: Option, + /// Typing over a selection replaces it; off, the typed text goes in front of it. + pub typing_replaces_selection: bool, + /// The Insert key switches overtype on and off. + pub insert_key_overtype: bool, + /// Overtype: typed characters replace the ones after the caret. + pub overtype: bool, + /// The unit rulers and dialogs show lengths in, when the user chose one (File › Options › + /// Advanced › Measurement units); `None` follows the region ([`Session::units`]). Saved under + /// a new key; an older `units` value other than inches (the old default) carries over on load. + #[serde(rename = "measurementUnits")] + pub units: Option, + /// The region the user chose for new documents, units and the decimal separator (a locale + /// tag like `pl-PL`, File › Options › Advanced › Region); `None` follows the system. + pub region: Option, + /// A folder searched for Hunspell dictionaries before the system locations (File › Options › + /// Language, #1063): an app started from Finder or the Dock doesn't see + /// `WORDCRAFT_DICTIONARIES`. `None` searches the system locations only. + pub dictionaries_folder: Option, + /// The user templates folder File › New lists personal templates from and Save as Word + /// Template starts in (File › Options › Advanced › File locations, #2852). `None`: the app's + /// own `Templates` folder in its settings folder. + pub user_templates_folder: Option, + /// Formatting marks shown even while Show/Hide ¶ is off. + pub marks: wordcraft_layout::display::FormattingMarks, + /// Mark grammar errors as you type (with Check spelling as you type on). + pub mark_grammar: bool, + /// Check spelling as you type (File › Options › Proofing). The live value is + /// `ViewState::proofing`; this is the copy that is saved and restored (#1792). + pub check_spelling: bool, + /// Read Mode's Page Color. The live value is `ViewState::read_page_color`. + #[serde(deserialize_with = "lenient_read_page_color")] + pub read_page_color: crate::cmd::view::ReadPageColor, + pub ignore_uppercase: bool, + pub ignore_numbers: bool, + pub ignore_internet: bool, + /// AutoCorrect Options: replacements, capitalization and AutoFormat As You Type (per user, as + /// in Word). A damaged saved value reads as the defaults. + #[serde(deserialize_with = "crate::cmd::tools::lenient_autocorrect")] + pub autocorrect: crate::cmd::tools::AutoCorrectPrefs, +} + +impl Default for Prefs { + fn default() -> Self { + Prefs { + paper_color: Default::default(), + count_notes: true, + markup: Default::default(), + print_markup: false, + typing_replaces_selection: true, + insert_key_overtype: false, + overtype: false, + units: Default::default(), + region: None, + dictionaries_folder: None, + user_templates_folder: None, + marks: Default::default(), + mark_grammar: true, + check_spelling: true, + read_page_color: Default::default(), + ignore_uppercase: true, + ignore_numbers: true, + ignore_internet: true, + detect_language: true, + default_language: None, + autocorrect: Default::default(), + } + } +} + +impl Prefs { + /// What the spelling check skips. + pub fn spelling(&self) -> wordcraft_proof::Options { + wordcraft_proof::Options { + ignore_uppercase: self.ignore_uppercase, + ignore_numbers: self.ignore_numbers, + ignore_internet: self.ignore_internet, + } + } +} + +/// Ignore malformed saved language tags without discarding the other preferences. +fn lenient_default_language<'de, D: serde::Deserializer<'de>>(d: D) -> Result, D::Error> { + let value = serde_json::Value::deserialize(d)?; + Ok(value.as_str().and_then(wordcraft_proof::lang::normalize_tag)) +} + +fn lenient_paper_color<'de, D: serde::Deserializer<'de>>(d: D) -> Result { + let value = serde_json::Value::deserialize(d)?; + Ok(serde_json::from_value(value).unwrap_or_default()) +} + +/// Ignore an invalid saved Page Color without discarding other preferences. +fn lenient_read_page_color<'de, D: serde::Deserializer<'de>>(d: D) -> Result { + let value = serde_json::Value::deserialize(d)?; + Ok(serde_json::from_value(value).unwrap_or_default()) +} + +#[cfg(test)] +mod tests { + use super::Prefs; + use crate::cmd::view::ReadPageColor; + + #[test] + fn read_page_color_loads_from_old_and_damaged_prefs() { + let old: Prefs = serde_json::from_str(r#"{"checkSpelling":false}"#).unwrap(); + assert_eq!(old.read_page_color, ReadPageColor::None); + assert!(!old.check_spelling); + for bad in [r#"{"readPageColor":"unknown","checkSpelling":false}"#, r#"{"readPageColor":42,"checkSpelling":false}"#] { + let prefs: Prefs = serde_json::from_str(bad).unwrap(); + assert_eq!(prefs.read_page_color, ReadPageColor::None); + assert!(!prefs.check_spelling); + } + let saved: Prefs = serde_json::from_str(r#"{"readPageColor":"sepia"}"#).unwrap(); + assert_eq!(saved.read_page_color, ReadPageColor::Sepia); + assert_eq!(serde_json::to_value(saved).unwrap()["readPageColor"], "sepia"); + } +} diff --git a/crates/engine/src/print.rs b/crates/engine/src/print.rs index 32c620eba9..29dd4fed37 100644 --- a/crates/engine/src/print.rs +++ b/crates/engine/src/print.rs @@ -1,425 +1,425 @@ -//! Print settings (#641): which pages go out, how many copies, as a PDF. -//! -//! WordCraft prints through a PDF (the host opens it in the system's print flow), so the settings -//! that can be delivered are the ones that shape the PDF: the page range, odd/even pages, copies -//! and collation. Physical sheet imposition is shared with the preview; printer choice and duplex -//! are handled by the host. -//! -//! Page ranges and copies come from users and control channels, so they are bounded: a range -//! text is at most [`MAX_SPEC_CHARS`] long, copies are clamped to [`MAX_COPIES`], and the -//! resulting page list never exceeds [`MAX_PRINT_PAGES`]. - -use serde_json::{Value, json}; -use wordcraft_layout::DocLayout; -pub use wordcraft_pdf::imposition::Imposition; - -use crate::{CmdError, Session, p}; - -/// Longest page-range text accepted. -pub const MAX_SPEC_CHARS: usize = 500; -/// Most copies one print job asks for. -pub const MAX_COPIES: u32 = 999; -/// Most pages (counting copies) one print job holds. -pub const MAX_PRINT_PAGES: usize = 5000; - -/// Parses a custom page range like `1-3, 5, 8-` into 0-based page indexes, in the order typed -/// (Word prints them in document order; so does this: sorted, without repeats). Pages are -/// 1-based in the text; `8-` means "to the last page" and `-3` "from the first". An empty text, -/// page 0, a page past `total`, a reversed range or anything else is an error. -pub fn parse_page_range(spec: &str, total: usize) -> Result, String> { - if spec.chars().count() > MAX_SPEC_CHARS { - return Err("the page range is too long".into()); - } - if total == 0 { - return Err("the document has no pages".into()); - } - let mut keep = vec![false; total]; - let mut any = false; - for item in spec.split([',', ';']) { - let item: String = item.chars().filter(|c| !c.is_whitespace()).collect(); - if item.is_empty() { - continue; - } - let page = |t: &str| -> Result { - let not_a_page = || format!("`{item}` isn't a page or a range like 2-4"); - if !t.bytes().all(|b| b.is_ascii_digit()) { - return Err(not_a_page()); - } - let n: usize = t.parse().map_err(|_| not_a_page())?; - if n == 0 || n > total { - return Err(format!("page {n} doesn't exist; the document has {total} page{}", if total == 1 { "" } else { "s" })); - } - Ok(n) - }; - let (from, to) = match item.split_once('-') { - None => { - let n = page(&item)?; - (n, n) - } - Some(("", "")) => return Err("`-` isn't a page or a range like 2-4".into()), - Some((a, b)) => { - let from = if a.is_empty() { 1 } else { page(a)? }; - let to = if b.is_empty() { total } else { page(b)? }; - if from > to { - return Err(format!("`{item}` runs backwards")); - } - (from, to) - } - }; - for flag in keep.iter_mut().take(to).skip(from - 1) { - *flag = true; - } - any = true; - } - if !any { - return Err("type the pages to print, like 1-3, 5".into()); - } - Ok(keep.iter().enumerate().filter(|(_, k)| **k).map(|(i, _)| i).collect()) -} - -/// What a print job holds: the pages (0-based, copies included, in print order). -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct PrintPlan { - /// Page indexes in print order, repeated for copies. - pub pages: Vec, - /// Distinct pages chosen (before copies). - pub chosen: usize, - pub copies: u32, -} - -/// Works out the print plan for `v` against a layout of `total` pages whose caret and selection -/// sit on pages `current` (first, last). Params: -/// `range` (`all` | `current` | `selection` | `custom`; `custom` is implied by `pages`), -/// `pages` (text like `1-3,5`), `parity` (`all` | `odd` | `even`), `copies` (1–999), -/// `collate` (default true: 1,2,3 1,2,3; false: 1,1,2,2,3,3). -pub fn plan(v: &Value, total: usize, current: (usize, usize)) -> Result { - let bad = |m: String| CmdError::Params(m); - let pages_text = p::str(v, "pages").filter(|t| !t.trim().is_empty()); - let range = p::str(v, "range").unwrap_or(if pages_text.is_some() { "custom" } else { "all" }); - let last = total.saturating_sub(1); - let mut pages: Vec = match range { - "all" => (0..total).collect(), - "current" => vec![current.0.min(last)], - "selection" => (current.0.min(last)..=current.1.min(last).max(current.0.min(last))).collect(), - "custom" => parse_page_range(pages_text.unwrap_or(""), total).map_err(bad)?, - other => return Err(bad(format!("unknown range `{other}`; use all, current, selection or custom"))), - }; - match p::str(v, "parity").unwrap_or("all") { - "all" => {} - // Page numbers are 1-based: the odd ones are indexes 0, 2, 4… - "odd" => pages.retain(|i| i % 2 == 0), - "even" => pages.retain(|i| i % 2 == 1), - other => return Err(bad(format!("unknown parity `{other}`; use all, odd or even"))), - } - if pages.is_empty() { - return Err(bad("no pages to print with these settings".into())); - } - let copies = p::u64(v, "copies").unwrap_or(1).clamp(1, u64::from(MAX_COPIES)) as u32; - let chosen = pages.len(); - let total_out = chosen.checked_mul(copies as usize).filter(|n| *n <= MAX_PRINT_PAGES); - let Some(total_out) = total_out else { - return Err(bad(format!("{chosen} pages × {copies} copies is more than the {MAX_PRINT_PAGES} pages one print job can hold"))); - }; - let mut out = Vec::with_capacity(total_out); - if p::bool(v, "collate").unwrap_or(true) { - for _ in 0..copies { - out.extend_from_slice(&pages); - } - } else { - for &pg in &pages { - out.extend(std::iter::repeat_n(pg, copies as usize)); - } - } - Ok(PrintPlan { pages: out, chosen, copies }) -} - -/// The `paper` param of a print job: the standard paper (see [`wordcraft_geom::PAPER_SIZES`]) to -/// scale every page to (Scale to paper size, #2108), or `None` to print the document's own page -/// sizes (absent, null, `""`, `none` or `document`). Any other value is an error. -pub fn paper(v: &Value) -> Result, CmdError> { - match v.get("paper") { - None | Some(Value::Null) => Ok(None), - Some(Value::String(name)) if name.trim().is_empty() || ["none", "document"].contains(&name.trim().to_ascii_lowercase().as_str()) => Ok(None), - Some(Value::String(name)) => wordcraft_geom::paper_by_name(name).map(Some).ok_or_else(|| { - let names: Vec<&str> = wordcraft_geom::PAPER_SIZES.iter().map(|(n, _, _)| *n).collect(); - CmdError::Params(format!("unknown paper `{}`; use none or one of {}", name.chars().take(40).collect::(), names.join(", "))) - }), - Some(_) => Err(CmdError::Params("paper must be a paper size name or none".into())), - } -} - -/// Validate the physical sheet layout without touching document page setup. -pub fn imposition(v: &Value) -> Result { - match v.get("pagesPerSheet") { - None => Ok(Imposition::Single), - Some(Value::Number(n)) => match n.as_u64() { - Some(1) => Ok(Imposition::Single), - Some(n @ (2 | 4 | 6 | 9 | 16)) => Ok(Imposition::NUp(n as u8)), - _ => Err(CmdError::Params("pagesPerSheet must be 1, 2, 4, 6, 9, 16, booklet or bookletReverse".into())), - }, - Some(Value::String(s)) if s == "booklet" || s == "bookletReverse" => Ok(Imposition::Booklet { reverse: s == "bookletReverse" }), - _ => Err(CmdError::Params("pagesPerSheet must be 1, 2, 4, 6, 9, 16, booklet or bookletReverse".into())), - } -} - -/// Resolve physical paper orientation for imposed jobs; ordinary export keeps its existing policy. -fn imposed_paper(v: &Value, lay: &DocLayout, pages: &[usize]) -> Result, CmdError> { - let paper = paper(v)?.map(|(_, w, h)| (w, h)); - if imposition(v)? == Imposition::Single { - return Ok(paper); - } - let own = pages.first().and_then(|p| lay.pages.get(*p)).map(|p| (p.w - p.markup, p.h)); - let Some((w, h)) = paper.or(own) else { return Ok(paper) }; - match v.get("orientation").and_then(Value::as_str).unwrap_or("auto") { - "auto" => Ok(paper), - "portrait" => Ok(Some((w.min(h), w.max(h)))), - "landscape" => Ok(Some((w.max(h), w.min(h)))), - _ => Err(CmdError::Params("orientation must be auto, portrait or landscape".into())), - } -} - -/// Plan physical sheet sides for one copy, using the same selection and geometry as printing. -pub fn sheets(v: &Value, lay: &DocLayout, current: (usize, usize)) -> Result, CmdError> { - if !v.is_object() { - return Err(CmdError::Params("print settings must be an object".into())); - } - let mut single = v.clone(); - single["copies"] = json!(1); - let pages = plan(&single, lay.pages.len(), current)?.pages; - imposition(v)?.sheets(lay, &pages, imposed_paper(v, lay, &pages)?).map_err(|e| CmdError::Failed(e.to_string())) -} - -impl Session { - /// The pages the caret (or selection) is on, in the output layout: (first, last). - pub fn current_pages(&self, lay: &DocLayout) -> (usize, usize) { - let (a, b) = self.sel.ordered(); - let first = lay.caret_on(&a, self.page_hint).map_or(0, |c| c.page); - let last = lay.caret_on(&b, self.page_hint).map_or(first, |c| c.page); - (first, last.max(first)) - } - - /// The document as the PDF of a print job `v` describes (see [`plan`]), with the number of - /// distinct pages chosen and copies, as File › Print › Save these pages as PDF keeps it: - /// links work. Does not touch the document. - pub fn print_pdf(&self, v: &Value) -> Result<(Vec, PrintPlan), CmdError> { - self.print_pdf_with(v, true) - } - - /// The PDF sent to a printer for job `v`: [`Self::print_pdf`] without link annotations, - /// which print nothing, and which a printer driver can't fit to the paper ("flatten page - /// annotations before using shrink-to-fit", #4734). - pub fn print_job_pdf(&self, v: &Value) -> Result<(Vec, PrintPlan), CmdError> { - self.print_pdf_with(v, false) - } - - /// Render a shared sheet plan for a saved PDF or an annotation-free printer job. - fn print_pdf_with(&self, v: &Value, links: bool) -> Result<(Vec, PrintPlan), CmdError> { - if !v.is_object() { - return Err(CmdError::Params("print settings must be an object".into())); - } - // Print Markup (File › Print) decides whether tracked changes and comments print. - let markup = crate::cmd::file::export_options(self, v); - let out = crate::cmd::references::output_fields(&self.doc); - let doc = out.as_ref().unwrap_or(&self.doc); - let lay = self.export_layout_of(doc, markup.markup); - let plan = plan(v, lay.pages.len(), self.current_pages(&lay))?; - let mut single = v.clone(); - single["copies"] = json!(1); - let single_plan = self::plan(&single, lay.pages.len(), self.current_pages(&lay))?; - let paper = imposed_paper(v, &lay, &single_plan.pages)?; - let opts = wordcraft_pdf::PdfOptions { - pages: Some(single_plan.pages), - imposition: imposition(v)?, - sheet_copies: plan.copies, - sheet_collate: p::bool(v, "collate").unwrap_or(true), - // Repeated pages would repeat the structure tree's entries too. - tagged: plan.copies <= 1, - include_markup: markup.markup, - comments: markup.comments, - paper, - links, - ..Default::default() - }; - let bytes = wordcraft_pdf::export_layout(doc, &lay, &opts).map_err(|e| CmdError::Failed(e.to_string()))?; - Ok((bytes, plan)) - } -} - -/// `file.printPdf`: write the print job's PDF to `path` (no dialogs). -pub(crate) fn print_pdf_to_file(s: &mut Session, v: &Value) -> Result { - let path = p::req_str(v, "path")?; - let path = if path.to_ascii_lowercase().ends_with(".pdf") { path.to_string() } else { format!("{path}.pdf") }; - let (bytes, plan) = s.print_pdf(v)?; - // Through `io`, so an agent confined to an automation write root (#769) can't print elsewhere. - #[cfg(not(target_arch = "wasm32"))] - crate::io::write_file(&path, &bytes).map_err(CmdError::Failed)?; - Ok(json!({"path": path, "pages": plan.pages.len(), "chosen": plan.chosen, "copies": plan.copies, "bytes": bytes.len()})) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn ranges_parse_and_hostile_ones_are_errors() { - assert_eq!(parse_page_range("1-3, 5", 6), Ok(vec![0, 1, 2, 4])); - assert_eq!(parse_page_range("5,1-2,2", 6), Ok(vec![0, 1, 4]), "document order, no repeats"); - assert_eq!(parse_page_range("4-", 6), Ok(vec![3, 4, 5])); - assert_eq!(parse_page_range("-2", 6), Ok(vec![0, 1])); - for bad in ["", " , ", "0", "7", "3-1", "a", "1-2-3", "99999999999999999999999", "-", "1..3", "+3", "1-+3", "+2-+4", &"1,".repeat(400)] { - assert!(parse_page_range(bad, 6).is_err(), "`{bad}` is refused"); - } - assert!(parse_page_range("1", 0).is_err()); - } - - #[test] - fn plans_apply_range_parity_copies_and_caps() { - let p = |v: Value| plan(&v, 6, (2, 3)).map(|p| p.pages); - assert_eq!(p(json!({})), Ok(vec![0, 1, 2, 3, 4, 5])); - assert_eq!(p(json!({"range": "current"})), Ok(vec![2])); - assert_eq!(p(json!({"range": "selection"})), Ok(vec![2, 3])); - assert_eq!(p(json!({"pages": "2-4", "copies": 2})), Ok(vec![1, 2, 3, 1, 2, 3])); - assert_eq!(p(json!({"pages": "2-3", "copies": 2, "collate": false})), Ok(vec![1, 1, 2, 2])); - assert_eq!(p(json!({"parity": "even"})), Ok(vec![1, 3, 5])); - assert!(p(json!({"range": "custom"})).is_err(), "custom needs pages"); - assert!(p(json!({"range": "everything"})).is_err()); - assert!(p(json!({"range": "current", "parity": "even"})).is_err(), "page 3 is odd: nothing left"); - // Hostile copies are clamped; a job that would be too big is refused, not allocated. - assert_eq!(p(json!({"range": "current", "copies": 1e12})).map(|v| v.len()), Ok(MAX_COPIES as usize)); - assert!(plan(&json!({"copies": 999}), 600, (0, 0)).is_err()); - assert_eq!(p(json!({"copies": -5, "range": "current"})), Ok(vec![2])); - } - - #[test] - fn print_pdf_writes_only_the_chosen_pages() { - let mut s = Session::new(crate::sample::sample_document()); - let pages = s.export_layout().pages.len(); - let (all, _) = s.print_pdf(&json!({})).unwrap(); - let (one, plan) = s.print_pdf(&json!({"range": "current", "copies": 2})).unwrap(); - assert!(all.starts_with(b"%PDF") && one.starts_with(b"%PDF")); - assert_eq!(plan.pages.len(), 2); - if pages > 2 { - assert!(one.len() < all.len()); - } - assert!(s.print_pdf(&json!({"pages": "9999"})).is_err()); - let r = s.run("file.printPdf", &json!({})).is_err(); - assert!(r, "printPdf needs a path"); - } - - /// #4734: the PDF sent to a printer has no link annotations, which a printer driver can't fit - /// to the paper ("flatten page annotations before using shrink-to-fit"). An exported PDF and - /// one saved with Save these pages as PDF (`file.printPdf`) keep them. - #[test] - fn only_the_print_job_leaves_out_link_annotations() { - let mut s = Session::new(wordcraft_doc::Document::new()); - s.run("insert.link", &json!({"url": "https://example.com", "text": "a link"})).unwrap(); - let lay = s.export_layout(); - let exported = wordcraft_pdf::export_layout(&s.doc, &lay, &wordcraft_pdf::PdfOptions::default()).unwrap(); - let links = |bytes: &[u8]| { - let raw = String::from_utf8_lossy(bytes); - raw.contains("/Annots") && raw.contains("/Link") - }; - assert!(links(&exported), "an exported PDF keeps the link"); - for params in [json!({}), json!({"paper": "A4"}), json!({"markup": true})] { - let (job, _) = s.print_job_pdf(¶ms).unwrap(); - assert!(!links(&job) && !String::from_utf8_lossy(&job).contains("/Annots"), "{params}"); - let (saved, _) = s.print_pdf(¶ms).unwrap(); - assert!(links(&saved), "{params}"); - } - #[cfg(not(target_arch = "wasm32"))] - { - let path = std::env::temp_dir().join(format!("wc-print-links-{}.pdf", std::process::id())); - s.run("file.printPdf", &json!({"path": path.to_string_lossy()})).unwrap(); - let saved = std::fs::read(&path).unwrap(); - let _ = std::fs::remove_file(&path); - assert!(links(&saved), "Save these pages as PDF keeps the link"); - } - } - - /// Scale to paper size (#2108): the PDF goes on the chosen paper; the document keeps its size. - #[test] - fn print_pdf_scales_to_the_chosen_paper() { - // Page dictionaries are written uncompressed: `/MediaBox[0 0 w h]`. - let media = |bytes: &[u8]| { - let raw = String::from_utf8_lossy(bytes).into_owned(); - raw.split("/MediaBox[") - .skip(1) - .filter_map(|r| r.split(']').next()) - .map(|r| r.split_whitespace().filter_map(|n| n.parse::().ok()).collect::>()) - .map(|n| (n[2].round(), n[3].round())) - .collect::>() - }; - let s = Session::new(crate::sample::sample_document()); - let (w, h) = (s.doc.last_section.page_w, s.doc.last_section.page_h); - let rev = s.rev(); - let (own, _) = s.print_pdf(&json!({"range": "current"})).unwrap(); - assert_eq!(media(&own), vec![(w.round(), h.round())]); - for (name, want) in [("A4", (595.0, 842.0)), ("letter", (612.0, 792.0)), ("Legal", (612.0, 1008.0))] { - let (bytes, _) = s.print_pdf(&json!({"range": "current", "paper": name})).unwrap(); - assert_eq!(media(&bytes), vec![want], "{name}"); - } - for none in [json!(null), json!(""), json!("none"), json!("Document")] { - let (bytes, _) = s.print_pdf(&json!({"range": "current", "paper": none})).unwrap(); - assert_eq!(media(&bytes), media(&own)); - } - for bad in [json!("Quarto"), json!(5), json!({"w": 1}), json!("x".repeat(10_000))] { - assert!(s.print_pdf(&json!({"paper": bad})).is_err(), "{bad}"); - } - assert_eq!((s.doc.last_section.page_w, s.doc.last_section.page_h, s.rev()), (w, h, rev), "the document is unchanged"); - } - - /// `file.printPdf` writes only inside the automation write root when there is one (#769). - #[cfg(not(target_arch = "wasm32"))] - #[test] - fn print_pdf_to_file_is_held_to_the_write_root() { - use crate::file_access::{AutomationRoots, confine}; - let base = std::env::temp_dir().join(format!("wc-print-roots-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&base); - for d in ["out", "outside"] { - std::fs::create_dir_all(base.join(d)).unwrap(); - } - let roots = AutomationRoots::new(None, Some(&base.join("out"))).unwrap(); - let mut s = Session::new(crate::sample::sample_document()); - let inside = base.join("out/job").to_string_lossy().into_owned(); - let outside = base.join("outside/job").to_string_lossy().into_owned(); - confine(roots.as_ref(), || { - let e = s.run("file.printPdf", &json!({"path": outside})).unwrap_err().to_string(); - assert!(e.contains("outside the write root"), "{e}"); - s.run("file.printPdf", &json!({"path": inside})).unwrap(); - }); - assert!(!base.join("outside/job.pdf").exists(), "nothing printed outside the root"); - assert!(std::fs::read(base.join("out/job.pdf")).unwrap().starts_with(b"%PDF")); - // Without roots nothing is checked: the behaviour is unchanged. - s.run("file.printPdf", &json!({"path": outside})).unwrap(); - assert!(base.join("outside/job.pdf").is_file()); - let _ = std::fs::remove_dir_all(&base); - } -} - -#[cfg(test)] -mod imposition_tests { - use super::*; - /// Sheet printing leaves document bytes, dirty state, selection and undo history untouched. - #[test] - fn print_imposition_is_pure_and_rejects_hostile_modes() { - let s = Session::new(wordcraft_doc::Document::from_text("one\u{c}two\u{c}three\u{c}four\u{c}five")); - let before = (s.rev(), s.dirty, s.sel.clone(), serde_json::to_value(&s.doc).unwrap()); - for (mode, sides) in - [(json!(2), 3), (json!(4), 2), (json!(6), 1), (json!(9), 1), (json!(16), 1), (json!("booklet"), 4), (json!("bookletReverse"), 4)] - { - let params = json!({"pagesPerSheet":mode, "copies":2}); - let lay = s.export_layout(); - assert_eq!(sheets(¶ms, &lay, s.current_pages(&lay)).unwrap().len(), sides); - let (bytes, plan) = s.print_pdf(¶ms).unwrap(); - assert_eq!(plan.copies, 2); - assert_eq!(String::from_utf8_lossy(&bytes).matches("/MediaBox[").count(), sides * 2); - } - for bad in [json!(0), json!(3), json!(-1), json!(1.5), json!(true), json!(null), json!({}), json!("2"), json!("x".repeat(10000))] { - assert!(s.print_pdf(&json!({"pagesPerSheet":bad})).is_err()); - } - for params in [json!(null), json!([]), json!(false)] { - assert!(s.print_pdf(¶ms).is_err()); - } - assert_eq!((s.rev(), s.dirty, s.sel.clone(), serde_json::to_value(&s.doc).unwrap()), before); - } -} +//! Print settings (#641): which pages go out, how many copies, as a PDF. +//! +//! WordCraft prints through a PDF (the host opens it in the system's print flow), so the settings +//! that can be delivered are the ones that shape the PDF: the page range, odd/even pages, copies +//! and collation. Physical sheet imposition is shared with the preview; printer choice and duplex +//! are handled by the host. +//! +//! Page ranges and copies come from users and control channels, so they are bounded: a range +//! text is at most [`MAX_SPEC_CHARS`] long, copies are clamped to [`MAX_COPIES`], and the +//! resulting page list never exceeds [`MAX_PRINT_PAGES`]. + +use serde_json::{Value, json}; +use wordcraft_layout::DocLayout; +pub use wordcraft_pdf::imposition::Imposition; + +use crate::{CmdError, Session, p}; + +/// Longest page-range text accepted. +pub const MAX_SPEC_CHARS: usize = 500; +/// Most copies one print job asks for. +pub const MAX_COPIES: u32 = 999; +/// Most pages (counting copies) one print job holds. +pub const MAX_PRINT_PAGES: usize = 5000; + +/// Parses a custom page range like `1-3, 5, 8-` into 0-based page indexes, in the order typed +/// (Word prints them in document order; so does this: sorted, without repeats). Pages are +/// 1-based in the text; `8-` means "to the last page" and `-3` "from the first". An empty text, +/// page 0, a page past `total`, a reversed range or anything else is an error. +pub fn parse_page_range(spec: &str, total: usize) -> Result, String> { + if spec.chars().count() > MAX_SPEC_CHARS { + return Err("the page range is too long".into()); + } + if total == 0 { + return Err("the document has no pages".into()); + } + let mut keep = vec![false; total]; + let mut any = false; + for item in spec.split([',', ';']) { + let item: String = item.chars().filter(|c| !c.is_whitespace()).collect(); + if item.is_empty() { + continue; + } + let page = |t: &str| -> Result { + let not_a_page = || format!("`{item}` isn't a page or a range like 2-4"); + if !t.bytes().all(|b| b.is_ascii_digit()) { + return Err(not_a_page()); + } + let n: usize = t.parse().map_err(|_| not_a_page())?; + if n == 0 || n > total { + return Err(format!("page {n} doesn't exist; the document has {total} page{}", if total == 1 { "" } else { "s" })); + } + Ok(n) + }; + let (from, to) = match item.split_once('-') { + None => { + let n = page(&item)?; + (n, n) + } + Some(("", "")) => return Err("`-` isn't a page or a range like 2-4".into()), + Some((a, b)) => { + let from = if a.is_empty() { 1 } else { page(a)? }; + let to = if b.is_empty() { total } else { page(b)? }; + if from > to { + return Err(format!("`{item}` runs backwards")); + } + (from, to) + } + }; + for flag in keep.iter_mut().take(to).skip(from - 1) { + *flag = true; + } + any = true; + } + if !any { + return Err("type the pages to print, like 1-3, 5".into()); + } + Ok(keep.iter().enumerate().filter(|(_, k)| **k).map(|(i, _)| i).collect()) +} + +/// What a print job holds: the pages (0-based, copies included, in print order). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PrintPlan { + /// Page indexes in print order, repeated for copies. + pub pages: Vec, + /// Distinct pages chosen (before copies). + pub chosen: usize, + pub copies: u32, +} + +/// Works out the print plan for `v` against a layout of `total` pages whose caret and selection +/// sit on pages `current` (first, last). Params: +/// `range` (`all` | `current` | `selection` | `custom`; `custom` is implied by `pages`), +/// `pages` (text like `1-3,5`), `parity` (`all` | `odd` | `even`), `copies` (1–999), +/// `collate` (default true: 1,2,3 1,2,3; false: 1,1,2,2,3,3). +pub fn plan(v: &Value, total: usize, current: (usize, usize)) -> Result { + let bad = |m: String| CmdError::Params(m); + let pages_text = p::str(v, "pages").filter(|t| !t.trim().is_empty()); + let range = p::str(v, "range").unwrap_or(if pages_text.is_some() { "custom" } else { "all" }); + let last = total.saturating_sub(1); + let mut pages: Vec = match range { + "all" => (0..total).collect(), + "current" => vec![current.0.min(last)], + "selection" => (current.0.min(last)..=current.1.min(last).max(current.0.min(last))).collect(), + "custom" => parse_page_range(pages_text.unwrap_or(""), total).map_err(bad)?, + other => return Err(bad(format!("unknown range `{other}`; use all, current, selection or custom"))), + }; + match p::str(v, "parity").unwrap_or("all") { + "all" => {} + // Page numbers are 1-based: the odd ones are indexes 0, 2, 4… + "odd" => pages.retain(|i| i % 2 == 0), + "even" => pages.retain(|i| i % 2 == 1), + other => return Err(bad(format!("unknown parity `{other}`; use all, odd or even"))), + } + if pages.is_empty() { + return Err(bad("no pages to print with these settings".into())); + } + let copies = p::u64(v, "copies").unwrap_or(1).clamp(1, u64::from(MAX_COPIES)) as u32; + let chosen = pages.len(); + let total_out = chosen.checked_mul(copies as usize).filter(|n| *n <= MAX_PRINT_PAGES); + let Some(total_out) = total_out else { + return Err(bad(format!("{chosen} pages × {copies} copies is more than the {MAX_PRINT_PAGES} pages one print job can hold"))); + }; + let mut out = Vec::with_capacity(total_out); + if p::bool(v, "collate").unwrap_or(true) { + for _ in 0..copies { + out.extend_from_slice(&pages); + } + } else { + for &pg in &pages { + out.extend(std::iter::repeat_n(pg, copies as usize)); + } + } + Ok(PrintPlan { pages: out, chosen, copies }) +} + +/// The `paper` param of a print job: the standard paper (see [`wordcraft_geom::PAPER_SIZES`]) to +/// scale every page to (Scale to paper size, #2108), or `None` to print the document's own page +/// sizes (absent, null, `""`, `none` or `document`). Any other value is an error. +pub fn paper(v: &Value) -> Result, CmdError> { + match v.get("paper") { + None | Some(Value::Null) => Ok(None), + Some(Value::String(name)) if name.trim().is_empty() || ["none", "document"].contains(&name.trim().to_ascii_lowercase().as_str()) => Ok(None), + Some(Value::String(name)) => wordcraft_geom::paper_by_name(name).map(Some).ok_or_else(|| { + let names: Vec<&str> = wordcraft_geom::PAPER_SIZES.iter().map(|(n, _, _)| *n).collect(); + CmdError::Params(format!("unknown paper `{}`; use none or one of {}", name.chars().take(40).collect::(), names.join(", "))) + }), + Some(_) => Err(CmdError::Params("paper must be a paper size name or none".into())), + } +} + +/// Validate the physical sheet layout without touching document page setup. +pub fn imposition(v: &Value) -> Result { + match v.get("pagesPerSheet") { + None => Ok(Imposition::Single), + Some(Value::Number(n)) => match n.as_u64() { + Some(1) => Ok(Imposition::Single), + Some(n @ (2 | 4 | 6 | 9 | 16)) => Ok(Imposition::NUp(n as u8)), + _ => Err(CmdError::Params("pagesPerSheet must be 1, 2, 4, 6, 9, 16, booklet or bookletReverse".into())), + }, + Some(Value::String(s)) if s == "booklet" || s == "bookletReverse" => Ok(Imposition::Booklet { reverse: s == "bookletReverse" }), + _ => Err(CmdError::Params("pagesPerSheet must be 1, 2, 4, 6, 9, 16, booklet or bookletReverse".into())), + } +} + +/// Resolve physical paper orientation for imposed jobs; ordinary export keeps its existing policy. +fn imposed_paper(v: &Value, lay: &DocLayout, pages: &[usize]) -> Result, CmdError> { + let paper = paper(v)?.map(|(_, w, h)| (w, h)); + if imposition(v)? == Imposition::Single { + return Ok(paper); + } + let own = pages.first().and_then(|p| lay.pages.get(*p)).map(|p| (p.w - p.markup, p.h)); + let Some((w, h)) = paper.or(own) else { return Ok(paper) }; + match v.get("orientation").and_then(Value::as_str).unwrap_or("auto") { + "auto" => Ok(paper), + "portrait" => Ok(Some((w.min(h), w.max(h)))), + "landscape" => Ok(Some((w.max(h), w.min(h)))), + _ => Err(CmdError::Params("orientation must be auto, portrait or landscape".into())), + } +} + +/// Plan physical sheet sides for one copy, using the same selection and geometry as printing. +pub fn sheets(v: &Value, lay: &DocLayout, current: (usize, usize)) -> Result, CmdError> { + if !v.is_object() { + return Err(CmdError::Params("print settings must be an object".into())); + } + let mut single = v.clone(); + single["copies"] = json!(1); + let pages = plan(&single, lay.pages.len(), current)?.pages; + imposition(v)?.sheets(lay, &pages, imposed_paper(v, lay, &pages)?).map_err(|e| CmdError::Failed(e.to_string())) +} + +impl Session { + /// The pages the caret (or selection) is on, in the output layout: (first, last). + pub fn current_pages(&self, lay: &DocLayout) -> (usize, usize) { + let (a, b) = self.sel.ordered(); + let first = lay.caret_on(&a, self.page_hint).map_or(0, |c| c.page); + let last = lay.caret_on(&b, self.page_hint).map_or(first, |c| c.page); + (first, last.max(first)) + } + + /// The document as the PDF of a print job `v` describes (see [`plan`]), with the number of + /// distinct pages chosen and copies, as File › Print › Save these pages as PDF keeps it: + /// links work. Does not touch the document. + pub fn print_pdf(&self, v: &Value) -> Result<(Vec, PrintPlan), CmdError> { + self.print_pdf_with(v, true) + } + + /// The PDF sent to a printer for job `v`: [`Self::print_pdf`] without link annotations, + /// which print nothing, and which a printer driver can't fit to the paper ("flatten page + /// annotations before using shrink-to-fit", #4734). + pub fn print_job_pdf(&self, v: &Value) -> Result<(Vec, PrintPlan), CmdError> { + self.print_pdf_with(v, false) + } + + /// Render a shared sheet plan for a saved PDF or an annotation-free printer job. + fn print_pdf_with(&self, v: &Value, links: bool) -> Result<(Vec, PrintPlan), CmdError> { + if !v.is_object() { + return Err(CmdError::Params("print settings must be an object".into())); + } + // Print Markup (File › Print) decides whether tracked changes and comments print. + let markup = crate::cmd::file::export_options(self, v); + let out = crate::cmd::references::output_fields(&self.doc); + let doc = out.as_ref().unwrap_or(&self.doc); + let lay = self.export_layout_of(doc, markup.markup); + let plan = plan(v, lay.pages.len(), self.current_pages(&lay))?; + let mut single = v.clone(); + single["copies"] = json!(1); + let single_plan = self::plan(&single, lay.pages.len(), self.current_pages(&lay))?; + let paper = imposed_paper(v, &lay, &single_plan.pages)?; + let opts = wordcraft_pdf::PdfOptions { + pages: Some(single_plan.pages), + imposition: imposition(v)?, + sheet_copies: plan.copies, + sheet_collate: p::bool(v, "collate").unwrap_or(true), + // Repeated pages would repeat the structure tree's entries too. + tagged: plan.copies <= 1, + include_markup: markup.markup, + comments: markup.comments, + paper, + links, + ..Default::default() + }; + let bytes = wordcraft_pdf::export_layout(doc, &lay, &opts).map_err(|e| CmdError::Failed(e.to_string()))?; + Ok((bytes, plan)) + } +} + +/// `file.printPdf`: write the print job's PDF to `path` (no dialogs). +pub(crate) fn print_pdf_to_file(s: &mut Session, v: &Value) -> Result { + let path = p::req_str(v, "path")?; + let path = if path.to_ascii_lowercase().ends_with(".pdf") { path.to_string() } else { format!("{path}.pdf") }; + let (bytes, plan) = s.print_pdf(v)?; + // Through `io`, so an agent confined to an automation write root (#769) can't print elsewhere. + #[cfg(not(target_arch = "wasm32"))] + crate::io::write_file(&path, &bytes).map_err(CmdError::Failed)?; + Ok(json!({"path": path, "pages": plan.pages.len(), "chosen": plan.chosen, "copies": plan.copies, "bytes": bytes.len()})) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ranges_parse_and_hostile_ones_are_errors() { + assert_eq!(parse_page_range("1-3, 5", 6), Ok(vec![0, 1, 2, 4])); + assert_eq!(parse_page_range("5,1-2,2", 6), Ok(vec![0, 1, 4]), "document order, no repeats"); + assert_eq!(parse_page_range("4-", 6), Ok(vec![3, 4, 5])); + assert_eq!(parse_page_range("-2", 6), Ok(vec![0, 1])); + for bad in ["", " , ", "0", "7", "3-1", "a", "1-2-3", "99999999999999999999999", "-", "1..3", "+3", "1-+3", "+2-+4", &"1,".repeat(400)] { + assert!(parse_page_range(bad, 6).is_err(), "`{bad}` is refused"); + } + assert!(parse_page_range("1", 0).is_err()); + } + + #[test] + fn plans_apply_range_parity_copies_and_caps() { + let p = |v: Value| plan(&v, 6, (2, 3)).map(|p| p.pages); + assert_eq!(p(json!({})), Ok(vec![0, 1, 2, 3, 4, 5])); + assert_eq!(p(json!({"range": "current"})), Ok(vec![2])); + assert_eq!(p(json!({"range": "selection"})), Ok(vec![2, 3])); + assert_eq!(p(json!({"pages": "2-4", "copies": 2})), Ok(vec![1, 2, 3, 1, 2, 3])); + assert_eq!(p(json!({"pages": "2-3", "copies": 2, "collate": false})), Ok(vec![1, 1, 2, 2])); + assert_eq!(p(json!({"parity": "even"})), Ok(vec![1, 3, 5])); + assert!(p(json!({"range": "custom"})).is_err(), "custom needs pages"); + assert!(p(json!({"range": "everything"})).is_err()); + assert!(p(json!({"range": "current", "parity": "even"})).is_err(), "page 3 is odd: nothing left"); + // Hostile copies are clamped; a job that would be too big is refused, not allocated. + assert_eq!(p(json!({"range": "current", "copies": 1e12})).map(|v| v.len()), Ok(MAX_COPIES as usize)); + assert!(plan(&json!({"copies": 999}), 600, (0, 0)).is_err()); + assert_eq!(p(json!({"copies": -5, "range": "current"})), Ok(vec![2])); + } + + #[test] + fn print_pdf_writes_only_the_chosen_pages() { + let mut s = Session::new(crate::sample::sample_document()); + let pages = s.export_layout().pages.len(); + let (all, _) = s.print_pdf(&json!({})).unwrap(); + let (one, plan) = s.print_pdf(&json!({"range": "current", "copies": 2})).unwrap(); + assert!(all.starts_with(b"%PDF") && one.starts_with(b"%PDF")); + assert_eq!(plan.pages.len(), 2); + if pages > 2 { + assert!(one.len() < all.len()); + } + assert!(s.print_pdf(&json!({"pages": "9999"})).is_err()); + let r = s.run("file.printPdf", &json!({})).is_err(); + assert!(r, "printPdf needs a path"); + } + + /// #4734: the PDF sent to a printer has no link annotations, which a printer driver can't fit + /// to the paper ("flatten page annotations before using shrink-to-fit"). An exported PDF and + /// one saved with Save these pages as PDF (`file.printPdf`) keep them. + #[test] + fn only_the_print_job_leaves_out_link_annotations() { + let mut s = Session::new(wordcraft_doc::Document::new()); + s.run("insert.link", &json!({"url": "https://example.com", "text": "a link"})).unwrap(); + let lay = s.export_layout(); + let exported = wordcraft_pdf::export_layout(&s.doc, &lay, &wordcraft_pdf::PdfOptions::default()).unwrap(); + let links = |bytes: &[u8]| { + let raw = String::from_utf8_lossy(bytes); + raw.contains("/Annots") && raw.contains("/Link") + }; + assert!(links(&exported), "an exported PDF keeps the link"); + for params in [json!({}), json!({"paper": "A4"}), json!({"markup": true})] { + let (job, _) = s.print_job_pdf(¶ms).unwrap(); + assert!(!links(&job) && !String::from_utf8_lossy(&job).contains("/Annots"), "{params}"); + let (saved, _) = s.print_pdf(¶ms).unwrap(); + assert!(links(&saved), "{params}"); + } + #[cfg(not(target_arch = "wasm32"))] + { + let path = std::env::temp_dir().join(format!("wc-print-links-{}.pdf", std::process::id())); + s.run("file.printPdf", &json!({"path": path.to_string_lossy()})).unwrap(); + let saved = std::fs::read(&path).unwrap(); + let _ = std::fs::remove_file(&path); + assert!(links(&saved), "Save these pages as PDF keeps the link"); + } + } + + /// Scale to paper size (#2108): the PDF goes on the chosen paper; the document keeps its size. + #[test] + fn print_pdf_scales_to_the_chosen_paper() { + // Page dictionaries are written uncompressed: `/MediaBox[0 0 w h]`. + let media = |bytes: &[u8]| { + let raw = String::from_utf8_lossy(bytes).into_owned(); + raw.split("/MediaBox[") + .skip(1) + .filter_map(|r| r.split(']').next()) + .map(|r| r.split_whitespace().filter_map(|n| n.parse::().ok()).collect::>()) + .map(|n| (n[2].round(), n[3].round())) + .collect::>() + }; + let s = Session::new(crate::sample::sample_document()); + let (w, h) = (s.doc.last_section.page_w, s.doc.last_section.page_h); + let rev = s.rev(); + let (own, _) = s.print_pdf(&json!({"range": "current"})).unwrap(); + assert_eq!(media(&own), vec![(w.round(), h.round())]); + for (name, want) in [("A4", (595.0, 842.0)), ("letter", (612.0, 792.0)), ("Legal", (612.0, 1008.0))] { + let (bytes, _) = s.print_pdf(&json!({"range": "current", "paper": name})).unwrap(); + assert_eq!(media(&bytes), vec![want], "{name}"); + } + for none in [json!(null), json!(""), json!("none"), json!("Document")] { + let (bytes, _) = s.print_pdf(&json!({"range": "current", "paper": none})).unwrap(); + assert_eq!(media(&bytes), media(&own)); + } + for bad in [json!("Quarto"), json!(5), json!({"w": 1}), json!("x".repeat(10_000))] { + assert!(s.print_pdf(&json!({"paper": bad})).is_err(), "{bad}"); + } + assert_eq!((s.doc.last_section.page_w, s.doc.last_section.page_h, s.rev()), (w, h, rev), "the document is unchanged"); + } + + /// `file.printPdf` writes only inside the automation write root when there is one (#769). + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn print_pdf_to_file_is_held_to_the_write_root() { + use crate::file_access::{AutomationRoots, confine}; + let base = std::env::temp_dir().join(format!("wc-print-roots-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + for d in ["out", "outside"] { + std::fs::create_dir_all(base.join(d)).unwrap(); + } + let roots = AutomationRoots::new(None, Some(&base.join("out"))).unwrap(); + let mut s = Session::new(crate::sample::sample_document()); + let inside = base.join("out/job").to_string_lossy().into_owned(); + let outside = base.join("outside/job").to_string_lossy().into_owned(); + confine(roots.as_ref(), || { + let e = s.run("file.printPdf", &json!({"path": outside})).unwrap_err().to_string(); + assert!(e.contains("outside the write root"), "{e}"); + s.run("file.printPdf", &json!({"path": inside})).unwrap(); + }); + assert!(!base.join("outside/job.pdf").exists(), "nothing printed outside the root"); + assert!(std::fs::read(base.join("out/job.pdf")).unwrap().starts_with(b"%PDF")); + // Without roots nothing is checked: the behaviour is unchanged. + s.run("file.printPdf", &json!({"path": outside})).unwrap(); + assert!(base.join("outside/job.pdf").is_file()); + let _ = std::fs::remove_dir_all(&base); + } +} + +#[cfg(test)] +mod imposition_tests { + use super::*; + /// Sheet printing leaves document bytes, dirty state, selection and undo history untouched. + #[test] + fn print_imposition_is_pure_and_rejects_hostile_modes() { + let s = Session::new(wordcraft_doc::Document::from_text("one\u{c}two\u{c}three\u{c}four\u{c}five")); + let before = (s.rev(), s.dirty, s.sel.clone(), serde_json::to_value(&s.doc).unwrap()); + for (mode, sides) in + [(json!(2), 3), (json!(4), 2), (json!(6), 1), (json!(9), 1), (json!(16), 1), (json!("booklet"), 4), (json!("bookletReverse"), 4)] + { + let params = json!({"pagesPerSheet":mode, "copies":2}); + let lay = s.export_layout(); + assert_eq!(sheets(¶ms, &lay, s.current_pages(&lay)).unwrap().len(), sides); + let (bytes, plan) = s.print_pdf(¶ms).unwrap(); + assert_eq!(plan.copies, 2); + assert_eq!(String::from_utf8_lossy(&bytes).matches("/MediaBox[").count(), sides * 2); + } + for bad in [json!(0), json!(3), json!(-1), json!(1.5), json!(true), json!(null), json!({}), json!("2"), json!("x".repeat(10000))] { + assert!(s.print_pdf(&json!({"pagesPerSheet":bad})).is_err()); + } + for params in [json!(null), json!([]), json!(false)] { + assert!(s.print_pdf(¶ms).is_err()); + } + assert_eq!((s.rev(), s.dirty, s.sel.clone(), serde_json::to_value(&s.doc).unwrap()), before); + } +} diff --git a/crates/engine/src/recovery.rs b/crates/engine/src/recovery.rs index ce210f5acc..6df7fb3371 100644 --- a/crates/engine/src/recovery.rs +++ b/crates/engine/src/recovery.rs @@ -1,229 +1,229 @@ -//! Local desktop recovery. Encoding and file I/O run on a worker, outside typing. -use crate::{CmdError, CmdResult, Session}; -use serde::{Deserialize, Serialize}; -use serde_json::{Value, json}; - -#[derive(Clone, Debug, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct RecoveryInfo { - pub id: String, - pub name: String, - pub saved_at: String, -} - -#[cfg(not(target_arch = "wasm32"))] -mod desktop; -#[cfg(not(target_arch = "wasm32"))] -use desktop::{Applied, Request}; -#[cfg(not(target_arch = "wasm32"))] -pub(crate) use desktop::{DocRecovery, Recovery, Slot}; - -impl Session { - /// The desktop host supplies the profile folder and a thread-safe wakeup. - /// Headless sessions and the browser never write recovery files implicitly. - #[cfg(not(target_arch = "wasm32"))] - pub fn enable_recovery(&mut self, root: std::path::PathBuf, wake: std::sync::Arc) -> Result<(), String> { - self.recovery = Some(Recovery::start(root, wake)?); - Ok(()) - } - /// Copies left by an earlier run (or another window's documents that have closed), newest - /// first. The copies this session's open documents keep are not listed: they are not lost. - pub fn recovery_documents(&self) -> Vec { - #[cfg(not(target_arch = "wasm32"))] - if let Some(r) = &self.recovery { - let held = |id: &str| self.recovery_slots().any(|(_, _, s)| s.id.as_deref() == Some(id)) || r.closing().any(|c| c == id); - return r.documents.iter().filter(|d| !held(&d.id)).cloned().collect(); - } - Vec::new() - } - pub fn recovery_ready(&self) -> bool { - #[cfg(not(target_arch = "wasm32"))] - if let Some(r) = &self.recovery { - return r.ready; - } - false - } - pub fn recovery_busy(&self) -> bool { - #[cfg(not(target_arch = "wasm32"))] - if let Some(r) = &self.recovery { - return r.busy; - } - false - } - pub fn recovery_error(&self) -> Option<&str> { - #[cfg(not(target_arch = "wasm32"))] - if let Some(r) = &self.recovery { - return r.error.as_deref(); - } - None - } - /// Called every frame: takes in the worker's answer, opens a restored copy, and queues the - /// next job. Every open document's copy is kept, the parked ones' too, without making them - /// active: a frame compares each document's revision and timer, and clones a document only - /// when its copy is due. - pub fn recovery_tick(&mut self) { - #[cfg(not(target_arch = "wasm32"))] - { - let Some(mut r) = self.recovery.take() else { return }; - if let Some(e) = r.receive(false) { - self.apply_recovery(&mut r, e); - } - self.open_held_restore(&mut r); - if let Err(e) = r.tick(self.recovery_docs(), std::time::Instant::now()) { - r.error = Some(e); - } - if let Some(e) = &r.error { - self.status = e.clone(); - } - self.recovery = Some(r); - } - } - #[cfg(not(target_arch = "wasm32"))] - fn apply_recovery(&mut self, r: &mut Recovery, e: desktop::Event) { - match r.apply(e) { - // Opened by the next tick, never in the middle of a command. - Applied::Loaded(snapshot, request) => r.held = Some((snapshot, request)), - applied => { - for d in self.recovery_docs() { - if let Applied::Failed { document, .. } = &applied - && *document != d.id - { - continue; - } - d.slot.apply(&applied); - } - } - } - } - /// A restored copy opens as another document, or replaces the untouched blank document that - /// asked for it once that document is active, so no work is ever replaced. The restored - /// document keeps its copy in the record it came from. - #[cfg(not(target_arch = "wasm32"))] - fn open_held_restore(&mut self, r: &mut Recovery) { - let Some((_, request)) = &r.held else { return }; - let request = *request; - if request.replace && self.is_open(request.document) && request.document != self.document_id() { - return; - } - let Some((snapshot, _)) = r.held.take() else { return }; - let id = snapshot.info.id.clone(); - if !request.replace { - self.add_document(snapshot.document); - } else if request.document == self.document_id() && request.revision == self.rev() && self.password.is_none() { - self.set_document(snapshot.document); - self.path = None; - } else { - r.error = Some("Recovery cancelled: the document changed while the copy was loading.".into()); - return; - } - self.dirty = true; - self.recovery_slot.adopt(id, self.rev()); - } - pub fn recovery_wait(&self) -> Option { - #[cfg(not(target_arch = "wasm32"))] - if let Some(r) = &self.recovery { - return r.wait(self.recovery_slots()); - } - None - } - /// Waits for recovery to finish anything already under way, then deletes the copies no - /// open document needs: closed documents', and those of documents saved or protected since - /// their last copy. Dirty documents keep theirs. Desktop hosts call this right before the - /// process exits, the same as they wait out pending print jobs: otherwise a save just before - /// close can race the process exiting before a now-redundant recovery copy actually gets - /// removed, so the next launch wrongly offers to recover a document that was in fact saved - /// cleanly. - pub fn finish_recovery(&mut self) { - #[cfg(not(target_arch = "wasm32"))] - if let Some(mut r) = self.recovery.take() { - if let Some(e) = r.receive(true) { - self.apply_recovery(&mut r, e); - } - let mut discards: Vec<(String, Option)> = r.take_closed().into_iter().map(|id| (id, None)).collect(); - discards.extend(self.recovery_docs().filter(|d| !d.wants_copy).filter_map(|d| d.slot.id.clone().map(|id| (id, Some(d.id))))); - for (id, document) in discards { - match r.discard_now(&id, document) { - Ok(e) => self.apply_recovery(&mut r, e), - Err(e) => r.error = Some(e), - } - } - if let Some(e) = &r.error { - log::warn!("recovery: {e}"); - } - self.recovery = Some(r); - } - } - /// The user discarded the active document's unsaved changes without closing it (Don't Save - /// when quitting): its recovery copy goes too, and another is made only after a further edit. - pub fn discard_recovery_copy(&mut self) { - #[cfg(not(target_arch = "wasm32"))] - if let Some(r) = &mut self.recovery - && let Some(id) = self.recovery_slot.id.take() - { - r.close(id); - self.recovery_slot.revision = Some(self.rev()); - } - } - /// Only an explicit protect/save action waits for the worker and removes plaintext copies. - /// A failure prevents applying the password; typing never waits. - pub(crate) fn clear_recovery_before_password(&mut self) -> Result<(), CmdError> { - #[cfg(not(target_arch = "wasm32"))] - if let Some(mut r) = self.recovery.take() { - let result = self.purge_recovery(&mut r); - self.recovery = Some(r); - result.map_err(CmdError::Failed)?; - } - Ok(()) - } - /// Waits out the job under way (it may be this document's copy being written), then deletes - /// the active document's copy. - #[cfg(not(target_arch = "wasm32"))] - fn purge_recovery(&mut self, r: &mut Recovery) -> Result<(), String> { - if let Some(e) = r.receive(true) { - self.apply_recovery(r, e); - } - let Some(id) = self.recovery_slot.id.clone() else { return Ok(()) }; - let e = r.discard_now(&id, Some(self.document_id()))?; - self.apply_recovery(r, e); - if self.recovery_slot.id.is_some() { - return Err(r.error.clone().unwrap_or_else(|| "the recovery copy could not be removed".into())); - } - self.recovery_slot.revision = None; - Ok(()) - } - pub(crate) fn recover(&mut self, v: &Value) -> CmdResult { - let restore = v.get("restore"); - let discard = v.get("discard"); - if restore.is_some() && discard.is_some() { - return Err(CmdError::Params("choose restore or discard".into())); - } - if restore.is_some() || discard.is_some() || v.get("refresh").is_some() { - #[cfg(not(target_arch = "wasm32"))] - { - let request = - Request { document: self.document_id(), revision: self.rev(), replace: self.is_untouched_blank() && self.password.is_none() }; - // Only listed copies: never one an open document keeps. - let listed = self.recovery_documents(); - let known = |id: Option<&str>, what: &str| -> Result { - let id = id.ok_or_else(|| CmdError::Params(format!("{what} must be a recovery id")))?; - if listed.iter().any(|d| d.id == id) { Ok(id.to_string()) } else { Err(CmdError::Failed("no such recovery copy".into())) } - }; - let r = self.recovery.as_mut().ok_or_else(|| CmdError::Disabled("local recovery requires the desktop host".into()))?; - if let Some(id) = restore { - r.restore(&known(id.as_str(), "restore")?, request).map_err(CmdError::Failed)?; - } else if let Some(id) = discard { - r.discard(&known(id.as_str(), "discard")?).map_err(CmdError::Failed)?; - } else if v.get("refresh").and_then(Value::as_bool) == Some(true) { - r.refresh().map_err(CmdError::Failed)?; - } else { - return Err(CmdError::Params("refresh must be true".into())); - } - } - #[cfg(target_arch = "wasm32")] - return Err(CmdError::Disabled("local recovery is available on desktop only".into())); - } - Ok( - json!({"enabled": self.recovery_ready(), "busy": self.recovery_busy(), "documents": self.recovery_documents(), "error": self.recovery_error()}), - ) - } -} +//! Local desktop recovery. Encoding and file I/O run on a worker, outside typing. +use crate::{CmdError, CmdResult, Session}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RecoveryInfo { + pub id: String, + pub name: String, + pub saved_at: String, +} + +#[cfg(not(target_arch = "wasm32"))] +mod desktop; +#[cfg(not(target_arch = "wasm32"))] +use desktop::{Applied, Request}; +#[cfg(not(target_arch = "wasm32"))] +pub(crate) use desktop::{DocRecovery, Recovery, Slot}; + +impl Session { + /// The desktop host supplies the profile folder and a thread-safe wakeup. + /// Headless sessions and the browser never write recovery files implicitly. + #[cfg(not(target_arch = "wasm32"))] + pub fn enable_recovery(&mut self, root: std::path::PathBuf, wake: std::sync::Arc) -> Result<(), String> { + self.recovery = Some(Recovery::start(root, wake)?); + Ok(()) + } + /// Copies left by an earlier run (or another window's documents that have closed), newest + /// first. The copies this session's open documents keep are not listed: they are not lost. + pub fn recovery_documents(&self) -> Vec { + #[cfg(not(target_arch = "wasm32"))] + if let Some(r) = &self.recovery { + let held = |id: &str| self.recovery_slots().any(|(_, _, s)| s.id.as_deref() == Some(id)) || r.closing().any(|c| c == id); + return r.documents.iter().filter(|d| !held(&d.id)).cloned().collect(); + } + Vec::new() + } + pub fn recovery_ready(&self) -> bool { + #[cfg(not(target_arch = "wasm32"))] + if let Some(r) = &self.recovery { + return r.ready; + } + false + } + pub fn recovery_busy(&self) -> bool { + #[cfg(not(target_arch = "wasm32"))] + if let Some(r) = &self.recovery { + return r.busy; + } + false + } + pub fn recovery_error(&self) -> Option<&str> { + #[cfg(not(target_arch = "wasm32"))] + if let Some(r) = &self.recovery { + return r.error.as_deref(); + } + None + } + /// Called every frame: takes in the worker's answer, opens a restored copy, and queues the + /// next job. Every open document's copy is kept, the parked ones' too, without making them + /// active: a frame compares each document's revision and timer, and clones a document only + /// when its copy is due. + pub fn recovery_tick(&mut self) { + #[cfg(not(target_arch = "wasm32"))] + { + let Some(mut r) = self.recovery.take() else { return }; + if let Some(e) = r.receive(false) { + self.apply_recovery(&mut r, e); + } + self.open_held_restore(&mut r); + if let Err(e) = r.tick(self.recovery_docs(), std::time::Instant::now()) { + r.error = Some(e); + } + if let Some(e) = &r.error { + self.status = e.clone(); + } + self.recovery = Some(r); + } + } + #[cfg(not(target_arch = "wasm32"))] + fn apply_recovery(&mut self, r: &mut Recovery, e: desktop::Event) { + match r.apply(e) { + // Opened by the next tick, never in the middle of a command. + Applied::Loaded(snapshot, request) => r.held = Some((snapshot, request)), + applied => { + for d in self.recovery_docs() { + if let Applied::Failed { document, .. } = &applied + && *document != d.id + { + continue; + } + d.slot.apply(&applied); + } + } + } + } + /// A restored copy opens as another document, or replaces the untouched blank document that + /// asked for it once that document is active, so no work is ever replaced. The restored + /// document keeps its copy in the record it came from. + #[cfg(not(target_arch = "wasm32"))] + fn open_held_restore(&mut self, r: &mut Recovery) { + let Some((_, request)) = &r.held else { return }; + let request = *request; + if request.replace && self.is_open(request.document) && request.document != self.document_id() { + return; + } + let Some((snapshot, _)) = r.held.take() else { return }; + let id = snapshot.info.id.clone(); + if !request.replace { + self.add_document(snapshot.document); + } else if request.document == self.document_id() && request.revision == self.rev() && self.password.is_none() { + self.set_document(snapshot.document); + self.path = None; + } else { + r.error = Some("Recovery cancelled: the document changed while the copy was loading.".into()); + return; + } + self.dirty = true; + self.recovery_slot.adopt(id, self.rev()); + } + pub fn recovery_wait(&self) -> Option { + #[cfg(not(target_arch = "wasm32"))] + if let Some(r) = &self.recovery { + return r.wait(self.recovery_slots()); + } + None + } + /// Waits for recovery to finish anything already under way, then deletes the copies no + /// open document needs: closed documents', and those of documents saved or protected since + /// their last copy. Dirty documents keep theirs. Desktop hosts call this right before the + /// process exits, the same as they wait out pending print jobs: otherwise a save just before + /// close can race the process exiting before a now-redundant recovery copy actually gets + /// removed, so the next launch wrongly offers to recover a document that was in fact saved + /// cleanly. + pub fn finish_recovery(&mut self) { + #[cfg(not(target_arch = "wasm32"))] + if let Some(mut r) = self.recovery.take() { + if let Some(e) = r.receive(true) { + self.apply_recovery(&mut r, e); + } + let mut discards: Vec<(String, Option)> = r.take_closed().into_iter().map(|id| (id, None)).collect(); + discards.extend(self.recovery_docs().filter(|d| !d.wants_copy).filter_map(|d| d.slot.id.clone().map(|id| (id, Some(d.id))))); + for (id, document) in discards { + match r.discard_now(&id, document) { + Ok(e) => self.apply_recovery(&mut r, e), + Err(e) => r.error = Some(e), + } + } + if let Some(e) = &r.error { + log::warn!("recovery: {e}"); + } + self.recovery = Some(r); + } + } + /// The user discarded the active document's unsaved changes without closing it (Don't Save + /// when quitting): its recovery copy goes too, and another is made only after a further edit. + pub fn discard_recovery_copy(&mut self) { + #[cfg(not(target_arch = "wasm32"))] + if let Some(r) = &mut self.recovery + && let Some(id) = self.recovery_slot.id.take() + { + r.close(id); + self.recovery_slot.revision = Some(self.rev()); + } + } + /// Only an explicit protect/save action waits for the worker and removes plaintext copies. + /// A failure prevents applying the password; typing never waits. + pub(crate) fn clear_recovery_before_password(&mut self) -> Result<(), CmdError> { + #[cfg(not(target_arch = "wasm32"))] + if let Some(mut r) = self.recovery.take() { + let result = self.purge_recovery(&mut r); + self.recovery = Some(r); + result.map_err(CmdError::Failed)?; + } + Ok(()) + } + /// Waits out the job under way (it may be this document's copy being written), then deletes + /// the active document's copy. + #[cfg(not(target_arch = "wasm32"))] + fn purge_recovery(&mut self, r: &mut Recovery) -> Result<(), String> { + if let Some(e) = r.receive(true) { + self.apply_recovery(r, e); + } + let Some(id) = self.recovery_slot.id.clone() else { return Ok(()) }; + let e = r.discard_now(&id, Some(self.document_id()))?; + self.apply_recovery(r, e); + if self.recovery_slot.id.is_some() { + return Err(r.error.clone().unwrap_or_else(|| "the recovery copy could not be removed".into())); + } + self.recovery_slot.revision = None; + Ok(()) + } + pub(crate) fn recover(&mut self, v: &Value) -> CmdResult { + let restore = v.get("restore"); + let discard = v.get("discard"); + if restore.is_some() && discard.is_some() { + return Err(CmdError::Params("choose restore or discard".into())); + } + if restore.is_some() || discard.is_some() || v.get("refresh").is_some() { + #[cfg(not(target_arch = "wasm32"))] + { + let request = + Request { document: self.document_id(), revision: self.rev(), replace: self.is_untouched_blank() && self.password.is_none() }; + // Only listed copies: never one an open document keeps. + let listed = self.recovery_documents(); + let known = |id: Option<&str>, what: &str| -> Result { + let id = id.ok_or_else(|| CmdError::Params(format!("{what} must be a recovery id")))?; + if listed.iter().any(|d| d.id == id) { Ok(id.to_string()) } else { Err(CmdError::Failed("no such recovery copy".into())) } + }; + let r = self.recovery.as_mut().ok_or_else(|| CmdError::Disabled("local recovery requires the desktop host".into()))?; + if let Some(id) = restore { + r.restore(&known(id.as_str(), "restore")?, request).map_err(CmdError::Failed)?; + } else if let Some(id) = discard { + r.discard(&known(id.as_str(), "discard")?).map_err(CmdError::Failed)?; + } else if v.get("refresh").and_then(Value::as_bool) == Some(true) { + r.refresh().map_err(CmdError::Failed)?; + } else { + return Err(CmdError::Params("refresh must be true".into())); + } + } + #[cfg(target_arch = "wasm32")] + return Err(CmdError::Disabled("local recovery is available on desktop only".into())); + } + Ok( + json!({"enabled": self.recovery_ready(), "busy": self.recovery_busy(), "documents": self.recovery_documents(), "error": self.recovery_error()}), + ) + } +} diff --git a/crates/engine/src/recovery/desktop.rs b/crates/engine/src/recovery/desktop.rs index c0e12abfd5..934ce32857 100644 --- a/crates/engine/src/recovery/desktop.rs +++ b/crates/engine/src/recovery/desktop.rs @@ -1,721 +1,721 @@ -use super::RecoveryInfo; -use serde::{Deserialize, Serialize}; -use std::collections::BTreeMap; -use std::fs::{self, File, OpenOptions}; -use std::io::{self, BufReader, BufWriter, Read, Write}; -use std::path::{Path, PathBuf}; -use std::sync::{Arc, mpsc}; -use std::time::{Duration, Instant}; -use wordcraft_doc::Document; - -const INTERVAL: Duration = Duration::from_secs(10); -const MAX_BYTES: u64 = 256 << 20; -const MAX_RECORDS: usize = 128; -const MAX_FILES: usize = 16; -const DISK_BUDGET: u64 = 2 << 30; -/// Times a record lock held for a moment (by a process being started) is tried again. -const LOCK_RETRIES: u32 = 10; - -#[derive(Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct Snapshot { - version: u32, - pub info: RecoveryInfo, - generation: u64, - pub document: Document, -} -/// A failed snapshot write. `permanent` failures come from the snapshot itself (its size, the -/// folder or disk budget, a value the format cannot keep): writing the same document again would -/// fail the same way, so it is not retried until the document changes (#2625). -#[derive(Debug)] -struct SaveError { - message: String, - permanent: bool, -} -impl SaveError { - fn permanent(message: impl Into) -> Self { - Self { message: message.into(), permanent: true } - } -} -impl From for SaveError { - fn from(message: String) -> Self { - Self { message, permanent: false } - } -} -impl From<&str> for SaveError { - fn from(message: &str) -> Self { - message.to_owned().into() - } -} -struct Entry { - info: RecoveryInfo, - path: PathBuf, -} -struct Store { - root: PathBuf, - entries: BTreeMap, - // Advisory locks prevent another running instance from restoring/discarding a live copy. - locks: BTreeMap, -} -fn private_dir(path: &Path) -> io::Result<()> { - let mut b = fs::DirBuilder::new(); - b.recursive(true); - #[cfg(unix)] - { - use std::os::unix::fs::DirBuilderExt; - b.mode(0o700); - } - b.create(path) -} -fn private_file(path: &Path, exclusive: bool) -> io::Result { - let mut o = OpenOptions::new(); - o.read(true).write(true); - if exclusive { - o.create_new(true); - } else { - o.create(true).truncate(false); - } - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - o.mode(0o600); - } - o.open(path) -} -fn sync_dir(path: &Path) -> io::Result<()> { - #[cfg(unix)] - { - File::open(path)?.sync_all()?; - } - #[cfg(not(unix))] - { - let _ = path; - } - Ok(()) -} -fn id_ok(id: &str) -> bool { - id.len() == 32 && id.bytes().all(|b| b.is_ascii_hexdigit()) -} -fn new_id() -> Result { - let mut bytes = [0u8; 16]; - getrandom::fill(&mut bytes).map_err(|e| e.to_string())?; - Ok(bytes.iter().map(|b| format!("{b:02x}")).collect()) -} -fn generation(path: &Path) -> Option { - path.file_name()?.to_str()?.strip_prefix("snapshot-")?.strip_suffix(".json")?.parse().ok() -} -fn read_snapshot(path: &Path) -> Result { - let meta = fs::symlink_metadata(path).map_err(|e| e.to_string())?; - if !meta.is_file() || meta.len() > MAX_BYTES { - return Err("not a regular recovery file, or it exceeds 256 MiB".into()); - } - let file = File::open(path).map_err(|e| e.to_string())?; - if !file.metadata().map_err(|e| e.to_string())?.is_file() { - return Err("not a regular recovery file".into()); - } - let s: Snapshot = serde_json::from_reader(BufReader::new(file.take(MAX_BYTES + 1))).map_err(|e| e.to_string())?; - if s.version != 1 || !id_ok(&s.info.id) { - return Err("unsupported recovery file".into()); - } - Ok(s) -} -impl Store { - fn new(root: PathBuf) -> Result { - private_dir(&root).map_err(|e| e.to_string())?; - Ok(Self { root, entries: BTreeMap::new(), locks: BTreeMap::new() }) - } - fn directories(&self) -> Result, String> { - let mut dirs = Vec::new(); - for e in fs::read_dir(&self.root).map_err(|e| e.to_string())? { - let e = e.map_err(|e| e.to_string())?; - if e.file_type().map_err(|e| e.to_string())?.is_dir() && e.file_name().to_str().is_some_and(id_ok) { - if dirs.len() >= MAX_RECORDS { - return Err("recovery folder has too many records".into()); - } - dirs.push(e.path()); - } - } - Ok(dirs) - } - fn files(dir: &Path) -> Result, String> { - let mut paths = Vec::new(); - for e in fs::read_dir(dir).map_err(|e| e.to_string())? { - let e = e.map_err(|e| e.to_string())?; - if e.file_type().map_err(|e| e.to_string())?.is_file() && generation(&e.path()).is_some() { - if paths.len() >= MAX_FILES { - return Err("recovery record has too many generations".into()); - } - paths.push(e.path()); - } - } - paths.sort_by_key(|p| std::cmp::Reverse(generation(p))); - Ok(paths) - } - /// Locks a record's `owner.lock`, retrying [`LOCK_RETRIES`] times 10 ms apart. - fn lock(&self, id: &str) -> Result { - if !id_ok(id) { - return Err("invalid recovery id".into()); - } - let file = private_file(&self.root.join(id).join("owner.lock"), false).map_err(|e| e.to_string())?; - // A process another part of the app (or a test) is starting holds the lock for a moment - // (a forked child shares it until it execs): wait that out, but not a live owner. - for _ in 0..LOCK_RETRIES { - match file.try_lock() { - Ok(()) => return Ok(file), - Err(std::fs::TryLockError::WouldBlock) => std::thread::sleep(Duration::from_millis(10)), - Err(e) => return Err(format!("recovery copy is in use or cannot be locked: {e}")), - } - } - file.try_lock().map_err(|e| format!("recovery copy is in use or cannot be locked: {e}"))?; - Ok(file) - } - fn acquire(&mut self, id: &str) -> Result<(), String> { - if !self.locks.contains_key(id) { - let lock = self.lock(id)?; - self.locks.insert(id.to_string(), lock); - } - Ok(()) - } - fn scan(&mut self) -> Result<(), String> { - let mut entries = BTreeMap::new(); - let mut unreadable = false; - for dir in self.directories()? { - let Some(id) = dir.file_name().and_then(std::ffi::OsStr::to_str) else { continue }; - let guard = if self.locks.contains_key(id) { - None - } else { - match self.lock(id) { - Ok(f) => Some(f), - Err(e) => { - log::debug!("recovery: {e}"); - continue; - } - } - }; - // A crashed writer can leave an incomplete temporary file. Only clean records - // we have locked, so another instance's write is never interrupted. - for entry in fs::read_dir(&dir).map_err(|e| e.to_string())? { - let entry = entry.map_err(|e| e.to_string())?; - if entry.file_type().map_err(|e| e.to_string())?.is_file() - && entry.file_name().to_str().is_some_and(|n| n.strip_prefix("pending-").and_then(|n| n.strip_suffix(".tmp")).is_some_and(id_ok)) - { - fs::remove_file(entry.path()).map_err(|e| e.to_string())?; - } - } - for path in Self::files(&dir)? { - match read_snapshot(&path) { - Ok(s) if s.info.id == id && Some(s.generation) == generation(&path) => { - entries.insert(id.to_string(), Entry { info: s.info, path }); - break; - } - Ok(_) => { - unreadable = true; - log::warn!("recovery: mismatched record {}", path.display()); - } - Err(e) => { - unreadable = true; - log::warn!("recovery: unreadable {}: {e}", path.display()); - } - } - } - drop(guard); - // A crash before the first commit leaves no document to recover. Reclaim - // that slot only under a fresh lock and the discard path's entry checks. - if Self::files(&dir)?.is_empty() - && let Err(e) = self.discard(id) - { - log::warn!("recovery: cannot remove empty record: {e}"); - } - } - self.entries = entries; - if unreadable { Err("Some recovery files could not be read. Earlier valid copies are listed when available.".into()) } else { Ok(()) } - } - fn inventory(&self) -> Vec { - let mut v: Vec<_> = self.entries.values().map(|e| e.info.clone()).collect(); - v.sort_by(|a, b| b.saved_at.cmp(&a.saved_at).then(a.id.cmp(&b.id))); - v - } - fn disk_used(&self) -> Result { - let mut used = 0u64; - for dir in self.directories()? { - for p in Self::files(&dir)? { - used = used.saturating_add(fs::metadata(p).map_err(|e| e.to_string())?.len()); - } - } - Ok(used) - } - fn save(&mut self, s: Snapshot) -> Result<(), SaveError> { - let id = s.info.id.clone(); - let result = self.write_snapshot(s); - // Include failures before temp-file creation (quota checks, locking, etc.). - // A record containing any committed generation is never removed here. - if result.is_err() - && Self::files(&self.root.join(&id)).is_ok_and(|files| files.is_empty()) - && let Err(e) = self.discard(&id) - { - log::warn!("recovery: cannot remove failed empty record: {e}"); - } - result - } - fn write_snapshot(&mut self, mut s: Snapshot) -> Result<(), SaveError> { - let dir = self.root.join(&s.info.id); - if !dir.exists() { - if self.directories()?.len() >= MAX_RECORDS { - return Err(SaveError::permanent("recovery folder is full; discard an older copy")); - } - private_dir(&dir).map_err(|e| e.to_string())?; - } - self.acquire(&s.info.id)?; - let files = Self::files(&dir)?; - if files.len() >= MAX_FILES { - return Err("older recovery generations could not be pruned; keeping the last valid copy".into()); - } - s.generation = files.first().and_then(|p| generation(p)).unwrap_or(0).checked_add(1).ok_or("recovery generation overflow")?; - let previous = self.entries.get(&s.info.id).map(|e| e.path.clone()); - let limit = MAX_BYTES.min(DISK_BUDGET.saturating_sub(self.disk_used()?)); - let tmp = dir.join(format!("pending-{}.tmp", new_id()?)); - let final_path = dir.join(format!("snapshot-{:020}.json", s.generation)); - let result = (|| -> Result<(), SaveError> { - let file = private_file(&tmp, true).map_err(|e| e.to_string())?; - let mut writer = Limited { inner: BufWriter::new(file), remaining: limit, exceeded: false }; - let written = serde_json::to_writer(&mut writer, &s).map_err(|e| e.to_string()).and_then(|()| writer.flush().map_err(|e| e.to_string())); - if let Err(e) = written { - return Err(SaveError { message: e, permanent: writer.exceeded }); - } - writer.inner.get_ref().sync_all().map_err(|e| e.to_string())?; - drop(writer); - // The copy was written and synchronized; one that does not decode back to the same - // document fails the same way every time. - let check = read_snapshot(&tmp).map_err(SaveError::permanent)?; - if check.document != s.document { - return Err(SaveError::permanent("recovery format did not preserve the full document")); - } - // A new generation is never written over the last valid file. - fs::rename(&tmp, &final_path).map_err(|e| e.to_string())?; - sync_dir(&dir).map_err(|e| e.to_string())?; - self.entries.insert(s.info.id.clone(), Entry { info: s.info, path: final_path.clone() }); - for old in Self::files(&dir)?.into_iter().filter(|p| p != &final_path && Some(p) != previous.as_ref()) { - if let Err(e) = fs::remove_file(old) { - log::warn!("recovery: cannot prune old generation: {e}"); - } - } - Ok(()) - })(); - if result.is_err() { - let _ = fs::remove_file(&tmp); - } - result - } - fn load(&mut self, id: &str) -> Result { - if !self.entries.contains_key(id) { - return Err("no such recovery copy".into()); - } - self.acquire(id)?; - read_snapshot(&self.entries.get(id).ok_or("no such recovery copy")?.path) - } - fn discard(&mut self, id: &str) -> Result<(), String> { - if !id_ok(id) { - return Err("invalid recovery id".into()); - } - let dir = self.root.join(id); - if !dir.exists() { - self.entries.remove(id); - return Ok(()); - } - self.acquire(id)?; - for entry in fs::read_dir(&dir).map_err(|e| e.to_string())? { - let entry = entry.map_err(|e| e.to_string())?; - let name = entry.file_name(); - let known = name == "owner.lock" - || generation(&entry.path()).is_some() - || name.to_str().is_some_and(|n| n.strip_prefix("pending-").and_then(|n| n.strip_suffix(".tmp")).is_some_and(id_ok)); - if !known || !entry.file_type().map_err(|e| e.to_string())?.is_file() { - return Err("recovery folder contains an unexpected entry".into()); - } - } - for p in Self::files(&dir)? { - fs::remove_file(p).map_err(|e| e.to_string())?; - } - for e in fs::read_dir(&dir).map_err(|e| e.to_string())? { - let e = e.map_err(|e| e.to_string())?; - let name = e.file_name(); - if name.to_str().is_some_and(|n| n.strip_prefix("pending-").and_then(|n| n.strip_suffix(".tmp")).is_some_and(id_ok)) { - fs::remove_file(e.path()).map_err(|e| e.to_string())?; - } - } - sync_dir(&dir).map_err(|e| e.to_string())?; - self.entries.remove(id); - self.locks.remove(id); - fs::remove_file(dir.join("owner.lock")).map_err(|e| e.to_string())?; - fs::remove_dir(&dir).map_err(|e| e.to_string())?; - sync_dir(&self.root).map_err(|e| e.to_string())?; - Ok(()) - } -} -struct Limited { - inner: W, - remaining: u64, - exceeded: bool, -} -impl Write for Limited { - fn write(&mut self, bytes: &[u8]) -> io::Result { - if bytes.len() as u64 > self.remaining { - self.exceeded = true; - return Err(io::Error::other("recovery snapshot or folder size limit exceeded")); - } - let n = self.inner.write(bytes)?; - self.remaining -= n as u64; - Ok(n) - } - fn flush(&mut self) -> io::Result<()> { - self.inner.flush() - } -} -enum Job { - /// Write `document`'s copy. - Save(Box, u64), - Scan, - Load(String, Request), - /// Delete a record: an open document's copy it no longer needs, a closed document's, or one - /// from an earlier run that the user discarded (`None`). - Discard(String, Option), -} -enum Outcome { - Done, - Discarded(String), - Loaded(Box, Request), -} -pub(crate) struct Event { - documents: Vec, - outcome: Result, - /// The failure would repeat for the same document; see [`SaveError`]. - permanent: bool, - /// The open document a failed save or discard was for. - document: Option, - /// The record a failed discard was for. - discard: Option, -} -/// Who asked for a restore, and whether the copy replaces that document (an untouched blank -/// one) or opens as another document. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) struct Request { - pub document: u64, - pub revision: u64, - pub replace: bool, -} -/// One open document's recovery state: the record its copy is kept in, the revision that copy -/// holds, and when the next copy may be written. Each open document has its own, parked with it. -#[derive(Clone, Debug)] -pub(crate) struct Slot { - pub id: Option, - pub revision: Option, - pub due: Instant, - /// A failed discard of this document's copy is tried again from then on. - pub retry: Option, -} -impl Default for Slot { - fn default() -> Self { - Self { id: None, revision: None, due: Instant::now() + INTERVAL, retry: None } - } -} -impl Slot { - /// The document now keeps its copy in record `id`, which holds `revision`. - pub fn adopt(&mut self, id: String, revision: u64) { - self.id = Some(id); - self.revision = Some(revision); - self.due = Instant::now() + INTERVAL; - self.retry = None; - } -} -/// A worker report, for the session to apply to its documents. -pub(crate) enum Applied { - Nothing, - /// Record `id` is gone: no document keeps its copy there any more. - Discarded(String), - /// A restored copy, to open. - Loaded(Box, Request), - /// Writing or discarding `document`'s copy failed. - Failed { - document: u64, - discard: bool, - permanent: bool, - }, -} -impl Slot { - /// Take in a report about this document's copy. - pub fn apply(&mut self, applied: &Applied) { - match applied { - Applied::Discarded(id) if self.id.as_deref() == Some(id) => { - self.id = None; - self.retry = None; - } - Applied::Failed { discard, permanent, .. } => { - let now = Instant::now(); - if *discard { - self.retry = Some(now + INTERVAL); - } else if !permanent { - // A transient failure (a lock, an I/O error) is retried at the next - // interval; one the snapshot itself causes waits for the next edit. - self.revision = None; - } - self.due = now + INTERVAL; - } - _ => {} - } - } -} -/// What [`Recovery::tick`] reads from an open document, and the document's [`Slot`]. -pub(crate) struct DocRecovery<'a> { - pub id: u64, - /// Dirty and without a password: the document should have a current copy. - pub wants_copy: bool, - pub rev: u64, - pub doc: &'a Document, - pub path: Option<&'a Path>, - pub slot: &'a mut Slot, -} -/// The process's recovery worker: one thread and one store for every open document, so records -/// are locked once per process and the list of copies is the same whichever document is shown. -pub(crate) struct Recovery { - tx: mpsc::SyncSender, - rx: mpsc::Receiver, - /// Every record the store found or wrote, this process's own included. - pub documents: Vec, - pub ready: bool, - pub busy: bool, - pub error: Option, - alive: bool, - /// Records of closed documents waiting to be deleted, and whether that is queued. - closed: Vec<(String, bool)>, - /// A loaded copy that replaces its (untouched blank) document once that document is active. - pub held: Option<(Box, Request)>, -} -impl Recovery { - pub fn start(root: PathBuf, wake: Arc) -> Result { - let (tx, jobs) = mpsc::sync_channel(1); - let (events, rx) = mpsc::channel(); - std::thread::Builder::new() - .name("wordcraft-recovery".into()) - .spawn(move || { - let mut store = match Store::new(root) { - Ok(s) => s, - Err(e) => { - let _ = events.send(Event { documents: Vec::new(), outcome: Err(e), permanent: false, document: None, discard: None }); - wake(); - return; - } - }; - let initial = store.scan().map(|_| Outcome::Done); - if events.send(Event { documents: store.inventory(), outcome: initial, permanent: false, document: None, discard: None }).is_err() { - return; - } - wake(); - while let Ok(job) = jobs.recv() { - let mut permanent = false; - let (mut document, mut discard) = (None, None); - let result = match job { - Job::Save(s, d) => { - document = Some(d); - store.save(*s).map(|_| Outcome::Done).map_err(|e| { - permanent = e.permanent; - e.message - }) - } - Job::Scan => store.scan().map(|_| Outcome::Done), - Job::Load(id, request) => store.load(&id).map(|s| Outcome::Loaded(Box::new(s), request)), - Job::Discard(id, d) => { - document = d; - let r = store.discard(&id).map(|_| Outcome::Discarded(id.clone())); - discard = Some(id); - r - } - }; - if result.is_ok() { - (document, discard) = (None, None); - } - if events.send(Event { documents: store.inventory(), outcome: result, permanent, document, discard }).is_err() { - break; - } - wake(); - } - }) - .map_err(|e| e.to_string())?; - Ok(Self { tx, rx, documents: Vec::new(), ready: false, busy: true, error: None, alive: true, closed: Vec::new(), held: None }) - } - fn queue(&mut self, job: Job) -> Result<(), String> { - if !self.alive { - return Err("recovery worker is unavailable".into()); - } - if self.busy { - return Err("recovery is busy; try again when it finishes".into()); - } - self.tx.try_send(job).map_err(|e| e.to_string())?; - self.busy = true; - self.error = None; - Ok(()) - } - /// The worker's answer to the job under way: waits for it when `block`, else only takes one - /// that has arrived. - pub fn receive(&mut self, block: bool) -> Option { - let e = if block { - if !self.busy { - return None; - } - self.rx.recv().map_err(|_| mpsc::TryRecvError::Disconnected) - } else { - self.rx.try_recv() - }; - match e { - Ok(e) => { - self.busy = false; - self.ready = true; - self.documents.clone_from(&e.documents); - Some(e) - } - Err(mpsc::TryRecvError::Empty) => None, - Err(mpsc::TryRecvError::Disconnected) => { - self.busy = false; - self.alive = false; - if self.error.is_none() { - self.error = Some("recovery worker is unavailable".into()); - } - None - } - } - } - /// Run `job` now and wait for its answer. - fn run_now(&mut self, job: Job) -> Result { - self.queue(job)?; - self.receive(true).ok_or_else(|| "recovery worker is unavailable".to_string()) - } - /// Discard record `id` now (the worker must be idle) and wait for it. - pub fn discard_now(&mut self, id: &str, document: Option) -> Result { - self.run_now(Job::Discard(id.into(), document)) - } - /// A closed document's record: deleted at the next chance, and not listed meanwhile. - pub fn close(&mut self, id: String) { - if !self.closed.iter().any(|(c, _)| *c == id) { - self.closed.push((id, false)); - } - } - /// Records the open documents of this process don't hold any more but haven't deleted yet. - pub fn closing(&self) -> impl Iterator { - self.closed.iter().map(|(id, _)| id.as_str()) - } - /// Closed documents' records not yet queued for deletion. - pub fn take_closed(&mut self) -> Vec { - self.closed - .iter_mut() - .filter(|(_, queued)| !*queued) - .map(|(id, queued)| { - *queued = true; - id.clone() - }) - .collect() - } - /// Take in what the worker reported: the record lists here, and what the session applies - /// to its documents. - pub fn apply(&mut self, e: Event) -> Applied { - match e.outcome { - Ok(Outcome::Done) => Applied::Nothing, - Ok(Outcome::Discarded(id)) => { - self.closed.retain(|(c, _)| *c != id); - Applied::Discarded(id) - } - Ok(Outcome::Loaded(s, request)) => Applied::Loaded(s, request), - Err(message) => { - self.error = Some(message); - if let Some(id) = &e.discard { - // A closed document's copy that can't be deleted stays listed, for the user - // to restore or discard. - self.closed.retain(|(c, _)| c != id); - } - match e.document { - Some(document) => Applied::Failed { document, discard: e.discard.is_some(), permanent: e.permanent }, - None => Applied::Nothing, - } - } - } - } - /// Queue at most one job: a closed document's discard first, then, document by document in - /// the order given, a discard of a copy its document no longer needs (saved, closed without - /// saving, password-protected) or a new copy of a dirty document whose interval has passed. - pub fn tick<'a>(&mut self, docs: impl Iterator>, now: Instant) -> Result<(), String> { - if self.busy || !self.ready || !self.alive { - return Ok(()); - } - if let Some(at) = self.closed.iter().position(|(_, queued)| !queued) { - let id = self.closed.get(at).map(|(id, _)| id.clone()).ok_or("recovery record list changed")?; - self.queue(Job::Discard(id, None))?; - if let Some(c) = self.closed.get_mut(at) { - c.1 = true; - } - return Ok(()); - } - for d in docs { - if !d.wants_copy { - if let Some(id) = d.slot.id.clone() - && d.slot.retry.is_none_or(|t| now >= t) - { - return self.queue(Job::Discard(id, Some(d.id))); - } - continue; - } - if d.slot.revision == Some(d.rev) || now < d.slot.due { - continue; - } - let id = match &d.slot.id { - Some(id) => id.clone(), - None => new_id()?, - }; - let name = d.path.and_then(|p| p.file_name()).map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "Untitled draft".into()); - let info = RecoveryInfo { id: id.clone(), name, saved_at: crate::cmd::now_iso() }; - self.queue(Job::Save(Box::new(Snapshot { version: 1, info, generation: 0, document: d.doc.clone() }), d.id))?; - d.slot.id = Some(id); - d.slot.revision = Some(d.rev); - d.slot.due = now + INTERVAL; - return Ok(()); - } - Ok(()) - } - /// How long until [`tick`](Self::tick) has work, for a host that repaints only on demand. - pub fn wait<'a>(&self, docs: impl Iterator) -> Option { - if !self.alive || !self.ready || self.busy { - return None; - } - let now = Instant::now(); - let min = Duration::from_millis(100); - if self.closed.iter().any(|(_, queued)| !queued) || self.held.as_ref().is_some_and(|(_, r)| !r.replace) { - return Some(min); - } - docs.filter_map(|(wants_copy, rev, slot)| match (wants_copy, &slot.id) { - (false, Some(_)) => Some(slot.retry.map_or(min, |t| t.saturating_duration_since(now))), - (false, None) => None, - (true, _) if slot.revision == Some(rev) => None, - (true, _) => Some(slot.due.saturating_duration_since(now)), - }) - .min() - .map(|d| d.max(min)) - } - pub fn restore(&mut self, id: &str, request: Request) -> Result<(), String> { - if !self.documents.iter().any(|d| d.id == id) { - return Err("no such recovery copy".into()); - } - if self.held.is_some() { - return Err("another restored copy is waiting for its document".into()); - } - self.queue(Job::Load(id.into(), request)) - } - pub fn discard(&mut self, id: &str) -> Result<(), String> { - if !self.documents.iter().any(|d| d.id == id) { - return Err("no such recovery copy".into()); - } - self.queue(Job::Discard(id.into(), None)) - } - pub fn refresh(&mut self) -> Result<(), String> { - self.queue(Job::Scan) - } -} - -#[cfg(test)] -mod tests; +use super::RecoveryInfo; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::fs::{self, File, OpenOptions}; +use std::io::{self, BufReader, BufWriter, Read, Write}; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, mpsc}; +use std::time::{Duration, Instant}; +use wordcraft_doc::Document; + +const INTERVAL: Duration = Duration::from_secs(10); +const MAX_BYTES: u64 = 256 << 20; +const MAX_RECORDS: usize = 128; +const MAX_FILES: usize = 16; +const DISK_BUDGET: u64 = 2 << 30; +/// Times a record lock held for a moment (by a process being started) is tried again. +const LOCK_RETRIES: u32 = 10; + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct Snapshot { + version: u32, + pub info: RecoveryInfo, + generation: u64, + pub document: Document, +} +/// A failed snapshot write. `permanent` failures come from the snapshot itself (its size, the +/// folder or disk budget, a value the format cannot keep): writing the same document again would +/// fail the same way, so it is not retried until the document changes (#2625). +#[derive(Debug)] +struct SaveError { + message: String, + permanent: bool, +} +impl SaveError { + fn permanent(message: impl Into) -> Self { + Self { message: message.into(), permanent: true } + } +} +impl From for SaveError { + fn from(message: String) -> Self { + Self { message, permanent: false } + } +} +impl From<&str> for SaveError { + fn from(message: &str) -> Self { + message.to_owned().into() + } +} +struct Entry { + info: RecoveryInfo, + path: PathBuf, +} +struct Store { + root: PathBuf, + entries: BTreeMap, + // Advisory locks prevent another running instance from restoring/discarding a live copy. + locks: BTreeMap, +} +fn private_dir(path: &Path) -> io::Result<()> { + let mut b = fs::DirBuilder::new(); + b.recursive(true); + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + b.mode(0o700); + } + b.create(path) +} +fn private_file(path: &Path, exclusive: bool) -> io::Result { + let mut o = OpenOptions::new(); + o.read(true).write(true); + if exclusive { + o.create_new(true); + } else { + o.create(true).truncate(false); + } + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + o.mode(0o600); + } + o.open(path) +} +fn sync_dir(path: &Path) -> io::Result<()> { + #[cfg(unix)] + { + File::open(path)?.sync_all()?; + } + #[cfg(not(unix))] + { + let _ = path; + } + Ok(()) +} +fn id_ok(id: &str) -> bool { + id.len() == 32 && id.bytes().all(|b| b.is_ascii_hexdigit()) +} +fn new_id() -> Result { + let mut bytes = [0u8; 16]; + getrandom::fill(&mut bytes).map_err(|e| e.to_string())?; + Ok(bytes.iter().map(|b| format!("{b:02x}")).collect()) +} +fn generation(path: &Path) -> Option { + path.file_name()?.to_str()?.strip_prefix("snapshot-")?.strip_suffix(".json")?.parse().ok() +} +fn read_snapshot(path: &Path) -> Result { + let meta = fs::symlink_metadata(path).map_err(|e| e.to_string())?; + if !meta.is_file() || meta.len() > MAX_BYTES { + return Err("not a regular recovery file, or it exceeds 256 MiB".into()); + } + let file = File::open(path).map_err(|e| e.to_string())?; + if !file.metadata().map_err(|e| e.to_string())?.is_file() { + return Err("not a regular recovery file".into()); + } + let s: Snapshot = serde_json::from_reader(BufReader::new(file.take(MAX_BYTES + 1))).map_err(|e| e.to_string())?; + if s.version != 1 || !id_ok(&s.info.id) { + return Err("unsupported recovery file".into()); + } + Ok(s) +} +impl Store { + fn new(root: PathBuf) -> Result { + private_dir(&root).map_err(|e| e.to_string())?; + Ok(Self { root, entries: BTreeMap::new(), locks: BTreeMap::new() }) + } + fn directories(&self) -> Result, String> { + let mut dirs = Vec::new(); + for e in fs::read_dir(&self.root).map_err(|e| e.to_string())? { + let e = e.map_err(|e| e.to_string())?; + if e.file_type().map_err(|e| e.to_string())?.is_dir() && e.file_name().to_str().is_some_and(id_ok) { + if dirs.len() >= MAX_RECORDS { + return Err("recovery folder has too many records".into()); + } + dirs.push(e.path()); + } + } + Ok(dirs) + } + fn files(dir: &Path) -> Result, String> { + let mut paths = Vec::new(); + for e in fs::read_dir(dir).map_err(|e| e.to_string())? { + let e = e.map_err(|e| e.to_string())?; + if e.file_type().map_err(|e| e.to_string())?.is_file() && generation(&e.path()).is_some() { + if paths.len() >= MAX_FILES { + return Err("recovery record has too many generations".into()); + } + paths.push(e.path()); + } + } + paths.sort_by_key(|p| std::cmp::Reverse(generation(p))); + Ok(paths) + } + /// Locks a record's `owner.lock`, retrying [`LOCK_RETRIES`] times 10 ms apart. + fn lock(&self, id: &str) -> Result { + if !id_ok(id) { + return Err("invalid recovery id".into()); + } + let file = private_file(&self.root.join(id).join("owner.lock"), false).map_err(|e| e.to_string())?; + // A process another part of the app (or a test) is starting holds the lock for a moment + // (a forked child shares it until it execs): wait that out, but not a live owner. + for _ in 0..LOCK_RETRIES { + match file.try_lock() { + Ok(()) => return Ok(file), + Err(std::fs::TryLockError::WouldBlock) => std::thread::sleep(Duration::from_millis(10)), + Err(e) => return Err(format!("recovery copy is in use or cannot be locked: {e}")), + } + } + file.try_lock().map_err(|e| format!("recovery copy is in use or cannot be locked: {e}"))?; + Ok(file) + } + fn acquire(&mut self, id: &str) -> Result<(), String> { + if !self.locks.contains_key(id) { + let lock = self.lock(id)?; + self.locks.insert(id.to_string(), lock); + } + Ok(()) + } + fn scan(&mut self) -> Result<(), String> { + let mut entries = BTreeMap::new(); + let mut unreadable = false; + for dir in self.directories()? { + let Some(id) = dir.file_name().and_then(std::ffi::OsStr::to_str) else { continue }; + let guard = if self.locks.contains_key(id) { + None + } else { + match self.lock(id) { + Ok(f) => Some(f), + Err(e) => { + log::debug!("recovery: {e}"); + continue; + } + } + }; + // A crashed writer can leave an incomplete temporary file. Only clean records + // we have locked, so another instance's write is never interrupted. + for entry in fs::read_dir(&dir).map_err(|e| e.to_string())? { + let entry = entry.map_err(|e| e.to_string())?; + if entry.file_type().map_err(|e| e.to_string())?.is_file() + && entry.file_name().to_str().is_some_and(|n| n.strip_prefix("pending-").and_then(|n| n.strip_suffix(".tmp")).is_some_and(id_ok)) + { + fs::remove_file(entry.path()).map_err(|e| e.to_string())?; + } + } + for path in Self::files(&dir)? { + match read_snapshot(&path) { + Ok(s) if s.info.id == id && Some(s.generation) == generation(&path) => { + entries.insert(id.to_string(), Entry { info: s.info, path }); + break; + } + Ok(_) => { + unreadable = true; + log::warn!("recovery: mismatched record {}", path.display()); + } + Err(e) => { + unreadable = true; + log::warn!("recovery: unreadable {}: {e}", path.display()); + } + } + } + drop(guard); + // A crash before the first commit leaves no document to recover. Reclaim + // that slot only under a fresh lock and the discard path's entry checks. + if Self::files(&dir)?.is_empty() + && let Err(e) = self.discard(id) + { + log::warn!("recovery: cannot remove empty record: {e}"); + } + } + self.entries = entries; + if unreadable { Err("Some recovery files could not be read. Earlier valid copies are listed when available.".into()) } else { Ok(()) } + } + fn inventory(&self) -> Vec { + let mut v: Vec<_> = self.entries.values().map(|e| e.info.clone()).collect(); + v.sort_by(|a, b| b.saved_at.cmp(&a.saved_at).then(a.id.cmp(&b.id))); + v + } + fn disk_used(&self) -> Result { + let mut used = 0u64; + for dir in self.directories()? { + for p in Self::files(&dir)? { + used = used.saturating_add(fs::metadata(p).map_err(|e| e.to_string())?.len()); + } + } + Ok(used) + } + fn save(&mut self, s: Snapshot) -> Result<(), SaveError> { + let id = s.info.id.clone(); + let result = self.write_snapshot(s); + // Include failures before temp-file creation (quota checks, locking, etc.). + // A record containing any committed generation is never removed here. + if result.is_err() + && Self::files(&self.root.join(&id)).is_ok_and(|files| files.is_empty()) + && let Err(e) = self.discard(&id) + { + log::warn!("recovery: cannot remove failed empty record: {e}"); + } + result + } + fn write_snapshot(&mut self, mut s: Snapshot) -> Result<(), SaveError> { + let dir = self.root.join(&s.info.id); + if !dir.exists() { + if self.directories()?.len() >= MAX_RECORDS { + return Err(SaveError::permanent("recovery folder is full; discard an older copy")); + } + private_dir(&dir).map_err(|e| e.to_string())?; + } + self.acquire(&s.info.id)?; + let files = Self::files(&dir)?; + if files.len() >= MAX_FILES { + return Err("older recovery generations could not be pruned; keeping the last valid copy".into()); + } + s.generation = files.first().and_then(|p| generation(p)).unwrap_or(0).checked_add(1).ok_or("recovery generation overflow")?; + let previous = self.entries.get(&s.info.id).map(|e| e.path.clone()); + let limit = MAX_BYTES.min(DISK_BUDGET.saturating_sub(self.disk_used()?)); + let tmp = dir.join(format!("pending-{}.tmp", new_id()?)); + let final_path = dir.join(format!("snapshot-{:020}.json", s.generation)); + let result = (|| -> Result<(), SaveError> { + let file = private_file(&tmp, true).map_err(|e| e.to_string())?; + let mut writer = Limited { inner: BufWriter::new(file), remaining: limit, exceeded: false }; + let written = serde_json::to_writer(&mut writer, &s).map_err(|e| e.to_string()).and_then(|()| writer.flush().map_err(|e| e.to_string())); + if let Err(e) = written { + return Err(SaveError { message: e, permanent: writer.exceeded }); + } + writer.inner.get_ref().sync_all().map_err(|e| e.to_string())?; + drop(writer); + // The copy was written and synchronized; one that does not decode back to the same + // document fails the same way every time. + let check = read_snapshot(&tmp).map_err(SaveError::permanent)?; + if check.document != s.document { + return Err(SaveError::permanent("recovery format did not preserve the full document")); + } + // A new generation is never written over the last valid file. + fs::rename(&tmp, &final_path).map_err(|e| e.to_string())?; + sync_dir(&dir).map_err(|e| e.to_string())?; + self.entries.insert(s.info.id.clone(), Entry { info: s.info, path: final_path.clone() }); + for old in Self::files(&dir)?.into_iter().filter(|p| p != &final_path && Some(p) != previous.as_ref()) { + if let Err(e) = fs::remove_file(old) { + log::warn!("recovery: cannot prune old generation: {e}"); + } + } + Ok(()) + })(); + if result.is_err() { + let _ = fs::remove_file(&tmp); + } + result + } + fn load(&mut self, id: &str) -> Result { + if !self.entries.contains_key(id) { + return Err("no such recovery copy".into()); + } + self.acquire(id)?; + read_snapshot(&self.entries.get(id).ok_or("no such recovery copy")?.path) + } + fn discard(&mut self, id: &str) -> Result<(), String> { + if !id_ok(id) { + return Err("invalid recovery id".into()); + } + let dir = self.root.join(id); + if !dir.exists() { + self.entries.remove(id); + return Ok(()); + } + self.acquire(id)?; + for entry in fs::read_dir(&dir).map_err(|e| e.to_string())? { + let entry = entry.map_err(|e| e.to_string())?; + let name = entry.file_name(); + let known = name == "owner.lock" + || generation(&entry.path()).is_some() + || name.to_str().is_some_and(|n| n.strip_prefix("pending-").and_then(|n| n.strip_suffix(".tmp")).is_some_and(id_ok)); + if !known || !entry.file_type().map_err(|e| e.to_string())?.is_file() { + return Err("recovery folder contains an unexpected entry".into()); + } + } + for p in Self::files(&dir)? { + fs::remove_file(p).map_err(|e| e.to_string())?; + } + for e in fs::read_dir(&dir).map_err(|e| e.to_string())? { + let e = e.map_err(|e| e.to_string())?; + let name = e.file_name(); + if name.to_str().is_some_and(|n| n.strip_prefix("pending-").and_then(|n| n.strip_suffix(".tmp")).is_some_and(id_ok)) { + fs::remove_file(e.path()).map_err(|e| e.to_string())?; + } + } + sync_dir(&dir).map_err(|e| e.to_string())?; + self.entries.remove(id); + self.locks.remove(id); + fs::remove_file(dir.join("owner.lock")).map_err(|e| e.to_string())?; + fs::remove_dir(&dir).map_err(|e| e.to_string())?; + sync_dir(&self.root).map_err(|e| e.to_string())?; + Ok(()) + } +} +struct Limited { + inner: W, + remaining: u64, + exceeded: bool, +} +impl Write for Limited { + fn write(&mut self, bytes: &[u8]) -> io::Result { + if bytes.len() as u64 > self.remaining { + self.exceeded = true; + return Err(io::Error::other("recovery snapshot or folder size limit exceeded")); + } + let n = self.inner.write(bytes)?; + self.remaining -= n as u64; + Ok(n) + } + fn flush(&mut self) -> io::Result<()> { + self.inner.flush() + } +} +enum Job { + /// Write `document`'s copy. + Save(Box, u64), + Scan, + Load(String, Request), + /// Delete a record: an open document's copy it no longer needs, a closed document's, or one + /// from an earlier run that the user discarded (`None`). + Discard(String, Option), +} +enum Outcome { + Done, + Discarded(String), + Loaded(Box, Request), +} +pub(crate) struct Event { + documents: Vec, + outcome: Result, + /// The failure would repeat for the same document; see [`SaveError`]. + permanent: bool, + /// The open document a failed save or discard was for. + document: Option, + /// The record a failed discard was for. + discard: Option, +} +/// Who asked for a restore, and whether the copy replaces that document (an untouched blank +/// one) or opens as another document. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct Request { + pub document: u64, + pub revision: u64, + pub replace: bool, +} +/// One open document's recovery state: the record its copy is kept in, the revision that copy +/// holds, and when the next copy may be written. Each open document has its own, parked with it. +#[derive(Clone, Debug)] +pub(crate) struct Slot { + pub id: Option, + pub revision: Option, + pub due: Instant, + /// A failed discard of this document's copy is tried again from then on. + pub retry: Option, +} +impl Default for Slot { + fn default() -> Self { + Self { id: None, revision: None, due: Instant::now() + INTERVAL, retry: None } + } +} +impl Slot { + /// The document now keeps its copy in record `id`, which holds `revision`. + pub fn adopt(&mut self, id: String, revision: u64) { + self.id = Some(id); + self.revision = Some(revision); + self.due = Instant::now() + INTERVAL; + self.retry = None; + } +} +/// A worker report, for the session to apply to its documents. +pub(crate) enum Applied { + Nothing, + /// Record `id` is gone: no document keeps its copy there any more. + Discarded(String), + /// A restored copy, to open. + Loaded(Box, Request), + /// Writing or discarding `document`'s copy failed. + Failed { + document: u64, + discard: bool, + permanent: bool, + }, +} +impl Slot { + /// Take in a report about this document's copy. + pub fn apply(&mut self, applied: &Applied) { + match applied { + Applied::Discarded(id) if self.id.as_deref() == Some(id) => { + self.id = None; + self.retry = None; + } + Applied::Failed { discard, permanent, .. } => { + let now = Instant::now(); + if *discard { + self.retry = Some(now + INTERVAL); + } else if !permanent { + // A transient failure (a lock, an I/O error) is retried at the next + // interval; one the snapshot itself causes waits for the next edit. + self.revision = None; + } + self.due = now + INTERVAL; + } + _ => {} + } + } +} +/// What [`Recovery::tick`] reads from an open document, and the document's [`Slot`]. +pub(crate) struct DocRecovery<'a> { + pub id: u64, + /// Dirty and without a password: the document should have a current copy. + pub wants_copy: bool, + pub rev: u64, + pub doc: &'a Document, + pub path: Option<&'a Path>, + pub slot: &'a mut Slot, +} +/// The process's recovery worker: one thread and one store for every open document, so records +/// are locked once per process and the list of copies is the same whichever document is shown. +pub(crate) struct Recovery { + tx: mpsc::SyncSender, + rx: mpsc::Receiver, + /// Every record the store found or wrote, this process's own included. + pub documents: Vec, + pub ready: bool, + pub busy: bool, + pub error: Option, + alive: bool, + /// Records of closed documents waiting to be deleted, and whether that is queued. + closed: Vec<(String, bool)>, + /// A loaded copy that replaces its (untouched blank) document once that document is active. + pub held: Option<(Box, Request)>, +} +impl Recovery { + pub fn start(root: PathBuf, wake: Arc) -> Result { + let (tx, jobs) = mpsc::sync_channel(1); + let (events, rx) = mpsc::channel(); + std::thread::Builder::new() + .name("wordcraft-recovery".into()) + .spawn(move || { + let mut store = match Store::new(root) { + Ok(s) => s, + Err(e) => { + let _ = events.send(Event { documents: Vec::new(), outcome: Err(e), permanent: false, document: None, discard: None }); + wake(); + return; + } + }; + let initial = store.scan().map(|_| Outcome::Done); + if events.send(Event { documents: store.inventory(), outcome: initial, permanent: false, document: None, discard: None }).is_err() { + return; + } + wake(); + while let Ok(job) = jobs.recv() { + let mut permanent = false; + let (mut document, mut discard) = (None, None); + let result = match job { + Job::Save(s, d) => { + document = Some(d); + store.save(*s).map(|_| Outcome::Done).map_err(|e| { + permanent = e.permanent; + e.message + }) + } + Job::Scan => store.scan().map(|_| Outcome::Done), + Job::Load(id, request) => store.load(&id).map(|s| Outcome::Loaded(Box::new(s), request)), + Job::Discard(id, d) => { + document = d; + let r = store.discard(&id).map(|_| Outcome::Discarded(id.clone())); + discard = Some(id); + r + } + }; + if result.is_ok() { + (document, discard) = (None, None); + } + if events.send(Event { documents: store.inventory(), outcome: result, permanent, document, discard }).is_err() { + break; + } + wake(); + } + }) + .map_err(|e| e.to_string())?; + Ok(Self { tx, rx, documents: Vec::new(), ready: false, busy: true, error: None, alive: true, closed: Vec::new(), held: None }) + } + fn queue(&mut self, job: Job) -> Result<(), String> { + if !self.alive { + return Err("recovery worker is unavailable".into()); + } + if self.busy { + return Err("recovery is busy; try again when it finishes".into()); + } + self.tx.try_send(job).map_err(|e| e.to_string())?; + self.busy = true; + self.error = None; + Ok(()) + } + /// The worker's answer to the job under way: waits for it when `block`, else only takes one + /// that has arrived. + pub fn receive(&mut self, block: bool) -> Option { + let e = if block { + if !self.busy { + return None; + } + self.rx.recv().map_err(|_| mpsc::TryRecvError::Disconnected) + } else { + self.rx.try_recv() + }; + match e { + Ok(e) => { + self.busy = false; + self.ready = true; + self.documents.clone_from(&e.documents); + Some(e) + } + Err(mpsc::TryRecvError::Empty) => None, + Err(mpsc::TryRecvError::Disconnected) => { + self.busy = false; + self.alive = false; + if self.error.is_none() { + self.error = Some("recovery worker is unavailable".into()); + } + None + } + } + } + /// Run `job` now and wait for its answer. + fn run_now(&mut self, job: Job) -> Result { + self.queue(job)?; + self.receive(true).ok_or_else(|| "recovery worker is unavailable".to_string()) + } + /// Discard record `id` now (the worker must be idle) and wait for it. + pub fn discard_now(&mut self, id: &str, document: Option) -> Result { + self.run_now(Job::Discard(id.into(), document)) + } + /// A closed document's record: deleted at the next chance, and not listed meanwhile. + pub fn close(&mut self, id: String) { + if !self.closed.iter().any(|(c, _)| *c == id) { + self.closed.push((id, false)); + } + } + /// Records the open documents of this process don't hold any more but haven't deleted yet. + pub fn closing(&self) -> impl Iterator { + self.closed.iter().map(|(id, _)| id.as_str()) + } + /// Closed documents' records not yet queued for deletion. + pub fn take_closed(&mut self) -> Vec { + self.closed + .iter_mut() + .filter(|(_, queued)| !*queued) + .map(|(id, queued)| { + *queued = true; + id.clone() + }) + .collect() + } + /// Take in what the worker reported: the record lists here, and what the session applies + /// to its documents. + pub fn apply(&mut self, e: Event) -> Applied { + match e.outcome { + Ok(Outcome::Done) => Applied::Nothing, + Ok(Outcome::Discarded(id)) => { + self.closed.retain(|(c, _)| *c != id); + Applied::Discarded(id) + } + Ok(Outcome::Loaded(s, request)) => Applied::Loaded(s, request), + Err(message) => { + self.error = Some(message); + if let Some(id) = &e.discard { + // A closed document's copy that can't be deleted stays listed, for the user + // to restore or discard. + self.closed.retain(|(c, _)| c != id); + } + match e.document { + Some(document) => Applied::Failed { document, discard: e.discard.is_some(), permanent: e.permanent }, + None => Applied::Nothing, + } + } + } + } + /// Queue at most one job: a closed document's discard first, then, document by document in + /// the order given, a discard of a copy its document no longer needs (saved, closed without + /// saving, password-protected) or a new copy of a dirty document whose interval has passed. + pub fn tick<'a>(&mut self, docs: impl Iterator>, now: Instant) -> Result<(), String> { + if self.busy || !self.ready || !self.alive { + return Ok(()); + } + if let Some(at) = self.closed.iter().position(|(_, queued)| !queued) { + let id = self.closed.get(at).map(|(id, _)| id.clone()).ok_or("recovery record list changed")?; + self.queue(Job::Discard(id, None))?; + if let Some(c) = self.closed.get_mut(at) { + c.1 = true; + } + return Ok(()); + } + for d in docs { + if !d.wants_copy { + if let Some(id) = d.slot.id.clone() + && d.slot.retry.is_none_or(|t| now >= t) + { + return self.queue(Job::Discard(id, Some(d.id))); + } + continue; + } + if d.slot.revision == Some(d.rev) || now < d.slot.due { + continue; + } + let id = match &d.slot.id { + Some(id) => id.clone(), + None => new_id()?, + }; + let name = d.path.and_then(|p| p.file_name()).map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "Untitled draft".into()); + let info = RecoveryInfo { id: id.clone(), name, saved_at: crate::cmd::now_iso() }; + self.queue(Job::Save(Box::new(Snapshot { version: 1, info, generation: 0, document: d.doc.clone() }), d.id))?; + d.slot.id = Some(id); + d.slot.revision = Some(d.rev); + d.slot.due = now + INTERVAL; + return Ok(()); + } + Ok(()) + } + /// How long until [`tick`](Self::tick) has work, for a host that repaints only on demand. + pub fn wait<'a>(&self, docs: impl Iterator) -> Option { + if !self.alive || !self.ready || self.busy { + return None; + } + let now = Instant::now(); + let min = Duration::from_millis(100); + if self.closed.iter().any(|(_, queued)| !queued) || self.held.as_ref().is_some_and(|(_, r)| !r.replace) { + return Some(min); + } + docs.filter_map(|(wants_copy, rev, slot)| match (wants_copy, &slot.id) { + (false, Some(_)) => Some(slot.retry.map_or(min, |t| t.saturating_duration_since(now))), + (false, None) => None, + (true, _) if slot.revision == Some(rev) => None, + (true, _) => Some(slot.due.saturating_duration_since(now)), + }) + .min() + .map(|d| d.max(min)) + } + pub fn restore(&mut self, id: &str, request: Request) -> Result<(), String> { + if !self.documents.iter().any(|d| d.id == id) { + return Err("no such recovery copy".into()); + } + if self.held.is_some() { + return Err("another restored copy is waiting for its document".into()); + } + self.queue(Job::Load(id.into(), request)) + } + pub fn discard(&mut self, id: &str) -> Result<(), String> { + if !self.documents.iter().any(|d| d.id == id) { + return Err("no such recovery copy".into()); + } + self.queue(Job::Discard(id.into(), None)) + } + pub fn refresh(&mut self) -> Result<(), String> { + self.queue(Job::Scan) + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/engine/src/recovery/desktop/tests.rs b/crates/engine/src/recovery/desktop/tests.rs index 5891b26d4d..36f22faf23 100644 --- a/crates/engine/src/recovery/desktop/tests.rs +++ b/crates/engine/src/recovery/desktop/tests.rs @@ -1,621 +1,621 @@ -use super::*; -use crate::Session; -use serde_json::json; -use wordcraft_doc::StoryRef; - -struct Temp(PathBuf); -impl Temp { - fn new() -> Self { - let path = std::env::temp_dir().join(format!("wordcraft-recovery-test-{}", new_id().unwrap())); - private_dir(&path).unwrap(); - Self(path) - } -} -impl Drop for Temp { - fn drop(&mut self) { - let _ = fs::remove_dir_all(&self.0); - } -} -/// A store reopened on `root` whose scan reports an unreadable record. The record's lock can be -/// briefly held by a process another test is starting (a forked child holds the descriptor -/// until it execs), and a record that can't be locked is skipped, not reported: so try again -/// for up to a second rather than read a skipped record as a clean scan. -fn reopen_reporting_unreadable(root: &std::path::Path) -> Store { - for _ in 0..50 { - let mut store = Store::new(root.to_path_buf()).unwrap(); - if store.scan().is_err() { - return store; - } - std::thread::sleep(std::time::Duration::from_millis(20)); - } - panic!("the scan never reported the unreadable record"); -} -/// `store.load(id)`, tried again for up to a second while the record can't be locked: a process -/// another test is starting holds the lock for a moment (see [`reopen_reporting_unreadable`]). -fn load_retrying(store: &mut Store, id: &str) -> Snapshot { - let mut last = String::new(); - for _ in 0..50 { - match store.load(id) { - Ok(snapshot) => return snapshot, - Err(e) => last = e, - } - std::thread::sleep(std::time::Duration::from_millis(20)); - } - panic!("the record never loaded: {last}"); -} -fn snapshot(id: &str, text: &str) -> Snapshot { - Snapshot { - version: 1, - info: RecoveryInfo { id: id.into(), name: "Synthetic draft".into(), saved_at: crate::cmd::now_iso() }, - generation: 0, - document: Document::from_text(text), - } -} -fn settle(s: &mut Session) { - let end = Instant::now() + Duration::from_secs(5); - loop { - s.recovery_tick(); - if s.recovery_ready() && !s.recovery_busy() { - break; - } - assert!(Instant::now() < end, "recovery did not finish"); - std::thread::sleep(Duration::from_millis(2)); - } -} -fn session(root: &Path) -> Session { - let mut s = Session::new(Document::new()); - s.enable_recovery(root.to_owned(), Arc::new(|| {})).unwrap(); - settle(&mut s); - s -} -/// Every open document's interval has passed. -fn due_now(s: &mut Session) { - for d in s.recovery_docs() { - d.slot.due = Instant::now(); - } -} -fn save_now(s: &mut Session) { - due_now(s); - s.recovery_tick(); - settle(s); -} -/// The records in the profile folder: the copies on disk. -fn records(root: &Path) -> Vec { - let mut ids: Vec = fs::read_dir(root) - .unwrap() - .filter_map(|e| e.ok()) - .filter(|e| e.path().is_dir()) - .map(|e| e.file_name().to_string_lossy().into_owned()) - .collect(); - ids.sort(); - ids -} - -/// #3140: a save that lands right before the app closes can race the process exiting before -/// the now-redundant recovery copy is actually removed, so the next launch wrongly offers to -/// recover a document that was in fact saved cleanly. `finish_recovery` (desktop hosts call it -/// on exit, the same as they wait for pending print jobs) must discard it synchronously -/// instead, with no regular tick in between — simulating closing right after Save, not after -/// however long it'd take the next normal autosave tick to notice and clean up. -#[test] -fn finish_recovery_discards_its_own_copy_for_a_document_saved_right_before_exit() { - let profile = Temp::new(); - let mut s = session(&profile.0); - s.run("document.setText", &json!({"text": "About to be saved"})).unwrap(); - save_now(&mut s); - assert_eq!(records(&profile.0).len(), 1, "a recovery copy exists for the dirty document"); - // A real Save does this, then the app can close on the very next frame, with no regular - // recovery_tick() in between to notice `!s.dirty` and queue the discard itself. - s.dirty = false; - assert_eq!(records(&profile.0).len(), 1, "confirms the race: setting dirty=false alone (what Save does) doesn't discard anything by itself"); - s.finish_recovery(); - assert!(records(&profile.0).is_empty(), "finish_recovery should have discarded the now-redundant copy before returning"); -} - -/// `tick` resets `self.id` the moment the session's document no longer matches what it last -/// saw (so it never discards under a stale identity); `finish_recovery` must guard the same -/// way. Switching documents and exiting immediately, with no regular tick in between to reset -/// that identity, must not let the *new* (clean, blank) document's state discard the -/// *previous* document's still-live recovery copy. -#[test] -fn finish_recovery_does_not_discard_a_previous_document_s_copy_after_switching() { - let profile = Temp::new(); - let mut s = session(&profile.0); - s.run("document.setText", &json!({"text": "The document the user is still working on"})).unwrap(); - save_now(&mut s); - assert_eq!(records(&profile.0).len(), 1, "a recovery copy exists for the dirty document"); - s.set_document(Document::new()); // the document is replaced, with no tick() in between - assert!(!s.dirty, "a freshly switched-to document starts clean"); - s.finish_recovery(); - assert_eq!(records(&profile.0).len(), 1, "replacing the document must not discard the previous one's recovery copy"); - assert_eq!(s.recovery_documents().len(), 1, "no open document keeps it: it is listed"); -} - -#[test] -fn recovery_commands_only_address_known_profile_records() { - use crate::file_access::{AutomationRoots, confine}; - let profile = Temp::new(); - let files = Temp::new(); - let original = files.0.join("original.txt"); - fs::write(&original, b"External synthetic original").unwrap(); - let roots = AutomationRoots::new(Some(&files.0), Some(&files.0)).unwrap().unwrap(); - let mut s = session(&profile.0); - s.run("document.setText", &json!({"text": "Recoverable synthetic draft"})).unwrap(); - save_now(&mut s); - drop(s); - let mut s = session(&profile.0); - let id = s.recovery_documents()[0].id.clone(); - confine(Some(&roots), || { - // A command cannot use a file path or choose a different storage folder. - for bad in [original.to_string_lossy().into_owned(), "../original.txt".into()] { - for action in ["restore", "discard"] { - let err = s.run("file.recover", &json!({action: bad, "root": files.0})).unwrap_err(); - assert!(err.to_string().contains("no such recovery copy"), "{err}"); - } - } - assert_eq!(s.recovery_documents().len(), 1); - s.run("file.recover", &json!({"restore": id})).unwrap(); - settle(&mut s); - assert!(s.path.is_none()); - assert!(s.dirty); - assert_eq!(s.doc.plain_text(StoryRef::Body), "Recoverable synthetic draft"); - // The restored document keeps its copy there: it isn't listed, and can't be discarded. - assert!(s.recovery_documents().is_empty()); - let err = s.run("file.recover", &json!({"discard": id})).unwrap_err(); - assert!(err.to_string().contains("no such recovery copy"), "{err}"); - settle(&mut s); - assert_eq!(records(&profile.0), std::slice::from_ref(&id)); - }); - assert_eq!(fs::read(&original).unwrap(), b"External synthetic original"); - assert_eq!(fs::read_dir(&files.0).unwrap().count(), 1); -} - -#[test] -fn failed_write_keeps_last_valid_generation_and_discovery_falls_back() { - let t = Temp::new(); - let id = new_id().unwrap(); - let mut store = Store::new(t.0.clone()).unwrap(); - store.save(snapshot(&id, "First valid copy")).unwrap(); - let first = store.entries[&id].path.clone(); - let bytes = fs::read(&first).unwrap(); - // Simulate a final rename failure without relying on permissions (root can bypass them). - let obstruction = t.0.join(&id).join("snapshot-00000000000000000002.json"); - fs::create_dir(&obstruction).unwrap(); - assert!(store.save(snapshot(&id, "Failed copy")).is_err()); - assert_eq!(fs::read(&first).unwrap(), bytes); - fs::remove_dir(obstruction).unwrap(); - store.save(snapshot(&id, "Second valid copy")).unwrap(); - let second = store.entries[&id].path.clone(); - fs::write(second, b"{truncated").unwrap(); - drop(store); - let mut reopened = reopen_reporting_unreadable(&t.0); - assert_eq!(load_retrying(&mut reopened, &id).document.plain_text(StoryRef::Body), "First valid copy"); - // Never reuse the corrupt generation's name or prune the valid fallback. - reopened.save(snapshot(&id, "Third valid copy")).unwrap(); - assert_eq!(fs::read(first).unwrap(), bytes); - assert_eq!(Store::files(&t.0.join(&id)).unwrap().len(), 2); -} - -#[test] -fn size_limit_and_lossy_values_fail_without_committing() { - let mut out = Limited { inner: Vec::new(), remaining: 3, exceeded: false }; - assert!(out.write_all(b"four").is_err()); - assert!(out.inner.is_empty()); - assert!(out.exceeded); - let t = Temp::new(); - let id = new_id().unwrap(); - let mut store = Store::new(t.0.clone()).unwrap(); - store.save(snapshot(&id, "valid")).unwrap(); - let first = store.entries[&id].path.clone(); - let bytes = fs::read(&first).unwrap(); - let mut invalid = snapshot(&id, "invalid"); - invalid.document.last_section.page_w = f32::NAN; - assert!(store.save(invalid).is_err_and(|e| e.permanent)); - assert_eq!(fs::read(first).unwrap(), bytes); - assert_eq!(Store::files(&t.0.join(id)).unwrap().len(), 1); -} - -/// Copies are written with picture bytes in base64 (#586 follow-up); those written before kept -/// them as arrays of numbers and are still listed and restored, bytes intact. -#[test] -fn copies_with_byte_arrays_written_before_still_restore() { - let t = Temp::new(); - let id = new_id().unwrap(); - let mut s = snapshot(&id, "Synthetic draft with a picture"); - s.generation = 1; - s.document.media.insert("media/image1.png".into(), Arc::new(vec![0, 1, 127, 128, 254, 255])); - let mut value = serde_json::to_value(&s).unwrap(); - assert_eq!(value["document"]["media"]["media/image1.png"], "AAF/gP7/"); - value["document"]["media"]["media/image1.png"] = json!([0, 1, 127, 128, 254, 255]); - private_dir(&t.0.join(&id)).unwrap(); - fs::write(t.0.join(&id).join("snapshot-00000000000000000001.json"), serde_json::to_vec(&value).unwrap()).unwrap(); - let mut store = Store::new(t.0.clone()).unwrap(); - store.scan().unwrap(); - assert_eq!(store.inventory().len(), 1); - assert_eq!(store.load(&id).unwrap().document, s.document); -} - -#[test] -fn snapshot_that_cannot_be_copied_is_not_retried_until_the_document_changes() { - // #2625: a failure caused by the snapshot itself (here a value the format cannot keep; - // in the field, the 256 MiB limit) used to be rewritten every interval while dirty. - let t = Temp::new(); - let mut s = session(&t.0); - s.run("document.setText", &json!({"text": "Synthetic draft"})).unwrap(); - s.doc.last_section.page_w = f32::NAN; - save_now(&mut s); - assert!(s.recovery_error().is_some()); - assert!(s.recovery_wait().is_none()); - due_now(&mut s); - s.recovery_tick(); - assert!(!s.recovery_busy(), "the unchanged document was snapshotted again"); - assert!(s.recovery_error().is_some()); - // An edit tries again. - s.doc.last_section.page_w = 612.0; - s.run("text.insert", &json!({"text": "!"})).unwrap(); - save_now(&mut s); - assert!(s.recovery_error().is_none()); - assert_eq!(records(&t.0).len(), 1); -} - -#[test] -fn failed_pruning_cannot_push_a_record_beyond_the_discovery_limit() { - let t = Temp::new(); - let id = new_id().unwrap(); - let mut store = Store::new(t.0.clone()).unwrap(); - store.save(snapshot(&id, "Last valid copy")).unwrap(); - let first = store.entries[&id].path.clone(); - let bytes = fs::read(&first).unwrap(); - // A failed cleanup must not allow the next write to exceed the reader's bound. - for n in 2..=MAX_FILES { - fs::write(t.0.join(&id).join(format!("snapshot-{n:020}.json")), b"{truncated").unwrap(); - } - assert!(store.save(snapshot(&id, "New copy")).is_err()); - assert_eq!(fs::read(first).unwrap(), bytes); - drop(store); - let mut reopened = reopen_reporting_unreadable(&t.0); - assert_eq!(load_retrying(&mut reopened, &id).document.plain_text(StoryRef::Body), "Last valid copy"); -} - -#[test] -fn failed_first_write_does_not_consume_a_record_slot() { - let t = Temp::new(); - let mut store = Store::new(t.0.clone()).unwrap(); - let mut invalid = snapshot(&new_id().unwrap(), "Synthetic draft"); - invalid.document.last_section.page_w = f32::NAN; - assert!(store.save(invalid).is_err()); - assert!(store.directories().unwrap().is_empty()); -} - -#[test] -fn discovery_reclaims_a_crashed_first_write_but_keeps_valid_records() { - let t = Temp::new(); - let id = new_id().unwrap(); - let empty = new_id().unwrap(); - let mut store = Store::new(t.0.clone()).unwrap(); - store.save(snapshot(&id, "Valid synthetic draft")).unwrap(); - drop(store); - let dir = t.0.join(&empty); - private_dir(&dir).unwrap(); - fs::write(dir.join(format!("pending-{}.tmp", new_id().unwrap())), b"{unfinished").unwrap(); - let mut reopened = Store::new(t.0.clone()).unwrap(); - reopened.scan().unwrap(); - assert!(!dir.exists()); - assert_eq!(load_retrying(&mut reopened, &id).document.plain_text(StoryRef::Body), "Valid synthetic draft"); -} - -#[test] -fn live_records_are_locked_and_discard_releases_storage() { - let t = Temp::new(); - let id = new_id().unwrap(); - let mut owner = Store::new(t.0.clone()).unwrap(); - owner.save(snapshot(&id, "live")).unwrap(); - let mut other = Store::new(t.0.clone()).unwrap(); - other.scan().unwrap(); - assert!(other.inventory().is_empty()); - assert!(other.discard(&id).is_err()); - assert!(other.discard("../outside").is_err()); - drop(owner); - other.scan().unwrap(); - assert_eq!(other.inventory().len(), 1); - other.discard(&id).unwrap(); - assert!(!t.0.join(id).exists(), "discard must release the record slot"); -} - -#[test] -fn new_draft_restarts_restores_unsaved_and_explicitly_discards() { - let t = Temp::new(); - let mut s = session(&t.0); - s.run("text.insert", &json!({"text": "Unsaved synthetic draft"})).unwrap(); - save_now(&mut s); - assert!(s.recovery_wait().is_none(), "unchanged documents must not schedule idle work"); - let expected = s.doc.clone(); - drop(s); - let mut s = session(&t.0); - let id = s.recovery_documents()[0].id.clone(); - s.run("file.recover", &json!({"restore": id})).unwrap(); - settle(&mut s); - assert_eq!(s.doc, expected); - assert!(s.dirty && s.path.is_none()); - assert!(s.recovery_documents().is_empty(), "the restored document keeps the copy"); - // A crash leaves the restored document's copy, in the same record. - drop(s); - let mut s = session(&t.0); - assert_eq!(s.recovery_documents().len(), 1); - s.run("file.recover", &json!({"discard": id})).unwrap(); - settle(&mut s); - save_now(&mut s); - assert!(s.recovery_documents().is_empty()); - assert!(records(&t.0).is_empty()); -} - -#[test] -fn existing_file_is_unchanged_and_successful_save_removes_copy() { - let t = Temp::new(); - let file = t.0.join("original.docx"); - let root = t.0.join("profile"); - crate::io::save_path(&file, &Document::from_text("Original business note")).unwrap(); - let original = fs::read(&file).unwrap(); - let mut s = session(&root); - s.run("file.open", &json!({"path": file})).unwrap(); - s.run("text.insert", &json!({"text": "Unsaved edit. "})).unwrap(); - save_now(&mut s); - let expected = s.doc.clone(); - drop(s); - let mut s = session(&root); - let id = s.recovery_documents()[0].id.clone(); - s.run("file.recover", &json!({"restore": id})).unwrap(); - settle(&mut s); - assert_eq!(s.doc, expected); - assert!(s.path.is_none() && s.dirty); - assert_eq!(fs::read(&file).unwrap(), original); - let saved = t.0.join("recovered.json"); - s.run("file.save", &json!({"path": saved})).unwrap(); - settle(&mut s); - assert!(records(&root).is_empty()); - assert_eq!(fs::read(file).unwrap(), original); -} - -#[test] -fn password_barrier_clears_in_flight_plaintext_and_blocks_new_copies() { - let t = Temp::new(); - let mut s = session(&t.0); - s.run("text.insert", &json!({"text": "Confidential synthetic draft"})).unwrap(); - due_now(&mut s); - s.recovery_tick(); - assert!(s.recovery_busy()); - s.run("file.encrypt", &json!({"password": "synthetic-password"})).unwrap(); - assert!(records(&t.0).is_empty()); - s.run("text.insert", &json!({"text": "More confidential edits"})).unwrap(); - save_now(&mut s); - assert!(fs::read_dir(&t.0).unwrap().next().is_none()); - // Removing protection makes future recovery possible again. - s.run("file.encrypt", &json!({"password": null})).unwrap(); - save_now(&mut s); - assert_eq!(records(&t.0).len(), 1); -} - -#[test] -fn failed_password_cleanup_rejects_protection_and_preserves_snapshot() { - let t = Temp::new(); - let mut s = session(&t.0); - s.run("text.insert", &json!({"text": "Synthetic text"})).unwrap(); - save_now(&mut s); - let id = records(&t.0).remove(0); - let dir = t.0.join(id); - // An unknown file prevents removal: don't report that plaintext was purged. - let first = Store::files(&dir).unwrap()[0].clone(); - let bytes = fs::read(&first).unwrap(); - fs::write(dir.join("unexpected"), b"unexpected").unwrap(); - assert!(s.run("file.encrypt", &json!({"password": "synthetic-password"})).is_err()); - assert!(s.password.is_none()); - assert_eq!(fs::read(first).unwrap(), bytes); -} - -#[test] -fn delayed_restore_cannot_replace_new_edits() { - let t = Temp::new(); - let mut s = session(&t.0); - s.run("text.insert", &json!({"text": "Saved draft"})).unwrap(); - save_now(&mut s); - drop(s); - let mut s = session(&t.0); - let id = s.recovery_documents()[0].id.clone(); - s.run("file.recover", &json!({"restore": id})).unwrap(); - s.run("text.insert", &json!({"text": "New work"})).unwrap(); - settle(&mut s); - assert_eq!(s.doc.plain_text(StoryRef::Body), "New work"); - assert!(s.recovery_error().unwrap().contains("document changed")); -} - -/// A restore asked for in one document lands in that document, even when another document -/// is active while the copy loads. -#[test] -fn delayed_restore_lands_in_the_document_that_asked_for_it() { - let t = Temp::new(); - let mut s = session(&t.0); - s.run("text.insert", &json!({"text": "Saved draft"})).unwrap(); - save_now(&mut s); - drop(s); - let mut s = session(&t.0); - let first = s.document_id(); - let id = s.recovery_documents()[0].id.clone(); - s.run("file.recover", &json!({"restore": id})).unwrap(); - s.run("file.new", &json!({})).unwrap(); - let second = s.document_id(); - s.run("text.insert", &json!({"text": "Other work"})).unwrap(); - settle(&mut s); - assert_eq!(s.doc.plain_text(StoryRef::Body), "Other work", "the active document is left alone"); - s.activate(first).unwrap(); - settle(&mut s); - assert_eq!(s.doc.plain_text(StoryRef::Body), "Saved draft"); - assert_eq!(s.with_document(second, |s| s.doc.plain_text(StoryRef::Body)).unwrap(), "Other work"); - assert_eq!(s.documents().len(), 2); -} - -#[test] -fn opening_protected_file_and_password_save_never_leave_plaintext_copies() { - let t = Temp::new(); - let original = t.0.join("protected.docx"); - let root = t.0.join("profile"); - crate::io::save_path_with( - &original, - &Document::from_text("Synthetic confidential note"), - Some("synthetic-password"), - &crate::io::ExportOptions::default(), - ) - .unwrap(); - let bytes = fs::read(&original).unwrap(); - let mut s = session(&root); - s.run("file.open", &json!({"path": original, "password": "synthetic-password"})).unwrap(); - s.run("text.insert", &json!({"text": "Confidential edit"})).unwrap(); - save_now(&mut s); - assert!(fs::read_dir(&root).unwrap().next().is_none()); - assert_eq!(fs::read(&original).unwrap(), bytes); - s.run("file.new", &json!({})).unwrap(); - s.run("text.insert", &json!({"text": "New confidential draft"})).unwrap(); - save_now(&mut s); - assert_eq!(records(&root).len(), 1); - let saved = t.0.join("saved.docx"); - s.run("file.save", &json!({"path": saved, "password": "synthetic-password"})).unwrap(); - settle(&mut s); - assert!(fs::read_dir(root).unwrap().next().is_none()); - assert_eq!(fs::read(original).unwrap(), bytes); -} - -/// Each open document keeps its own recovery copy: switching between documents doesn't start -/// a new copy of either. -#[test] -fn each_open_document_keeps_its_own_copy() { - let t = Temp::new(); - let mut s = session(&t.0); - let first = s.document_id(); - s.run("text.insert", &json!({"text": "First synthetic draft"})).unwrap(); - save_now(&mut s); - s.run("file.new", &json!({})).unwrap(); - settle(&mut s); - s.run("text.insert", &json!({"text": "Second synthetic draft"})).unwrap(); - save_now(&mut s); - s.activate(first).unwrap(); - s.run("text.insert", &json!({"text": ", edited"})).unwrap(); - save_now(&mut s); - assert_eq!(records(&t.0).len(), 2, "one copy per document"); - assert!(s.recovery_documents().is_empty(), "the copies open documents keep are not listed"); -} - -#[test] -fn protection_after_document_switch_does_not_discard_another_draft() { - let t = Temp::new(); - let mut s = session(&t.0); - s.run("text.insert", &json!({"text": "First synthetic draft"})).unwrap(); - save_now(&mut s); - let first = s.document_id(); - s.run("file.new", &json!({})).unwrap(); - // Protection can be commanded before the host's next recovery tick. - s.run("file.encrypt", &json!({"password": "synthetic-password"})).unwrap(); - settle(&mut s); - assert!(s.with_document(first, |s| s.recovery_slot.id.is_some()).unwrap()); - assert_eq!(records(&t.0).len(), 1, "the first draft is kept"); -} - -/// Two dirty documents, one of them parked since before its first copy: both get copies once -/// their interval has passed, without being made active. -#[test] -fn parked_documents_keep_their_copies() { - let t = Temp::new(); - let mut s = session(&t.0); - s.run("text.insert", &json!({"text": "Parked synthetic draft"})).unwrap(); - let parked = s.document_id(); - s.run("file.new", &json!({})).unwrap(); - s.run("text.insert", &json!({"text": "Active synthetic draft"})).unwrap(); - let active = s.document_id(); - assert_ne!(parked, active); - save_now(&mut s); - assert_eq!(s.document_id(), active, "the parked document was not made active"); - assert_eq!(records(&t.0).len(), 2, "both documents have copies"); - // An edit to the parked document reaches its copy too. - s.with_document(parked, |s| s.run("text.insert", &json!({"text": ", edited"})).unwrap()).unwrap(); - let id = s.with_document(parked, |s| s.recovery_slot.id.clone()).unwrap().unwrap(); - save_now(&mut s); - drop(s); - let mut store = Store::new(t.0.clone()).unwrap(); - store.scan().unwrap(); - assert_eq!(load_retrying(&mut store, &id).document.plain_text(StoryRef::Body), "Parked synthetic draft, edited"); -} - -/// Closing a dirty document and saving another delete both their copies; a third document -/// keeps its own. -#[test] -fn closing_or_saving_a_document_deletes_only_its_copy() { - let t = Temp::new(); - let files = Temp::new(); - let mut s = session(&t.0); - let mut ids = Vec::new(); - for text in ["Closed draft", "Saved draft", "Kept draft"] { - if !ids.is_empty() { - s.run("file.new", &json!({})).unwrap(); - } - s.run("text.insert", &json!({"text": text})).unwrap(); - ids.push(s.document_id()); - } - save_now(&mut s); - assert_eq!(records(&t.0).len(), 3); - let kept = s.recovery_slot.id.clone().unwrap(); - s.close_document(ids[0]).unwrap(); - s.with_document(ids[1], |s| s.run("file.save", &json!({"path": files.0.join("saved.json")})).unwrap()).unwrap(); - settle(&mut s); - assert_eq!(records(&t.0), [kept], "only the open dirty document's copy is left"); - assert!(s.recovery_documents().is_empty()); -} - -/// A crash with two dirty documents leaves two copies; the next run lists both and restores -/// each into a document of its own, beside the work already there. -#[test] -fn a_crash_leaves_every_dirty_document_s_copy() { - let t = Temp::new(); - let mut s = session(&t.0); - s.run("text.insert", &json!({"text": "First synthetic draft"})).unwrap(); - s.run("file.new", &json!({})).unwrap(); - s.run("text.insert", &json!({"text": "Second synthetic draft"})).unwrap(); - save_now(&mut s); - drop(s); // no finish_recovery: the process died - let mut s = session(&t.0); - let copies = s.recovery_documents(); - assert_eq!(copies.len(), 2); - s.run("text.insert", &json!({"text": "Work in progress"})).unwrap(); - let work = s.document_id(); - for copy in &copies { - s.run("file.recover", &json!({"restore": copy.id})).unwrap(); - settle(&mut s); - } - assert!(s.recovery_documents().is_empty(), "the restored documents keep their copies"); - let mut texts: Vec = s.documents().iter().map(|d| s.with_document(d.id, |s| s.doc.plain_text(StoryRef::Body)).unwrap()).collect(); - texts.sort(); - assert_eq!(texts, ["First synthetic draft", "Second synthetic draft", "Work in progress"]); - assert!(s.with_document(work, |s| s.dirty).unwrap()); -} - -/// A clean exit deletes the copies of documents saved or closed since their last copy, the -/// parked ones' too, and keeps the dirty ones'. -#[test] -fn finish_recovery_covers_every_open_document() { - let t = Temp::new(); - let files = Temp::new(); - let mut s = session(&t.0); - let mut ids = Vec::new(); - for text in ["Saved draft", "Closed draft", "Dirty draft"] { - if !ids.is_empty() { - s.run("file.new", &json!({})).unwrap(); - } - s.run("text.insert", &json!({"text": text})).unwrap(); - ids.push(s.document_id()); - } - save_now(&mut s); - assert_eq!(records(&t.0).len(), 3); - let dirty = s.recovery_slot.id.clone().unwrap(); - // No tick between these and the exit. - s.with_document(ids[0], |s| s.run("file.save", &json!({"path": files.0.join("saved.json")})).unwrap()).unwrap(); - s.close_document(ids[1]).unwrap(); - s.finish_recovery(); - assert_eq!(records(&t.0), [dirty]); -} +use super::*; +use crate::Session; +use serde_json::json; +use wordcraft_doc::StoryRef; + +struct Temp(PathBuf); +impl Temp { + fn new() -> Self { + let path = std::env::temp_dir().join(format!("wordcraft-recovery-test-{}", new_id().unwrap())); + private_dir(&path).unwrap(); + Self(path) + } +} +impl Drop for Temp { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} +/// A store reopened on `root` whose scan reports an unreadable record. The record's lock can be +/// briefly held by a process another test is starting (a forked child holds the descriptor +/// until it execs), and a record that can't be locked is skipped, not reported: so try again +/// for up to a second rather than read a skipped record as a clean scan. +fn reopen_reporting_unreadable(root: &std::path::Path) -> Store { + for _ in 0..50 { + let mut store = Store::new(root.to_path_buf()).unwrap(); + if store.scan().is_err() { + return store; + } + std::thread::sleep(std::time::Duration::from_millis(20)); + } + panic!("the scan never reported the unreadable record"); +} +/// `store.load(id)`, tried again for up to a second while the record can't be locked: a process +/// another test is starting holds the lock for a moment (see [`reopen_reporting_unreadable`]). +fn load_retrying(store: &mut Store, id: &str) -> Snapshot { + let mut last = String::new(); + for _ in 0..50 { + match store.load(id) { + Ok(snapshot) => return snapshot, + Err(e) => last = e, + } + std::thread::sleep(std::time::Duration::from_millis(20)); + } + panic!("the record never loaded: {last}"); +} +fn snapshot(id: &str, text: &str) -> Snapshot { + Snapshot { + version: 1, + info: RecoveryInfo { id: id.into(), name: "Synthetic draft".into(), saved_at: crate::cmd::now_iso() }, + generation: 0, + document: Document::from_text(text), + } +} +fn settle(s: &mut Session) { + let end = Instant::now() + Duration::from_secs(5); + loop { + s.recovery_tick(); + if s.recovery_ready() && !s.recovery_busy() { + break; + } + assert!(Instant::now() < end, "recovery did not finish"); + std::thread::sleep(Duration::from_millis(2)); + } +} +fn session(root: &Path) -> Session { + let mut s = Session::new(Document::new()); + s.enable_recovery(root.to_owned(), Arc::new(|| {})).unwrap(); + settle(&mut s); + s +} +/// Every open document's interval has passed. +fn due_now(s: &mut Session) { + for d in s.recovery_docs() { + d.slot.due = Instant::now(); + } +} +fn save_now(s: &mut Session) { + due_now(s); + s.recovery_tick(); + settle(s); +} +/// The records in the profile folder: the copies on disk. +fn records(root: &Path) -> Vec { + let mut ids: Vec = fs::read_dir(root) + .unwrap() + .filter_map(|e| e.ok()) + .filter(|e| e.path().is_dir()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .collect(); + ids.sort(); + ids +} + +/// #3140: a save that lands right before the app closes can race the process exiting before +/// the now-redundant recovery copy is actually removed, so the next launch wrongly offers to +/// recover a document that was in fact saved cleanly. `finish_recovery` (desktop hosts call it +/// on exit, the same as they wait for pending print jobs) must discard it synchronously +/// instead, with no regular tick in between — simulating closing right after Save, not after +/// however long it'd take the next normal autosave tick to notice and clean up. +#[test] +fn finish_recovery_discards_its_own_copy_for_a_document_saved_right_before_exit() { + let profile = Temp::new(); + let mut s = session(&profile.0); + s.run("document.setText", &json!({"text": "About to be saved"})).unwrap(); + save_now(&mut s); + assert_eq!(records(&profile.0).len(), 1, "a recovery copy exists for the dirty document"); + // A real Save does this, then the app can close on the very next frame, with no regular + // recovery_tick() in between to notice `!s.dirty` and queue the discard itself. + s.dirty = false; + assert_eq!(records(&profile.0).len(), 1, "confirms the race: setting dirty=false alone (what Save does) doesn't discard anything by itself"); + s.finish_recovery(); + assert!(records(&profile.0).is_empty(), "finish_recovery should have discarded the now-redundant copy before returning"); +} + +/// `tick` resets `self.id` the moment the session's document no longer matches what it last +/// saw (so it never discards under a stale identity); `finish_recovery` must guard the same +/// way. Switching documents and exiting immediately, with no regular tick in between to reset +/// that identity, must not let the *new* (clean, blank) document's state discard the +/// *previous* document's still-live recovery copy. +#[test] +fn finish_recovery_does_not_discard_a_previous_document_s_copy_after_switching() { + let profile = Temp::new(); + let mut s = session(&profile.0); + s.run("document.setText", &json!({"text": "The document the user is still working on"})).unwrap(); + save_now(&mut s); + assert_eq!(records(&profile.0).len(), 1, "a recovery copy exists for the dirty document"); + s.set_document(Document::new()); // the document is replaced, with no tick() in between + assert!(!s.dirty, "a freshly switched-to document starts clean"); + s.finish_recovery(); + assert_eq!(records(&profile.0).len(), 1, "replacing the document must not discard the previous one's recovery copy"); + assert_eq!(s.recovery_documents().len(), 1, "no open document keeps it: it is listed"); +} + +#[test] +fn recovery_commands_only_address_known_profile_records() { + use crate::file_access::{AutomationRoots, confine}; + let profile = Temp::new(); + let files = Temp::new(); + let original = files.0.join("original.txt"); + fs::write(&original, b"External synthetic original").unwrap(); + let roots = AutomationRoots::new(Some(&files.0), Some(&files.0)).unwrap().unwrap(); + let mut s = session(&profile.0); + s.run("document.setText", &json!({"text": "Recoverable synthetic draft"})).unwrap(); + save_now(&mut s); + drop(s); + let mut s = session(&profile.0); + let id = s.recovery_documents()[0].id.clone(); + confine(Some(&roots), || { + // A command cannot use a file path or choose a different storage folder. + for bad in [original.to_string_lossy().into_owned(), "../original.txt".into()] { + for action in ["restore", "discard"] { + let err = s.run("file.recover", &json!({action: bad, "root": files.0})).unwrap_err(); + assert!(err.to_string().contains("no such recovery copy"), "{err}"); + } + } + assert_eq!(s.recovery_documents().len(), 1); + s.run("file.recover", &json!({"restore": id})).unwrap(); + settle(&mut s); + assert!(s.path.is_none()); + assert!(s.dirty); + assert_eq!(s.doc.plain_text(StoryRef::Body), "Recoverable synthetic draft"); + // The restored document keeps its copy there: it isn't listed, and can't be discarded. + assert!(s.recovery_documents().is_empty()); + let err = s.run("file.recover", &json!({"discard": id})).unwrap_err(); + assert!(err.to_string().contains("no such recovery copy"), "{err}"); + settle(&mut s); + assert_eq!(records(&profile.0), std::slice::from_ref(&id)); + }); + assert_eq!(fs::read(&original).unwrap(), b"External synthetic original"); + assert_eq!(fs::read_dir(&files.0).unwrap().count(), 1); +} + +#[test] +fn failed_write_keeps_last_valid_generation_and_discovery_falls_back() { + let t = Temp::new(); + let id = new_id().unwrap(); + let mut store = Store::new(t.0.clone()).unwrap(); + store.save(snapshot(&id, "First valid copy")).unwrap(); + let first = store.entries[&id].path.clone(); + let bytes = fs::read(&first).unwrap(); + // Simulate a final rename failure without relying on permissions (root can bypass them). + let obstruction = t.0.join(&id).join("snapshot-00000000000000000002.json"); + fs::create_dir(&obstruction).unwrap(); + assert!(store.save(snapshot(&id, "Failed copy")).is_err()); + assert_eq!(fs::read(&first).unwrap(), bytes); + fs::remove_dir(obstruction).unwrap(); + store.save(snapshot(&id, "Second valid copy")).unwrap(); + let second = store.entries[&id].path.clone(); + fs::write(second, b"{truncated").unwrap(); + drop(store); + let mut reopened = reopen_reporting_unreadable(&t.0); + assert_eq!(load_retrying(&mut reopened, &id).document.plain_text(StoryRef::Body), "First valid copy"); + // Never reuse the corrupt generation's name or prune the valid fallback. + reopened.save(snapshot(&id, "Third valid copy")).unwrap(); + assert_eq!(fs::read(first).unwrap(), bytes); + assert_eq!(Store::files(&t.0.join(&id)).unwrap().len(), 2); +} + +#[test] +fn size_limit_and_lossy_values_fail_without_committing() { + let mut out = Limited { inner: Vec::new(), remaining: 3, exceeded: false }; + assert!(out.write_all(b"four").is_err()); + assert!(out.inner.is_empty()); + assert!(out.exceeded); + let t = Temp::new(); + let id = new_id().unwrap(); + let mut store = Store::new(t.0.clone()).unwrap(); + store.save(snapshot(&id, "valid")).unwrap(); + let first = store.entries[&id].path.clone(); + let bytes = fs::read(&first).unwrap(); + let mut invalid = snapshot(&id, "invalid"); + invalid.document.last_section.page_w = f32::NAN; + assert!(store.save(invalid).is_err_and(|e| e.permanent)); + assert_eq!(fs::read(first).unwrap(), bytes); + assert_eq!(Store::files(&t.0.join(id)).unwrap().len(), 1); +} + +/// Copies are written with picture bytes in base64 (#586 follow-up); those written before kept +/// them as arrays of numbers and are still listed and restored, bytes intact. +#[test] +fn copies_with_byte_arrays_written_before_still_restore() { + let t = Temp::new(); + let id = new_id().unwrap(); + let mut s = snapshot(&id, "Synthetic draft with a picture"); + s.generation = 1; + s.document.media.insert("media/image1.png".into(), Arc::new(vec![0, 1, 127, 128, 254, 255])); + let mut value = serde_json::to_value(&s).unwrap(); + assert_eq!(value["document"]["media"]["media/image1.png"], "AAF/gP7/"); + value["document"]["media"]["media/image1.png"] = json!([0, 1, 127, 128, 254, 255]); + private_dir(&t.0.join(&id)).unwrap(); + fs::write(t.0.join(&id).join("snapshot-00000000000000000001.json"), serde_json::to_vec(&value).unwrap()).unwrap(); + let mut store = Store::new(t.0.clone()).unwrap(); + store.scan().unwrap(); + assert_eq!(store.inventory().len(), 1); + assert_eq!(store.load(&id).unwrap().document, s.document); +} + +#[test] +fn snapshot_that_cannot_be_copied_is_not_retried_until_the_document_changes() { + // #2625: a failure caused by the snapshot itself (here a value the format cannot keep; + // in the field, the 256 MiB limit) used to be rewritten every interval while dirty. + let t = Temp::new(); + let mut s = session(&t.0); + s.run("document.setText", &json!({"text": "Synthetic draft"})).unwrap(); + s.doc.last_section.page_w = f32::NAN; + save_now(&mut s); + assert!(s.recovery_error().is_some()); + assert!(s.recovery_wait().is_none()); + due_now(&mut s); + s.recovery_tick(); + assert!(!s.recovery_busy(), "the unchanged document was snapshotted again"); + assert!(s.recovery_error().is_some()); + // An edit tries again. + s.doc.last_section.page_w = 612.0; + s.run("text.insert", &json!({"text": "!"})).unwrap(); + save_now(&mut s); + assert!(s.recovery_error().is_none()); + assert_eq!(records(&t.0).len(), 1); +} + +#[test] +fn failed_pruning_cannot_push_a_record_beyond_the_discovery_limit() { + let t = Temp::new(); + let id = new_id().unwrap(); + let mut store = Store::new(t.0.clone()).unwrap(); + store.save(snapshot(&id, "Last valid copy")).unwrap(); + let first = store.entries[&id].path.clone(); + let bytes = fs::read(&first).unwrap(); + // A failed cleanup must not allow the next write to exceed the reader's bound. + for n in 2..=MAX_FILES { + fs::write(t.0.join(&id).join(format!("snapshot-{n:020}.json")), b"{truncated").unwrap(); + } + assert!(store.save(snapshot(&id, "New copy")).is_err()); + assert_eq!(fs::read(first).unwrap(), bytes); + drop(store); + let mut reopened = reopen_reporting_unreadable(&t.0); + assert_eq!(load_retrying(&mut reopened, &id).document.plain_text(StoryRef::Body), "Last valid copy"); +} + +#[test] +fn failed_first_write_does_not_consume_a_record_slot() { + let t = Temp::new(); + let mut store = Store::new(t.0.clone()).unwrap(); + let mut invalid = snapshot(&new_id().unwrap(), "Synthetic draft"); + invalid.document.last_section.page_w = f32::NAN; + assert!(store.save(invalid).is_err()); + assert!(store.directories().unwrap().is_empty()); +} + +#[test] +fn discovery_reclaims_a_crashed_first_write_but_keeps_valid_records() { + let t = Temp::new(); + let id = new_id().unwrap(); + let empty = new_id().unwrap(); + let mut store = Store::new(t.0.clone()).unwrap(); + store.save(snapshot(&id, "Valid synthetic draft")).unwrap(); + drop(store); + let dir = t.0.join(&empty); + private_dir(&dir).unwrap(); + fs::write(dir.join(format!("pending-{}.tmp", new_id().unwrap())), b"{unfinished").unwrap(); + let mut reopened = Store::new(t.0.clone()).unwrap(); + reopened.scan().unwrap(); + assert!(!dir.exists()); + assert_eq!(load_retrying(&mut reopened, &id).document.plain_text(StoryRef::Body), "Valid synthetic draft"); +} + +#[test] +fn live_records_are_locked_and_discard_releases_storage() { + let t = Temp::new(); + let id = new_id().unwrap(); + let mut owner = Store::new(t.0.clone()).unwrap(); + owner.save(snapshot(&id, "live")).unwrap(); + let mut other = Store::new(t.0.clone()).unwrap(); + other.scan().unwrap(); + assert!(other.inventory().is_empty()); + assert!(other.discard(&id).is_err()); + assert!(other.discard("../outside").is_err()); + drop(owner); + other.scan().unwrap(); + assert_eq!(other.inventory().len(), 1); + other.discard(&id).unwrap(); + assert!(!t.0.join(id).exists(), "discard must release the record slot"); +} + +#[test] +fn new_draft_restarts_restores_unsaved_and_explicitly_discards() { + let t = Temp::new(); + let mut s = session(&t.0); + s.run("text.insert", &json!({"text": "Unsaved synthetic draft"})).unwrap(); + save_now(&mut s); + assert!(s.recovery_wait().is_none(), "unchanged documents must not schedule idle work"); + let expected = s.doc.clone(); + drop(s); + let mut s = session(&t.0); + let id = s.recovery_documents()[0].id.clone(); + s.run("file.recover", &json!({"restore": id})).unwrap(); + settle(&mut s); + assert_eq!(s.doc, expected); + assert!(s.dirty && s.path.is_none()); + assert!(s.recovery_documents().is_empty(), "the restored document keeps the copy"); + // A crash leaves the restored document's copy, in the same record. + drop(s); + let mut s = session(&t.0); + assert_eq!(s.recovery_documents().len(), 1); + s.run("file.recover", &json!({"discard": id})).unwrap(); + settle(&mut s); + save_now(&mut s); + assert!(s.recovery_documents().is_empty()); + assert!(records(&t.0).is_empty()); +} + +#[test] +fn existing_file_is_unchanged_and_successful_save_removes_copy() { + let t = Temp::new(); + let file = t.0.join("original.docx"); + let root = t.0.join("profile"); + crate::io::save_path(&file, &Document::from_text("Original business note")).unwrap(); + let original = fs::read(&file).unwrap(); + let mut s = session(&root); + s.run("file.open", &json!({"path": file})).unwrap(); + s.run("text.insert", &json!({"text": "Unsaved edit. "})).unwrap(); + save_now(&mut s); + let expected = s.doc.clone(); + drop(s); + let mut s = session(&root); + let id = s.recovery_documents()[0].id.clone(); + s.run("file.recover", &json!({"restore": id})).unwrap(); + settle(&mut s); + assert_eq!(s.doc, expected); + assert!(s.path.is_none() && s.dirty); + assert_eq!(fs::read(&file).unwrap(), original); + let saved = t.0.join("recovered.json"); + s.run("file.save", &json!({"path": saved})).unwrap(); + settle(&mut s); + assert!(records(&root).is_empty()); + assert_eq!(fs::read(file).unwrap(), original); +} + +#[test] +fn password_barrier_clears_in_flight_plaintext_and_blocks_new_copies() { + let t = Temp::new(); + let mut s = session(&t.0); + s.run("text.insert", &json!({"text": "Confidential synthetic draft"})).unwrap(); + due_now(&mut s); + s.recovery_tick(); + assert!(s.recovery_busy()); + s.run("file.encrypt", &json!({"password": "synthetic-password"})).unwrap(); + assert!(records(&t.0).is_empty()); + s.run("text.insert", &json!({"text": "More confidential edits"})).unwrap(); + save_now(&mut s); + assert!(fs::read_dir(&t.0).unwrap().next().is_none()); + // Removing protection makes future recovery possible again. + s.run("file.encrypt", &json!({"password": null})).unwrap(); + save_now(&mut s); + assert_eq!(records(&t.0).len(), 1); +} + +#[test] +fn failed_password_cleanup_rejects_protection_and_preserves_snapshot() { + let t = Temp::new(); + let mut s = session(&t.0); + s.run("text.insert", &json!({"text": "Synthetic text"})).unwrap(); + save_now(&mut s); + let id = records(&t.0).remove(0); + let dir = t.0.join(id); + // An unknown file prevents removal: don't report that plaintext was purged. + let first = Store::files(&dir).unwrap()[0].clone(); + let bytes = fs::read(&first).unwrap(); + fs::write(dir.join("unexpected"), b"unexpected").unwrap(); + assert!(s.run("file.encrypt", &json!({"password": "synthetic-password"})).is_err()); + assert!(s.password.is_none()); + assert_eq!(fs::read(first).unwrap(), bytes); +} + +#[test] +fn delayed_restore_cannot_replace_new_edits() { + let t = Temp::new(); + let mut s = session(&t.0); + s.run("text.insert", &json!({"text": "Saved draft"})).unwrap(); + save_now(&mut s); + drop(s); + let mut s = session(&t.0); + let id = s.recovery_documents()[0].id.clone(); + s.run("file.recover", &json!({"restore": id})).unwrap(); + s.run("text.insert", &json!({"text": "New work"})).unwrap(); + settle(&mut s); + assert_eq!(s.doc.plain_text(StoryRef::Body), "New work"); + assert!(s.recovery_error().unwrap().contains("document changed")); +} + +/// A restore asked for in one document lands in that document, even when another document +/// is active while the copy loads. +#[test] +fn delayed_restore_lands_in_the_document_that_asked_for_it() { + let t = Temp::new(); + let mut s = session(&t.0); + s.run("text.insert", &json!({"text": "Saved draft"})).unwrap(); + save_now(&mut s); + drop(s); + let mut s = session(&t.0); + let first = s.document_id(); + let id = s.recovery_documents()[0].id.clone(); + s.run("file.recover", &json!({"restore": id})).unwrap(); + s.run("file.new", &json!({})).unwrap(); + let second = s.document_id(); + s.run("text.insert", &json!({"text": "Other work"})).unwrap(); + settle(&mut s); + assert_eq!(s.doc.plain_text(StoryRef::Body), "Other work", "the active document is left alone"); + s.activate(first).unwrap(); + settle(&mut s); + assert_eq!(s.doc.plain_text(StoryRef::Body), "Saved draft"); + assert_eq!(s.with_document(second, |s| s.doc.plain_text(StoryRef::Body)).unwrap(), "Other work"); + assert_eq!(s.documents().len(), 2); +} + +#[test] +fn opening_protected_file_and_password_save_never_leave_plaintext_copies() { + let t = Temp::new(); + let original = t.0.join("protected.docx"); + let root = t.0.join("profile"); + crate::io::save_path_with( + &original, + &Document::from_text("Synthetic confidential note"), + Some("synthetic-password"), + &crate::io::ExportOptions::default(), + ) + .unwrap(); + let bytes = fs::read(&original).unwrap(); + let mut s = session(&root); + s.run("file.open", &json!({"path": original, "password": "synthetic-password"})).unwrap(); + s.run("text.insert", &json!({"text": "Confidential edit"})).unwrap(); + save_now(&mut s); + assert!(fs::read_dir(&root).unwrap().next().is_none()); + assert_eq!(fs::read(&original).unwrap(), bytes); + s.run("file.new", &json!({})).unwrap(); + s.run("text.insert", &json!({"text": "New confidential draft"})).unwrap(); + save_now(&mut s); + assert_eq!(records(&root).len(), 1); + let saved = t.0.join("saved.docx"); + s.run("file.save", &json!({"path": saved, "password": "synthetic-password"})).unwrap(); + settle(&mut s); + assert!(fs::read_dir(root).unwrap().next().is_none()); + assert_eq!(fs::read(original).unwrap(), bytes); +} + +/// Each open document keeps its own recovery copy: switching between documents doesn't start +/// a new copy of either. +#[test] +fn each_open_document_keeps_its_own_copy() { + let t = Temp::new(); + let mut s = session(&t.0); + let first = s.document_id(); + s.run("text.insert", &json!({"text": "First synthetic draft"})).unwrap(); + save_now(&mut s); + s.run("file.new", &json!({})).unwrap(); + settle(&mut s); + s.run("text.insert", &json!({"text": "Second synthetic draft"})).unwrap(); + save_now(&mut s); + s.activate(first).unwrap(); + s.run("text.insert", &json!({"text": ", edited"})).unwrap(); + save_now(&mut s); + assert_eq!(records(&t.0).len(), 2, "one copy per document"); + assert!(s.recovery_documents().is_empty(), "the copies open documents keep are not listed"); +} + +#[test] +fn protection_after_document_switch_does_not_discard_another_draft() { + let t = Temp::new(); + let mut s = session(&t.0); + s.run("text.insert", &json!({"text": "First synthetic draft"})).unwrap(); + save_now(&mut s); + let first = s.document_id(); + s.run("file.new", &json!({})).unwrap(); + // Protection can be commanded before the host's next recovery tick. + s.run("file.encrypt", &json!({"password": "synthetic-password"})).unwrap(); + settle(&mut s); + assert!(s.with_document(first, |s| s.recovery_slot.id.is_some()).unwrap()); + assert_eq!(records(&t.0).len(), 1, "the first draft is kept"); +} + +/// Two dirty documents, one of them parked since before its first copy: both get copies once +/// their interval has passed, without being made active. +#[test] +fn parked_documents_keep_their_copies() { + let t = Temp::new(); + let mut s = session(&t.0); + s.run("text.insert", &json!({"text": "Parked synthetic draft"})).unwrap(); + let parked = s.document_id(); + s.run("file.new", &json!({})).unwrap(); + s.run("text.insert", &json!({"text": "Active synthetic draft"})).unwrap(); + let active = s.document_id(); + assert_ne!(parked, active); + save_now(&mut s); + assert_eq!(s.document_id(), active, "the parked document was not made active"); + assert_eq!(records(&t.0).len(), 2, "both documents have copies"); + // An edit to the parked document reaches its copy too. + s.with_document(parked, |s| s.run("text.insert", &json!({"text": ", edited"})).unwrap()).unwrap(); + let id = s.with_document(parked, |s| s.recovery_slot.id.clone()).unwrap().unwrap(); + save_now(&mut s); + drop(s); + let mut store = Store::new(t.0.clone()).unwrap(); + store.scan().unwrap(); + assert_eq!(load_retrying(&mut store, &id).document.plain_text(StoryRef::Body), "Parked synthetic draft, edited"); +} + +/// Closing a dirty document and saving another delete both their copies; a third document +/// keeps its own. +#[test] +fn closing_or_saving_a_document_deletes_only_its_copy() { + let t = Temp::new(); + let files = Temp::new(); + let mut s = session(&t.0); + let mut ids = Vec::new(); + for text in ["Closed draft", "Saved draft", "Kept draft"] { + if !ids.is_empty() { + s.run("file.new", &json!({})).unwrap(); + } + s.run("text.insert", &json!({"text": text})).unwrap(); + ids.push(s.document_id()); + } + save_now(&mut s); + assert_eq!(records(&t.0).len(), 3); + let kept = s.recovery_slot.id.clone().unwrap(); + s.close_document(ids[0]).unwrap(); + s.with_document(ids[1], |s| s.run("file.save", &json!({"path": files.0.join("saved.json")})).unwrap()).unwrap(); + settle(&mut s); + assert_eq!(records(&t.0), [kept], "only the open dirty document's copy is left"); + assert!(s.recovery_documents().is_empty()); +} + +/// A crash with two dirty documents leaves two copies; the next run lists both and restores +/// each into a document of its own, beside the work already there. +#[test] +fn a_crash_leaves_every_dirty_document_s_copy() { + let t = Temp::new(); + let mut s = session(&t.0); + s.run("text.insert", &json!({"text": "First synthetic draft"})).unwrap(); + s.run("file.new", &json!({})).unwrap(); + s.run("text.insert", &json!({"text": "Second synthetic draft"})).unwrap(); + save_now(&mut s); + drop(s); // no finish_recovery: the process died + let mut s = session(&t.0); + let copies = s.recovery_documents(); + assert_eq!(copies.len(), 2); + s.run("text.insert", &json!({"text": "Work in progress"})).unwrap(); + let work = s.document_id(); + for copy in &copies { + s.run("file.recover", &json!({"restore": copy.id})).unwrap(); + settle(&mut s); + } + assert!(s.recovery_documents().is_empty(), "the restored documents keep their copies"); + let mut texts: Vec = s.documents().iter().map(|d| s.with_document(d.id, |s| s.doc.plain_text(StoryRef::Body)).unwrap()).collect(); + texts.sort(); + assert_eq!(texts, ["First synthetic draft", "Second synthetic draft", "Work in progress"]); + assert!(s.with_document(work, |s| s.dirty).unwrap()); +} + +/// A clean exit deletes the copies of documents saved or closed since their last copy, the +/// parked ones' too, and keeps the dirty ones'. +#[test] +fn finish_recovery_covers_every_open_document() { + let t = Temp::new(); + let files = Temp::new(); + let mut s = session(&t.0); + let mut ids = Vec::new(); + for text in ["Saved draft", "Closed draft", "Dirty draft"] { + if !ids.is_empty() { + s.run("file.new", &json!({})).unwrap(); + } + s.run("text.insert", &json!({"text": text})).unwrap(); + ids.push(s.document_id()); + } + save_now(&mut s); + assert_eq!(records(&t.0).len(), 3); + let dirty = s.recovery_slot.id.clone().unwrap(); + // No tick between these and the exit. + s.with_document(ids[0], |s| s.run("file.save", &json!({"path": files.0.join("saved.json")})).unwrap()).unwrap(); + s.close_document(ids[1]).unwrap(); + s.finish_recovery(); + assert_eq!(records(&t.0), [dirty]); +} diff --git a/crates/engine/src/ribbon.rs b/crates/engine/src/ribbon.rs index 49cb8793d9..f66e9ce824 100644 --- a/crates/engine/src/ribbon.rs +++ b/crates/engine/src/ribbon.rs @@ -1,506 +1,506 @@ -//! Customize Ribbon and the Quick Access Toolbar (`tools.customizeRibbon`): which ribbon tabs are -//! shown and in what order, custom tabs, custom groups (on built-in or custom tabs) holding any -//! commands, and the commands on the Quick Access Toolbar. -//! -//! Built-in groups aren't editable: a built-in tab only gains custom groups after its own. The -//! layout is saved with the front end's preferences; reading a saved layout never fails: junk, -//! duplicates and commands this version doesn't have are dropped. - -use std::borrow::Cow; - -use serde::{Deserialize, Serialize}; -use serde_json::Value; - -use crate::Registry; - -/// The ribbon's tabs in their default order. `File` opens Backstage: it is always first and -/// isn't part of the customizable list. -pub const TABS: [&str; 13] = - ["File", "Home", "Insert", "Draw", "Design", "Layout", "References", "Mailings", "Review", "View", "Developer", "Zotero", "Help"]; -/// Tabs that come up with the selection; custom tabs can't take their names. -pub const CONTEXTUAL_TABS: [&str; 7] = - ["Table Design", "Table Layout", "Picture Format", "Shape Format", "Equation", "Chart Design", "SmartArt Design"]; -/// The Quick Access Toolbar's commands until the user changes them. -pub const DEFAULT_QAT: [&str; 3] = ["file.save", "edit.undo", "edit.redo"]; - -/// Limits on what a (possibly hand-edited) saved layout can hold. -const MAX_TABS: usize = 64; -const MAX_GROUPS: usize = 64; -const MAX_COMMANDS: usize = 200; -const MAX_NAME: usize = 64; -const MAX_ID: usize = 128; - -/// A ribbon tab in the customized order: a built-in tab (which can be hidden and gain custom -/// groups) or a custom one. -#[derive(Clone, Debug, PartialEq, Serialize)] -pub struct TabEntry { - pub name: String, - #[serde(skip_serializing_if = "is_false")] - pub custom: bool, - #[serde(skip_serializing_if = "is_false")] - pub hidden: bool, - /// Custom groups, after a built-in tab's own groups. - #[serde(skip_serializing_if = "Vec::is_empty")] - pub groups: Vec, -} - -/// A custom group: a name and the commands it shows, in order. -#[derive(Clone, Debug, PartialEq, Serialize)] -pub struct CustomGroup { - pub name: String, - pub commands: Vec, -} - -/// The user's ribbon and Quick Access Toolbar. -#[derive(Clone, Debug, Default, PartialEq, Serialize)] -pub struct RibbonLayout { - /// Every tab but File, in order, once the ribbon is customized; empty for the default ribbon. - #[serde(skip_serializing_if = "Vec::is_empty")] - pub tabs: Vec, - /// The Quick Access Toolbar's commands; `None` for the default ones ([`DEFAULT_QAT`]). - #[serde(skip_serializing_if = "Option::is_none")] - pub qat: Option>, -} - -fn is_false(b: &bool) -> bool { - !*b -} - -/// A name the user typed: trimmed, without control characters, at most [`MAX_NAME`] characters. -fn clean_name(s: &str) -> Option { - let s: String = s.trim().chars().filter(|c| !c.is_control()).take(MAX_NAME).collect(); - let s = s.trim().to_string(); - (!s.is_empty()).then_some(s) -} - -fn same(a: &str, b: &str) -> bool { - a.to_lowercase() == b.to_lowercase() -} - -fn built_in(name: &str) -> bool { - TABS.iter().skip(1).any(|t| *t == name) -} - -/// Names a custom tab can't have. -fn reserved(name: &str) -> bool { - TABS.iter().chain(CONTEXTUAL_TABS.iter()).any(|t| same(t, name)) -} - -fn default_tabs() -> Vec { - TABS.iter().skip(1).map(|t| TabEntry { name: (*t).to_string(), custom: false, hidden: false, groups: Vec::new() }).collect() -} - -/// `base`, or `base 2`, `base 3`… whichever `taken` doesn't have yet. -fn unique(base: &str, taken: impl Fn(&str) -> bool) -> String { - if !taken(base) { - return base.to_string(); - } - (2..1000).map(|n| format!("{base} {n}")).find(|n| !taken(n)).unwrap_or_else(|| base.to_string()) -} - -/// Move the item at `i` by `by` places (clamped to the list). Returns its new index. -fn shift(v: &mut [T], i: usize, by: i64) -> usize { - let last = v.len().saturating_sub(1) as i64; - let to = (i as i64).saturating_add(by).clamp(0, last) as usize; - if to < i { - if let Some(s) = v.get_mut(to..=i) { - s.rotate_right(1); - } - } else if let Some(s) = v.get_mut(i..=to) { - s.rotate_left(1); - } - to -} - -impl RibbonLayout { - /// Read a saved layout, keeping only well-formed entries (never fails). - pub fn from_value(v: &Value) -> RibbonLayout { - let mut out = RibbonLayout::default(); - for t in v.get("tabs").and_then(Value::as_array).into_iter().flatten().take(MAX_TABS) { - let Some(name) = t.get("name").and_then(Value::as_str).and_then(clean_name) else { continue }; - let custom = t.get("custom").and_then(Value::as_bool).unwrap_or(false); - let hidden = t.get("hidden").and_then(Value::as_bool).unwrap_or(false); - let mut groups: Vec = Vec::new(); - for g in t.get("groups").and_then(Value::as_array).into_iter().flatten().take(MAX_GROUPS) { - let Some(gname) = g.get("name").and_then(Value::as_str).and_then(clean_name) else { continue }; - if groups.iter().any(|x| same(&x.name, &gname)) { - continue; - } - let mut commands: Vec = Vec::new(); - for id in g.get("commands").and_then(Value::as_array).into_iter().flatten().take(MAX_COMMANDS).filter_map(Value::as_str) { - if !id.is_empty() && id.len() <= MAX_ID && !commands.iter().any(|c| c == id) { - commands.push(id.to_string()); - } - } - groups.push(CustomGroup { name: gname, commands }); - } - out.tabs.push(TabEntry { name, custom, hidden, groups }); - } - if let Some(q) = v.get("qat").and_then(Value::as_array) { - let mut ids: Vec = Vec::new(); - for id in q.iter().take(MAX_COMMANDS).filter_map(Value::as_str) { - if !id.is_empty() && id.len() <= MAX_ID && !ids.iter().any(|c| c == id) { - ids.push(id.to_string()); - } - } - out.qat = Some(ids); - } - out.normalize(); - out - } - - /// Keep the tab list consistent: built-in tabs once each (missing ones back in their default - /// place), custom tabs with unique, unreserved names; the default ribbon is stored as empty. - fn normalize(&mut self) { - if self.tabs.is_empty() { - return; - } - let mut tabs: Vec = Vec::new(); - for t in std::mem::take(&mut self.tabs) { - let ok = if t.custom { !reserved(&t.name) } else { built_in(&t.name) }; - if ok && !tabs.iter().any(|x| same(&x.name, &t.name)) { - tabs.push(t); - } - } - // Built-in tabs a saved layout lacks (a newer version's) go after the tab before them. - for (i, name) in TABS.iter().enumerate().skip(1) { - if tabs.iter().any(|t| !t.custom && t.name == *name) { - continue; - } - let at = TABS.get(1..i).and_then(|before| before.iter().rev().find_map(|b| tabs.iter().position(|t| !t.custom && t.name == *b))); - let entry = TabEntry { name: (*name).to_string(), custom: false, hidden: false, groups: Vec::new() }; - tabs.insert(at.map_or(0, |p| p + 1), entry); - } - self.tabs = if tabs == default_tabs() { Vec::new() } else { tabs }; - } - - /// The tabs in order (File excluded), customized or not. - pub fn tabs(&self) -> Cow<'_, [TabEntry]> { - if self.tabs.is_empty() { Cow::Owned(default_tabs()) } else { Cow::Borrowed(&self.tabs) } - } - - /// Whether the ribbon is the default one. - pub fn ribbon_is_default(&self) -> bool { - self.tabs.is_empty() - } - - /// The Quick Access Toolbar's commands. - pub fn qat(&self) -> Vec<&str> { - match &self.qat { - Some(q) => q.iter().map(String::as_str).collect(), - None => DEFAULT_QAT.to_vec(), - } - } - - /// Drop commands that don't exist (saved by another version, or junk). - pub fn retain_known(&mut self, reg: &Registry) { - for t in &mut self.tabs { - for g in &mut t.groups { - g.commands.retain(|id| reg.get(id).is_some()); - } - } - if let Some(q) = &mut self.qat { - q.retain(|id| reg.get(id).is_some()); - } - } - - /// Edit the tab list (customizing a default ribbon first), then tidy it up. - fn edit(&mut self, f: impl FnOnce(&mut Vec) -> Result) -> Result { - let mut tabs = self.tabs().into_owned(); - let r = f(&mut tabs)?; - self.tabs = tabs; - self.normalize(); - Ok(r) - } - - fn tab_index(tabs: &[TabEntry], name: &str) -> Result { - tabs.iter().position(|t| t.name == name).ok_or_else(|| format!("no tab `{name}`")) - } - - fn group_index(tab: &TabEntry, name: &str) -> Result { - tab.groups.iter().position(|g| g.name == name).ok_or_else(|| format!("tab `{}` has no custom group `{name}`", tab.name)) - } - - /// Show or hide a tab. - pub fn set_hidden(&mut self, tab: &str, hidden: bool) -> Result<(), String> { - self.edit(|tabs| { - let i = Self::tab_index(tabs, tab)?; - if let Some(t) = tabs.get_mut(i) { - t.hidden = hidden; - } - Ok(()) - }) - } - - /// Add a custom tab (with one empty group) after `after`, or at the end. Returns its name. - pub fn new_tab(&mut self, name: Option<&str>, after: Option<&str>) -> Result { - let name = match name { - Some(n) => clean_name(n).ok_or("a tab needs a name")?, - None => "New Tab".to_string(), - }; - self.edit(|tabs| { - if tabs.len() >= MAX_TABS { - return Err("too many tabs".into()); - } - let name = unique(&name, |n| reserved(n) || tabs.iter().any(|t| same(&t.name, n))); - let at = match after { - Some(a) => Self::tab_index(tabs, a)? + 1, - None => tabs.len(), - }; - let group = CustomGroup { name: "New Group".into(), commands: Vec::new() }; - tabs.insert(at, TabEntry { name: name.clone(), custom: true, hidden: false, groups: vec![group] }); - Ok(name) - }) - } - - /// Add a custom group at the end of a tab. Returns its name. - pub fn new_group(&mut self, tab: &str, name: Option<&str>) -> Result { - let name = match name { - Some(n) => clean_name(n).ok_or("a group needs a name")?, - None => "New Group".to_string(), - }; - self.edit(|tabs| { - let i = Self::tab_index(tabs, tab)?; - let t = tabs.get_mut(i).ok_or("no such tab")?; - if t.groups.len() >= MAX_GROUPS { - return Err("too many groups on this tab".into()); - } - let name = unique(&name, |n| t.groups.iter().any(|g| same(&g.name, n))); - t.groups.push(CustomGroup { name: name.clone(), commands: Vec::new() }); - Ok(name) - }) - } - - /// Add a command to a custom group (after `after`, or at the end). False when it's already there. - pub fn add(&mut self, reg: &Registry, tab: &str, group: &str, id: &str, after: Option<&str>) -> Result { - if reg.get(id).is_none() { - return Err(format!("unknown command `{id}`")); - } - self.edit(|tabs| { - let i = Self::tab_index(tabs, tab)?; - let t = tabs.get_mut(i).ok_or("no such tab")?; - let j = Self::group_index(t, group)?; - let g = t.groups.get_mut(j).ok_or("no such group")?; - if g.commands.iter().any(|c| c == id) { - return Ok(false); - } - if g.commands.len() >= MAX_COMMANDS { - return Err("too many commands in this group".into()); - } - let at = after.and_then(|a| g.commands.iter().position(|c| c == a)).map_or(g.commands.len(), |p| p + 1); - g.commands.insert(at, id.to_string()); - Ok(true) - }) - } - - /// Remove a command from a custom group, a custom group, or a custom tab. - pub fn remove(&mut self, tab: &str, group: Option<&str>, command: Option<&str>) -> Result<(), String> { - self.edit(|tabs| { - let i = Self::tab_index(tabs, tab)?; - let Some(group) = group else { - if !tabs.get(i).is_some_and(|t| t.custom) { - return Err(format!("`{tab}` is a built-in tab: hide it instead")); - } - tabs.remove(i); - return Ok(()); - }; - let t = tabs.get_mut(i).ok_or("no such tab")?; - let j = Self::group_index(t, group)?; - match command { - None => { - t.groups.remove(j); - } - Some(id) => { - let g = t.groups.get_mut(j).ok_or("no such group")?; - let k = g.commands.iter().position(|c| c == id).ok_or_else(|| format!("`{group}` doesn't have `{id}`"))?; - g.commands.remove(k); - } - } - Ok(()) - }) - } - - /// Rename a custom tab or a custom group. Returns the new name. - pub fn rename(&mut self, tab: &str, group: Option<&str>, to: &str) -> Result { - let to = clean_name(to).ok_or("the new name is empty")?; - self.edit(|tabs| { - let i = Self::tab_index(tabs, tab)?; - match group { - None => { - if !tabs.get(i).is_some_and(|t| t.custom) { - return Err(format!("`{tab}` is a built-in tab and keeps its name")); - } - if reserved(&to) || tabs.iter().enumerate().any(|(k, t)| k != i && same(&t.name, &to)) { - return Err(format!("there is already a tab named `{to}`")); - } - if let Some(t) = tabs.get_mut(i) { - t.name = to.clone(); - } - } - Some(group) => { - let t = tabs.get_mut(i).ok_or("no such tab")?; - let j = Self::group_index(t, group)?; - if t.groups.iter().enumerate().any(|(k, g)| k != j && same(&g.name, &to)) { - return Err(format!("`{tab}` already has a group named `{to}`")); - } - if let Some(g) = t.groups.get_mut(j) { - g.name = to.clone(); - } - } - } - Ok(to) - }) - } - - /// Move a tab, a custom group (within its tab) or a command (within its group) by `by` places. - pub fn move_by(&mut self, tab: &str, group: Option<&str>, command: Option<&str>, by: i64) -> Result<(), String> { - self.edit(|tabs| { - let i = Self::tab_index(tabs, tab)?; - let Some(group) = group else { - shift(tabs, i, by); - return Ok(()); - }; - let t = tabs.get_mut(i).ok_or("no such tab")?; - let j = Self::group_index(t, group)?; - match command { - None => { - shift(&mut t.groups, j, by); - } - Some(id) => { - let g = t.groups.get_mut(j).ok_or("no such group")?; - let k = g.commands.iter().position(|c| c == id).ok_or_else(|| format!("`{group}` doesn't have `{id}`"))?; - shift(&mut g.commands, k, by); - } - } - Ok(()) - }) - } - - /// Back to the default ribbon (the Quick Access Toolbar stays). - pub fn reset_ribbon(&mut self) { - self.tabs.clear(); - } - - /// Back to the default Quick Access Toolbar. - pub fn reset_qat(&mut self) { - self.qat = None; - } - - /// Put a command on the Quick Access Toolbar (after `after`, or at the end). False when it's - /// already there. - pub fn qat_add(&mut self, reg: &Registry, id: &str, after: Option<&str>) -> Result { - if reg.get(id).is_none() { - return Err(format!("unknown command `{id}`")); - } - let mut q: Vec = self.qat().into_iter().map(str::to_string).collect(); - if q.iter().any(|c| c == id) { - return Ok(false); - } - if q.len() >= MAX_COMMANDS { - return Err("the Quick Access Toolbar is full".into()); - } - let at = after.and_then(|a| q.iter().position(|c| c == a)).map_or(q.len(), |p| p + 1); - q.insert(at, id.to_string()); - self.qat = Some(q); - Ok(true) - } - - /// Take a command off the Quick Access Toolbar. False when it wasn't there. - pub fn qat_remove(&mut self, id: &str) -> bool { - let mut q: Vec = self.qat().into_iter().map(str::to_string).collect(); - let Some(i) = q.iter().position(|c| c == id) else { return false }; - q.remove(i); - self.qat = Some(q); - true - } - - /// Move a Quick Access Toolbar command by `by` places. - pub fn qat_move(&mut self, id: &str, by: i64) -> Result<(), String> { - let mut q: Vec = self.qat().into_iter().map(str::to_string).collect(); - let i = q.iter().position(|c| c == id).ok_or_else(|| format!("`{id}` isn't on the Quick Access Toolbar"))?; - shift(&mut q, i, by); - self.qat = Some(q); - Ok(()) - } -} - -impl<'de> Deserialize<'de> for RibbonLayout { - /// Lenient: any JSON reads (junk dropped), so a damaged layout never loses the other - /// preferences saved next to it. - fn deserialize>(d: D) -> Result { - Ok(RibbonLayout::from_value(&Value::deserialize(d)?)) - } -} - -#[cfg(test)] -mod tests { - use serde_json::json; - - use super::*; - - /// #379: custom tabs and groups, hidden and moved tabs, and the Quick Access Toolbar, changed - /// through the command (as agents do) without opening the dialog. - #[test] - fn layout_changes_through_the_command() { - let mut s = crate::Session::new(wordcraft_doc::Document::new()); - let mut run = |v: Value| s.run("tools.customizeRibbon", &v); - let r = run(json!({"newTab": {"name": "Mine", "after": "Home"}, "hide": "Draw"})).unwrap(); - assert_eq!(r["tab"], "Mine"); - run(json!({"add": {"tab": "Mine", "group": "New Group", "command": "format.bold"}})).unwrap(); - run(json!({"newGroup": {"tab": "Insert", "name": "Extras"}})).unwrap(); - run(json!({"add": {"tab": "Insert", "group": "Extras", "command": "edit.copy"}})).unwrap(); - run(json!({"rename": {"tab": "Mine", "to": "Writing"}})).unwrap(); - let r = run(json!({"move": {"tab": "Writing", "by": -5}, "qatAdd": "format.bold", "qatRemove": "file.save"})).unwrap(); - let tabs = r["tabs"].as_array().unwrap(); - assert_eq!(tabs[0]["name"], "Writing"); - assert_eq!(tabs[0]["groups"][0]["commands"], json!(["format.bold"])); - assert!(tabs.iter().any(|t| t["name"] == "Draw" && t["hidden"] == true)); - assert_eq!(r["qat"], json!(["edit.undo", "edit.redo", "format.bold"])); - // Built-in tabs keep their names and can only be hidden; reserved and unknown names fail. - assert!(run(json!({"rename": {"tab": "Home", "to": "Start"}})).is_err()); - assert!(run(json!({"remove": {"tab": "Home"}})).is_err()); - assert!(run(json!({"rename": {"tab": "Writing", "to": "insert"}})).is_err()); - assert!(run(json!({"add": {"tab": "Writing", "group": "New Group", "command": "no.such"}})).is_err()); - assert!(run(json!({"move": {"tab": "Nope", "by": 1}})).is_err()); - assert!(s.ui_requests.is_empty(), "programmatic calls never open dialogs"); - s.run("tools.customizeRibbon", &json!({"reset": true})).unwrap(); - assert_eq!(s.ribbon, RibbonLayout::default()); - s.run("tools.customizeRibbon", &json!({})).unwrap(); - assert_eq!(s.ui_requests.last(), Some(&json!({"open": "customizeRibbon"}))); - } - - #[test] - fn saved_layouts_round_trip_and_junk_is_ignored() { - let reg = crate::cmd::registry(); - let mut l = RibbonLayout::default(); - l.new_tab(None, None).unwrap(); - l.add(®, "New Tab", "New Group", "format.italic", None).unwrap(); - l.set_hidden("Mailings", true).unwrap(); - l.qat_add(®, "edit.repeat", None).unwrap(); - let back: RibbonLayout = serde_json::from_str(&serde_json::to_string(&l).unwrap()).unwrap(); - assert_eq!(back, l); - - let junk = json!({ - "tabs": [ - {"name": "Home", "groups": [{"name": "G", "commands": ["format.bold", "gone.command", 7, "format.bold"]}]}, - {"name": "Home"}, - {"name": "Bogus"}, - {"name": "Review", "custom": true}, - {"name": " "}, - {"name": "Mine", "custom": true, "hidden": "yes", "groups": "x"}, - 5 - ], - "qat": ["edit.undo", "gone.command", 3, "edit.undo"] - }); - let mut j: RibbonLayout = serde_json::from_value(junk).unwrap(); - j.retain_known(®); - let tabs = j.tabs(); - assert_eq!(tabs.len(), TABS.len(), "11 built-in tabs and Mine: {tabs:?}"); - assert_eq!(tabs[0].groups[0].commands, ["format.bold"]); - assert!(tabs.iter().any(|t| t.custom && t.name == "Mine" && !t.hidden)); - assert_eq!(j.qat(), ["edit.undo"]); - for v in [json!(null), json!(5), json!("x"), json!({"tabs": {"a": 1}, "qat": "file.save"})] { - assert_eq!(serde_json::from_value::(v).unwrap(), RibbonLayout::default()); - } - } -} +//! Customize Ribbon and the Quick Access Toolbar (`tools.customizeRibbon`): which ribbon tabs are +//! shown and in what order, custom tabs, custom groups (on built-in or custom tabs) holding any +//! commands, and the commands on the Quick Access Toolbar. +//! +//! Built-in groups aren't editable: a built-in tab only gains custom groups after its own. The +//! layout is saved with the front end's preferences; reading a saved layout never fails: junk, +//! duplicates and commands this version doesn't have are dropped. + +use std::borrow::Cow; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::Registry; + +/// The ribbon's tabs in their default order. `File` opens Backstage: it is always first and +/// isn't part of the customizable list. +pub const TABS: [&str; 13] = + ["File", "Home", "Insert", "Draw", "Design", "Layout", "References", "Mailings", "Review", "View", "Developer", "Zotero", "Help"]; +/// Tabs that come up with the selection; custom tabs can't take their names. +pub const CONTEXTUAL_TABS: [&str; 7] = + ["Table Design", "Table Layout", "Picture Format", "Shape Format", "Equation", "Chart Design", "SmartArt Design"]; +/// The Quick Access Toolbar's commands until the user changes them. +pub const DEFAULT_QAT: [&str; 3] = ["file.save", "edit.undo", "edit.redo"]; + +/// Limits on what a (possibly hand-edited) saved layout can hold. +const MAX_TABS: usize = 64; +const MAX_GROUPS: usize = 64; +const MAX_COMMANDS: usize = 200; +const MAX_NAME: usize = 64; +const MAX_ID: usize = 128; + +/// A ribbon tab in the customized order: a built-in tab (which can be hidden and gain custom +/// groups) or a custom one. +#[derive(Clone, Debug, PartialEq, Serialize)] +pub struct TabEntry { + pub name: String, + #[serde(skip_serializing_if = "is_false")] + pub custom: bool, + #[serde(skip_serializing_if = "is_false")] + pub hidden: bool, + /// Custom groups, after a built-in tab's own groups. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub groups: Vec, +} + +/// A custom group: a name and the commands it shows, in order. +#[derive(Clone, Debug, PartialEq, Serialize)] +pub struct CustomGroup { + pub name: String, + pub commands: Vec, +} + +/// The user's ribbon and Quick Access Toolbar. +#[derive(Clone, Debug, Default, PartialEq, Serialize)] +pub struct RibbonLayout { + /// Every tab but File, in order, once the ribbon is customized; empty for the default ribbon. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub tabs: Vec, + /// The Quick Access Toolbar's commands; `None` for the default ones ([`DEFAULT_QAT`]). + #[serde(skip_serializing_if = "Option::is_none")] + pub qat: Option>, +} + +fn is_false(b: &bool) -> bool { + !*b +} + +/// A name the user typed: trimmed, without control characters, at most [`MAX_NAME`] characters. +fn clean_name(s: &str) -> Option { + let s: String = s.trim().chars().filter(|c| !c.is_control()).take(MAX_NAME).collect(); + let s = s.trim().to_string(); + (!s.is_empty()).then_some(s) +} + +fn same(a: &str, b: &str) -> bool { + a.to_lowercase() == b.to_lowercase() +} + +fn built_in(name: &str) -> bool { + TABS.iter().skip(1).any(|t| *t == name) +} + +/// Names a custom tab can't have. +fn reserved(name: &str) -> bool { + TABS.iter().chain(CONTEXTUAL_TABS.iter()).any(|t| same(t, name)) +} + +fn default_tabs() -> Vec { + TABS.iter().skip(1).map(|t| TabEntry { name: (*t).to_string(), custom: false, hidden: false, groups: Vec::new() }).collect() +} + +/// `base`, or `base 2`, `base 3`… whichever `taken` doesn't have yet. +fn unique(base: &str, taken: impl Fn(&str) -> bool) -> String { + if !taken(base) { + return base.to_string(); + } + (2..1000).map(|n| format!("{base} {n}")).find(|n| !taken(n)).unwrap_or_else(|| base.to_string()) +} + +/// Move the item at `i` by `by` places (clamped to the list). Returns its new index. +fn shift(v: &mut [T], i: usize, by: i64) -> usize { + let last = v.len().saturating_sub(1) as i64; + let to = (i as i64).saturating_add(by).clamp(0, last) as usize; + if to < i { + if let Some(s) = v.get_mut(to..=i) { + s.rotate_right(1); + } + } else if let Some(s) = v.get_mut(i..=to) { + s.rotate_left(1); + } + to +} + +impl RibbonLayout { + /// Read a saved layout, keeping only well-formed entries (never fails). + pub fn from_value(v: &Value) -> RibbonLayout { + let mut out = RibbonLayout::default(); + for t in v.get("tabs").and_then(Value::as_array).into_iter().flatten().take(MAX_TABS) { + let Some(name) = t.get("name").and_then(Value::as_str).and_then(clean_name) else { continue }; + let custom = t.get("custom").and_then(Value::as_bool).unwrap_or(false); + let hidden = t.get("hidden").and_then(Value::as_bool).unwrap_or(false); + let mut groups: Vec = Vec::new(); + for g in t.get("groups").and_then(Value::as_array).into_iter().flatten().take(MAX_GROUPS) { + let Some(gname) = g.get("name").and_then(Value::as_str).and_then(clean_name) else { continue }; + if groups.iter().any(|x| same(&x.name, &gname)) { + continue; + } + let mut commands: Vec = Vec::new(); + for id in g.get("commands").and_then(Value::as_array).into_iter().flatten().take(MAX_COMMANDS).filter_map(Value::as_str) { + if !id.is_empty() && id.len() <= MAX_ID && !commands.iter().any(|c| c == id) { + commands.push(id.to_string()); + } + } + groups.push(CustomGroup { name: gname, commands }); + } + out.tabs.push(TabEntry { name, custom, hidden, groups }); + } + if let Some(q) = v.get("qat").and_then(Value::as_array) { + let mut ids: Vec = Vec::new(); + for id in q.iter().take(MAX_COMMANDS).filter_map(Value::as_str) { + if !id.is_empty() && id.len() <= MAX_ID && !ids.iter().any(|c| c == id) { + ids.push(id.to_string()); + } + } + out.qat = Some(ids); + } + out.normalize(); + out + } + + /// Keep the tab list consistent: built-in tabs once each (missing ones back in their default + /// place), custom tabs with unique, unreserved names; the default ribbon is stored as empty. + fn normalize(&mut self) { + if self.tabs.is_empty() { + return; + } + let mut tabs: Vec = Vec::new(); + for t in std::mem::take(&mut self.tabs) { + let ok = if t.custom { !reserved(&t.name) } else { built_in(&t.name) }; + if ok && !tabs.iter().any(|x| same(&x.name, &t.name)) { + tabs.push(t); + } + } + // Built-in tabs a saved layout lacks (a newer version's) go after the tab before them. + for (i, name) in TABS.iter().enumerate().skip(1) { + if tabs.iter().any(|t| !t.custom && t.name == *name) { + continue; + } + let at = TABS.get(1..i).and_then(|before| before.iter().rev().find_map(|b| tabs.iter().position(|t| !t.custom && t.name == *b))); + let entry = TabEntry { name: (*name).to_string(), custom: false, hidden: false, groups: Vec::new() }; + tabs.insert(at.map_or(0, |p| p + 1), entry); + } + self.tabs = if tabs == default_tabs() { Vec::new() } else { tabs }; + } + + /// The tabs in order (File excluded), customized or not. + pub fn tabs(&self) -> Cow<'_, [TabEntry]> { + if self.tabs.is_empty() { Cow::Owned(default_tabs()) } else { Cow::Borrowed(&self.tabs) } + } + + /// Whether the ribbon is the default one. + pub fn ribbon_is_default(&self) -> bool { + self.tabs.is_empty() + } + + /// The Quick Access Toolbar's commands. + pub fn qat(&self) -> Vec<&str> { + match &self.qat { + Some(q) => q.iter().map(String::as_str).collect(), + None => DEFAULT_QAT.to_vec(), + } + } + + /// Drop commands that don't exist (saved by another version, or junk). + pub fn retain_known(&mut self, reg: &Registry) { + for t in &mut self.tabs { + for g in &mut t.groups { + g.commands.retain(|id| reg.get(id).is_some()); + } + } + if let Some(q) = &mut self.qat { + q.retain(|id| reg.get(id).is_some()); + } + } + + /// Edit the tab list (customizing a default ribbon first), then tidy it up. + fn edit(&mut self, f: impl FnOnce(&mut Vec) -> Result) -> Result { + let mut tabs = self.tabs().into_owned(); + let r = f(&mut tabs)?; + self.tabs = tabs; + self.normalize(); + Ok(r) + } + + fn tab_index(tabs: &[TabEntry], name: &str) -> Result { + tabs.iter().position(|t| t.name == name).ok_or_else(|| format!("no tab `{name}`")) + } + + fn group_index(tab: &TabEntry, name: &str) -> Result { + tab.groups.iter().position(|g| g.name == name).ok_or_else(|| format!("tab `{}` has no custom group `{name}`", tab.name)) + } + + /// Show or hide a tab. + pub fn set_hidden(&mut self, tab: &str, hidden: bool) -> Result<(), String> { + self.edit(|tabs| { + let i = Self::tab_index(tabs, tab)?; + if let Some(t) = tabs.get_mut(i) { + t.hidden = hidden; + } + Ok(()) + }) + } + + /// Add a custom tab (with one empty group) after `after`, or at the end. Returns its name. + pub fn new_tab(&mut self, name: Option<&str>, after: Option<&str>) -> Result { + let name = match name { + Some(n) => clean_name(n).ok_or("a tab needs a name")?, + None => "New Tab".to_string(), + }; + self.edit(|tabs| { + if tabs.len() >= MAX_TABS { + return Err("too many tabs".into()); + } + let name = unique(&name, |n| reserved(n) || tabs.iter().any(|t| same(&t.name, n))); + let at = match after { + Some(a) => Self::tab_index(tabs, a)? + 1, + None => tabs.len(), + }; + let group = CustomGroup { name: "New Group".into(), commands: Vec::new() }; + tabs.insert(at, TabEntry { name: name.clone(), custom: true, hidden: false, groups: vec![group] }); + Ok(name) + }) + } + + /// Add a custom group at the end of a tab. Returns its name. + pub fn new_group(&mut self, tab: &str, name: Option<&str>) -> Result { + let name = match name { + Some(n) => clean_name(n).ok_or("a group needs a name")?, + None => "New Group".to_string(), + }; + self.edit(|tabs| { + let i = Self::tab_index(tabs, tab)?; + let t = tabs.get_mut(i).ok_or("no such tab")?; + if t.groups.len() >= MAX_GROUPS { + return Err("too many groups on this tab".into()); + } + let name = unique(&name, |n| t.groups.iter().any(|g| same(&g.name, n))); + t.groups.push(CustomGroup { name: name.clone(), commands: Vec::new() }); + Ok(name) + }) + } + + /// Add a command to a custom group (after `after`, or at the end). False when it's already there. + pub fn add(&mut self, reg: &Registry, tab: &str, group: &str, id: &str, after: Option<&str>) -> Result { + if reg.get(id).is_none() { + return Err(format!("unknown command `{id}`")); + } + self.edit(|tabs| { + let i = Self::tab_index(tabs, tab)?; + let t = tabs.get_mut(i).ok_or("no such tab")?; + let j = Self::group_index(t, group)?; + let g = t.groups.get_mut(j).ok_or("no such group")?; + if g.commands.iter().any(|c| c == id) { + return Ok(false); + } + if g.commands.len() >= MAX_COMMANDS { + return Err("too many commands in this group".into()); + } + let at = after.and_then(|a| g.commands.iter().position(|c| c == a)).map_or(g.commands.len(), |p| p + 1); + g.commands.insert(at, id.to_string()); + Ok(true) + }) + } + + /// Remove a command from a custom group, a custom group, or a custom tab. + pub fn remove(&mut self, tab: &str, group: Option<&str>, command: Option<&str>) -> Result<(), String> { + self.edit(|tabs| { + let i = Self::tab_index(tabs, tab)?; + let Some(group) = group else { + if !tabs.get(i).is_some_and(|t| t.custom) { + return Err(format!("`{tab}` is a built-in tab: hide it instead")); + } + tabs.remove(i); + return Ok(()); + }; + let t = tabs.get_mut(i).ok_or("no such tab")?; + let j = Self::group_index(t, group)?; + match command { + None => { + t.groups.remove(j); + } + Some(id) => { + let g = t.groups.get_mut(j).ok_or("no such group")?; + let k = g.commands.iter().position(|c| c == id).ok_or_else(|| format!("`{group}` doesn't have `{id}`"))?; + g.commands.remove(k); + } + } + Ok(()) + }) + } + + /// Rename a custom tab or a custom group. Returns the new name. + pub fn rename(&mut self, tab: &str, group: Option<&str>, to: &str) -> Result { + let to = clean_name(to).ok_or("the new name is empty")?; + self.edit(|tabs| { + let i = Self::tab_index(tabs, tab)?; + match group { + None => { + if !tabs.get(i).is_some_and(|t| t.custom) { + return Err(format!("`{tab}` is a built-in tab and keeps its name")); + } + if reserved(&to) || tabs.iter().enumerate().any(|(k, t)| k != i && same(&t.name, &to)) { + return Err(format!("there is already a tab named `{to}`")); + } + if let Some(t) = tabs.get_mut(i) { + t.name = to.clone(); + } + } + Some(group) => { + let t = tabs.get_mut(i).ok_or("no such tab")?; + let j = Self::group_index(t, group)?; + if t.groups.iter().enumerate().any(|(k, g)| k != j && same(&g.name, &to)) { + return Err(format!("`{tab}` already has a group named `{to}`")); + } + if let Some(g) = t.groups.get_mut(j) { + g.name = to.clone(); + } + } + } + Ok(to) + }) + } + + /// Move a tab, a custom group (within its tab) or a command (within its group) by `by` places. + pub fn move_by(&mut self, tab: &str, group: Option<&str>, command: Option<&str>, by: i64) -> Result<(), String> { + self.edit(|tabs| { + let i = Self::tab_index(tabs, tab)?; + let Some(group) = group else { + shift(tabs, i, by); + return Ok(()); + }; + let t = tabs.get_mut(i).ok_or("no such tab")?; + let j = Self::group_index(t, group)?; + match command { + None => { + shift(&mut t.groups, j, by); + } + Some(id) => { + let g = t.groups.get_mut(j).ok_or("no such group")?; + let k = g.commands.iter().position(|c| c == id).ok_or_else(|| format!("`{group}` doesn't have `{id}`"))?; + shift(&mut g.commands, k, by); + } + } + Ok(()) + }) + } + + /// Back to the default ribbon (the Quick Access Toolbar stays). + pub fn reset_ribbon(&mut self) { + self.tabs.clear(); + } + + /// Back to the default Quick Access Toolbar. + pub fn reset_qat(&mut self) { + self.qat = None; + } + + /// Put a command on the Quick Access Toolbar (after `after`, or at the end). False when it's + /// already there. + pub fn qat_add(&mut self, reg: &Registry, id: &str, after: Option<&str>) -> Result { + if reg.get(id).is_none() { + return Err(format!("unknown command `{id}`")); + } + let mut q: Vec = self.qat().into_iter().map(str::to_string).collect(); + if q.iter().any(|c| c == id) { + return Ok(false); + } + if q.len() >= MAX_COMMANDS { + return Err("the Quick Access Toolbar is full".into()); + } + let at = after.and_then(|a| q.iter().position(|c| c == a)).map_or(q.len(), |p| p + 1); + q.insert(at, id.to_string()); + self.qat = Some(q); + Ok(true) + } + + /// Take a command off the Quick Access Toolbar. False when it wasn't there. + pub fn qat_remove(&mut self, id: &str) -> bool { + let mut q: Vec = self.qat().into_iter().map(str::to_string).collect(); + let Some(i) = q.iter().position(|c| c == id) else { return false }; + q.remove(i); + self.qat = Some(q); + true + } + + /// Move a Quick Access Toolbar command by `by` places. + pub fn qat_move(&mut self, id: &str, by: i64) -> Result<(), String> { + let mut q: Vec = self.qat().into_iter().map(str::to_string).collect(); + let i = q.iter().position(|c| c == id).ok_or_else(|| format!("`{id}` isn't on the Quick Access Toolbar"))?; + shift(&mut q, i, by); + self.qat = Some(q); + Ok(()) + } +} + +impl<'de> Deserialize<'de> for RibbonLayout { + /// Lenient: any JSON reads (junk dropped), so a damaged layout never loses the other + /// preferences saved next to it. + fn deserialize>(d: D) -> Result { + Ok(RibbonLayout::from_value(&Value::deserialize(d)?)) + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + /// #379: custom tabs and groups, hidden and moved tabs, and the Quick Access Toolbar, changed + /// through the command (as agents do) without opening the dialog. + #[test] + fn layout_changes_through_the_command() { + let mut s = crate::Session::new(wordcraft_doc::Document::new()); + let mut run = |v: Value| s.run("tools.customizeRibbon", &v); + let r = run(json!({"newTab": {"name": "Mine", "after": "Home"}, "hide": "Draw"})).unwrap(); + assert_eq!(r["tab"], "Mine"); + run(json!({"add": {"tab": "Mine", "group": "New Group", "command": "format.bold"}})).unwrap(); + run(json!({"newGroup": {"tab": "Insert", "name": "Extras"}})).unwrap(); + run(json!({"add": {"tab": "Insert", "group": "Extras", "command": "edit.copy"}})).unwrap(); + run(json!({"rename": {"tab": "Mine", "to": "Writing"}})).unwrap(); + let r = run(json!({"move": {"tab": "Writing", "by": -5}, "qatAdd": "format.bold", "qatRemove": "file.save"})).unwrap(); + let tabs = r["tabs"].as_array().unwrap(); + assert_eq!(tabs[0]["name"], "Writing"); + assert_eq!(tabs[0]["groups"][0]["commands"], json!(["format.bold"])); + assert!(tabs.iter().any(|t| t["name"] == "Draw" && t["hidden"] == true)); + assert_eq!(r["qat"], json!(["edit.undo", "edit.redo", "format.bold"])); + // Built-in tabs keep their names and can only be hidden; reserved and unknown names fail. + assert!(run(json!({"rename": {"tab": "Home", "to": "Start"}})).is_err()); + assert!(run(json!({"remove": {"tab": "Home"}})).is_err()); + assert!(run(json!({"rename": {"tab": "Writing", "to": "insert"}})).is_err()); + assert!(run(json!({"add": {"tab": "Writing", "group": "New Group", "command": "no.such"}})).is_err()); + assert!(run(json!({"move": {"tab": "Nope", "by": 1}})).is_err()); + assert!(s.ui_requests.is_empty(), "programmatic calls never open dialogs"); + s.run("tools.customizeRibbon", &json!({"reset": true})).unwrap(); + assert_eq!(s.ribbon, RibbonLayout::default()); + s.run("tools.customizeRibbon", &json!({})).unwrap(); + assert_eq!(s.ui_requests.last(), Some(&json!({"open": "customizeRibbon"}))); + } + + #[test] + fn saved_layouts_round_trip_and_junk_is_ignored() { + let reg = crate::cmd::registry(); + let mut l = RibbonLayout::default(); + l.new_tab(None, None).unwrap(); + l.add(®, "New Tab", "New Group", "format.italic", None).unwrap(); + l.set_hidden("Mailings", true).unwrap(); + l.qat_add(®, "edit.repeat", None).unwrap(); + let back: RibbonLayout = serde_json::from_str(&serde_json::to_string(&l).unwrap()).unwrap(); + assert_eq!(back, l); + + let junk = json!({ + "tabs": [ + {"name": "Home", "groups": [{"name": "G", "commands": ["format.bold", "gone.command", 7, "format.bold"]}]}, + {"name": "Home"}, + {"name": "Bogus"}, + {"name": "Review", "custom": true}, + {"name": " "}, + {"name": "Mine", "custom": true, "hidden": "yes", "groups": "x"}, + 5 + ], + "qat": ["edit.undo", "gone.command", 3, "edit.undo"] + }); + let mut j: RibbonLayout = serde_json::from_value(junk).unwrap(); + j.retain_known(®); + let tabs = j.tabs(); + assert_eq!(tabs.len(), TABS.len(), "11 built-in tabs and Mine: {tabs:?}"); + assert_eq!(tabs[0].groups[0].commands, ["format.bold"]); + assert!(tabs.iter().any(|t| t.custom && t.name == "Mine" && !t.hidden)); + assert_eq!(j.qat(), ["edit.undo"]); + for v in [json!(null), json!(5), json!("x"), json!({"tabs": {"a": 1}, "qat": "file.save"})] { + assert_eq!(serde_json::from_value::(v).unwrap(), RibbonLayout::default()); + } + } +} diff --git a/crates/engine/src/sample.rs b/crates/engine/src/sample.rs index db2719428a..59f45b033c 100644 --- a/crates/engine/src/sample.rs +++ b/crates/engine/src/sample.rs @@ -1,224 +1,224 @@ -//! Built-in sample documents and templates (original text, generated in code). - -use std::sync::Arc; -use wordcraft_doc::para::InlineObject; -use wordcraft_doc::props::{Align, Border, BorderStyle, Borders, CharProps, NumRef, Rgb, TabAlign, TabLeader, TabStop, TextColor}; -use wordcraft_doc::{Block, Document, ListKind, Paragraph, PartKind, Table, para_block}; - -fn para(text: &str) -> Paragraph { - Paragraph::with_text(text, CharProps::default()) -} - -fn styled(text: &str, style: &str) -> Paragraph { - para(text).styled(style) -} - -/// Append text with formatting to a paragraph. -fn add(p: &mut Paragraph, text: &str, props: CharProps) { - let at = p.len(); - let _ = p.insert_text(at, text, &props); -} - -fn bold() -> CharProps { - CharProps { bold: Some(true), ..Default::default() } -} -fn italic() -> CharProps { - CharProps { italic: Some(true), ..Default::default() } -} - -/// The showcase document: a short studio handbook with headings, lists, a table, a quote, -/// a footer with page numbers. -pub fn sample_document() -> Document { - let mut d = Document::new(); - d.core.title = "The Open Studio Handbook".into(); - d.core.creator = "WordCraft".into(); - let mut b: Vec = Vec::new(); - b.push(Block::Para(styled("The Open Studio Handbook", "Title"))); - b.push(Block::Para(styled("How a small community of makers shares space, tools and time", "Subtitle"))); - let mut intro = para("Every Thursday evening the old print shop on Alder Street fills with people who make things. "); - add(&mut intro, "Painters", bold()); - add(&mut intro, " set up by the north windows, ", CharProps::default()); - add(&mut intro, "letterpress", italic()); - add( - &mut intro, - " regulars claim the back room, and somebody always brings bread. This handbook collects what we have learned about running a shared studio: the routines that keep it pleasant, the tools we pool, and the small agreements that let thirty strangers become a crew.", - CharProps::default(), - ); - b.push(Block::Para(intro)); - b.push(Block::Para(styled("Why share a studio?", "Heading1"))); - b.push(Block::Para(para("Rent is the obvious reason, but it is not the best one. A shared room puts your work next to work you would never have seen otherwise. A ceramicist explains glaze chemistry to a comic artist; a photographer borrows a weaver's loom for a still life. The cross-talk is the point."))); - let mut quote = styled("A studio is a place where unfinished things are welcome.", "Quote"); - quote.props.space_before = Some(12.0); - b.push(Block::Para(quote)); - b.push(Block::Para(styled("What we agree on", "Heading2"))); - let mut num = (*d.numbering).clone(); - let bullets = num.add_list(ListKind::Bullet); - let steps = num.add_list(ListKind::Numbered); - d.numbering = Arc::new(num); - for (lead, rest) in [ - ("Clean as you go. ", "Leave a table the way you would like to find it."), - ("Label what is yours. ", "Unlabelled supplies on the commons shelf are fair game."), - ("Ask before you borrow. ", "Even from the shelf, a quick note helps everyone keep track."), - ("Quiet hours ", "run from 9 to 11 on weekday mornings."), - ] { - let mut p = Paragraph::new().styled("ListParagraph"); - add(&mut p, lead, bold()); - add(&mut p, rest, CharProps::default()); - p.props.numbering = Some(NumRef { num: bullets, level: 0 }); - b.push(Block::Para(p)); - } - b.push(Block::Para(styled("Shared equipment", "Heading2"))); - b.push(Block::Para(para("The table below lists what the membership owns together and who keeps each item in working order."))); - let rows = [ - ["Equipment", "Location", "Steward", "Booking"], - ["Etching press", "Back room", "Mara O.", "Sign-up sheet"], - ["Kiln (cone 10)", "Yard shed", "Theo P.", "48 h notice"], - ["Large-format printer", "Office", "Ines R.", "Calendar"], - ["Light table", "North windows", "Anyone", "First come"], - ]; - let mut t = Table::new(rows.len(), 4, 468.0); - t.props.style = Some("GridTable4AccentBlue".into()); - for (r, row) in rows.iter().enumerate() { - for (c, txt) in row.iter().enumerate() { - if let Some(cell) = t.rows.get_mut(r).and_then(|x| x.cells.get_mut(c)) { - cell.blocks = vec![para_block(para(txt))]; - } - } - } - if let Some(r) = t.rows.first_mut() { - r.props.header = true; - } - b.push(Block::Table(t)); - b.push(Block::Para(styled("Opening the studio", "Heading2"))); - for s in [ - "Switch on the main breaker by the door.", - "Open both skylights unless it is raining.", - "Start the kettle. This step is not optional.", - "Check the booking sheet for the kiln and press.", - ] { - let mut p = para(s).styled("ListParagraph"); - p.props.numbering = Some(NumRef { num: steps, level: 0 }); - b.push(Block::Para(p)); - } - b.push(Block::Para(styled("Membership", "Heading1"))); - let mut m = para("Membership is "); - add(&mut m, "pay what you can", CharProps { bold: Some(true), color: Some(TextColor::Rgb(Rgb(0x15, 0x60, 0x82))), ..Default::default() }); - add( - &mut m, - ", reviewed every season. New members shadow a regular for their first two visits, and everyone takes one cleaning shift a month. If you have never made anything before, you are exactly who we hoped would show up.", - CharProps::default(), - ); - b.push(Block::Para(m)); - let mut h = para("Questions go to the front desk, or to the notice board by the sink. "); - add( - &mut h, - "Visit the community board", - CharProps { style: Some("Hyperlink".into()), link: Some("https://getartcraft.com/".into()), ..Default::default() }, - ); - add(&mut h, " for events.", CharProps::default()); - b.push(Block::Para(h)); - d.body = b.into_iter().map(std::sync::Arc::new).collect(); - // Footer: centred page number. - let mut f = Paragraph::new().styled("Footer"); - f.props.align = Some(Align::Center); - let _ = f.insert_object( - 0, - InlineObject::Field { instr: "PAGE".into(), result: "1".into(), locked: false, dirty: false, code: false, form: String::new() }, - &CharProps::default(), - ); - let fid = d.add_part(PartKind::Footer, vec![para_block(f)]); - d.last_section.footers.default = Some(fid); - let mut hd = styled("The Open Studio Handbook\t\tSpring edition", "Header"); - hd.props.borders = Some(Borders { - bottom: Some(Border { style: BorderStyle::Single, width: 0.5, color: Some(Rgb(0xBF, 0xBF, 0xBF)), space: 4.0 }), - ..Default::default() - }); - let hid = d.add_part(PartKind::Header, vec![para_block(hd)]); - d.last_section.headers.default = Some(hid); - d -} - -/// A block-style letter. -pub fn letter() -> Document { - let mut d = Document::new(); - let lines: Vec = vec![ - styled("Your Name", "Title"), - para("123 Your Street · City, ST 00000 · you@example.com"), - para(""), - para(&crate::cmd::insert::format_date("MMMM d, yyyy")), - para(""), - para("Recipient Name"), - para("Title, Company"), - para("Street Address"), - para(""), - para("Dear Recipient,"), - para( - "Start with a sentence that says why you are writing. Keep paragraphs short and specific, and close with what you would like to happen next.", - ), - para("Thank you for your time."), - para("Sincerely,"), - para(""), - para("Your Name"), - ]; - d.body = lines.into_iter().map(para_block).collect(); - d -} - -/// A one-page résumé. -pub fn resume() -> Document { - let mut d = Document::new(); - let mut b: Vec = - vec![Block::Para(styled("Alex Rivera", "Title")), Block::Para(para("Illustrator · alex@example.com · portfolio.example.com"))]; - for (head, items) in [ - ("Experience", vec![("Lead Illustrator, Harbor Books", "2021 – present"), ("Freelance Illustrator", "2016 – 2021")]), - ("Education", vec![("BFA Illustration, Coastal College of Art", "2016")]), - ("Skills", vec![("Ink, gouache, digital painting, book layout, lettering", "")]), - ] { - b.push(Block::Para(styled(head, "Heading1"))); - for (what, when) in items { - let mut p = para(what); - p.props.tabs = Some(vec![TabStop { pos: 468.0, align: TabAlign::Right, leader: TabLeader::None }]); - if !when.is_empty() { - add(&mut p, &format!("\t{when}"), italic()); - } - b.push(Block::Para(p)); - } - } - d.body = b.into_iter().map(std::sync::Arc::new).collect(); - d -} - -/// A report with a cover heading and sections. -pub fn report() -> Document { - let mut d = Document::new(); - let b: Vec = vec![ - Block::Para(styled("Report Title", "Title")), - Block::Para(styled("Subtitle or date", "Subtitle")), - Block::Para(styled("Summary", "Heading1")), - Block::Para(para("Summarise the findings in two or three sentences.")), - Block::Para(styled("Background", "Heading1")), - Block::Para(para("Describe the context and the question the report answers.")), - Block::Para(styled("Findings", "Heading1")), - Block::Para(styled("First finding", "Heading2")), - Block::Para(para("Explain the evidence.")), - Block::Para(styled("Recommendations", "Heading1")), - Block::Para(para("List what should happen next.")), - ]; - d.body = b.into_iter().map(std::sync::Arc::new).collect(); - d -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn samples_are_valid() { - assert_eq!(sample_document().word_count(), 361); - for d in [sample_document(), letter(), resume(), report()] { - assert!(d.word_count() > 5); - let l = wordcraft_layout::layout(&d, &mut wordcraft_layout::LayoutCache::new(), &Default::default()); - assert!(!l.pages.is_empty()); - } - } -} +//! Built-in sample documents and templates (original text, generated in code). + +use std::sync::Arc; +use wordcraft_doc::para::InlineObject; +use wordcraft_doc::props::{Align, Border, BorderStyle, Borders, CharProps, NumRef, Rgb, TabAlign, TabLeader, TabStop, TextColor}; +use wordcraft_doc::{Block, Document, ListKind, Paragraph, PartKind, Table, para_block}; + +fn para(text: &str) -> Paragraph { + Paragraph::with_text(text, CharProps::default()) +} + +fn styled(text: &str, style: &str) -> Paragraph { + para(text).styled(style) +} + +/// Append text with formatting to a paragraph. +fn add(p: &mut Paragraph, text: &str, props: CharProps) { + let at = p.len(); + let _ = p.insert_text(at, text, &props); +} + +fn bold() -> CharProps { + CharProps { bold: Some(true), ..Default::default() } +} +fn italic() -> CharProps { + CharProps { italic: Some(true), ..Default::default() } +} + +/// The showcase document: a short studio handbook with headings, lists, a table, a quote, +/// a footer with page numbers. +pub fn sample_document() -> Document { + let mut d = Document::new(); + d.core.title = "The Open Studio Handbook".into(); + d.core.creator = "WordCraft".into(); + let mut b: Vec = Vec::new(); + b.push(Block::Para(styled("The Open Studio Handbook", "Title"))); + b.push(Block::Para(styled("How a small community of makers shares space, tools and time", "Subtitle"))); + let mut intro = para("Every Thursday evening the old print shop on Alder Street fills with people who make things. "); + add(&mut intro, "Painters", bold()); + add(&mut intro, " set up by the north windows, ", CharProps::default()); + add(&mut intro, "letterpress", italic()); + add( + &mut intro, + " regulars claim the back room, and somebody always brings bread. This handbook collects what we have learned about running a shared studio: the routines that keep it pleasant, the tools we pool, and the small agreements that let thirty strangers become a crew.", + CharProps::default(), + ); + b.push(Block::Para(intro)); + b.push(Block::Para(styled("Why share a studio?", "Heading1"))); + b.push(Block::Para(para("Rent is the obvious reason, but it is not the best one. A shared room puts your work next to work you would never have seen otherwise. A ceramicist explains glaze chemistry to a comic artist; a photographer borrows a weaver's loom for a still life. The cross-talk is the point."))); + let mut quote = styled("A studio is a place where unfinished things are welcome.", "Quote"); + quote.props.space_before = Some(12.0); + b.push(Block::Para(quote)); + b.push(Block::Para(styled("What we agree on", "Heading2"))); + let mut num = (*d.numbering).clone(); + let bullets = num.add_list(ListKind::Bullet); + let steps = num.add_list(ListKind::Numbered); + d.numbering = Arc::new(num); + for (lead, rest) in [ + ("Clean as you go. ", "Leave a table the way you would like to find it."), + ("Label what is yours. ", "Unlabelled supplies on the commons shelf are fair game."), + ("Ask before you borrow. ", "Even from the shelf, a quick note helps everyone keep track."), + ("Quiet hours ", "run from 9 to 11 on weekday mornings."), + ] { + let mut p = Paragraph::new().styled("ListParagraph"); + add(&mut p, lead, bold()); + add(&mut p, rest, CharProps::default()); + p.props.numbering = Some(NumRef { num: bullets, level: 0 }); + b.push(Block::Para(p)); + } + b.push(Block::Para(styled("Shared equipment", "Heading2"))); + b.push(Block::Para(para("The table below lists what the membership owns together and who keeps each item in working order."))); + let rows = [ + ["Equipment", "Location", "Steward", "Booking"], + ["Etching press", "Back room", "Mara O.", "Sign-up sheet"], + ["Kiln (cone 10)", "Yard shed", "Theo P.", "48 h notice"], + ["Large-format printer", "Office", "Ines R.", "Calendar"], + ["Light table", "North windows", "Anyone", "First come"], + ]; + let mut t = Table::new(rows.len(), 4, 468.0); + t.props.style = Some("GridTable4AccentBlue".into()); + for (r, row) in rows.iter().enumerate() { + for (c, txt) in row.iter().enumerate() { + if let Some(cell) = t.rows.get_mut(r).and_then(|x| x.cells.get_mut(c)) { + cell.blocks = vec![para_block(para(txt))]; + } + } + } + if let Some(r) = t.rows.first_mut() { + r.props.header = true; + } + b.push(Block::Table(t)); + b.push(Block::Para(styled("Opening the studio", "Heading2"))); + for s in [ + "Switch on the main breaker by the door.", + "Open both skylights unless it is raining.", + "Start the kettle. This step is not optional.", + "Check the booking sheet for the kiln and press.", + ] { + let mut p = para(s).styled("ListParagraph"); + p.props.numbering = Some(NumRef { num: steps, level: 0 }); + b.push(Block::Para(p)); + } + b.push(Block::Para(styled("Membership", "Heading1"))); + let mut m = para("Membership is "); + add(&mut m, "pay what you can", CharProps { bold: Some(true), color: Some(TextColor::Rgb(Rgb(0x15, 0x60, 0x82))), ..Default::default() }); + add( + &mut m, + ", reviewed every season. New members shadow a regular for their first two visits, and everyone takes one cleaning shift a month. If you have never made anything before, you are exactly who we hoped would show up.", + CharProps::default(), + ); + b.push(Block::Para(m)); + let mut h = para("Questions go to the front desk, or to the notice board by the sink. "); + add( + &mut h, + "Visit the community board", + CharProps { style: Some("Hyperlink".into()), link: Some("https://getartcraft.com/".into()), ..Default::default() }, + ); + add(&mut h, " for events.", CharProps::default()); + b.push(Block::Para(h)); + d.body = b.into_iter().map(std::sync::Arc::new).collect(); + // Footer: centred page number. + let mut f = Paragraph::new().styled("Footer"); + f.props.align = Some(Align::Center); + let _ = f.insert_object( + 0, + InlineObject::Field { instr: "PAGE".into(), result: "1".into(), locked: false, dirty: false, code: false, form: String::new() }, + &CharProps::default(), + ); + let fid = d.add_part(PartKind::Footer, vec![para_block(f)]); + d.last_section.footers.default = Some(fid); + let mut hd = styled("The Open Studio Handbook\t\tSpring edition", "Header"); + hd.props.borders = Some(Borders { + bottom: Some(Border { style: BorderStyle::Single, width: 0.5, color: Some(Rgb(0xBF, 0xBF, 0xBF)), space: 4.0 }), + ..Default::default() + }); + let hid = d.add_part(PartKind::Header, vec![para_block(hd)]); + d.last_section.headers.default = Some(hid); + d +} + +/// A block-style letter. +pub fn letter() -> Document { + let mut d = Document::new(); + let lines: Vec = vec![ + styled("Your Name", "Title"), + para("123 Your Street · City, ST 00000 · you@example.com"), + para(""), + para(&crate::cmd::insert::format_date("MMMM d, yyyy")), + para(""), + para("Recipient Name"), + para("Title, Company"), + para("Street Address"), + para(""), + para("Dear Recipient,"), + para( + "Start with a sentence that says why you are writing. Keep paragraphs short and specific, and close with what you would like to happen next.", + ), + para("Thank you for your time."), + para("Sincerely,"), + para(""), + para("Your Name"), + ]; + d.body = lines.into_iter().map(para_block).collect(); + d +} + +/// A one-page résumé. +pub fn resume() -> Document { + let mut d = Document::new(); + let mut b: Vec = + vec![Block::Para(styled("Alex Rivera", "Title")), Block::Para(para("Illustrator · alex@example.com · portfolio.example.com"))]; + for (head, items) in [ + ("Experience", vec![("Lead Illustrator, Harbor Books", "2021 – present"), ("Freelance Illustrator", "2016 – 2021")]), + ("Education", vec![("BFA Illustration, Coastal College of Art", "2016")]), + ("Skills", vec![("Ink, gouache, digital painting, book layout, lettering", "")]), + ] { + b.push(Block::Para(styled(head, "Heading1"))); + for (what, when) in items { + let mut p = para(what); + p.props.tabs = Some(vec![TabStop { pos: 468.0, align: TabAlign::Right, leader: TabLeader::None }]); + if !when.is_empty() { + add(&mut p, &format!("\t{when}"), italic()); + } + b.push(Block::Para(p)); + } + } + d.body = b.into_iter().map(std::sync::Arc::new).collect(); + d +} + +/// A report with a cover heading and sections. +pub fn report() -> Document { + let mut d = Document::new(); + let b: Vec = vec![ + Block::Para(styled("Report Title", "Title")), + Block::Para(styled("Subtitle or date", "Subtitle")), + Block::Para(styled("Summary", "Heading1")), + Block::Para(para("Summarise the findings in two or three sentences.")), + Block::Para(styled("Background", "Heading1")), + Block::Para(para("Describe the context and the question the report answers.")), + Block::Para(styled("Findings", "Heading1")), + Block::Para(styled("First finding", "Heading2")), + Block::Para(para("Explain the evidence.")), + Block::Para(styled("Recommendations", "Heading1")), + Block::Para(para("List what should happen next.")), + ]; + d.body = b.into_iter().map(std::sync::Arc::new).collect(); + d +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn samples_are_valid() { + assert_eq!(sample_document().word_count(), 361); + for d in [sample_document(), letter(), resume(), report()] { + assert!(d.word_count() > 5); + let l = wordcraft_layout::layout(&d, &mut wordcraft_layout::LayoutCache::new(), &Default::default()); + assert!(!l.pages.is_empty()); + } + } +} diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index 9a15ff6447..f8b8de6057 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -1,259 +1,259 @@ -//! The editing session: document, selection, undo/redo, layout, view state. - -use std::sync::Arc; - -use serde_json::Value; -use wordcraft_doc::edit::Fragment; -use wordcraft_doc::props::CharProps; -use wordcraft_doc::{Document, PartKind, Path, Pos, StoryRef}; -use wordcraft_layout::{DocLayout, LayoutCache, LayoutOptions, ViewMode}; - -use crate::{CmdError, ImeEditOutcome, Registry}; - -mod documents; -mod views; -pub use documents::{DocInfo, DocState}; -use documents::{document_id_mark, next_document_id, untitled_name}; -pub use views::WindowView; -mod history; -mod ime; -mod layout; -mod lifecycle; -mod query; -mod run; -mod snapshot; - -use crate::doc_state; -use crate::ime as ime_state; -use crate::locale; -use crate::prefs; -use crate::snapshot as snapshot_types; -use crate::view; -pub(crate) use doc_state::Widened; -pub use doc_state::{CellRange, ColumnBlock, FindState, MathEdit, Selection, UNDO_LIST_MAX, next_math_session}; -use doc_state::{TypedWord, Undo, doc_bib_style}; -use ime_state::ImeCaretState; -pub use locale::set_system_locale; -pub(crate) use locale::system_locale; -pub use prefs::Prefs; -pub use query::blank_document; -pub use snapshot_types::EditSnapshot; -use snapshot_types::{LayoutSlots, RunSnapshot}; -pub use view::{StylesPaneDock, ViewState}; - -/// An editing session: the app-wide state (clipboard, preferences, macros, the command registry…) -/// and the open documents. The active document's state is in the session's own fields, which -/// every command works on; the other open documents wait in [`DocState`] values and are swapped -/// in when they become active (see [`Session::documents`]). -pub struct Session { - pub doc: Document, - pub sel: Selection, - pub view: ViewState, - /// Formatting picked with a collapsed caret, applied to the next typed text. - pub pending: Option, - /// Last highlighter choice for the shortcut; palette state, outside document undo. - pub(crate) last_highlight: wordcraft_doc::props::Highlight, - pub path: Option, - /// Name given to a document not saved yet (File › Info › Rename): what Save suggests. A file - /// name, not document metadata, so it is not part of the document or its undo history. - pub file_name: Option, - pub dirty: bool, - /// Where the custom dictionary is kept on disk, if the host wired one up. Only the desktop - /// app does: headless sessions (MCP, `wordcraft-cli run`), tests and the web build leave - /// `None`, so an agent's words stay in memory and never change the person's dictionary. - /// Add to Dictionary and File › Options › Proofing save through it (#40). - pub user_dict_path: Option, - pub clipboard: Option, - /// A tracked cut the next paste can turn into a move (see `cmd::moves`). - pub pending_move: Option, - /// Plain text mirror of the clipboard (for the system clipboard). - pub clipboard_text: String, - /// HTML for the system clipboard beside [`Self::clipboard_text`]: the copy's formatting for - /// programs that read HTML, equations as MathML (#816); empty when nothing was copied. - pub clipboard_html: String, - /// RTF for the system clipboard beside [`Self::clipboard_html`], for programs that read RTF - /// rather than HTML; empty when nothing was copied. - pub clipboard_rtf: String, - /// Items collected by Copy and Cut this session, for the Clipboard pane. - pub clip_history: crate::cmd::edit::ClipHistory, - pub find: FindState, - /// Page x the caret tries to keep on Up/Down. - pub goal_x: Option, - /// Page the caret was last on (headers/footers repeat on many pages). - pub page_hint: usize, - pub registry: Arc, - /// Author name for comments and tracked changes. - pub author: String, - /// Initials for comments (File › Options › General); empty = the name's initials. - pub initials: String, - /// Format painter: copied formatting waiting to be applied (and whether it stays on). - pub painter: Option<(CharProps, wordcraft_doc::props::ParaProps, bool)>, - /// Last message for the status bar / agents. - pub status: String, - /// Headers/footers `insert.createHeader`/`insert.createFooter` made for a page that had none - /// (#960): `insert.closeHeader` takes those still empty away again, as Word does, and saving - /// leaves them out. Kept after that, so one an undo brings back goes again on the next close. - pub new_header_parts: Vec, - /// The body selection a command left for a header or footer: `insert.closeHeader` puts it back. - pub hf_return: Option, - /// The style-definition change Next / Previous Change selected, for the Accept or Reject that - /// follows (`cmd::review`). It names its document and selection, so it is shared by the open - /// documents and stops counting once either changes. - pub(crate) review_style: Option, - /// Undo and redo steps. Shared so `run` can snapshot both stacks cheaply (a pointer per - /// step) and put them back exactly when a command fails. - history: Vec>, - /// Undo steps dropped so far because of the undo limit. - undo_evicted: u64, - redo: Vec>, - /// Typing is coalesced into one undo step until something else happens. - typing_open: bool, - /// The next mutating command joins the previous undo step (later frames of a drag). - join_next: bool, - /// The next equation typing command joins a successful equation `deleteSurrounding`'s undo - /// step. Unlike [`Self::join_next`], any other command consumes and discards it, so an - /// unrelated Bold or document replacement can never be merged into the deletion. - equation_join_next: bool, - /// The next `text.insert` at this exact selection joins a successful body - /// `DeleteSurrounding`'s undo step. Any other command consumes and discards it, so an - /// intervening edit owns its own checkpoint instead of being merged into the deletion. - ime_text_join: Option, - /// Whether the undo group has a step of its own for the next joined command to merge - /// into. A command that changed nothing leaves the group without one, so the next - /// command in the group starts the step the group needs. - join_anchor: bool, - /// The newest automatic capitalisation, for Word's "Automatically add words to list" (#1857). - pub(crate) autocap: Option, - rev: u64, - /// Which document this is, unique in the process: changes when [`Session::set_document`] - /// replaces it (an open into an untouched blank document, recover), never on an edit or when - /// another document becomes active and this one comes back. - document_id: u64, - /// The number in an untitled document's name (`Document2`); 0 for one opened from a file. - untitled: u32, - /// `rev` when the document was made, for [`Session::is_untouched_blank`]. - made_rev: u64, - /// What Restrict Editing's outright refusals depend on, for greying out commands - /// ([`Session::why_disabled`]): computed once per document, revision and author instead of - /// for every button on every frame. - protect_facts: std::sync::Mutex>, - /// How many times the document's body was replaced under the same [`Self::document_id`] - /// (`document.setText`, version restore, compare, Undo/Redo). It is the body's IME - /// replacement epoch: a composition must not survive the swap, even when the new body has - /// the same text or the same bounded surrounding window. - ime_body_epoch: u64, - /// Engine-owned, checked, process-monotonic revision for IME receipts whose target cannot - /// provide surrounding text. `None` means the allocator is exhausted (fail closed). - ime_state_revision: Option, - /// The caret/equation-session token the revision was last allocated for. It covers selection - /// and caret changes without walking the document. - ime_state_seen: Option, - /// The current command's explicit IME effect classification. - ime_edit_outcome: ImeEditOutcome, - /// Simulation of a globally exhausted IME revision allocator for front-end producer tests. - /// Only [`Self::exhaust_ime_state_revision`] sets it; production builds do not compile it, - /// and it only makes receipt classification fail closed. - #[cfg(any(test, feature = "test-support"))] - ime_state_exhausted: bool, - /// A successful non-IME command changed document state since the last IME observation. It is - /// drained before publishing a receipt and at observation time; it is never cleared by a later - /// command. It covers mixed formatting/IME frames when available stamps ignore the revision. - pending_ime_application_change: bool, - /// Whether the current command was entered through [`Session::run_ime`]. Nested commands - /// inherit the origin. - ime_origin: bool, - cache: LayoutCache, - layout: LayoutSlots, - /// The word being typed, left without a spelling squiggle ([`TypedWord`]). - typing: Option, - /// Picture edits: edited media key → original media key (Reset Picture). - pub originals: std::collections::HashMap, - /// View › Markdown Source: its text, while it is open. - pub md_source: Option, - /// A Markdown file's own text, so Save can write it unchanged. - pub md_file: crate::cmd::md_source::FileText, - /// Picture effects, styles and borders: drawn media key → what was drawn on which bitmap, - /// so the next one replaces them instead of drawing over them (#2052). - pub picture_looks: std::collections::HashMap, - /// Last mutating command (Repeat). - pub last_command: Option<(String, Value)>, - /// Macro being recorded: (name, steps). - pub recording: Option<(String, Vec<(String, Value)>)>, - pub macros: std::collections::BTreeMap>, - /// Saved versions: (label, date, document). - pub versions: Vec<(String, String, Document)>, - /// Quick Parts / AutoText entries. - pub building_blocks: std::collections::BTreeMap, - pub autosave: bool, - /// Citation style: APA, MLA, Chicago, IEEE. - pub bib_style: String, - /// Mail merge data source and preview. - pub merge: crate::cmd::mailings::MergeState, - /// Requests from commands to the UI (open a dialog, scroll…), drained by the front end. - pub ui_requests: Vec, - /// View › Window: the other WordCraft windows and Side by Side (#322). - pub windows: crate::cmd::view::Windows, - /// Read Aloud player (Review › Speech). - pub read_aloud: crate::speech::ReadAloud, - /// Preferences the front end saves between runs. - pub prefs: Prefs, - /// The system region (#838): the paper a new blank document starts on, the default - /// measurement unit and the decimal separator. The front end sets it from the platform (the - /// browser's locale on the web); headless sessions keep `en-US`. A region the user chose - /// ([`Prefs::region`]) wins: read it with [`Session::region`]. - pub system_region: wordcraft_geom::Region, - /// Custom keyboard shortcuts (`tools.customizeKeyboard`); the front end saves them too. - pub keymap: crate::KeyMap, - /// Custom ribbon and Quick Access Toolbar (`tools.customizeRibbon`); the front end saves it too. - pub ribbon: crate::RibbonLayout, - /// The language new blank documents (and opened plain text) are written in, a BCP 47 tag; - /// `None` is WordCraft's default, US English. The front end sets it from the interface - /// language with [`Session::set_editing_language`] (a Polish interface writes Polish). - pub editing_lang: Option, - /// Editing inside an equation: which one and the caret in it. - pub math: Option, - /// Equations are typed in LaTeX rather than the linear format. - pub math_latex: bool, - /// Text typed into equations is normal (non-math) text. - pub math_normal_text: bool, - /// The password Word documents are saved with (File › Info › Protect Document › Encrypt with - /// Password, `file.encrypt`); `None` saves them unencrypted. Kept from a password-protected - /// file that was opened, cleared when the document is replaced. - pub password: Option, - /// The recovery worker every open document's copy is kept through (desktop hosts only). - #[cfg(not(target_arch = "wasm32"))] - pub(crate) recovery: Option, - /// The active document's recovery copy. - #[cfg(not(target_arch = "wasm32"))] - pub(crate) recovery_slot: crate::recovery::Slot, - /// More pictures and shapes selected along with the one the selection holds (Shift+click), - /// for Group. Cleared by any edit or selection change other than adding to it. - pub also_selected: Vec, - /// The member of the selected Drawing Canvas picked on its own (#2637), which Shape Format, - /// Delete, Copy and the frame act on; see [`crate::cmd::canvas::selected_member`]. Cleared by - /// any selection change, caret movement or undo. - pub member: Option, - /// Column (block) selection, if one was made (see [`Session::column_segments`]). - pub column: Option, - /// Column selection mode (Ctrl+Shift+F8): caret movement extends the block. - pub column_mode: bool, - /// A selection widened to whole table cells or rows, and where it really runs (see - /// [`Session::sel_ends`]). - pub(crate) widened: Option, - /// Where the last edits happened, oldest first (at most [`EDIT_MARKS`]), for Go Back - /// (Shift+F5). Positions aren't mapped through later edits; they're clamped when used. - edit_marks: Vec, - /// How many Go Back presses since the last edit (which place comes next). - go_back_step: usize, - /// The open documents other than the active one, in no particular order. - others: Vec, - /// Every open document's id, the active one's too, in the order they were opened. - order: Vec, - /// Untitled documents made so far, for the next one's name (`Document3`). - untitled_count: u32, -} - -impl Session { - /// File name of the persisted custom dictionary inside the settings folder. - pub const USER_DICT_FILE: &str = "custom-dictionary.txt"; -} +//! The editing session: document, selection, undo/redo, layout, view state. + +use std::sync::Arc; + +use serde_json::Value; +use wordcraft_doc::edit::Fragment; +use wordcraft_doc::props::CharProps; +use wordcraft_doc::{Document, PartKind, Path, Pos, StoryRef}; +use wordcraft_layout::{DocLayout, LayoutCache, LayoutOptions, ViewMode}; + +use crate::{CmdError, ImeEditOutcome, Registry}; + +mod documents; +mod views; +pub use documents::{DocInfo, DocState}; +use documents::{document_id_mark, next_document_id, untitled_name}; +pub use views::WindowView; +mod history; +mod ime; +mod layout; +mod lifecycle; +mod query; +mod run; +mod snapshot; + +use crate::doc_state; +use crate::ime as ime_state; +use crate::locale; +use crate::prefs; +use crate::snapshot as snapshot_types; +use crate::view; +pub(crate) use doc_state::Widened; +pub use doc_state::{CellRange, ColumnBlock, FindState, MathEdit, Selection, UNDO_LIST_MAX, next_math_session}; +use doc_state::{TypedWord, Undo, doc_bib_style}; +use ime_state::ImeCaretState; +pub use locale::set_system_locale; +pub(crate) use locale::system_locale; +pub use prefs::Prefs; +pub use query::blank_document; +pub use snapshot_types::EditSnapshot; +use snapshot_types::{LayoutSlots, RunSnapshot}; +pub use view::{StylesPaneDock, ViewState}; + +/// An editing session: the app-wide state (clipboard, preferences, macros, the command registry…) +/// and the open documents. The active document's state is in the session's own fields, which +/// every command works on; the other open documents wait in [`DocState`] values and are swapped +/// in when they become active (see [`Session::documents`]). +pub struct Session { + pub doc: Document, + pub sel: Selection, + pub view: ViewState, + /// Formatting picked with a collapsed caret, applied to the next typed text. + pub pending: Option, + /// Last highlighter choice for the shortcut; palette state, outside document undo. + pub(crate) last_highlight: wordcraft_doc::props::Highlight, + pub path: Option, + /// Name given to a document not saved yet (File › Info › Rename): what Save suggests. A file + /// name, not document metadata, so it is not part of the document or its undo history. + pub file_name: Option, + pub dirty: bool, + /// Where the custom dictionary is kept on disk, if the host wired one up. Only the desktop + /// app does: headless sessions (MCP, `wordcraft-cli run`), tests and the web build leave + /// `None`, so an agent's words stay in memory and never change the person's dictionary. + /// Add to Dictionary and File › Options › Proofing save through it (#40). + pub user_dict_path: Option, + pub clipboard: Option, + /// A tracked cut the next paste can turn into a move (see `cmd::moves`). + pub pending_move: Option, + /// Plain text mirror of the clipboard (for the system clipboard). + pub clipboard_text: String, + /// HTML for the system clipboard beside [`Self::clipboard_text`]: the copy's formatting for + /// programs that read HTML, equations as MathML (#816); empty when nothing was copied. + pub clipboard_html: String, + /// RTF for the system clipboard beside [`Self::clipboard_html`], for programs that read RTF + /// rather than HTML; empty when nothing was copied. + pub clipboard_rtf: String, + /// Items collected by Copy and Cut this session, for the Clipboard pane. + pub clip_history: crate::cmd::edit::ClipHistory, + pub find: FindState, + /// Page x the caret tries to keep on Up/Down. + pub goal_x: Option, + /// Page the caret was last on (headers/footers repeat on many pages). + pub page_hint: usize, + pub registry: Arc, + /// Author name for comments and tracked changes. + pub author: String, + /// Initials for comments (File › Options › General); empty = the name's initials. + pub initials: String, + /// Format painter: copied formatting waiting to be applied (and whether it stays on). + pub painter: Option<(CharProps, wordcraft_doc::props::ParaProps, bool)>, + /// Last message for the status bar / agents. + pub status: String, + /// Headers/footers `insert.createHeader`/`insert.createFooter` made for a page that had none + /// (#960): `insert.closeHeader` takes those still empty away again, as Word does, and saving + /// leaves them out. Kept after that, so one an undo brings back goes again on the next close. + pub new_header_parts: Vec, + /// The body selection a command left for a header or footer: `insert.closeHeader` puts it back. + pub hf_return: Option, + /// The style-definition change Next / Previous Change selected, for the Accept or Reject that + /// follows (`cmd::review`). It names its document and selection, so it is shared by the open + /// documents and stops counting once either changes. + pub(crate) review_style: Option, + /// Undo and redo steps. Shared so `run` can snapshot both stacks cheaply (a pointer per + /// step) and put them back exactly when a command fails. + history: Vec>, + /// Undo steps dropped so far because of the undo limit. + undo_evicted: u64, + redo: Vec>, + /// Typing is coalesced into one undo step until something else happens. + typing_open: bool, + /// The next mutating command joins the previous undo step (later frames of a drag). + join_next: bool, + /// The next equation typing command joins a successful equation `deleteSurrounding`'s undo + /// step. Unlike [`Self::join_next`], any other command consumes and discards it, so an + /// unrelated Bold or document replacement can never be merged into the deletion. + equation_join_next: bool, + /// The next `text.insert` at this exact selection joins a successful body + /// `DeleteSurrounding`'s undo step. Any other command consumes and discards it, so an + /// intervening edit owns its own checkpoint instead of being merged into the deletion. + ime_text_join: Option, + /// Whether the undo group has a step of its own for the next joined command to merge + /// into. A command that changed nothing leaves the group without one, so the next + /// command in the group starts the step the group needs. + join_anchor: bool, + /// The newest automatic capitalisation, for Word's "Automatically add words to list" (#1857). + pub(crate) autocap: Option, + rev: u64, + /// Which document this is, unique in the process: changes when [`Session::set_document`] + /// replaces it (an open into an untouched blank document, recover), never on an edit or when + /// another document becomes active and this one comes back. + document_id: u64, + /// The number in an untitled document's name (`Document2`); 0 for one opened from a file. + untitled: u32, + /// `rev` when the document was made, for [`Session::is_untouched_blank`]. + made_rev: u64, + /// What Restrict Editing's outright refusals depend on, for greying out commands + /// ([`Session::why_disabled`]): computed once per document, revision and author instead of + /// for every button on every frame. + protect_facts: std::sync::Mutex>, + /// How many times the document's body was replaced under the same [`Self::document_id`] + /// (`document.setText`, version restore, compare, Undo/Redo). It is the body's IME + /// replacement epoch: a composition must not survive the swap, even when the new body has + /// the same text or the same bounded surrounding window. + ime_body_epoch: u64, + /// Engine-owned, checked, process-monotonic revision for IME receipts whose target cannot + /// provide surrounding text. `None` means the allocator is exhausted (fail closed). + ime_state_revision: Option, + /// The caret/equation-session token the revision was last allocated for. It covers selection + /// and caret changes without walking the document. + ime_state_seen: Option, + /// The current command's explicit IME effect classification. + ime_edit_outcome: ImeEditOutcome, + /// Simulation of a globally exhausted IME revision allocator for front-end producer tests. + /// Only [`Self::exhaust_ime_state_revision`] sets it; production builds do not compile it, + /// and it only makes receipt classification fail closed. + #[cfg(any(test, feature = "test-support"))] + ime_state_exhausted: bool, + /// A successful non-IME command changed document state since the last IME observation. It is + /// drained before publishing a receipt and at observation time; it is never cleared by a later + /// command. It covers mixed formatting/IME frames when available stamps ignore the revision. + pending_ime_application_change: bool, + /// Whether the current command was entered through [`Session::run_ime`]. Nested commands + /// inherit the origin. + ime_origin: bool, + cache: LayoutCache, + layout: LayoutSlots, + /// The word being typed, left without a spelling squiggle ([`TypedWord`]). + typing: Option, + /// Picture edits: edited media key → original media key (Reset Picture). + pub originals: std::collections::HashMap, + /// View › Markdown Source: its text, while it is open. + pub md_source: Option, + /// A Markdown file's own text, so Save can write it unchanged. + pub md_file: crate::cmd::md_source::FileText, + /// Picture effects, styles and borders: drawn media key → what was drawn on which bitmap, + /// so the next one replaces them instead of drawing over them (#2052). + pub picture_looks: std::collections::HashMap, + /// Last mutating command (Repeat). + pub last_command: Option<(String, Value)>, + /// Macro being recorded: (name, steps). + pub recording: Option<(String, Vec<(String, Value)>)>, + pub macros: std::collections::BTreeMap>, + /// Saved versions: (label, date, document). + pub versions: Vec<(String, String, Document)>, + /// Quick Parts / AutoText entries. + pub building_blocks: std::collections::BTreeMap, + pub autosave: bool, + /// Citation style: APA, MLA, Chicago, IEEE. + pub bib_style: String, + /// Mail merge data source and preview. + pub merge: crate::cmd::mailings::MergeState, + /// Requests from commands to the UI (open a dialog, scroll…), drained by the front end. + pub ui_requests: Vec, + /// View › Window: the other WordCraft windows and Side by Side (#322). + pub windows: crate::cmd::view::Windows, + /// Read Aloud player (Review › Speech). + pub read_aloud: crate::speech::ReadAloud, + /// Preferences the front end saves between runs. + pub prefs: Prefs, + /// The system region (#838): the paper a new blank document starts on, the default + /// measurement unit and the decimal separator. The front end sets it from the platform (the + /// browser's locale on the web); headless sessions keep `en-US`. A region the user chose + /// ([`Prefs::region`]) wins: read it with [`Session::region`]. + pub system_region: wordcraft_geom::Region, + /// Custom keyboard shortcuts (`tools.customizeKeyboard`); the front end saves them too. + pub keymap: crate::KeyMap, + /// Custom ribbon and Quick Access Toolbar (`tools.customizeRibbon`); the front end saves it too. + pub ribbon: crate::RibbonLayout, + /// The language new blank documents (and opened plain text) are written in, a BCP 47 tag; + /// `None` is WordCraft's default, US English. The front end sets it from the interface + /// language with [`Session::set_editing_language`] (a Polish interface writes Polish). + pub editing_lang: Option, + /// Editing inside an equation: which one and the caret in it. + pub math: Option, + /// Equations are typed in LaTeX rather than the linear format. + pub math_latex: bool, + /// Text typed into equations is normal (non-math) text. + pub math_normal_text: bool, + /// The password Word documents are saved with (File › Info › Protect Document › Encrypt with + /// Password, `file.encrypt`); `None` saves them unencrypted. Kept from a password-protected + /// file that was opened, cleared when the document is replaced. + pub password: Option, + /// The recovery worker every open document's copy is kept through (desktop hosts only). + #[cfg(not(target_arch = "wasm32"))] + pub(crate) recovery: Option, + /// The active document's recovery copy. + #[cfg(not(target_arch = "wasm32"))] + pub(crate) recovery_slot: crate::recovery::Slot, + /// More pictures and shapes selected along with the one the selection holds (Shift+click), + /// for Group. Cleared by any edit or selection change other than adding to it. + pub also_selected: Vec, + /// The member of the selected Drawing Canvas picked on its own (#2637), which Shape Format, + /// Delete, Copy and the frame act on; see [`crate::cmd::canvas::selected_member`]. Cleared by + /// any selection change, caret movement or undo. + pub member: Option, + /// Column (block) selection, if one was made (see [`Session::column_segments`]). + pub column: Option, + /// Column selection mode (Ctrl+Shift+F8): caret movement extends the block. + pub column_mode: bool, + /// A selection widened to whole table cells or rows, and where it really runs (see + /// [`Session::sel_ends`]). + pub(crate) widened: Option, + /// Where the last edits happened, oldest first (at most [`EDIT_MARKS`]), for Go Back + /// (Shift+F5). Positions aren't mapped through later edits; they're clamped when used. + edit_marks: Vec, + /// How many Go Back presses since the last edit (which place comes next). + go_back_step: usize, + /// The open documents other than the active one, in no particular order. + others: Vec, + /// Every open document's id, the active one's too, in the order they were opened. + order: Vec, + /// Untitled documents made so far, for the next one's name (`Document3`). + untitled_count: u32, +} + +impl Session { + /// File name of the persisted custom dictionary inside the settings folder. + pub const USER_DICT_FILE: &str = "custom-dictionary.txt"; +} diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index 74fbcae28a..425ec98697 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -1,689 +1,689 @@ -//! Several open documents in one session. The active document lives in the session's own fields, -//! where every command works on it; the others wait as [`DocState`] values and are swapped in, -//! field by field, when they become active. Swapping moves the fields: nothing is cloned, laid -//! out again or thrown away, so a host may switch documents as often as it likes. - -use std::path::{Path as FsPath, PathBuf}; -use std::sync::Arc; -use std::sync::atomic::{AtomicU64, Ordering}; - -use serde::Serialize; -use serde_json::json; -use wordcraft_doc::props::CharProps; -use wordcraft_doc::{Document, Pos, StoryRef}; -use wordcraft_layout::LayoutCache; - -use super::{ColumnBlock, FindState, LayoutSlots, MathEdit, Selection, Session, TypedWord, Undo, ViewState, Widened, doc_bib_style}; -use crate::CmdError; - -/// Document ids are unique in the process, so an id an agent or a window holds never names -/// another document, even across sessions. -static NEXT_DOCUMENT_ID: AtomicU64 = AtomicU64::new(1); - -pub(super) fn next_document_id() -> u64 { - NEXT_DOCUMENT_ID.fetch_add(1, Ordering::Relaxed) -} - -/// The id the next document will get: every document made after this call has an id at least -/// this big. -pub(super) fn document_id_mark() -> u64 { - NEXT_DOCUMENT_ID.load(Ordering::Relaxed) -} - -/// An untitled document's name: `Document1`, `Document2`… -pub(super) fn untitled_name(n: u32) -> String { - format!("Document{}", n.max(1)) -} - -/// An open document that isn't the active one: every per-document field of [`Session`], waiting -/// to be swapped back in. The session's app-wide state (clipboard, preferences, macros, Quick -/// Parts, the custom dictionary, Read Aloud…) is not here. -pub struct DocState { - pub(super) doc: Document, - pub(super) sel: Selection, - pub(super) view: ViewState, - pub(super) pending: Option, - pub(super) path: Option, - pub(super) file_name: Option, - pub(super) dirty: bool, - pub(super) pending_move: Option, - pub(super) find: FindState, - pub(super) goal_x: Option, - pub(super) page_hint: usize, - pub(super) new_header_parts: Vec, - pub(super) hf_return: Option, - pub(super) history: Vec>, - pub(super) undo_evicted: u64, - pub(super) redo: Vec>, - pub(super) typing_open: bool, - pub(super) join_next: bool, - pub(super) equation_join_next: bool, - pub(super) ime_text_join: Option, - pub(super) join_anchor: bool, - pub(super) autocap: Option, - pub(super) rev: u64, - pub(super) document_id: u64, - pub(super) untitled: u32, - pub(super) made_rev: u64, - pub(super) ime_body_epoch: u64, - pub(super) cache: LayoutCache, - pub(super) layout: LayoutSlots, - pub(super) typing: Option, - pub(super) originals: std::collections::HashMap, - pub(super) md_source: Option, - pub(super) md_file: crate::cmd::md_source::FileText, - pub(super) picture_looks: std::collections::HashMap, - pub(super) versions: Vec<(String, String, Document)>, - pub(super) autosave: bool, - pub(super) bib_style: String, - pub(super) merge: crate::cmd::mailings::MergeState, - pub(super) math: Option, - pub(super) password: Option, - #[cfg(not(target_arch = "wasm32"))] - pub(super) recovery_slot: crate::recovery::Slot, - pub(super) also_selected: Vec, - pub(super) column: Option, - pub(super) column_mode: bool, - pub(super) widened: Option, - pub(super) edit_marks: Vec, - pub(super) go_back_step: usize, -} - -impl DocState { - /// A fresh document state: caret at the start, no history, not dirty, a new id. - pub(super) fn new(doc: Document, untitled: u32, view: ViewState) -> Self { - let start = doc.start_of(StoryRef::Body); - let bib_style = doc_bib_style(&doc); - DocState { - doc, - sel: Selection::caret(start), - view, - pending: None, - path: None, - file_name: None, - dirty: false, - pending_move: None, - find: FindState::default(), - goal_x: None, - page_hint: 0, - new_header_parts: Vec::new(), - hf_return: None, - history: Vec::new(), - undo_evicted: 0, - redo: Vec::new(), - typing_open: false, - join_next: false, - equation_join_next: false, - ime_text_join: None, - join_anchor: false, - autocap: None, - rev: 1, - document_id: next_document_id(), - untitled, - made_rev: 1, - ime_body_epoch: 0, - cache: LayoutCache::new(), - layout: Default::default(), - typing: None, - originals: Default::default(), - md_source: None, - md_file: Default::default(), - picture_looks: Default::default(), - versions: Vec::new(), - autosave: true, - bib_style, - merge: Default::default(), - math: None, - password: None, - #[cfg(not(target_arch = "wasm32"))] - recovery_slot: Default::default(), - also_selected: Vec::new(), - column: None, - column_mode: false, - widened: None, - edit_marks: Vec::new(), - go_back_step: 0, - } - } - - /// The document's id ([`Session::document_id`] while it is active). - pub fn id(&self) -> u64 { - self.document_id - } - - fn info(&self) -> DocInfo { - DocInfo { - id: self.document_id, - title: title(self.path.as_deref(), self.file_name.as_deref(), &self.doc, self.untitled), - path: self.path.clone(), - dirty: self.dirty, - active: false, - } - } -} - -/// One open document, as [`Session::documents`] lists it. -#[derive(Clone, Debug, PartialEq, Eq, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct DocInfo { - /// The document's id: unique in the process, kept while the document is open. - pub id: u64, - /// The name the title bar shows: the file's name without its extension, else the name given - /// with Rename, else the document's Title property, else `Document1`, `Document2`… (English; - /// a front end names untitled documents in its own language). - pub title: String, - pub path: Option, - /// Changed since it was opened or last saved. - pub dirty: bool, - /// The active document, the one commands work on. - pub active: bool, -} - -fn title(path: Option<&FsPath>, file_name: Option<&str>, doc: &Document, untitled: u32) -> String { - match (path, file_name) { - (Some(p), _) => p.file_stem().map_or_else(|| p.to_string_lossy().to_string(), |s| s.to_string_lossy().to_string()), - (None, Some(name)) => name.to_string(), - (None, None) if !doc.core.title.is_empty() => doc.core.title.clone(), - (None, None) => untitled_name(untitled), - } -} - -impl Session { - /// Swap the active document's state with `d`'s: `d` then holds the document that was active. - fn swap_doc(&mut self, d: &mut DocState) { - crate::cmd::md_source::apply(self); - use std::mem::swap; - // Every field is listed, so a new one doesn't build until someone decides whether each - // open document has its own (then it goes in `DocState` and is swapped below) or the - // session shares it. - let Session { - // Per document. - doc: _, - sel: _, - view: _, - pending: _, - path: _, - file_name: _, - dirty: _, - pending_move: _, - find: _, - goal_x: _, - page_hint: _, - new_header_parts: _, - hf_return: _, - history: _, - undo_evicted: _, - redo: _, - typing_open: _, - join_next: _, - equation_join_next: _, - ime_text_join: _, - join_anchor: _, - autocap: _, - rev: _, - document_id: _, - untitled: _, - made_rev: _, - ime_body_epoch: _, - cache: _, - layout: _, - typing: _, - originals: _, - md_source: _, - md_file: _, - picture_looks: _, - versions: _, - autosave: _, - bib_style: _, - merge: _, - math: _, - password: _, - #[cfg(not(target_arch = "wasm32"))] - recovery_slot: _, - also_selected: _, - column: _, - column_mode: _, - widened: _, - edit_marks: _, - go_back_step: _, - // Shared by every open document. - // Names the document it belongs to, so it never applies to another one. - review_style: _, - user_dict_path: _, - clipboard: _, - clipboard_text: _, - clipboard_html: _, - clipboard_rtf: _, - clip_history: _, - registry: _, - author: _, - initials: _, - painter: _, - // What the drawn window knows of the others: its front end sets it as it draws. - windows: _, - status: _, - last_command: _, - recording: _, - macros: _, - building_blocks: _, - ui_requests: _, - read_aloud: _, - prefs: _, - system_region: _, - keymap: _, - ribbon: _, - editing_lang: _, - math_latex: _, - math_normal_text: _, - last_highlight: _, - // Keyed by document id and revision, so one cache serves every document. - protect_facts: _, - // The input method talks to the window, whichever document it shows; the IME - // context is keyed by `document_id`. - ime_state_revision: _, - ime_state_seen: _, - ime_edit_outcome: _, - #[cfg(any(test, feature = "test-support"))] - ime_state_exhausted: _, - pending_ime_application_change: _, - ime_origin: _, - // A picked Drawing Canvas member is cleared by a switch, not kept per document. - member: _, - others: _, - order: _, - untitled_count: _, - #[cfg(not(target_arch = "wasm32"))] - recovery: _, - } = self; - let DocState { - doc, - sel, - view, - pending, - path, - file_name, - dirty, - pending_move, - find, - goal_x, - page_hint, - new_header_parts, - hf_return, - history, - undo_evicted, - redo, - typing_open, - join_next, - equation_join_next, - ime_text_join, - join_anchor, - autocap, - rev, - document_id, - untitled, - made_rev, - ime_body_epoch, - cache, - layout, - typing, - originals, - md_source, - md_file, - picture_looks, - versions, - autosave, - bib_style, - merge, - math, - password, - #[cfg(not(target_arch = "wasm32"))] - recovery_slot, - also_selected, - column, - column_mode, - widened, - edit_marks, - go_back_step, - } = d; - swap(&mut self.doc, doc); - swap(&mut self.sel, sel); - swap(&mut self.view, view); - swap(&mut self.pending, pending); - swap(&mut self.path, path); - swap(&mut self.file_name, file_name); - swap(&mut self.dirty, dirty); - swap(&mut self.pending_move, pending_move); - swap(&mut self.find, find); - swap(&mut self.goal_x, goal_x); - swap(&mut self.page_hint, page_hint); - swap(&mut self.new_header_parts, new_header_parts); - swap(&mut self.hf_return, hf_return); - swap(&mut self.history, history); - swap(&mut self.undo_evicted, undo_evicted); - swap(&mut self.redo, redo); - swap(&mut self.typing_open, typing_open); - swap(&mut self.join_next, join_next); - swap(&mut self.equation_join_next, equation_join_next); - swap(&mut self.ime_text_join, ime_text_join); - swap(&mut self.join_anchor, join_anchor); - swap(&mut self.autocap, autocap); - swap(&mut self.rev, rev); - swap(&mut self.document_id, document_id); - swap(&mut self.untitled, untitled); - swap(&mut self.made_rev, made_rev); - swap(&mut self.ime_body_epoch, ime_body_epoch); - swap(&mut self.cache, cache); - swap(&mut self.layout, layout); - swap(&mut self.typing, typing); - swap(&mut self.originals, originals); - swap(&mut self.md_source, md_source); - swap(&mut self.md_file, md_file); - swap(&mut self.picture_looks, picture_looks); - swap(&mut self.versions, versions); - swap(&mut self.autosave, autosave); - swap(&mut self.bib_style, bib_style); - swap(&mut self.merge, merge); - swap(&mut self.math, math); - swap(&mut self.password, password); - #[cfg(not(target_arch = "wasm32"))] - swap(&mut self.recovery_slot, recovery_slot); - swap(&mut self.also_selected, also_selected); - swap(&mut self.column, column); - swap(&mut self.column_mode, column_mode); - swap(&mut self.widened, widened); - swap(&mut self.edit_marks, edit_marks); - swap(&mut self.go_back_step, go_back_step); - // A picked Drawing Canvas member (#2637) belongs to the document left behind. - self.member = None; - } - - /// Make document `id` active without anything else a switch by the user does. - pub(super) fn switch_to(&mut self, id: u64) -> Result<(), CmdError> { - if id == self.document_id { - return Ok(()); - } - crate::cmd::md_source::apply(self); - let at = self.others.iter().position(|d| d.document_id == id).ok_or_else(|| unknown(id))?; - let mut d = self.others.swap_remove(at); - self.swap_doc(&mut d); - self.view.follow_prefs(&self.prefs); - self.others.push(d); - Ok(()) - } - - /// Take parked document `id` out of the session; false when it isn't parked here. - fn remove_parked(&mut self, id: u64) -> bool { - let Some(at) = self.others.iter().position(|d| d.document_id == id) else { return false }; - let d = self.others.swap_remove(at); - self.order.retain(|d| *d != id); - self.forget_recovery_copy(&d); - true - } - - /// Whether document `id` is open in this session (active or not). - pub fn is_open(&self, id: u64) -> bool { - id == self.document_id || self.others.iter().any(|d| d.document_id == id) - } - - /// Whether the open document `id` has changes that aren't written to a file yet: the - /// active document's own flag, or a background document's. - pub fn document_dirty(&self, id: u64) -> bool { - if id == self.document_id { self.dirty } else { self.others.iter().any(|d| d.document_id == id && d.dirty) } - } - - /// Every open document in the order they were opened, the active one included. - pub fn documents(&self) -> Vec { - self.order - .iter() - .filter_map(|&id| { - if id == self.document_id { - Some(DocInfo { - id, - title: title(self.path.as_deref(), self.file_name.as_deref(), &self.doc, self.untitled), - path: self.path.clone(), - dirty: self.dirty, - active: true, - }) - } else { - self.others.iter().find(|d| d.document_id == id).map(DocState::info) - } - }) - .collect() - } - - /// Make document `id` the active one, the one commands work on (nothing happens when it - /// already is). A Read Aloud reading of another document stops. An id that isn't open is an - /// error. - pub fn activate(&mut self, id: u64) -> Result<(), CmdError> { - self.switch_to(id)?; - self.stop_reading_elsewhere(); - Ok(()) - } - - /// Make document `id` the active one, as [`Session::activate`] does, but a Read Aloud - /// reading of another document carries on: for a front end that shows each document in a - /// window of its own, and makes each window's document active to draw it. An id that isn't - /// open is an error. - pub fn switch_document(&mut self, id: u64) -> Result<(), CmdError> { - self.switch_to(id) - } - - /// Open `doc` as another document and make it active; the one that was active stays open. - /// The new document starts as a replaced one does ([`Session::set_document`]): caret at the - /// start, no undo history, not dirty, no path, an untitled name (`Document2`), and the view - /// the active document had. Returns its id. - pub fn add_document(&mut self, doc: Document) -> u64 { - self.untitled_count = self.untitled_count.saturating_add(1); - self.add(doc, self.untitled_count) - } - - /// [`Session::add_document`] for a document opened from a file: it doesn't use up an - /// untitled name. - pub(crate) fn add_opened_document(&mut self, doc: Document) -> u64 { - self.add(doc, 0) - } - - /// Adds `doc` to the session and makes it active, parking the previously active document - /// and applying any pending Markdown Source text first. - fn add(&mut self, mut doc: Document, untitled: u32) -> u64 { - crate::cmd::md_source::apply(self); - doc.ensure_nonempty(); - let mut view = self.view.clone(); - view.outline.collapsed.clear(); - view.follow_prefs(&self.prefs); - let mut d = DocState::new(doc, untitled, view); - let id = d.document_id; - self.order.push(id); - self.swap_doc(&mut d); - self.others.push(d); - self.stop_reading_elsewhere(); - id - } - - /// A closed document's recovery copy goes with it, as its unsaved changes do. - fn forget_recovery_copy(&mut self, d: &DocState) { - #[cfg(not(target_arch = "wasm32"))] - if let (Some(r), Some(id)) = (&mut self.recovery, &d.recovery_slot.id) { - r.close(id.clone()); - } - #[cfg(target_arch = "wasm32")] - let _ = d; - } - - /// What recovery reads from every open document, the active one first, with each - /// document's slot to update. Nothing is cloned or laid out. - #[cfg(not(target_arch = "wasm32"))] - pub(crate) fn recovery_docs(&mut self) -> impl Iterator> { - let active = crate::recovery::DocRecovery { - id: self.document_id, - wants_copy: self.dirty && self.password.is_none(), - rev: self.rev, - doc: &self.doc, - path: self.path.as_deref(), - slot: &mut self.recovery_slot, - }; - std::iter::once(active).chain(self.others.iter_mut().map(|d| crate::recovery::DocRecovery { - id: d.document_id, - wants_copy: d.dirty && d.password.is_none(), - rev: d.rev, - doc: &d.doc, - path: d.path.as_deref(), - slot: &mut d.recovery_slot, - })) - } - - /// Every open document's recovery slot, with whether it should have a current copy and - /// its revision. - #[cfg(not(target_arch = "wasm32"))] - pub(crate) fn recovery_slots(&self) -> impl Iterator { - std::iter::once((self.dirty && self.password.is_none(), self.rev, &self.recovery_slot)) - .chain(self.others.iter().map(|d| (d.dirty && d.password.is_none(), d.rev, &d.recovery_slot))) - } - - /// Close document `id` without asking: its unsaved changes are lost (callers ask first), - /// and so is its recovery copy. - /// Closing the active document makes the one opened before it active (else the one after). - /// Closing the last open document leaves a new blank one, so a session always has an - /// active document; what a window does then is the front end's choice. An id that isn't - /// open is an error. - pub fn close_document(&mut self, id: u64) -> Result<(), CmdError> { - if id != self.document_id { - if !self.remove_parked(id) { - return Err(unknown(id)); - } - return Ok(()); - } - let at = self.order.iter().position(|d| *d == id); - let next = at.and_then(|i| i.checked_sub(1).and_then(|p| self.order.get(p)).or_else(|| self.order.get(i.saturating_add(1)))).copied(); - match next { - Some(n) => { - self.switch_to(n)?; - self.remove_parked(id); - } - None => { - let mut doc = crate::cmd::file::normal_document(self); - doc.ensure_nonempty(); - self.untitled_count = self.untitled_count.saturating_add(1); - let mut view = self.view.clone(); - view.outline.collapsed.clear(); - let mut fresh = DocState::new(doc, self.untitled_count, view); - self.order = vec![fresh.document_id]; - self.swap_doc(&mut fresh); - self.forget_recovery_copy(&fresh); - } - } - self.stop_reading_elsewhere(); - Ok(()) - } - - /// The active document stands in for no document at all (a Start screen's, or what is left - /// after closing the last one): an untouched blank document gives back its untitled name - /// when it is the newest, so the first document a person sees is `Document1`, the next - /// `Document2`. - pub fn release_untitled_name(&mut self) { - if self.untitled != 0 && self.untitled == self.untitled_count && self.is_untouched_blank() { - self.untitled_count = self.untitled_count.saturating_sub(1); - self.untitled = 0; - } - } - - /// Whether the active document is a blank one nobody has used: no file and no name, never - /// changed, nothing to undo or redo and no text. Opening a file replaces such a document - /// rather than opening beside it. - pub fn is_untouched_blank(&self) -> bool { - self.used_because().is_none() - } - - /// Why the active document doesn't count as an untouched blank one, if it doesn't. - pub(crate) fn used_because(&self) -> Option<&'static str> { - if self.path.is_some() { - Some("it has a file") - } else if self.file_name.is_some() { - Some("it has a name") - } else if self.dirty { - Some("it has unsaved changes") - } else if !self.history.is_empty() || !self.redo.is_empty() { - Some("it has undo or redo steps") - } else if self.rev != self.made_rev { - Some("it changed since it was made") - } else if self.doc.paragraph_count() > 1 || !self.doc.plain_text(StoryRef::Body).trim().is_empty() { - Some("it has text") - } else { - None - } - } - - /// Run `f` with document `id` active, then make the document that was active before active - /// again (unless `f` closed it). For work addressed to a document other than the one a - /// window shows: an agent's command, a result that arrives later. Read Aloud carries on. - /// UI requests `f` makes (`ui_requests`) carry `"document": id` when that isn't the - /// document that was active. An id that isn't open is an error and `f` doesn't run. - pub fn with_document(&mut self, id: u64, f: impl FnOnce(&mut Session) -> R) -> Result { - let home = self.document_id; - self.switch_to(id)?; - // The queue drops its oldest entries when full, so `f`'s requests are told apart by - // collecting them on their own rather than by position. - let earlier = std::mem::take(&mut self.ui_requests); - let r = f(self); - let made = std::mem::replace(&mut self.ui_requests, earlier); - for mut q in made { - if id != home - && let Some(o) = q.as_object_mut() - { - o.entry("document").or_insert(json!(id)); - } - self.push_ui_request(q); - } - if self.document_id != home && self.is_open(home) { - self.switch_to(home)?; - } - Ok(r) - } - - /// The open document saved at `path`, if any: the same file reached by another spelling - /// (`./a.docx`, a symbolic link) counts on desktop. - pub fn find_open(&self, path: &FsPath) -> Option { - let wanted = crate::io::canonical(path); - let same = |p: &FsPath| p == path || wanted.is_some() && crate::io::canonical(p) == wanted; - self.order.iter().copied().find(|&id| { - if id == self.document_id { - self.path.as_deref().is_some_and(same) - } else { - self.others.iter().find(|d| d.document_id == id).and_then(|d| d.path.as_deref()).is_some_and(same) - } - }) - } - - /// Stop a Read Aloud reading of a document that isn't the active one (it highlights its - /// sentences in the document shown). - fn stop_reading_elsewhere(&mut self) { - if self.read_aloud.document.is_some_and(|d| d != self.document_id) && self.read_aloud.state() != crate::speech::State::Stopped { - self.read_aloud.stop(); - } - } - - /// Take the undo step `run` made for a command back out of the document it started in, when - /// the command ended up in another document (it opened or made one) and left the first - /// document's text as it was: opening a document is no edit to undo there. - pub(super) fn put_back_parked(&mut self, id: u64, before: Option) { - let Some((doc, sel, history, redo, typing_open, evicted, column)) = before else { return }; - if !self.is_open(id) { - return; - } - let _ = self.with_document(id, |s| { - if s.doc != doc { - return; - } - s.sel = sel; - s.history = history; - s.redo = redo; - s.typing_open = typing_open; - s.undo_evicted = evicted; - s.column = column; - }); - } -} - -fn unknown(id: u64) -> CmdError { - CmdError::Params(format!("no open document has id {id}")) -} +//! Several open documents in one session. The active document lives in the session's own fields, +//! where every command works on it; the others wait as [`DocState`] values and are swapped in, +//! field by field, when they become active. Swapping moves the fields: nothing is cloned, laid +//! out again or thrown away, so a host may switch documents as often as it likes. + +use std::path::{Path as FsPath, PathBuf}; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; + +use serde::Serialize; +use serde_json::json; +use wordcraft_doc::props::CharProps; +use wordcraft_doc::{Document, Pos, StoryRef}; +use wordcraft_layout::LayoutCache; + +use super::{ColumnBlock, FindState, LayoutSlots, MathEdit, Selection, Session, TypedWord, Undo, ViewState, Widened, doc_bib_style}; +use crate::CmdError; + +/// Document ids are unique in the process, so an id an agent or a window holds never names +/// another document, even across sessions. +static NEXT_DOCUMENT_ID: AtomicU64 = AtomicU64::new(1); + +pub(super) fn next_document_id() -> u64 { + NEXT_DOCUMENT_ID.fetch_add(1, Ordering::Relaxed) +} + +/// The id the next document will get: every document made after this call has an id at least +/// this big. +pub(super) fn document_id_mark() -> u64 { + NEXT_DOCUMENT_ID.load(Ordering::Relaxed) +} + +/// An untitled document's name: `Document1`, `Document2`… +pub(super) fn untitled_name(n: u32) -> String { + format!("Document{}", n.max(1)) +} + +/// An open document that isn't the active one: every per-document field of [`Session`], waiting +/// to be swapped back in. The session's app-wide state (clipboard, preferences, macros, Quick +/// Parts, the custom dictionary, Read Aloud…) is not here. +pub struct DocState { + pub(super) doc: Document, + pub(super) sel: Selection, + pub(super) view: ViewState, + pub(super) pending: Option, + pub(super) path: Option, + pub(super) file_name: Option, + pub(super) dirty: bool, + pub(super) pending_move: Option, + pub(super) find: FindState, + pub(super) goal_x: Option, + pub(super) page_hint: usize, + pub(super) new_header_parts: Vec, + pub(super) hf_return: Option, + pub(super) history: Vec>, + pub(super) undo_evicted: u64, + pub(super) redo: Vec>, + pub(super) typing_open: bool, + pub(super) join_next: bool, + pub(super) equation_join_next: bool, + pub(super) ime_text_join: Option, + pub(super) join_anchor: bool, + pub(super) autocap: Option, + pub(super) rev: u64, + pub(super) document_id: u64, + pub(super) untitled: u32, + pub(super) made_rev: u64, + pub(super) ime_body_epoch: u64, + pub(super) cache: LayoutCache, + pub(super) layout: LayoutSlots, + pub(super) typing: Option, + pub(super) originals: std::collections::HashMap, + pub(super) md_source: Option, + pub(super) md_file: crate::cmd::md_source::FileText, + pub(super) picture_looks: std::collections::HashMap, + pub(super) versions: Vec<(String, String, Document)>, + pub(super) autosave: bool, + pub(super) bib_style: String, + pub(super) merge: crate::cmd::mailings::MergeState, + pub(super) math: Option, + pub(super) password: Option, + #[cfg(not(target_arch = "wasm32"))] + pub(super) recovery_slot: crate::recovery::Slot, + pub(super) also_selected: Vec, + pub(super) column: Option, + pub(super) column_mode: bool, + pub(super) widened: Option, + pub(super) edit_marks: Vec, + pub(super) go_back_step: usize, +} + +impl DocState { + /// A fresh document state: caret at the start, no history, not dirty, a new id. + pub(super) fn new(doc: Document, untitled: u32, view: ViewState) -> Self { + let start = doc.start_of(StoryRef::Body); + let bib_style = doc_bib_style(&doc); + DocState { + doc, + sel: Selection::caret(start), + view, + pending: None, + path: None, + file_name: None, + dirty: false, + pending_move: None, + find: FindState::default(), + goal_x: None, + page_hint: 0, + new_header_parts: Vec::new(), + hf_return: None, + history: Vec::new(), + undo_evicted: 0, + redo: Vec::new(), + typing_open: false, + join_next: false, + equation_join_next: false, + ime_text_join: None, + join_anchor: false, + autocap: None, + rev: 1, + document_id: next_document_id(), + untitled, + made_rev: 1, + ime_body_epoch: 0, + cache: LayoutCache::new(), + layout: Default::default(), + typing: None, + originals: Default::default(), + md_source: None, + md_file: Default::default(), + picture_looks: Default::default(), + versions: Vec::new(), + autosave: true, + bib_style, + merge: Default::default(), + math: None, + password: None, + #[cfg(not(target_arch = "wasm32"))] + recovery_slot: Default::default(), + also_selected: Vec::new(), + column: None, + column_mode: false, + widened: None, + edit_marks: Vec::new(), + go_back_step: 0, + } + } + + /// The document's id ([`Session::document_id`] while it is active). + pub fn id(&self) -> u64 { + self.document_id + } + + fn info(&self) -> DocInfo { + DocInfo { + id: self.document_id, + title: title(self.path.as_deref(), self.file_name.as_deref(), &self.doc, self.untitled), + path: self.path.clone(), + dirty: self.dirty, + active: false, + } + } +} + +/// One open document, as [`Session::documents`] lists it. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DocInfo { + /// The document's id: unique in the process, kept while the document is open. + pub id: u64, + /// The name the title bar shows: the file's name without its extension, else the name given + /// with Rename, else the document's Title property, else `Document1`, `Document2`… (English; + /// a front end names untitled documents in its own language). + pub title: String, + pub path: Option, + /// Changed since it was opened or last saved. + pub dirty: bool, + /// The active document, the one commands work on. + pub active: bool, +} + +fn title(path: Option<&FsPath>, file_name: Option<&str>, doc: &Document, untitled: u32) -> String { + match (path, file_name) { + (Some(p), _) => p.file_stem().map_or_else(|| p.to_string_lossy().to_string(), |s| s.to_string_lossy().to_string()), + (None, Some(name)) => name.to_string(), + (None, None) if !doc.core.title.is_empty() => doc.core.title.clone(), + (None, None) => untitled_name(untitled), + } +} + +impl Session { + /// Swap the active document's state with `d`'s: `d` then holds the document that was active. + fn swap_doc(&mut self, d: &mut DocState) { + crate::cmd::md_source::apply(self); + use std::mem::swap; + // Every field is listed, so a new one doesn't build until someone decides whether each + // open document has its own (then it goes in `DocState` and is swapped below) or the + // session shares it. + let Session { + // Per document. + doc: _, + sel: _, + view: _, + pending: _, + path: _, + file_name: _, + dirty: _, + pending_move: _, + find: _, + goal_x: _, + page_hint: _, + new_header_parts: _, + hf_return: _, + history: _, + undo_evicted: _, + redo: _, + typing_open: _, + join_next: _, + equation_join_next: _, + ime_text_join: _, + join_anchor: _, + autocap: _, + rev: _, + document_id: _, + untitled: _, + made_rev: _, + ime_body_epoch: _, + cache: _, + layout: _, + typing: _, + originals: _, + md_source: _, + md_file: _, + picture_looks: _, + versions: _, + autosave: _, + bib_style: _, + merge: _, + math: _, + password: _, + #[cfg(not(target_arch = "wasm32"))] + recovery_slot: _, + also_selected: _, + column: _, + column_mode: _, + widened: _, + edit_marks: _, + go_back_step: _, + // Shared by every open document. + // Names the document it belongs to, so it never applies to another one. + review_style: _, + user_dict_path: _, + clipboard: _, + clipboard_text: _, + clipboard_html: _, + clipboard_rtf: _, + clip_history: _, + registry: _, + author: _, + initials: _, + painter: _, + // What the drawn window knows of the others: its front end sets it as it draws. + windows: _, + status: _, + last_command: _, + recording: _, + macros: _, + building_blocks: _, + ui_requests: _, + read_aloud: _, + prefs: _, + system_region: _, + keymap: _, + ribbon: _, + editing_lang: _, + math_latex: _, + math_normal_text: _, + last_highlight: _, + // Keyed by document id and revision, so one cache serves every document. + protect_facts: _, + // The input method talks to the window, whichever document it shows; the IME + // context is keyed by `document_id`. + ime_state_revision: _, + ime_state_seen: _, + ime_edit_outcome: _, + #[cfg(any(test, feature = "test-support"))] + ime_state_exhausted: _, + pending_ime_application_change: _, + ime_origin: _, + // A picked Drawing Canvas member is cleared by a switch, not kept per document. + member: _, + others: _, + order: _, + untitled_count: _, + #[cfg(not(target_arch = "wasm32"))] + recovery: _, + } = self; + let DocState { + doc, + sel, + view, + pending, + path, + file_name, + dirty, + pending_move, + find, + goal_x, + page_hint, + new_header_parts, + hf_return, + history, + undo_evicted, + redo, + typing_open, + join_next, + equation_join_next, + ime_text_join, + join_anchor, + autocap, + rev, + document_id, + untitled, + made_rev, + ime_body_epoch, + cache, + layout, + typing, + originals, + md_source, + md_file, + picture_looks, + versions, + autosave, + bib_style, + merge, + math, + password, + #[cfg(not(target_arch = "wasm32"))] + recovery_slot, + also_selected, + column, + column_mode, + widened, + edit_marks, + go_back_step, + } = d; + swap(&mut self.doc, doc); + swap(&mut self.sel, sel); + swap(&mut self.view, view); + swap(&mut self.pending, pending); + swap(&mut self.path, path); + swap(&mut self.file_name, file_name); + swap(&mut self.dirty, dirty); + swap(&mut self.pending_move, pending_move); + swap(&mut self.find, find); + swap(&mut self.goal_x, goal_x); + swap(&mut self.page_hint, page_hint); + swap(&mut self.new_header_parts, new_header_parts); + swap(&mut self.hf_return, hf_return); + swap(&mut self.history, history); + swap(&mut self.undo_evicted, undo_evicted); + swap(&mut self.redo, redo); + swap(&mut self.typing_open, typing_open); + swap(&mut self.join_next, join_next); + swap(&mut self.equation_join_next, equation_join_next); + swap(&mut self.ime_text_join, ime_text_join); + swap(&mut self.join_anchor, join_anchor); + swap(&mut self.autocap, autocap); + swap(&mut self.rev, rev); + swap(&mut self.document_id, document_id); + swap(&mut self.untitled, untitled); + swap(&mut self.made_rev, made_rev); + swap(&mut self.ime_body_epoch, ime_body_epoch); + swap(&mut self.cache, cache); + swap(&mut self.layout, layout); + swap(&mut self.typing, typing); + swap(&mut self.originals, originals); + swap(&mut self.md_source, md_source); + swap(&mut self.md_file, md_file); + swap(&mut self.picture_looks, picture_looks); + swap(&mut self.versions, versions); + swap(&mut self.autosave, autosave); + swap(&mut self.bib_style, bib_style); + swap(&mut self.merge, merge); + swap(&mut self.math, math); + swap(&mut self.password, password); + #[cfg(not(target_arch = "wasm32"))] + swap(&mut self.recovery_slot, recovery_slot); + swap(&mut self.also_selected, also_selected); + swap(&mut self.column, column); + swap(&mut self.column_mode, column_mode); + swap(&mut self.widened, widened); + swap(&mut self.edit_marks, edit_marks); + swap(&mut self.go_back_step, go_back_step); + // A picked Drawing Canvas member (#2637) belongs to the document left behind. + self.member = None; + } + + /// Make document `id` active without anything else a switch by the user does. + pub(super) fn switch_to(&mut self, id: u64) -> Result<(), CmdError> { + if id == self.document_id { + return Ok(()); + } + crate::cmd::md_source::apply(self); + let at = self.others.iter().position(|d| d.document_id == id).ok_or_else(|| unknown(id))?; + let mut d = self.others.swap_remove(at); + self.swap_doc(&mut d); + self.view.follow_prefs(&self.prefs); + self.others.push(d); + Ok(()) + } + + /// Take parked document `id` out of the session; false when it isn't parked here. + fn remove_parked(&mut self, id: u64) -> bool { + let Some(at) = self.others.iter().position(|d| d.document_id == id) else { return false }; + let d = self.others.swap_remove(at); + self.order.retain(|d| *d != id); + self.forget_recovery_copy(&d); + true + } + + /// Whether document `id` is open in this session (active or not). + pub fn is_open(&self, id: u64) -> bool { + id == self.document_id || self.others.iter().any(|d| d.document_id == id) + } + + /// Whether the open document `id` has changes that aren't written to a file yet: the + /// active document's own flag, or a background document's. + pub fn document_dirty(&self, id: u64) -> bool { + if id == self.document_id { self.dirty } else { self.others.iter().any(|d| d.document_id == id && d.dirty) } + } + + /// Every open document in the order they were opened, the active one included. + pub fn documents(&self) -> Vec { + self.order + .iter() + .filter_map(|&id| { + if id == self.document_id { + Some(DocInfo { + id, + title: title(self.path.as_deref(), self.file_name.as_deref(), &self.doc, self.untitled), + path: self.path.clone(), + dirty: self.dirty, + active: true, + }) + } else { + self.others.iter().find(|d| d.document_id == id).map(DocState::info) + } + }) + .collect() + } + + /// Make document `id` the active one, the one commands work on (nothing happens when it + /// already is). A Read Aloud reading of another document stops. An id that isn't open is an + /// error. + pub fn activate(&mut self, id: u64) -> Result<(), CmdError> { + self.switch_to(id)?; + self.stop_reading_elsewhere(); + Ok(()) + } + + /// Make document `id` the active one, as [`Session::activate`] does, but a Read Aloud + /// reading of another document carries on: for a front end that shows each document in a + /// window of its own, and makes each window's document active to draw it. An id that isn't + /// open is an error. + pub fn switch_document(&mut self, id: u64) -> Result<(), CmdError> { + self.switch_to(id) + } + + /// Open `doc` as another document and make it active; the one that was active stays open. + /// The new document starts as a replaced one does ([`Session::set_document`]): caret at the + /// start, no undo history, not dirty, no path, an untitled name (`Document2`), and the view + /// the active document had. Returns its id. + pub fn add_document(&mut self, doc: Document) -> u64 { + self.untitled_count = self.untitled_count.saturating_add(1); + self.add(doc, self.untitled_count) + } + + /// [`Session::add_document`] for a document opened from a file: it doesn't use up an + /// untitled name. + pub(crate) fn add_opened_document(&mut self, doc: Document) -> u64 { + self.add(doc, 0) + } + + /// Adds `doc` to the session and makes it active, parking the previously active document + /// and applying any pending Markdown Source text first. + fn add(&mut self, mut doc: Document, untitled: u32) -> u64 { + crate::cmd::md_source::apply(self); + doc.ensure_nonempty(); + let mut view = self.view.clone(); + view.outline.collapsed.clear(); + view.follow_prefs(&self.prefs); + let mut d = DocState::new(doc, untitled, view); + let id = d.document_id; + self.order.push(id); + self.swap_doc(&mut d); + self.others.push(d); + self.stop_reading_elsewhere(); + id + } + + /// A closed document's recovery copy goes with it, as its unsaved changes do. + fn forget_recovery_copy(&mut self, d: &DocState) { + #[cfg(not(target_arch = "wasm32"))] + if let (Some(r), Some(id)) = (&mut self.recovery, &d.recovery_slot.id) { + r.close(id.clone()); + } + #[cfg(target_arch = "wasm32")] + let _ = d; + } + + /// What recovery reads from every open document, the active one first, with each + /// document's slot to update. Nothing is cloned or laid out. + #[cfg(not(target_arch = "wasm32"))] + pub(crate) fn recovery_docs(&mut self) -> impl Iterator> { + let active = crate::recovery::DocRecovery { + id: self.document_id, + wants_copy: self.dirty && self.password.is_none(), + rev: self.rev, + doc: &self.doc, + path: self.path.as_deref(), + slot: &mut self.recovery_slot, + }; + std::iter::once(active).chain(self.others.iter_mut().map(|d| crate::recovery::DocRecovery { + id: d.document_id, + wants_copy: d.dirty && d.password.is_none(), + rev: d.rev, + doc: &d.doc, + path: d.path.as_deref(), + slot: &mut d.recovery_slot, + })) + } + + /// Every open document's recovery slot, with whether it should have a current copy and + /// its revision. + #[cfg(not(target_arch = "wasm32"))] + pub(crate) fn recovery_slots(&self) -> impl Iterator { + std::iter::once((self.dirty && self.password.is_none(), self.rev, &self.recovery_slot)) + .chain(self.others.iter().map(|d| (d.dirty && d.password.is_none(), d.rev, &d.recovery_slot))) + } + + /// Close document `id` without asking: its unsaved changes are lost (callers ask first), + /// and so is its recovery copy. + /// Closing the active document makes the one opened before it active (else the one after). + /// Closing the last open document leaves a new blank one, so a session always has an + /// active document; what a window does then is the front end's choice. An id that isn't + /// open is an error. + pub fn close_document(&mut self, id: u64) -> Result<(), CmdError> { + if id != self.document_id { + if !self.remove_parked(id) { + return Err(unknown(id)); + } + return Ok(()); + } + let at = self.order.iter().position(|d| *d == id); + let next = at.and_then(|i| i.checked_sub(1).and_then(|p| self.order.get(p)).or_else(|| self.order.get(i.saturating_add(1)))).copied(); + match next { + Some(n) => { + self.switch_to(n)?; + self.remove_parked(id); + } + None => { + let mut doc = crate::cmd::file::normal_document(self); + doc.ensure_nonempty(); + self.untitled_count = self.untitled_count.saturating_add(1); + let mut view = self.view.clone(); + view.outline.collapsed.clear(); + let mut fresh = DocState::new(doc, self.untitled_count, view); + self.order = vec![fresh.document_id]; + self.swap_doc(&mut fresh); + self.forget_recovery_copy(&fresh); + } + } + self.stop_reading_elsewhere(); + Ok(()) + } + + /// The active document stands in for no document at all (a Start screen's, or what is left + /// after closing the last one): an untouched blank document gives back its untitled name + /// when it is the newest, so the first document a person sees is `Document1`, the next + /// `Document2`. + pub fn release_untitled_name(&mut self) { + if self.untitled != 0 && self.untitled == self.untitled_count && self.is_untouched_blank() { + self.untitled_count = self.untitled_count.saturating_sub(1); + self.untitled = 0; + } + } + + /// Whether the active document is a blank one nobody has used: no file and no name, never + /// changed, nothing to undo or redo and no text. Opening a file replaces such a document + /// rather than opening beside it. + pub fn is_untouched_blank(&self) -> bool { + self.used_because().is_none() + } + + /// Why the active document doesn't count as an untouched blank one, if it doesn't. + pub(crate) fn used_because(&self) -> Option<&'static str> { + if self.path.is_some() { + Some("it has a file") + } else if self.file_name.is_some() { + Some("it has a name") + } else if self.dirty { + Some("it has unsaved changes") + } else if !self.history.is_empty() || !self.redo.is_empty() { + Some("it has undo or redo steps") + } else if self.rev != self.made_rev { + Some("it changed since it was made") + } else if self.doc.paragraph_count() > 1 || !self.doc.plain_text(StoryRef::Body).trim().is_empty() { + Some("it has text") + } else { + None + } + } + + /// Run `f` with document `id` active, then make the document that was active before active + /// again (unless `f` closed it). For work addressed to a document other than the one a + /// window shows: an agent's command, a result that arrives later. Read Aloud carries on. + /// UI requests `f` makes (`ui_requests`) carry `"document": id` when that isn't the + /// document that was active. An id that isn't open is an error and `f` doesn't run. + pub fn with_document(&mut self, id: u64, f: impl FnOnce(&mut Session) -> R) -> Result { + let home = self.document_id; + self.switch_to(id)?; + // The queue drops its oldest entries when full, so `f`'s requests are told apart by + // collecting them on their own rather than by position. + let earlier = std::mem::take(&mut self.ui_requests); + let r = f(self); + let made = std::mem::replace(&mut self.ui_requests, earlier); + for mut q in made { + if id != home + && let Some(o) = q.as_object_mut() + { + o.entry("document").or_insert(json!(id)); + } + self.push_ui_request(q); + } + if self.document_id != home && self.is_open(home) { + self.switch_to(home)?; + } + Ok(r) + } + + /// The open document saved at `path`, if any: the same file reached by another spelling + /// (`./a.docx`, a symbolic link) counts on desktop. + pub fn find_open(&self, path: &FsPath) -> Option { + let wanted = crate::io::canonical(path); + let same = |p: &FsPath| p == path || wanted.is_some() && crate::io::canonical(p) == wanted; + self.order.iter().copied().find(|&id| { + if id == self.document_id { + self.path.as_deref().is_some_and(same) + } else { + self.others.iter().find(|d| d.document_id == id).and_then(|d| d.path.as_deref()).is_some_and(same) + } + }) + } + + /// Stop a Read Aloud reading of a document that isn't the active one (it highlights its + /// sentences in the document shown). + fn stop_reading_elsewhere(&mut self) { + if self.read_aloud.document.is_some_and(|d| d != self.document_id) && self.read_aloud.state() != crate::speech::State::Stopped { + self.read_aloud.stop(); + } + } + + /// Take the undo step `run` made for a command back out of the document it started in, when + /// the command ended up in another document (it opened or made one) and left the first + /// document's text as it was: opening a document is no edit to undo there. + pub(super) fn put_back_parked(&mut self, id: u64, before: Option) { + let Some((doc, sel, history, redo, typing_open, evicted, column)) = before else { return }; + if !self.is_open(id) { + return; + } + let _ = self.with_document(id, |s| { + if s.doc != doc { + return; + } + s.sel = sel; + s.history = history; + s.redo = redo; + s.typing_open = typing_open; + s.undo_evicted = evicted; + s.column = column; + }); + } +} + +fn unknown(id: u64) -> CmdError { + CmdError::Params(format!("no open document has id {id}")) +} diff --git a/crates/engine/src/session/history.rs b/crates/engine/src/session/history.rs index be15c2cd77..10664d160f 100644 --- a/crates/engine/src/session/history.rs +++ b/crates/engine/src/session/history.rs @@ -1,263 +1,263 @@ -use super::doc_state::{MAX_UNDO, Selection, UNDO_LIST_MAX, Undo, next_math_session}; -use super::{Arc, Document, Session}; - -impl Session { - /// Snapshot for undo before a change. Returns whether it took a step: typing that is - /// already in an open step is not a new one. - pub fn checkpoint(&mut self, label: &str) -> bool { - if label == "Typing" && self.typing_open { - return false; - } - self.typing_open = label == "Typing"; - self.history.push(Arc::new(Undo { - label: label.to_string(), - doc: self.doc.clone(), - sel: self.sel.clone(), - pending: self.pending.clone(), - column: self.column.clone(), - })); - if self.history.len() > MAX_UNDO { - self.history.remove(0); - self.undo_evicted += 1; - } - self.redo.clear(); - // The newest step is what a joined command merges into. - self.join_anchor = true; - true - } - - /// Take back the newest undo step: a command that joins an earlier step of its own. - pub(crate) fn drop_last_undo_step(&mut self) { - self.history.pop(); - } - - /// Record `doc`/`sel` as their own undo step after the fact and close the typing group, so - /// Undo right after an automatic change (AutoFormat, AutoCorrect) reverts only that change. - pub fn push_undo(&mut self, label: &str, doc: Document, sel: Selection) { - self.history.push(Arc::new(Undo { label: label.to_string(), doc, sel, pending: self.pending.clone(), column: None })); - if self.history.len() > MAX_UNDO { - self.history.remove(0); - self.undo_evicted += 1; - } - self.typing_open = false; - self.join_anchor = true; - } - - /// Make the next mutating command part of the previous undo step instead of a new one, so a - /// drag that runs a command every frame is a single Undo. Call it on every frame of the drag - /// except the first; it is consumed by the next `run`. - pub fn join_next_undo(&mut self) { - self.join_next = true; - } - - /// Make the next equation typing command (`equation.type` or `equation.backspace`) join the - /// previous equation deletion's undo step. Unlike [`Self::join_next_undo`], the promise is - /// not generic: any other command consumes and discards it, so unrelated edits get their own - /// checkpoint. A deletion-only IME request therefore cannot leak a join into the next edit. - pub fn join_next_equation_edit(&mut self) { - self.equation_join_next = true; - } - - /// Discard an equation replacement join that received no replacement (for example an empty - /// commit), so it cannot join a later composition's typing. - pub fn clear_equation_join(&mut self) { - self.equation_join_next = false; - } - - /// Make the next `text.insert` at the current selection join a successful body - /// `DeleteSurrounding`'s undo step. Unlike [`Self::join_next_undo`], the promise is scoped to - /// that exact replacement: any other command consumes and discards it, so an intervening - /// ordinary edit gets its own checkpoint. - pub fn join_next_ime_text_replacement(&mut self) { - self.ime_text_join = Some(self.sel.clone()); - } - - /// Discard a body replacement join that received no replacement (an empty or refused - /// commit, a new request or a refusal), so it cannot join a later composition. - pub fn clear_ime_text_join(&mut self) { - self.ime_text_join = None; - } - - /// Discard every pending IME replacement join. A refused surrounding request supersedes the - /// composition it interrupts, including any promise from an earlier accepted request. - pub fn clear_ime_joins(&mut self) { - self.equation_join_next = false; - self.ime_text_join = None; - } - - /// Stamp the document's save metadata (modified time, last modified by, revision; created - /// when it has none) the way Word does on every save. `file.save` calls it, and so does any - /// front end that writes the bytes itself (the web build's download, #262). Like Word, the - /// document also keeps the zoom it is shown at (#995). - pub fn stamp_save(&mut self) { - self.doc.settings.zoom = crate::cmd::view::saved_zoom(&self.view); - let core = &mut self.doc.core; - core.modified = crate::cmd::now_iso(); - if core.created.is_empty() { - core.created = core.modified.clone(); - } - core.last_modified_by = self.author.clone(); - core.revision = core.revision.saturating_add(1); - } - - /// Whether typing is still collecting into one undo step (no other command since). - pub fn typing_open(&self) -> bool { - self.typing_open - } - - /// Reopen the typing group when the last undo step is typing, so the keys typed next join - /// it: after an input method's rewrite was joined to it (`join_next_undo` closes the group). - pub fn continue_typing(&mut self) { - if self.undo_label() == Some("Typing") { - self.typing_open = true; - } - } - - /// Close an open typing group (caret moved, other command). - pub fn close_typing(&mut self) { - self.typing_open = false; - } - - pub fn can_undo(&self) -> bool { - !self.history.is_empty() - } - - pub fn can_redo(&self) -> bool { - !self.redo.is_empty() - } - - pub fn undo_label(&self) -> Option<&str> { - self.history.last().map(|u| u.label.as_str()) - } - - pub fn redo_label(&self) -> Option<&str> { - self.redo.last().map(|u| u.label.as_str()) - } - - /// Undo steps pushed so far, evicted ones included: only grows while steps are added. - pub(crate) fn undo_steps_so_far(&self) -> u64 { - self.history.len() as u64 + self.undo_evicted - } - - /// The number of undo steps. - pub fn undo_depth(&self) -> usize { - self.history.len() - } - - /// Where the undo history stands, for [`Session::merge_undo_since`]. - pub fn undo_mark(&self) -> u64 { - (self.history.len() as u64).saturating_add(self.undo_evicted) - } - - /// Makes the steps recorded since `mark` one undo step, the first: a drag that applied its - /// value as it went (a slider in a pane) undoes in one go. - pub fn merge_undo_since(&mut self, mark: u64) { - let keep = mark.saturating_sub(self.undo_evicted).saturating_add(1); - self.history.truncate(usize::try_from(keep).unwrap_or(usize::MAX)); - } - - pub fn undo_labels(&self) -> Vec { - self.history.iter().rev().map(|u| u.label.clone()).collect() - } - - /// The Undo button's list (#701): the undo steps' labels (English, as commands name them), - /// newest first, at most [`UNDO_LIST_MAX`]. - pub fn undo_history(&self) -> Vec<&str> { - self.history.iter().rev().take(UNDO_LIST_MAX).map(|u| u.label.as_str()).collect() - } - - /// Undo the newest `count` steps in one go, as picking an entry in the Undo list does (#701). - /// More than the undo depth undoes everything; a step that would take the document's - /// protection away stops it (only Stop Protection lifts it). Returns the steps undone. - pub fn undo_steps(&mut self, count: usize) -> usize { - let mut done = 0; - while done < count && !self.step_lifts_protection(false) && self.undo() { - done += 1; - } - done - } - - /// Undoes the newest step: closes typing, records an AutoCorrect exception when the undone step - /// was its automatic capital, swaps the document, selection, pending formatting and column with - /// the redo snapshot, replaces the body identity, refreshes equation editing and touches layout. - pub fn undo(&mut self) -> bool { - self.typing_open = false; - // Undoing the automatic capitalisation's own step makes its word an exception; any - // other Undo forgets the capital. - let steps = self.history.len() as u64 + self.undo_evicted; - if let Some(c) = self.autocap.take().filter(|c| c.depth == steps) { - c.add_exceptions(&mut self.prefs.autocorrect); - } - let Some(u) = self.history.pop().map(Arc::unwrap_or_clone) else { return false }; - let cur = Undo { - label: u.label.clone(), - doc: std::mem::replace(&mut self.doc, u.doc), - sel: std::mem::replace(&mut self.sel, u.sel), - pending: std::mem::replace(&mut self.pending, u.pending), - column: std::mem::replace(&mut self.column, u.column), - }; - self.redo.push(Arc::new(cur)); - // Undo swaps the whole body back: a composition belongs to the text it showed. - self.note_body_replaced(); - self.refresh_math_session(); - self.touch(); - true - } - - /// Would Undo (or Redo) take the document's protection away? Only Stop Protection lifts it. - pub fn step_lifts_protection(&self, redo: bool) -> bool { - let next = if redo { self.redo.last() } else { self.history.last() }; - next.is_some_and(|u| crate::cmd::protect::protection_lifted(&self.doc, &u.doc)) - } - - /// Redoes the newest undone step by swapping the snapshots back, replacing the body identity, - /// refreshing equation editing and touching layout. - pub fn redo(&mut self) -> bool { - self.typing_open = false; - let Some(u) = self.redo.pop().map(Arc::unwrap_or_clone) else { return false }; - let cur = Undo { - label: u.label.clone(), - doc: std::mem::replace(&mut self.doc, u.doc), - sel: std::mem::replace(&mut self.sel, u.sel), - pending: std::mem::replace(&mut self.pending, u.pending), - column: std::mem::replace(&mut self.column, u.column), - }; - self.history.push(Arc::new(cur)); - self.note_body_replaced(); - self.refresh_math_session(); - self.touch(); - true - } - - /// An equation's editing session cannot ride across Undo/Redo: the equation tree, or the - /// object at that offset, may be a different one now. A new session token starts a new IME - /// context; if tokens are exhausted, the equation is left (fail closed). - fn refresh_math_session(&mut self) { - if self.math.is_none() { - return; - } - match next_math_session() { - Some(session) => { - if let Some(m) = self.math.as_mut() { - m.session = session; - } - } - None => self.math = None, - } - } - - /// Drop history (after open / new). - pub fn reset_history(&mut self) { - self.autocap = None; - self.history.clear(); - self.redo.clear(); - self.typing_open = false; - self.join_anchor = false; - } - - /// The version snapshots belong to the document they were taken from: keeping the old ones - /// would let a restore put the previous document's content into this one (#1327 follow-up). - pub fn clear_versions(&mut self) { - self.versions.clear(); - } -} +use super::doc_state::{MAX_UNDO, Selection, UNDO_LIST_MAX, Undo, next_math_session}; +use super::{Arc, Document, Session}; + +impl Session { + /// Snapshot for undo before a change. Returns whether it took a step: typing that is + /// already in an open step is not a new one. + pub fn checkpoint(&mut self, label: &str) -> bool { + if label == "Typing" && self.typing_open { + return false; + } + self.typing_open = label == "Typing"; + self.history.push(Arc::new(Undo { + label: label.to_string(), + doc: self.doc.clone(), + sel: self.sel.clone(), + pending: self.pending.clone(), + column: self.column.clone(), + })); + if self.history.len() > MAX_UNDO { + self.history.remove(0); + self.undo_evicted += 1; + } + self.redo.clear(); + // The newest step is what a joined command merges into. + self.join_anchor = true; + true + } + + /// Take back the newest undo step: a command that joins an earlier step of its own. + pub(crate) fn drop_last_undo_step(&mut self) { + self.history.pop(); + } + + /// Record `doc`/`sel` as their own undo step after the fact and close the typing group, so + /// Undo right after an automatic change (AutoFormat, AutoCorrect) reverts only that change. + pub fn push_undo(&mut self, label: &str, doc: Document, sel: Selection) { + self.history.push(Arc::new(Undo { label: label.to_string(), doc, sel, pending: self.pending.clone(), column: None })); + if self.history.len() > MAX_UNDO { + self.history.remove(0); + self.undo_evicted += 1; + } + self.typing_open = false; + self.join_anchor = true; + } + + /// Make the next mutating command part of the previous undo step instead of a new one, so a + /// drag that runs a command every frame is a single Undo. Call it on every frame of the drag + /// except the first; it is consumed by the next `run`. + pub fn join_next_undo(&mut self) { + self.join_next = true; + } + + /// Make the next equation typing command (`equation.type` or `equation.backspace`) join the + /// previous equation deletion's undo step. Unlike [`Self::join_next_undo`], the promise is + /// not generic: any other command consumes and discards it, so unrelated edits get their own + /// checkpoint. A deletion-only IME request therefore cannot leak a join into the next edit. + pub fn join_next_equation_edit(&mut self) { + self.equation_join_next = true; + } + + /// Discard an equation replacement join that received no replacement (for example an empty + /// commit), so it cannot join a later composition's typing. + pub fn clear_equation_join(&mut self) { + self.equation_join_next = false; + } + + /// Make the next `text.insert` at the current selection join a successful body + /// `DeleteSurrounding`'s undo step. Unlike [`Self::join_next_undo`], the promise is scoped to + /// that exact replacement: any other command consumes and discards it, so an intervening + /// ordinary edit gets its own checkpoint. + pub fn join_next_ime_text_replacement(&mut self) { + self.ime_text_join = Some(self.sel.clone()); + } + + /// Discard a body replacement join that received no replacement (an empty or refused + /// commit, a new request or a refusal), so it cannot join a later composition. + pub fn clear_ime_text_join(&mut self) { + self.ime_text_join = None; + } + + /// Discard every pending IME replacement join. A refused surrounding request supersedes the + /// composition it interrupts, including any promise from an earlier accepted request. + pub fn clear_ime_joins(&mut self) { + self.equation_join_next = false; + self.ime_text_join = None; + } + + /// Stamp the document's save metadata (modified time, last modified by, revision; created + /// when it has none) the way Word does on every save. `file.save` calls it, and so does any + /// front end that writes the bytes itself (the web build's download, #262). Like Word, the + /// document also keeps the zoom it is shown at (#995). + pub fn stamp_save(&mut self) { + self.doc.settings.zoom = crate::cmd::view::saved_zoom(&self.view); + let core = &mut self.doc.core; + core.modified = crate::cmd::now_iso(); + if core.created.is_empty() { + core.created = core.modified.clone(); + } + core.last_modified_by = self.author.clone(); + core.revision = core.revision.saturating_add(1); + } + + /// Whether typing is still collecting into one undo step (no other command since). + pub fn typing_open(&self) -> bool { + self.typing_open + } + + /// Reopen the typing group when the last undo step is typing, so the keys typed next join + /// it: after an input method's rewrite was joined to it (`join_next_undo` closes the group). + pub fn continue_typing(&mut self) { + if self.undo_label() == Some("Typing") { + self.typing_open = true; + } + } + + /// Close an open typing group (caret moved, other command). + pub fn close_typing(&mut self) { + self.typing_open = false; + } + + pub fn can_undo(&self) -> bool { + !self.history.is_empty() + } + + pub fn can_redo(&self) -> bool { + !self.redo.is_empty() + } + + pub fn undo_label(&self) -> Option<&str> { + self.history.last().map(|u| u.label.as_str()) + } + + pub fn redo_label(&self) -> Option<&str> { + self.redo.last().map(|u| u.label.as_str()) + } + + /// Undo steps pushed so far, evicted ones included: only grows while steps are added. + pub(crate) fn undo_steps_so_far(&self) -> u64 { + self.history.len() as u64 + self.undo_evicted + } + + /// The number of undo steps. + pub fn undo_depth(&self) -> usize { + self.history.len() + } + + /// Where the undo history stands, for [`Session::merge_undo_since`]. + pub fn undo_mark(&self) -> u64 { + (self.history.len() as u64).saturating_add(self.undo_evicted) + } + + /// Makes the steps recorded since `mark` one undo step, the first: a drag that applied its + /// value as it went (a slider in a pane) undoes in one go. + pub fn merge_undo_since(&mut self, mark: u64) { + let keep = mark.saturating_sub(self.undo_evicted).saturating_add(1); + self.history.truncate(usize::try_from(keep).unwrap_or(usize::MAX)); + } + + pub fn undo_labels(&self) -> Vec { + self.history.iter().rev().map(|u| u.label.clone()).collect() + } + + /// The Undo button's list (#701): the undo steps' labels (English, as commands name them), + /// newest first, at most [`UNDO_LIST_MAX`]. + pub fn undo_history(&self) -> Vec<&str> { + self.history.iter().rev().take(UNDO_LIST_MAX).map(|u| u.label.as_str()).collect() + } + + /// Undo the newest `count` steps in one go, as picking an entry in the Undo list does (#701). + /// More than the undo depth undoes everything; a step that would take the document's + /// protection away stops it (only Stop Protection lifts it). Returns the steps undone. + pub fn undo_steps(&mut self, count: usize) -> usize { + let mut done = 0; + while done < count && !self.step_lifts_protection(false) && self.undo() { + done += 1; + } + done + } + + /// Undoes the newest step: closes typing, records an AutoCorrect exception when the undone step + /// was its automatic capital, swaps the document, selection, pending formatting and column with + /// the redo snapshot, replaces the body identity, refreshes equation editing and touches layout. + pub fn undo(&mut self) -> bool { + self.typing_open = false; + // Undoing the automatic capitalisation's own step makes its word an exception; any + // other Undo forgets the capital. + let steps = self.history.len() as u64 + self.undo_evicted; + if let Some(c) = self.autocap.take().filter(|c| c.depth == steps) { + c.add_exceptions(&mut self.prefs.autocorrect); + } + let Some(u) = self.history.pop().map(Arc::unwrap_or_clone) else { return false }; + let cur = Undo { + label: u.label.clone(), + doc: std::mem::replace(&mut self.doc, u.doc), + sel: std::mem::replace(&mut self.sel, u.sel), + pending: std::mem::replace(&mut self.pending, u.pending), + column: std::mem::replace(&mut self.column, u.column), + }; + self.redo.push(Arc::new(cur)); + // Undo swaps the whole body back: a composition belongs to the text it showed. + self.note_body_replaced(); + self.refresh_math_session(); + self.touch(); + true + } + + /// Would Undo (or Redo) take the document's protection away? Only Stop Protection lifts it. + pub fn step_lifts_protection(&self, redo: bool) -> bool { + let next = if redo { self.redo.last() } else { self.history.last() }; + next.is_some_and(|u| crate::cmd::protect::protection_lifted(&self.doc, &u.doc)) + } + + /// Redoes the newest undone step by swapping the snapshots back, replacing the body identity, + /// refreshing equation editing and touching layout. + pub fn redo(&mut self) -> bool { + self.typing_open = false; + let Some(u) = self.redo.pop().map(Arc::unwrap_or_clone) else { return false }; + let cur = Undo { + label: u.label.clone(), + doc: std::mem::replace(&mut self.doc, u.doc), + sel: std::mem::replace(&mut self.sel, u.sel), + pending: std::mem::replace(&mut self.pending, u.pending), + column: std::mem::replace(&mut self.column, u.column), + }; + self.history.push(Arc::new(cur)); + self.note_body_replaced(); + self.refresh_math_session(); + self.touch(); + true + } + + /// An equation's editing session cannot ride across Undo/Redo: the equation tree, or the + /// object at that offset, may be a different one now. A new session token starts a new IME + /// context; if tokens are exhausted, the equation is left (fail closed). + fn refresh_math_session(&mut self) { + if self.math.is_none() { + return; + } + match next_math_session() { + Some(session) => { + if let Some(m) = self.math.as_mut() { + m.session = session; + } + } + None => self.math = None, + } + } + + /// Drop history (after open / new). + pub fn reset_history(&mut self) { + self.autocap = None; + self.history.clear(); + self.redo.clear(); + self.typing_open = false; + self.join_anchor = false; + } + + /// The version snapshots belong to the document they were taken from: keeping the old ones + /// would let a restore put the previous document's content into this one (#1327 follow-up). + pub fn clear_versions(&mut self) { + self.versions.clear(); + } +} diff --git a/crates/engine/src/session/ime.rs b/crates/engine/src/session/ime.rs index 19fccad95e..ede6dea2e9 100644 --- a/crates/engine/src/session/ime.rs +++ b/crates/engine/src/session/ime.rs @@ -1,85 +1,85 @@ -use super::ime_state::{ImeCaretState, next_ime_state_revision}; -use super::{CmdError, ImeEditOutcome, Session, Value}; - -impl Session { - /// The current command's explicit IME effect classification. - pub fn ime_edit_outcome(&self) -> ImeEditOutcome { - self.ime_edit_outcome - } - - /// Set the effect classification from a command handler. [`Session::run`] fills - /// `selection_changed` and enforces the content/state invariant after the command. - pub(crate) fn set_ime_edit_outcome(&mut self, outcome: ImeEditOutcome) { - self.ime_edit_outcome = outcome; - } - - /// Take the pending application-origin change signal. The UI owns one session, so a simple - /// bool is enough; it deliberately survives later commands until drained. - pub fn take_pending_ime_application_change(&mut self) -> bool { - std::mem::take(&mut self.pending_ime_application_change) - } - - /// Simulate a globally exhausted IME revision allocator in this session (test support). - /// - /// This only ever makes receipt classification fail closed. It exists so front-end producer - /// tests can exercise the real `ime_stamp` path instead of hand-building an `Unavailable` - /// stamp; production builds do not compile it. - #[cfg(any(test, feature = "test-support"))] - #[doc(hidden)] - pub fn exhaust_ime_state_revision(&mut self) { - self.ime_state_exhausted = true; - self.ime_state_revision = None; - } - - /// The checked, process-monotonic revision for IME receipts with unavailable surrounding - /// text. Selection/caret motion allocates on the next call; `None` means exhausted. - pub fn ime_state_revision(&mut self) -> Option { - self.sync_ime_caret_state(); - self.ime_state_revision - } - - /// Returns the caret identity used for IME state revisions: the equation session and position - /// while editing, otherwise the body selection. - pub(super) fn current_ime_caret_state(&self) -> ImeCaretState { - match &self.math { - Some(m) => ImeCaretState::Equation { session: m.session, pos: m.pos.clone() }, - None => ImeCaretState::Body(self.sel.clone()), - } - } - - /// Allocate the next IME state revision, or store `None` when the allocator is exhausted. - /// Test support can pin exhaustion for this session so a front-end producer test sees the - /// real `ime_stamp`/receipt path fail closed. - pub(super) fn reset_ime_state_revision(&mut self) { - #[cfg(any(test, feature = "test-support"))] - if self.ime_state_exhausted { - self.ime_state_revision = None; - return; - } - // v5: allocation failure must store `None`, never retain a stale revision. - self.ime_state_revision = next_ime_state_revision(); - } - - /// Allocates a new revision only when the body selection or the equation session and position - /// differs from the last observed state. - fn sync_ime_caret_state(&mut self) { - let state = self.current_ime_caret_state(); - if self.ime_state_seen.as_ref() != Some(&state) { - self.ime_state_seen = Some(state); - self.reset_ime_state_revision(); - } - } - - /// Run one command as the input method's own transaction. - /// - /// Commands run inside this scope are IME-origin: a successful command run *outside* this - /// scope sets the pending application-change signal, but nothing inside it does. Nested commands inherit - /// the origin. The previous origin is restored on every return, including unknown, disabled or - /// refused commands. - pub fn run_ime(&mut self, id: &str, params: &Value) -> Result { - let previous = std::mem::replace(&mut self.ime_origin, true); - let result = self.run(id, params); - self.ime_origin = previous; - result - } -} +use super::ime_state::{ImeCaretState, next_ime_state_revision}; +use super::{CmdError, ImeEditOutcome, Session, Value}; + +impl Session { + /// The current command's explicit IME effect classification. + pub fn ime_edit_outcome(&self) -> ImeEditOutcome { + self.ime_edit_outcome + } + + /// Set the effect classification from a command handler. [`Session::run`] fills + /// `selection_changed` and enforces the content/state invariant after the command. + pub(crate) fn set_ime_edit_outcome(&mut self, outcome: ImeEditOutcome) { + self.ime_edit_outcome = outcome; + } + + /// Take the pending application-origin change signal. The UI owns one session, so a simple + /// bool is enough; it deliberately survives later commands until drained. + pub fn take_pending_ime_application_change(&mut self) -> bool { + std::mem::take(&mut self.pending_ime_application_change) + } + + /// Simulate a globally exhausted IME revision allocator in this session (test support). + /// + /// This only ever makes receipt classification fail closed. It exists so front-end producer + /// tests can exercise the real `ime_stamp` path instead of hand-building an `Unavailable` + /// stamp; production builds do not compile it. + #[cfg(any(test, feature = "test-support"))] + #[doc(hidden)] + pub fn exhaust_ime_state_revision(&mut self) { + self.ime_state_exhausted = true; + self.ime_state_revision = None; + } + + /// The checked, process-monotonic revision for IME receipts with unavailable surrounding + /// text. Selection/caret motion allocates on the next call; `None` means exhausted. + pub fn ime_state_revision(&mut self) -> Option { + self.sync_ime_caret_state(); + self.ime_state_revision + } + + /// Returns the caret identity used for IME state revisions: the equation session and position + /// while editing, otherwise the body selection. + pub(super) fn current_ime_caret_state(&self) -> ImeCaretState { + match &self.math { + Some(m) => ImeCaretState::Equation { session: m.session, pos: m.pos.clone() }, + None => ImeCaretState::Body(self.sel.clone()), + } + } + + /// Allocate the next IME state revision, or store `None` when the allocator is exhausted. + /// Test support can pin exhaustion for this session so a front-end producer test sees the + /// real `ime_stamp`/receipt path fail closed. + pub(super) fn reset_ime_state_revision(&mut self) { + #[cfg(any(test, feature = "test-support"))] + if self.ime_state_exhausted { + self.ime_state_revision = None; + return; + } + // v5: allocation failure must store `None`, never retain a stale revision. + self.ime_state_revision = next_ime_state_revision(); + } + + /// Allocates a new revision only when the body selection or the equation session and position + /// differs from the last observed state. + fn sync_ime_caret_state(&mut self) { + let state = self.current_ime_caret_state(); + if self.ime_state_seen.as_ref() != Some(&state) { + self.ime_state_seen = Some(state); + self.reset_ime_state_revision(); + } + } + + /// Run one command as the input method's own transaction. + /// + /// Commands run inside this scope are IME-origin: a successful command run *outside* this + /// scope sets the pending application-change signal, but nothing inside it does. Nested commands inherit + /// the origin. The previous origin is restored on every return, including unknown, disabled or + /// refused commands. + pub fn run_ime(&mut self, id: &str, params: &Value) -> Result { + let previous = std::mem::replace(&mut self.ime_origin, true); + let result = self.run(id, params); + self.ime_origin = previous; + result + } +} diff --git a/crates/engine/src/session/layout.rs b/crates/engine/src/session/layout.rs index 2f773e308f..823f751894 100644 --- a/crates/engine/src/session/layout.rs +++ b/crates/engine/src/session/layout.rs @@ -1,218 +1,218 @@ -use super::doc_state::{Selection, TypedWord, typed_range}; -use super::locale::SYSTEM_LOCALE; -use super::snapshot_types::LayoutEntry; -use super::view::DRAFT_WIDTH; -use super::{Arc, DocLayout, Document, LayoutCache, LayoutOptions, Session, StoryRef, Value, ViewMode}; - -impl Session { - /// The current layout (recomputed when the document or view changed). - pub fn layout(&mut self) -> Arc { - let typing = self.typing_word(); - self.layout_with(typing) - } - - /// [`Session::layout`] with `typing` as the word being typed. - fn layout_with(&mut self, typing: Option) -> Arc { - let mode = if self.view.read_mode { ViewMode::Read } else { self.view.mode }; - let width = match mode { - ViewMode::Read => self.view.read_width, - ViewMode::Web => self.view.web_width, - _ => DRAFT_WIDTH, - }; - let height = if self.view.read_mode { self.view.read_height } else { 0.0 }; - let spacing = if self.view.read_mode { self.view.read_line_spacing.map(f32::to_bits) } else { None }; - // Deletions in balloons depend on the view (the Comments pane, Read Mode, side by side), so - // a view change that moves them in or out of the text lays it out again. - let hide_deleted = !self.view.show_markup; - let deleted = (hide_deleted, self.prefs.markup.hidden_deletions(), self.revisions_in_balloons()); - let outline = (mode == ViewMode::Outline).then(|| self.view.outline.view()); - let shown = (self.view.marks || self.prefs.marks.hidden, self.view.field_codes); - // A dictionary or Language pack installed, removed or edited (in this document's window or - // any other) proofs it again, open or parked. - let rev = (self.rev, wordcraft_proof::dictionary_generation()); - let (proofing, numeral) = (self.view.proofing, self.view.numeral); - let fits = |e: &Option| { - e.as_ref().is_some_and(|(r, w, h, m, sp, _, pf, o, hd, n, t, sh)| { - *r == rev - && (*w == width || mode == ViewMode::Print) - && *h == height - && *m == mode - && *sp == spacing - && *pf == proofing - && *n == numeral - && *hd == deleted - && *o == outline - && *t == typing - && *sh == shown - }) - }; - if !fits(&self.layout[0]) && fits(&self.layout[1]) { - self.layout.swap(0, 1); - } - if fits(&self.layout[0]) - && let Some(e) = &self.layout[0] - { - return e.5.clone(); - } - let (fn_name, fn_path) = self.resolved_filenames(); - let (filename, filepath): (Option>, Option>) = (Some(Arc::from(fn_name.as_str())), Some(Arc::from(fn_path.as_str()))); - let opts = LayoutOptions { - view: mode, - web_width: width, - read_height: height, - read_line_spacing: spacing.map(f32::from_bits), - show_hidden: shown.0, - hide_deleted, - hide_marked: deleted.1, - deletions_in_balloons: deleted.2, - proofing: self.view.proofing, - numeral: self.view.numeral.resolve(SYSTEM_LOCALE.get().map(String::as_str)), - comment_balloons: false, - field_codes: shown.1, - outline: outline.clone(), - proof: wordcraft_layout::ProofOptions { grammar: self.prefs.mark_grammar, spelling: self.prefs.spelling() }, - typing_word: typing, - filename: filename.clone(), - filepath: filepath.clone(), - }; - let l = Arc::new(wordcraft_layout::layout(&self.doc, &mut self.cache, &opts)); - let [newest, _] = std::mem::take(&mut self.layout); - self.layout = - [Some((rev, width, height, mode, spacing, l.clone(), self.view.proofing, outline, deleted, self.view.numeral, typing, shown)), newest]; - l - } - - /// The recorded word while the caret and the word are as typing left them; else drops it. - fn typing_word(&mut self) -> Option { - let t = self.typing.as_ref()?; - let p = if self.sel.is_collapsed() && self.sel.focus == t.at { self.doc.para_at(&t.at) } else { None }; - match p { - Some(p) if typed_range(&p.text, t.at.off) == Some((t.start, t.end)) => { - Some(wordcraft_layout::TypingWord { rev: p.rev, start: t.start, end: t.end }) - } - _ => { - self.typing = None; - None - } - } - } - - /// After a successful command: a `text.*` edit at the caret records the word being typed; - /// any other caret move ends it. - pub(super) fn note_typing(&mut self, id: &str, before: Option<&Document>, sel_before: &Selection) { - let at = &self.sel.focus; - let edited = || before.is_some_and(|d| d.para_at(at).map(|p| &p.text) != self.doc.para_at(at).map(|p| &p.text)); - if id.starts_with("text.") && edited() { - self.typing = self.word_being_typed(); - } else if self.sel != *sel_before { - self.typing = None; - } - } - - /// The word the collapsed caret ends or sits in, as [`TypedWord`]. - fn word_being_typed(&self) -> Option { - if !self.sel.is_collapsed() { - return None; - } - let at = self.sel.focus.clone(); - let (start, end) = typed_range(&self.doc.para_at(&at)?.text, at.off)?; - Some(TypedWord { at, start, end }) - } - - /// The page as command `id` would leave it, for a live preview (Insert › Table's grid, #3931): - /// the command runs on a copy of the document, with this caret and view, and the copy is laid - /// out. The copy comes back with its layout, which is drawn from it: the layout's paragraph - /// paths name the copy's paragraphs, not this document's, and so is the copy's selection, - /// which is where the caret would be. The document, the selection, the undo history and Repeat - /// are untouched. `None` when the command fails or isn't allowed here. - pub fn preview_layout(&mut self, id: &str, params: &Value) -> Option<(Document, Selection, Arc)> { - // The word being typed keeps going without a squiggle on the preview: the command moves - // the copy's caret off it and may split its paragraph, so it is found again by its place. - let typing = self.typing_word(); - let at = self.sel.focus.clone(); - let mut copy = Session::with_registry(self.doc.clone(), self.registry.clone()); - copy.sel = self.sel.clone(); - copy.column = self.column.clone(); - copy.widened = self.widened.clone(); - copy.view = self.view.clone(); - copy.prefs = self.prefs.clone(); - copy.path = self.path.clone(); - copy.file_name = self.file_name.clone(); - copy.author = self.author.clone(); - copy.last_highlight = self.last_highlight; - copy.system_region = self.system_region.clone(); - copy.editing_lang = self.editing_lang.clone(); - // The copy shares the paragraph layouts, so only what the command changed is laid out. - std::mem::swap(&mut copy.cache, &mut self.cache); - let layout = copy.run(id, params).is_ok().then(|| { - let typing = typing.and_then(|t| { - let p = copy.doc.para_at(&at)?; - (t.end <= p.len()).then_some(wordcraft_layout::TypingWord { rev: p.rev, ..t }) - }); - copy.layout_with(typing) - }); - std::mem::swap(&mut copy.cache, &mut self.cache); - layout.map(|l| (copy.doc, copy.sel, l)) - } - - /// A layout for output (PDF, images, print): no proofing marks, print view. - pub fn export_layout(&self) -> Arc { - self.export_layout_with(false) - } - - /// [`Session::export_layout`]; with `markup` (Print Markup) each page also has the markup - /// area with the comments in balloons. Fields Word fills in for output show their fresh - /// results ([`crate::cmd::references::output_fields`]). - pub fn export_layout_with(&self, markup: bool) -> Arc { - let out = crate::cmd::references::output_fields(&self.doc); - self.export_layout_of(out.as_ref().unwrap_or(&self.doc), markup) - } - - /// [`Session::export_layout_with`] of `doc`, this document as it is output. - pub(crate) fn export_layout_of(&self, doc: &Document, markup: bool) -> Arc { - let (fn_name, fn_path) = self.resolved_filenames(); - let opts = LayoutOptions { - view: ViewMode::Print, - comment_balloons: markup && self.prefs.markup.comments, - numeral: self.view.numeral.resolve(SYSTEM_LOCALE.get().map(String::as_str)), - filename: Some(Arc::from(fn_name.as_str())), - filepath: Some(Arc::from(fn_path.as_str())), - ..Default::default() - }; - Arc::new(wordcraft_layout::layout(doc, &mut LayoutCache::new(), &opts)) - } - - /// Invalidate the cached layout (fonts changed etc.). - pub fn relayout(&mut self) { - self.layout = Default::default(); - self.cache.clear(); - } - - /// Set the language new documents are written in ([`Session::editing_lang`]; `None` or a - /// malformed tag: US English). A document nobody has touched yet — untitled, unchanged, no - /// undo steps, no text — switches too, so the blank document a session starts with follows - /// the saved proofing default, or the interface language when none is saved. Nothing else - /// changes and no undo step is added. - pub fn set_editing_language(&mut self, tag: Option<&str>) { - let tag = self - .prefs - .default_language - .as_deref() - .and_then(wordcraft_proof::lang::normalize_tag) - .or_else(|| tag.and_then(wordcraft_proof::lang::normalize_tag)); - if self.editing_lang == tag { - return; - } - self.editing_lang = tag; - let untouched = self.path.is_none() && !self.dirty && self.history.is_empty() && self.redo.is_empty(); - if untouched && self.doc.plain_text(StoryRef::Body).trim().is_empty() && self.doc.paragraph_count() <= 1 { - Arc::make_mut(&mut self.doc.styles).default_chr.lang = Some(self.editing_lang.clone().unwrap_or_else(|| "en-US".into())); - self.rev = self.rev.wrapping_add(1); - // Not an edit: the document is as untouched as it was. - if self.made_rev == self.rev.wrapping_sub(1) { - self.made_rev = self.rev; - } - self.relayout(); - } - } -} +use super::doc_state::{Selection, TypedWord, typed_range}; +use super::locale::SYSTEM_LOCALE; +use super::snapshot_types::LayoutEntry; +use super::view::DRAFT_WIDTH; +use super::{Arc, DocLayout, Document, LayoutCache, LayoutOptions, Session, StoryRef, Value, ViewMode}; + +impl Session { + /// The current layout (recomputed when the document or view changed). + pub fn layout(&mut self) -> Arc { + let typing = self.typing_word(); + self.layout_with(typing) + } + + /// [`Session::layout`] with `typing` as the word being typed. + fn layout_with(&mut self, typing: Option) -> Arc { + let mode = if self.view.read_mode { ViewMode::Read } else { self.view.mode }; + let width = match mode { + ViewMode::Read => self.view.read_width, + ViewMode::Web => self.view.web_width, + _ => DRAFT_WIDTH, + }; + let height = if self.view.read_mode { self.view.read_height } else { 0.0 }; + let spacing = if self.view.read_mode { self.view.read_line_spacing.map(f32::to_bits) } else { None }; + // Deletions in balloons depend on the view (the Comments pane, Read Mode, side by side), so + // a view change that moves them in or out of the text lays it out again. + let hide_deleted = !self.view.show_markup; + let deleted = (hide_deleted, self.prefs.markup.hidden_deletions(), self.revisions_in_balloons()); + let outline = (mode == ViewMode::Outline).then(|| self.view.outline.view()); + let shown = (self.view.marks || self.prefs.marks.hidden, self.view.field_codes); + // A dictionary or Language pack installed, removed or edited (in this document's window or + // any other) proofs it again, open or parked. + let rev = (self.rev, wordcraft_proof::dictionary_generation()); + let (proofing, numeral) = (self.view.proofing, self.view.numeral); + let fits = |e: &Option| { + e.as_ref().is_some_and(|(r, w, h, m, sp, _, pf, o, hd, n, t, sh)| { + *r == rev + && (*w == width || mode == ViewMode::Print) + && *h == height + && *m == mode + && *sp == spacing + && *pf == proofing + && *n == numeral + && *hd == deleted + && *o == outline + && *t == typing + && *sh == shown + }) + }; + if !fits(&self.layout[0]) && fits(&self.layout[1]) { + self.layout.swap(0, 1); + } + if fits(&self.layout[0]) + && let Some(e) = &self.layout[0] + { + return e.5.clone(); + } + let (fn_name, fn_path) = self.resolved_filenames(); + let (filename, filepath): (Option>, Option>) = (Some(Arc::from(fn_name.as_str())), Some(Arc::from(fn_path.as_str()))); + let opts = LayoutOptions { + view: mode, + web_width: width, + read_height: height, + read_line_spacing: spacing.map(f32::from_bits), + show_hidden: shown.0, + hide_deleted, + hide_marked: deleted.1, + deletions_in_balloons: deleted.2, + proofing: self.view.proofing, + numeral: self.view.numeral.resolve(SYSTEM_LOCALE.get().map(String::as_str)), + comment_balloons: false, + field_codes: shown.1, + outline: outline.clone(), + proof: wordcraft_layout::ProofOptions { grammar: self.prefs.mark_grammar, spelling: self.prefs.spelling() }, + typing_word: typing, + filename: filename.clone(), + filepath: filepath.clone(), + }; + let l = Arc::new(wordcraft_layout::layout(&self.doc, &mut self.cache, &opts)); + let [newest, _] = std::mem::take(&mut self.layout); + self.layout = + [Some((rev, width, height, mode, spacing, l.clone(), self.view.proofing, outline, deleted, self.view.numeral, typing, shown)), newest]; + l + } + + /// The recorded word while the caret and the word are as typing left them; else drops it. + fn typing_word(&mut self) -> Option { + let t = self.typing.as_ref()?; + let p = if self.sel.is_collapsed() && self.sel.focus == t.at { self.doc.para_at(&t.at) } else { None }; + match p { + Some(p) if typed_range(&p.text, t.at.off) == Some((t.start, t.end)) => { + Some(wordcraft_layout::TypingWord { rev: p.rev, start: t.start, end: t.end }) + } + _ => { + self.typing = None; + None + } + } + } + + /// After a successful command: a `text.*` edit at the caret records the word being typed; + /// any other caret move ends it. + pub(super) fn note_typing(&mut self, id: &str, before: Option<&Document>, sel_before: &Selection) { + let at = &self.sel.focus; + let edited = || before.is_some_and(|d| d.para_at(at).map(|p| &p.text) != self.doc.para_at(at).map(|p| &p.text)); + if id.starts_with("text.") && edited() { + self.typing = self.word_being_typed(); + } else if self.sel != *sel_before { + self.typing = None; + } + } + + /// The word the collapsed caret ends or sits in, as [`TypedWord`]. + fn word_being_typed(&self) -> Option { + if !self.sel.is_collapsed() { + return None; + } + let at = self.sel.focus.clone(); + let (start, end) = typed_range(&self.doc.para_at(&at)?.text, at.off)?; + Some(TypedWord { at, start, end }) + } + + /// The page as command `id` would leave it, for a live preview (Insert › Table's grid, #3931): + /// the command runs on a copy of the document, with this caret and view, and the copy is laid + /// out. The copy comes back with its layout, which is drawn from it: the layout's paragraph + /// paths name the copy's paragraphs, not this document's, and so is the copy's selection, + /// which is where the caret would be. The document, the selection, the undo history and Repeat + /// are untouched. `None` when the command fails or isn't allowed here. + pub fn preview_layout(&mut self, id: &str, params: &Value) -> Option<(Document, Selection, Arc)> { + // The word being typed keeps going without a squiggle on the preview: the command moves + // the copy's caret off it and may split its paragraph, so it is found again by its place. + let typing = self.typing_word(); + let at = self.sel.focus.clone(); + let mut copy = Session::with_registry(self.doc.clone(), self.registry.clone()); + copy.sel = self.sel.clone(); + copy.column = self.column.clone(); + copy.widened = self.widened.clone(); + copy.view = self.view.clone(); + copy.prefs = self.prefs.clone(); + copy.path = self.path.clone(); + copy.file_name = self.file_name.clone(); + copy.author = self.author.clone(); + copy.last_highlight = self.last_highlight; + copy.system_region = self.system_region.clone(); + copy.editing_lang = self.editing_lang.clone(); + // The copy shares the paragraph layouts, so only what the command changed is laid out. + std::mem::swap(&mut copy.cache, &mut self.cache); + let layout = copy.run(id, params).is_ok().then(|| { + let typing = typing.and_then(|t| { + let p = copy.doc.para_at(&at)?; + (t.end <= p.len()).then_some(wordcraft_layout::TypingWord { rev: p.rev, ..t }) + }); + copy.layout_with(typing) + }); + std::mem::swap(&mut copy.cache, &mut self.cache); + layout.map(|l| (copy.doc, copy.sel, l)) + } + + /// A layout for output (PDF, images, print): no proofing marks, print view. + pub fn export_layout(&self) -> Arc { + self.export_layout_with(false) + } + + /// [`Session::export_layout`]; with `markup` (Print Markup) each page also has the markup + /// area with the comments in balloons. Fields Word fills in for output show their fresh + /// results ([`crate::cmd::references::output_fields`]). + pub fn export_layout_with(&self, markup: bool) -> Arc { + let out = crate::cmd::references::output_fields(&self.doc); + self.export_layout_of(out.as_ref().unwrap_or(&self.doc), markup) + } + + /// [`Session::export_layout_with`] of `doc`, this document as it is output. + pub(crate) fn export_layout_of(&self, doc: &Document, markup: bool) -> Arc { + let (fn_name, fn_path) = self.resolved_filenames(); + let opts = LayoutOptions { + view: ViewMode::Print, + comment_balloons: markup && self.prefs.markup.comments, + numeral: self.view.numeral.resolve(SYSTEM_LOCALE.get().map(String::as_str)), + filename: Some(Arc::from(fn_name.as_str())), + filepath: Some(Arc::from(fn_path.as_str())), + ..Default::default() + }; + Arc::new(wordcraft_layout::layout(doc, &mut LayoutCache::new(), &opts)) + } + + /// Invalidate the cached layout (fonts changed etc.). + pub fn relayout(&mut self) { + self.layout = Default::default(); + self.cache.clear(); + } + + /// Set the language new documents are written in ([`Session::editing_lang`]; `None` or a + /// malformed tag: US English). A document nobody has touched yet — untitled, unchanged, no + /// undo steps, no text — switches too, so the blank document a session starts with follows + /// the saved proofing default, or the interface language when none is saved. Nothing else + /// changes and no undo step is added. + pub fn set_editing_language(&mut self, tag: Option<&str>) { + let tag = self + .prefs + .default_language + .as_deref() + .and_then(wordcraft_proof::lang::normalize_tag) + .or_else(|| tag.and_then(wordcraft_proof::lang::normalize_tag)); + if self.editing_lang == tag { + return; + } + self.editing_lang = tag; + let untouched = self.path.is_none() && !self.dirty && self.history.is_empty() && self.redo.is_empty(); + if untouched && self.doc.plain_text(StoryRef::Body).trim().is_empty() && self.doc.paragraph_count() <= 1 { + Arc::make_mut(&mut self.doc.styles).default_chr.lang = Some(self.editing_lang.clone().unwrap_or_else(|| "en-US".into())); + self.rev = self.rev.wrapping_add(1); + // Not an edit: the document is as untouched as it was. + if self.made_rev == self.rev.wrapping_sub(1) { + self.made_rev = self.rev; + } + self.relayout(); + } + } +} diff --git a/crates/engine/src/session/lifecycle.rs b/crates/engine/src/session/lifecycle.rs index 9e818a5155..c80293e9f6 100644 --- a/crates/engine/src/session/lifecycle.rs +++ b/crates/engine/src/session/lifecycle.rs @@ -1,181 +1,181 @@ -use super::doc_state::MAX_UI_REQUESTS; -use super::prefs::Prefs; -use super::view::ViewState; -use super::{Arc, DocState, Document, ImeEditOutcome, Registry, Session, Value}; - -impl Session { - /// Queue a request for the front end. A headless session never drains the queue, so only - /// the newest [`MAX_UI_REQUESTS`] are kept. - pub fn push_ui_request(&mut self, req: Value) { - if self.ui_requests.len() >= MAX_UI_REQUESTS { - self.ui_requests.remove(0); - } - self.ui_requests.push(req); - } - - /// Save the custom dictionary through [`Session::user_dict_path`], if the host wired one up, - /// with `removed` (words just taken out) left out of the merge with the file. A failed save - /// keeps the words in memory and never fails the command (#40), as does a file outside the - /// automation roots in force: it is read, then replaced beside itself (#3737). - pub(crate) fn persist_user_dictionary(&self, removed: &[String]) { - let Some(path) = self.user_dict_path.as_deref() else { return }; - let name = path.to_string_lossy(); - if let Err(e) = crate::file_access::check_read(&name) - .and_then(|()| crate::file_access::check_write(&name)) - .and_then(|()| wordcraft_proof::save_user_dictionary(path, removed).map_err(|e| e.to_string())) - { - log::warn!("custom dictionary: save failed: {e}"); - } - } - - /// Start an editing session at the document beginning with empty history and - /// default view, preferences and a yellow highlighter independent of document undo. - pub fn new(doc: Document) -> Self { - Self::with_registry(doc, Arc::new(crate::cmd::registry())) - } - - /// Initialise session defaults while sharing the immutable command registry with previews. - pub(super) fn with_registry(doc: Document, registry: Arc) -> Self { - let DocState { - doc, - sel, - view, - pending, - path, - file_name, - dirty, - pending_move, - find, - goal_x, - page_hint, - new_header_parts, - hf_return, - history, - undo_evicted, - redo, - typing_open, - join_next, - equation_join_next, - ime_text_join, - join_anchor, - autocap, - rev, - document_id, - untitled, - made_rev, - ime_body_epoch, - cache, - layout, - typing, - originals, - md_source, - md_file, - picture_looks, - versions, - autosave, - bib_style, - merge, - math, - password, - #[cfg(not(target_arch = "wasm32"))] - recovery_slot, - also_selected, - column, - column_mode, - widened, - edit_marks, - go_back_step, - } = DocState::new(doc, 1, ViewState::default()); - Session { - doc, - sel, - view, - pending, - last_highlight: wordcraft_doc::props::Highlight::Yellow, - path, - file_name, - user_dict_path: None, - dirty, - clipboard: None, - pending_move, - clipboard_text: String::new(), - clipboard_html: String::new(), - clipboard_rtf: String::new(), - clip_history: Default::default(), - find, - goal_x, - page_hint, - registry, - author: "WordCraft User".into(), - initials: String::new(), - painter: None, - status: String::new(), - new_header_parts, - hf_return, - review_style: None, - history, - undo_evicted, - redo, - typing_open, - join_next, - equation_join_next, - ime_text_join, - join_anchor, - autocap, - rev, - document_id, - untitled, - made_rev, - protect_facts: std::sync::Mutex::new(None), - ime_body_epoch, - ime_state_revision: None, - ime_state_seen: None, - ime_edit_outcome: ImeEditOutcome::default(), - #[cfg(any(test, feature = "test-support"))] - ime_state_exhausted: false, - pending_ime_application_change: false, - ime_origin: false, - cache, - layout, - typing, - originals, - md_source, - md_file, - picture_looks, - last_command: None, - recording: None, - macros: Default::default(), - versions, - building_blocks: Default::default(), - autosave, - bib_style, - merge, - ui_requests: Vec::new(), - math, - math_latex: false, - math_normal_text: false, - also_selected, - member: None, - prefs: Prefs::default(), - system_region: Default::default(), - keymap: crate::KeyMap::default(), - ribbon: crate::RibbonLayout::default(), - editing_lang: None, - read_aloud: Default::default(), - windows: Default::default(), - password, - #[cfg(not(target_arch = "wasm32"))] - recovery: None, - #[cfg(not(target_arch = "wasm32"))] - recovery_slot, - column, - column_mode, - widened, - edit_marks, - go_back_step, - others: Vec::new(), - order: vec![document_id], - untitled_count: 1, - } - } -} +use super::doc_state::MAX_UI_REQUESTS; +use super::prefs::Prefs; +use super::view::ViewState; +use super::{Arc, DocState, Document, ImeEditOutcome, Registry, Session, Value}; + +impl Session { + /// Queue a request for the front end. A headless session never drains the queue, so only + /// the newest [`MAX_UI_REQUESTS`] are kept. + pub fn push_ui_request(&mut self, req: Value) { + if self.ui_requests.len() >= MAX_UI_REQUESTS { + self.ui_requests.remove(0); + } + self.ui_requests.push(req); + } + + /// Save the custom dictionary through [`Session::user_dict_path`], if the host wired one up, + /// with `removed` (words just taken out) left out of the merge with the file. A failed save + /// keeps the words in memory and never fails the command (#40), as does a file outside the + /// automation roots in force: it is read, then replaced beside itself (#3737). + pub(crate) fn persist_user_dictionary(&self, removed: &[String]) { + let Some(path) = self.user_dict_path.as_deref() else { return }; + let name = path.to_string_lossy(); + if let Err(e) = crate::file_access::check_read(&name) + .and_then(|()| crate::file_access::check_write(&name)) + .and_then(|()| wordcraft_proof::save_user_dictionary(path, removed).map_err(|e| e.to_string())) + { + log::warn!("custom dictionary: save failed: {e}"); + } + } + + /// Start an editing session at the document beginning with empty history and + /// default view, preferences and a yellow highlighter independent of document undo. + pub fn new(doc: Document) -> Self { + Self::with_registry(doc, Arc::new(crate::cmd::registry())) + } + + /// Initialise session defaults while sharing the immutable command registry with previews. + pub(super) fn with_registry(doc: Document, registry: Arc) -> Self { + let DocState { + doc, + sel, + view, + pending, + path, + file_name, + dirty, + pending_move, + find, + goal_x, + page_hint, + new_header_parts, + hf_return, + history, + undo_evicted, + redo, + typing_open, + join_next, + equation_join_next, + ime_text_join, + join_anchor, + autocap, + rev, + document_id, + untitled, + made_rev, + ime_body_epoch, + cache, + layout, + typing, + originals, + md_source, + md_file, + picture_looks, + versions, + autosave, + bib_style, + merge, + math, + password, + #[cfg(not(target_arch = "wasm32"))] + recovery_slot, + also_selected, + column, + column_mode, + widened, + edit_marks, + go_back_step, + } = DocState::new(doc, 1, ViewState::default()); + Session { + doc, + sel, + view, + pending, + last_highlight: wordcraft_doc::props::Highlight::Yellow, + path, + file_name, + user_dict_path: None, + dirty, + clipboard: None, + pending_move, + clipboard_text: String::new(), + clipboard_html: String::new(), + clipboard_rtf: String::new(), + clip_history: Default::default(), + find, + goal_x, + page_hint, + registry, + author: "WordCraft User".into(), + initials: String::new(), + painter: None, + status: String::new(), + new_header_parts, + hf_return, + review_style: None, + history, + undo_evicted, + redo, + typing_open, + join_next, + equation_join_next, + ime_text_join, + join_anchor, + autocap, + rev, + document_id, + untitled, + made_rev, + protect_facts: std::sync::Mutex::new(None), + ime_body_epoch, + ime_state_revision: None, + ime_state_seen: None, + ime_edit_outcome: ImeEditOutcome::default(), + #[cfg(any(test, feature = "test-support"))] + ime_state_exhausted: false, + pending_ime_application_change: false, + ime_origin: false, + cache, + layout, + typing, + originals, + md_source, + md_file, + picture_looks, + last_command: None, + recording: None, + macros: Default::default(), + versions, + building_blocks: Default::default(), + autosave, + bib_style, + merge, + ui_requests: Vec::new(), + math, + math_latex: false, + math_normal_text: false, + also_selected, + member: None, + prefs: Prefs::default(), + system_region: Default::default(), + keymap: crate::KeyMap::default(), + ribbon: crate::RibbonLayout::default(), + editing_lang: None, + read_aloud: Default::default(), + windows: Default::default(), + password, + #[cfg(not(target_arch = "wasm32"))] + recovery: None, + #[cfg(not(target_arch = "wasm32"))] + recovery_slot, + column, + column_mode, + widened, + edit_marks, + go_back_step, + others: Vec::new(), + order: vec![document_id], + untitled_count: 1, + } + } +} diff --git a/crates/engine/src/session/query.rs b/crates/engine/src/session/query.rs index fdd1c503a7..3c731e15a3 100644 --- a/crates/engine/src/session/query.rs +++ b/crates/engine/src/session/query.rs @@ -1,302 +1,302 @@ -use super::doc_state::{CellRange, EDIT_MARKS, Selection}; -use super::{CharProps, Document, PartKind, Path, Pos, Session, StoryRef, ViewMode, untitled_name}; - -impl Session { - /// Remember the caret as the place of an edit, for Go Back. Edits in the same paragraph as - /// the last one (typing on) update that place rather than adding one. - pub(super) fn note_edit(&mut self) { - let at = self.sel.focus.clone(); - match self.edit_marks.last_mut() { - Some(last) if last.story == at.story && last.path == at.path => *last = at, - _ => { - self.edit_marks.push(at); - if self.edit_marks.len() > EDIT_MARKS { - self.edit_marks.remove(0); - } - } - } - self.go_back_step = 0; - } - - /// Go Back (Shift+F5): the next of the last edit places, most recent first, cycling; a place - /// the caret is already at is skipped. `None` before any edit. - pub fn go_back(&mut self) -> Option { - let n = self.edit_marks.len(); - for _ in 0..n { - let i = n - 1 - self.go_back_step % n; - self.go_back_step = self.go_back_step.wrapping_add(1); - let Some(p) = self.edit_marks.get(i) else { continue }; - let p = self.doc.clamp(p); - if p != self.sel.focus || n == 1 { - return Some(p); - } - } - self.edit_marks.last().map(|p| self.doc.clamp(p)) - } - - /// The line pieces of the current column selection, if there is one (the selection hasn't - /// moved since the block was made). - pub fn column_segments(&self) -> Option<&[(Pos, Pos)]> { - // Cells that are one run of the text are an ordinary stream selection. - self.column.as_ref().filter(|c| c.sel == self.sel && !c.cells.as_ref().is_some_and(|r| r.stream)).map(|c| c.segments.as_slice()) - } - - /// The whole table cells selected, if the selection is that (see [`CellRange`]). - pub fn cell_range(&self) -> Option<&CellRange> { - self.column.as_ref().filter(|c| c.sel == self.sel).and_then(|c| c.cells.as_ref()) - } - - /// The selection as it was made: where a drag, Shift+click or Shift+arrows started and where - /// the caret went, even when Word's table rule widened it to whole cells or rows (`sel` holds - /// those). - pub fn sel_ends(&self) -> &Selection { - self.widened.as_ref().filter(|w| w.sel == self.sel).map_or(&self.sel, |w| &w.ends) - } - - pub fn selects_whole_story(&self) -> bool { - let st = self.sel.focus.story; - let (a, b) = self.sel.ordered(); - a == self.doc.start_of(st) && b == self.doc.end_of(st) - } - - /// Whether the caret is in a header or footer. - pub fn in_header_footer(&self) -> bool { - let StoryRef::Part(id) = self.sel.focus.story else { return false }; - self.doc.parts.get(&id).is_some_and(|p| matches!(p.kind, PartKind::Header | PartKind::Footer)) - } - - /// Document revision (bumped by every change). - pub fn rev(&self) -> u64 { - self.rev - } - - /// Which document is open (see `document_id`): lets a caller tell an edit from a replacement. - pub fn document_id(&self) -> u64 { - self.document_id - } - - /// How many times the body was replaced under this [`Self::document_id`]. - /// - /// Front ends use it as the body's IME replacement epoch and to tell a body swap from an - /// ordinary edit; it advances for `document.setText`, a version restore, a compare and - /// Undo/Redo, never for typing. - pub fn ime_body_epoch(&self) -> u64 { - self.ime_body_epoch - } - - /// Note that a command replaced the document or its whole body under this document id. - /// - /// The counter fails closed: if it were ever exhausted (unreachable in practice), the - /// document owner id advances too, so a cached IME context still cannot be reused. - pub fn note_body_replaced(&mut self) { - self.ime_body_epoch = match self.ime_body_epoch.checked_add(1) { - Some(epoch) => epoch, - None => { - self.document_id = self.document_id.saturating_add(1); - u64::MAX - } - }; - } - - pub fn touch(&mut self) { - self.rev = self.rev.wrapping_add(1); - self.dirty = true; - } - - /// Reading Highlight ranges for the current document (searched again after edits); empty when it's off. - pub fn find_highlights(&mut self) -> &[(Pos, Pos)] { - if !self.find.highlight { - return &[]; - } - if self.find.highlights.as_ref().is_none_or(|(r, _)| *r != self.rev) { - let found = crate::cmd::edit::search(self, StoryRef::Body).unwrap_or_default(); - self.find.highlights = Some((self.rev, found)); - } - self.find.highlights.as_ref().map(|(_, v)| v.as_slice()).unwrap_or(&[]) - } - - /// Whether the markup area beside the page can show balloons: markup shown in Print Layout - /// (not Read Mode or pages side by side), comments not listed in the Comments pane instead, - /// and Track Changes Options not set to show every revision inline. - pub fn balloons_shown(&self) -> bool { - let v = &self.view; - v.show_markup - && !v.comments_pane - && !v.read_mode - && !v.multi_page - && v.mode == ViewMode::Print - && self.prefs.markup.balloons != wordcraft_layout::display::BalloonMode::Inline - } - - /// Show revisions in balloons, with the balloons on screen: tracked deletions in the body leave - /// the text for balloons in the markup area. Where no balloons can be shown they stay inline, - /// as in Word; so do deletions in headers, footers, notes and text boxes, which get no balloons. - pub fn revisions_in_balloons(&self) -> bool { - let m = &self.prefs.markup; - self.balloons_shown() && m.balloons == wordcraft_layout::display::BalloonMode::Revisions && m.insertions_deletions - } - - /// Initials for new comments: the ones set in File › Options, else the user name's. - pub fn user_initials(&self) -> String { - let set = self.initials.trim(); - if set.is_empty() { self.author.split_whitespace().filter_map(|w| w.chars().next()).collect() } else { set.to_string() } - } - - /// The document's file name and full path for fields (`FILENAME`) and layout context. - /// Returns `(filename, filepath)`. Unsaved documents with no explicit file name default - /// consistently to their name, `("Document1", "Document1")` for the first. Document metadata title is never used - /// as a filename fallback (#3449). - pub fn resolved_filenames(&self) -> (String, String) { - if let Some(path) = &self.path { - let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string_lossy().to_string()); - let full = path.to_string_lossy().to_string(); - (name, full) - } else if let Some(name) = &self.file_name { - (name.clone(), name.clone()) - } else { - let name = untitled_name(self.untitled); - (name.clone(), name) - } - } - - /// The region in effect (#838): the one the user chose ([`Prefs::region`]), else the - /// system's. - pub fn region(&self) -> wordcraft_geom::Region { - match self.prefs.region.as_deref() { - Some(tag) => wordcraft_geom::Region::from_tag(tag), - None => self.system_region.clone(), - } - } - - /// The English of the region in effect (`en-GB` for a British region), or `None` when the - /// region's language isn't English, it names no territory, or it is the United States (US - /// English is the default anyway). An English interface writes new documents in it, as Word - /// does on a British or Canadian system (#1045). - pub fn region_english(&self) -> Option { - let r = self.region(); - (r.language == "en" && !r.territory.is_empty() && r.territory != "US") - .then(|| format!("en-{}", r.territory)) - .and_then(|t| wordcraft_proof::lang::normalize_tag(&t)) - } - - /// The Spanish of the region in effect (`es-MX` on a Mexican system, `es-UY` on a Uruguayan - /// one; the ISO 639-2 `spa` counts as Spanish), or Spain's (`es-ES`) when the region's - /// language isn't Spanish or it names no territory. A Spanish interface writes new documents in it, so they are proofed in the - /// writer's Spanish from the first word (#3360). - pub fn region_spanish(&self) -> String { - let r = self.region(); - (matches!(r.language.as_str(), "es" | "spa") && !r.territory.is_empty()) - .then(|| format!("es-{}", r.territory)) - .and_then(|t| wordcraft_proof::lang::normalize_tag(&t)) - .unwrap_or_else(|| "es-ES".into()) - } - - /// The unit rulers and dialogs show lengths in: the user's choice, else the region's. - pub fn units(&self) -> wordcraft_geom::Unit { - self.prefs.units.unwrap_or_else(|| self.region().units()) - } - - /// `pt` in [`Session::units`], written the region's way (`2,54 cm` in Poland). - pub fn format_length(&self, pt: f32) -> String { - self.region().format(self.units(), pt) - } - - /// A new blank document (the Normal template) on the region's paper: Letter in the US and - /// other Letter regions, A4 elsewhere (#838). Margins stay 2.54 cm (1") all round. - pub fn blank_document(&self) -> Document { - blank_document(&self.region()) - } - - /// The paragraph path the caret is in. - pub fn caret_path(&self) -> &Path { - &self.sel.focus.path - } - - pub fn story(&self) -> StoryRef { - self.sel.focus.story - } - - /// Formatting typing would use at the caret: the caret's, except run direction. New text - /// takes its direction from its own letters, as on screen, so Latin typed or inserted into - /// reopened Hebrew isn't saved as right-to-left; the DOCX writer marks right-to-left pieces - /// itself (#1543). The bidi language stays (it only applies to complex-script text). - /// Over a selection, as in Word, typing takes the first selected character's formatting, - /// whichever way the selection was made (#2879). - pub fn typing_props(&self) -> CharProps { - let (a, _) = self.sel.ordered(); - let first = match self.doc.para_at(&a) { - Some(p) if self.pending.is_none() && !self.sel.is_collapsed() && a.off < p.len() => Some(p.props_of_char(a.off)), - _ => None, - }; - let mut c = match first { - Some(props) => { - // The selection is replaced, so the new text isn't part of a link or a note number. - let mut out = if props.link.is_some() { props.unlinked() } else { props.clone() }; - if matches!(out.style.as_deref(), Some("FootnoteReference" | "EndnoteReference")) { - out.style = None; - } - out - } - None => self.caret_props(), - }; - c.rtl = None; - c - } - - /// The caret's formatting, as Reveal Formatting shows it. - pub fn caret_props(&self) -> CharProps { - if let Some(p) = &self.pending { - return p.clone(); - } - let f = &self.sel.focus; - let Some(p) = self.doc.para_at(f) else { return CharProps::default() }; - let props = p.props_at(f.off); - // As in Word, text typed at either end of a hyperlink isn't part of it; only typing - // inside the link extends it. - let inside_link = props.link.is_some() && f.off > 0 && f.off < p.len() && p.props_of_char(f.off).link == props.link; - // Text typed next to a symbol is in the text's font, not the symbol's (`w:sym`). - let mut out = (if props.link.is_some() && !inside_link { props.unlinked() } else { props.clone() }).without_symbol(); - // A footnote or endnote number is raised by its character style; text typed after it is body text. - if matches!(out.style.as_deref(), Some("FootnoteReference" | "EndnoteReference")) { - out.style = None; - } - out - } - - /// Words in the document, as the status bar shows them (see [`Prefs::count_notes`]). - pub fn word_count(&self) -> usize { - if self.prefs.count_notes { self.doc.word_count_including_notes() } else { self.doc.word_count() } - } - - /// The stories the Word Count dialog and the status bar count: the body plus notes and text - /// boxes when [`Prefs::count_notes`] is on ([`Document::counted_stories`]). - pub fn counted_stories(&self) -> Vec { - if self.prefs.count_notes { self.doc.counted_stories() } else { vec![StoryRef::Body] } - } - - /// The whole document's text the counts cover (#2797): the stories [`Session::counted_stories`] - /// names, joined with paragraph breaks, as the Word Count dialog counts them. - pub fn counted_text(&self) -> String { - self.counted_stories().iter().map(|st| self.doc.plain_text(*st)).collect::>().join("\n") - } - - /// Selected plain text. - pub fn selected_text(&self) -> String { - // A column block or whole cells that aren't one run of the text: each piece's own text, - // a line each, so the status bar and Word Count count those cells only. - if let Some(segs) = self.column_segments() { - return segs.iter().map(|(a, b)| self.doc.copy_range(a.min(b), a.max(b)).plain_text()).collect::>().join("\n"); - } - let (a, b) = self.sel.ordered(); - self.doc.copy_range(&a, &b).plain_text() - } -} - -/// A new blank document on `region`'s paper (see [`Session::blank_document`]). -pub fn blank_document(region: &wordcraft_geom::Region) -> Document { - let mut d = Document::new(); - let (_, w, h) = region.paper(); - d.last_section.page_w = w; - d.last_section.page_h = h; - d -} +use super::doc_state::{CellRange, EDIT_MARKS, Selection}; +use super::{CharProps, Document, PartKind, Path, Pos, Session, StoryRef, ViewMode, untitled_name}; + +impl Session { + /// Remember the caret as the place of an edit, for Go Back. Edits in the same paragraph as + /// the last one (typing on) update that place rather than adding one. + pub(super) fn note_edit(&mut self) { + let at = self.sel.focus.clone(); + match self.edit_marks.last_mut() { + Some(last) if last.story == at.story && last.path == at.path => *last = at, + _ => { + self.edit_marks.push(at); + if self.edit_marks.len() > EDIT_MARKS { + self.edit_marks.remove(0); + } + } + } + self.go_back_step = 0; + } + + /// Go Back (Shift+F5): the next of the last edit places, most recent first, cycling; a place + /// the caret is already at is skipped. `None` before any edit. + pub fn go_back(&mut self) -> Option { + let n = self.edit_marks.len(); + for _ in 0..n { + let i = n - 1 - self.go_back_step % n; + self.go_back_step = self.go_back_step.wrapping_add(1); + let Some(p) = self.edit_marks.get(i) else { continue }; + let p = self.doc.clamp(p); + if p != self.sel.focus || n == 1 { + return Some(p); + } + } + self.edit_marks.last().map(|p| self.doc.clamp(p)) + } + + /// The line pieces of the current column selection, if there is one (the selection hasn't + /// moved since the block was made). + pub fn column_segments(&self) -> Option<&[(Pos, Pos)]> { + // Cells that are one run of the text are an ordinary stream selection. + self.column.as_ref().filter(|c| c.sel == self.sel && !c.cells.as_ref().is_some_and(|r| r.stream)).map(|c| c.segments.as_slice()) + } + + /// The whole table cells selected, if the selection is that (see [`CellRange`]). + pub fn cell_range(&self) -> Option<&CellRange> { + self.column.as_ref().filter(|c| c.sel == self.sel).and_then(|c| c.cells.as_ref()) + } + + /// The selection as it was made: where a drag, Shift+click or Shift+arrows started and where + /// the caret went, even when Word's table rule widened it to whole cells or rows (`sel` holds + /// those). + pub fn sel_ends(&self) -> &Selection { + self.widened.as_ref().filter(|w| w.sel == self.sel).map_or(&self.sel, |w| &w.ends) + } + + pub fn selects_whole_story(&self) -> bool { + let st = self.sel.focus.story; + let (a, b) = self.sel.ordered(); + a == self.doc.start_of(st) && b == self.doc.end_of(st) + } + + /// Whether the caret is in a header or footer. + pub fn in_header_footer(&self) -> bool { + let StoryRef::Part(id) = self.sel.focus.story else { return false }; + self.doc.parts.get(&id).is_some_and(|p| matches!(p.kind, PartKind::Header | PartKind::Footer)) + } + + /// Document revision (bumped by every change). + pub fn rev(&self) -> u64 { + self.rev + } + + /// Which document is open (see `document_id`): lets a caller tell an edit from a replacement. + pub fn document_id(&self) -> u64 { + self.document_id + } + + /// How many times the body was replaced under this [`Self::document_id`]. + /// + /// Front ends use it as the body's IME replacement epoch and to tell a body swap from an + /// ordinary edit; it advances for `document.setText`, a version restore, a compare and + /// Undo/Redo, never for typing. + pub fn ime_body_epoch(&self) -> u64 { + self.ime_body_epoch + } + + /// Note that a command replaced the document or its whole body under this document id. + /// + /// The counter fails closed: if it were ever exhausted (unreachable in practice), the + /// document owner id advances too, so a cached IME context still cannot be reused. + pub fn note_body_replaced(&mut self) { + self.ime_body_epoch = match self.ime_body_epoch.checked_add(1) { + Some(epoch) => epoch, + None => { + self.document_id = self.document_id.saturating_add(1); + u64::MAX + } + }; + } + + pub fn touch(&mut self) { + self.rev = self.rev.wrapping_add(1); + self.dirty = true; + } + + /// Reading Highlight ranges for the current document (searched again after edits); empty when it's off. + pub fn find_highlights(&mut self) -> &[(Pos, Pos)] { + if !self.find.highlight { + return &[]; + } + if self.find.highlights.as_ref().is_none_or(|(r, _)| *r != self.rev) { + let found = crate::cmd::edit::search(self, StoryRef::Body).unwrap_or_default(); + self.find.highlights = Some((self.rev, found)); + } + self.find.highlights.as_ref().map(|(_, v)| v.as_slice()).unwrap_or(&[]) + } + + /// Whether the markup area beside the page can show balloons: markup shown in Print Layout + /// (not Read Mode or pages side by side), comments not listed in the Comments pane instead, + /// and Track Changes Options not set to show every revision inline. + pub fn balloons_shown(&self) -> bool { + let v = &self.view; + v.show_markup + && !v.comments_pane + && !v.read_mode + && !v.multi_page + && v.mode == ViewMode::Print + && self.prefs.markup.balloons != wordcraft_layout::display::BalloonMode::Inline + } + + /// Show revisions in balloons, with the balloons on screen: tracked deletions in the body leave + /// the text for balloons in the markup area. Where no balloons can be shown they stay inline, + /// as in Word; so do deletions in headers, footers, notes and text boxes, which get no balloons. + pub fn revisions_in_balloons(&self) -> bool { + let m = &self.prefs.markup; + self.balloons_shown() && m.balloons == wordcraft_layout::display::BalloonMode::Revisions && m.insertions_deletions + } + + /// Initials for new comments: the ones set in File › Options, else the user name's. + pub fn user_initials(&self) -> String { + let set = self.initials.trim(); + if set.is_empty() { self.author.split_whitespace().filter_map(|w| w.chars().next()).collect() } else { set.to_string() } + } + + /// The document's file name and full path for fields (`FILENAME`) and layout context. + /// Returns `(filename, filepath)`. Unsaved documents with no explicit file name default + /// consistently to their name, `("Document1", "Document1")` for the first. Document metadata title is never used + /// as a filename fallback (#3449). + pub fn resolved_filenames(&self) -> (String, String) { + if let Some(path) = &self.path { + let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string_lossy().to_string()); + let full = path.to_string_lossy().to_string(); + (name, full) + } else if let Some(name) = &self.file_name { + (name.clone(), name.clone()) + } else { + let name = untitled_name(self.untitled); + (name.clone(), name) + } + } + + /// The region in effect (#838): the one the user chose ([`Prefs::region`]), else the + /// system's. + pub fn region(&self) -> wordcraft_geom::Region { + match self.prefs.region.as_deref() { + Some(tag) => wordcraft_geom::Region::from_tag(tag), + None => self.system_region.clone(), + } + } + + /// The English of the region in effect (`en-GB` for a British region), or `None` when the + /// region's language isn't English, it names no territory, or it is the United States (US + /// English is the default anyway). An English interface writes new documents in it, as Word + /// does on a British or Canadian system (#1045). + pub fn region_english(&self) -> Option { + let r = self.region(); + (r.language == "en" && !r.territory.is_empty() && r.territory != "US") + .then(|| format!("en-{}", r.territory)) + .and_then(|t| wordcraft_proof::lang::normalize_tag(&t)) + } + + /// The Spanish of the region in effect (`es-MX` on a Mexican system, `es-UY` on a Uruguayan + /// one; the ISO 639-2 `spa` counts as Spanish), or Spain's (`es-ES`) when the region's + /// language isn't Spanish or it names no territory. A Spanish interface writes new documents in it, so they are proofed in the + /// writer's Spanish from the first word (#3360). + pub fn region_spanish(&self) -> String { + let r = self.region(); + (matches!(r.language.as_str(), "es" | "spa") && !r.territory.is_empty()) + .then(|| format!("es-{}", r.territory)) + .and_then(|t| wordcraft_proof::lang::normalize_tag(&t)) + .unwrap_or_else(|| "es-ES".into()) + } + + /// The unit rulers and dialogs show lengths in: the user's choice, else the region's. + pub fn units(&self) -> wordcraft_geom::Unit { + self.prefs.units.unwrap_or_else(|| self.region().units()) + } + + /// `pt` in [`Session::units`], written the region's way (`2,54 cm` in Poland). + pub fn format_length(&self, pt: f32) -> String { + self.region().format(self.units(), pt) + } + + /// A new blank document (the Normal template) on the region's paper: Letter in the US and + /// other Letter regions, A4 elsewhere (#838). Margins stay 2.54 cm (1") all round. + pub fn blank_document(&self) -> Document { + blank_document(&self.region()) + } + + /// The paragraph path the caret is in. + pub fn caret_path(&self) -> &Path { + &self.sel.focus.path + } + + pub fn story(&self) -> StoryRef { + self.sel.focus.story + } + + /// Formatting typing would use at the caret: the caret's, except run direction. New text + /// takes its direction from its own letters, as on screen, so Latin typed or inserted into + /// reopened Hebrew isn't saved as right-to-left; the DOCX writer marks right-to-left pieces + /// itself (#1543). The bidi language stays (it only applies to complex-script text). + /// Over a selection, as in Word, typing takes the first selected character's formatting, + /// whichever way the selection was made (#2879). + pub fn typing_props(&self) -> CharProps { + let (a, _) = self.sel.ordered(); + let first = match self.doc.para_at(&a) { + Some(p) if self.pending.is_none() && !self.sel.is_collapsed() && a.off < p.len() => Some(p.props_of_char(a.off)), + _ => None, + }; + let mut c = match first { + Some(props) => { + // The selection is replaced, so the new text isn't part of a link or a note number. + let mut out = if props.link.is_some() { props.unlinked() } else { props.clone() }; + if matches!(out.style.as_deref(), Some("FootnoteReference" | "EndnoteReference")) { + out.style = None; + } + out + } + None => self.caret_props(), + }; + c.rtl = None; + c + } + + /// The caret's formatting, as Reveal Formatting shows it. + pub fn caret_props(&self) -> CharProps { + if let Some(p) = &self.pending { + return p.clone(); + } + let f = &self.sel.focus; + let Some(p) = self.doc.para_at(f) else { return CharProps::default() }; + let props = p.props_at(f.off); + // As in Word, text typed at either end of a hyperlink isn't part of it; only typing + // inside the link extends it. + let inside_link = props.link.is_some() && f.off > 0 && f.off < p.len() && p.props_of_char(f.off).link == props.link; + // Text typed next to a symbol is in the text's font, not the symbol's (`w:sym`). + let mut out = (if props.link.is_some() && !inside_link { props.unlinked() } else { props.clone() }).without_symbol(); + // A footnote or endnote number is raised by its character style; text typed after it is body text. + if matches!(out.style.as_deref(), Some("FootnoteReference" | "EndnoteReference")) { + out.style = None; + } + out + } + + /// Words in the document, as the status bar shows them (see [`Prefs::count_notes`]). + pub fn word_count(&self) -> usize { + if self.prefs.count_notes { self.doc.word_count_including_notes() } else { self.doc.word_count() } + } + + /// The stories the Word Count dialog and the status bar count: the body plus notes and text + /// boxes when [`Prefs::count_notes`] is on ([`Document::counted_stories`]). + pub fn counted_stories(&self) -> Vec { + if self.prefs.count_notes { self.doc.counted_stories() } else { vec![StoryRef::Body] } + } + + /// The whole document's text the counts cover (#2797): the stories [`Session::counted_stories`] + /// names, joined with paragraph breaks, as the Word Count dialog counts them. + pub fn counted_text(&self) -> String { + self.counted_stories().iter().map(|st| self.doc.plain_text(*st)).collect::>().join("\n") + } + + /// Selected plain text. + pub fn selected_text(&self) -> String { + // A column block or whole cells that aren't one run of the text: each piece's own text, + // a line each, so the status bar and Word Count count those cells only. + if let Some(segs) = self.column_segments() { + return segs.iter().map(|(a, b)| self.doc.copy_range(a.min(b), a.max(b)).plain_text()).collect::>().join("\n"); + } + let (a, b) = self.sel.ordered(); + self.doc.copy_range(&a, &b).plain_text() + } +} + +/// A new blank document on `region`'s paper (see [`Session::blank_document`]). +pub fn blank_document(region: &wordcraft_geom::Region) -> Document { + let mut d = Document::new(); + let (_, w, h) = region.paper(); + d.last_section.page_w = w; + d.last_section.page_h = h; + d +} diff --git a/crates/engine/src/session/run.rs b/crates/engine/src/session/run.rs index 948cdc4d4c..5d39d50762 100644 --- a/crates/engine/src/session/run.rs +++ b/crates/engine/src/session/run.rs @@ -1,481 +1,481 @@ -use super::doc_state::{ColumnBlock, Selection}; -use super::ime_state::ImeCaretState; -use super::snapshot_types::RunSnapshot; -use super::{CharProps, CmdError, Document, Fragment, ImeEditOutcome, Pos, Session, StoryRef, Value, document_id_mark}; -use crate::CommandSpec; - -struct SuccessContext<'a> { - id: &'a str, - spec: &'a CommandSpec, - before_doc: &'a Option, - pending_before: &'a Option>, - document_id_before: u64, - replaces_document: bool, - dirty_before: Option, - rev_restore: Option, - pushed: bool, - mutates: bool, - ime_caret_before: ImeCaretState, - rev_before: u64, - sel_before: &'a Selection, -} - -struct ElsewhereContext { - home: u64, - mark: u64, - before_doc: Option, - blocks_before: Option>, - default_language_before: Option, - pending_application_change_before: bool, -} - -impl Session { - fn leave_math_for_command(&mut self, id: &str) { - if self.math.is_some() - && (id.starts_with("text.") || id.starts_with("caret.") || id.starts_with("select.") || id.starts_with("edit.paste")) - && let Some(m) = self.math.take() - { - let off = m.at.off + wordcraft_doc::para::OBJ.len_utf8(); - self.sel = Selection::caret(Pos { off, ..m.at }); - } - } - - /// Whether the command changed nothing: the document, the selection, the formatting - /// waiting at the caret and the column selection are as they were before it ran. - /// - /// A command that changes nothing is not an edit: it adds no undo step, marks no - /// unsaved changes, forces no relayout and leaves the redo stack alone (#23). - fn unchanged(&self, doc: &Document, sel: &Selection, column: &Option, pending: &Option) -> bool { - doc.same_content(&self.doc) - && *sel == self.sel - && *column == self.column - && *pending == self.pending - && !self.md_source.as_ref().is_some_and(|src| src.is_pending()) - } - - /// A navigated style change is for the Accept or Reject that follows: a command that edits - /// or moves the selection forgets it. - fn forget_review_style(&mut self, id: &str, mutates: bool) { - if (mutates && !matches!(id, "review.accept" | "review.reject")) || id.starts_with("caret.") || id.starts_with("select.") { - self.review_style = None; - } - } - - /// Run a command by id with JSON params (the single entry point for the UI, CLI, MCP and - /// control channel). Mutating commands are undoable; a failed command leaves the document as - /// it was; a panic inside a command becomes an error. - pub fn run(&mut self, id: &str, params: &Value) -> Result { - if self.md_source.is_some() - && let Some(mutates) = self.registry.get(id).map(|spec| spec.mutates) - { - crate::cmd::md_source::before_command(self, id, mutates)?; - } - self.run_command(id, params) - } - - /// Dispatches `id` through the command registry with transactional rollback on failure, - /// tracking history, undo checkpoints, and IME outcomes. - fn run_command(&mut self, id: &str, params: &Value) -> Result { - // The document the command starts in, and the first id a document it opens can have. - let home = self.document_id; - let mark = document_id_mark(); - // Every dispatch starts from an unclassified outcome. Early returns (unknown, disabled, - // protected) leave it at the default. - self.ime_edit_outcome = ImeEditOutcome::default(); - // A rolled-back transaction must not leave a successful application-change signal from a - // nested command that ran before the failure. - let pending_application_change_before = self.pending_ime_application_change; - let join = std::mem::take(&mut self.join_next); - // The equation replacement promise is not a generic undo join: only the command that - // actually types into the equation may consume it. Any other command discards it. - let equation_join = std::mem::take(&mut self.equation_join_next); - // The body replacement promise is likewise scoped: only the `text.insert` at the - // selection the deletion left may consume it. - let ime_text_join = self.ime_text_join.take(); - let reg = self.registry.clone(); - let Some(spec) = reg.get(id) else { return Err(CmdError::Unknown(id.to_string())) }; - self.forget_review_style(id, spec.mutates); - let join = join - || (equation_join && matches!(id, "equation.type" | "equation.backspace")) - || (id == "text.insert" && ime_text_join.as_ref().is_some_and(|sel| sel == &self.sel)); - if let Some(why) = (spec.enabled)(self) { - return Err(CmdError::Disabled(format!("{id}: {why}"))); - } - // Restrict Editing: refused outright, or allowed as long as it only edits the exceptions. - let guard = if spec.mutates { crate::cmd::protect::check_before(self, id, params)? } else { false }; - // Quick Parts aren't part of the document, so a refused edit puts them back itself (the Spike). - let blocks_before = guard.then(|| self.building_blocks.clone()); - if spec.mutates && self.doc.settings.protection.as_deref() == Some("trackedChanges") { - self.doc.settings.track_changes = true; - } - // Macro recording and Repeat. - let record = !matches!(id, "tools.recordMacro" | "tools.macros" | "edit.undo" | "edit.redo" | "edit.repeat") - && (spec.mutates || id.starts_with("caret.") || id.starts_with("select.")); - // A password never lands in a recorded macro or Repeat. - let kept = || { - let mut p = params.clone(); - if let Some(m) = p.as_object_mut() { - m.remove("password"); - } - p - }; - if record && let Some((_, steps)) = self.recording.as_mut() { - steps.push((id.to_string(), kept())); - } - if spec.mutates && !matches!(id, "edit.undo" | "edit.redo" | "edit.repeat") { - self.last_command = Some((id.to_string(), kept())); - } - // Typing, caret movement and selection outside the equation commands leave the equation. - self.leave_math_for_command(id); - // The undo stacks are snapshotted whole, not as a length: commands run nested commands, - // whose checkpoints, undos and redos (and evictions at the limit) must be put back too. - let ime_epoch_before = spec.mutates.then_some(self.ime_body_epoch); - // Commands may leave an equation (a whole-body replacement) even after they fail; put - // the editing session back with the document. - let math_before = spec.mutates.then(|| self.math.clone()); - let before_doc: Option = if spec.mutates { - Some(( - self.doc.clone(), - self.sel.clone(), - self.history.clone(), - self.redo.clone(), - self.typing_open, - self.undo_evicted, - self.column.clone(), - )) - } else { - None - }; - // The formatting waiting at the caret (⌘B with nothing selected): an undo step restores - // it, so a command that only changes it is an edit. - let pending_before = spec.mutates.then(|| self.pending.clone()); - // Which document is open. A command that replaces the document (a mail merge) opens a - // new one, with a new undo history: it is not an edit of this document, whatever the - // new document holds. - let document_id_before = self.document_id; - // Whether the document has unsaved changes, and the revision its layout is keyed on: a - // command that changes nothing leaves both as they were, and a failed one leaves no - // trace. - let dirty_before = spec.mutates.then_some(self.dirty); - let rev_restore = spec.mutates.then_some(self.rev); - // These mutating commands can replace the open document and discard its saved versions. - // Keep the versions until the command and the protected-region post-check have both - // succeeded; rejected commands must leave the old document and its snapshots together. - // `mailings.finish` with a path saves the merged copy there and leaves the open - // document alone, so it replaces nothing. - let replaces_document = matches!(id, "review.compare" | "review.combine" | "mailings.envelopes" | "mailings.labels") - || (id == "mailings.finish" && crate::p::str(params, "path").is_none()); - let versions_before = replaces_document.then(|| self.versions.clone()); - let identity_before = replaces_document.then(|| { - ( - self.path.clone(), - self.file_name.clone(), - self.document_id, - self.password.clone(), - self.dirty, - self.bib_style.clone(), - self.merge.preview, - ) - }); - let typing = spec.id == "text.insert" || spec.id == "equation.type"; - if spec.mutates && !typing { - self.typing_open = false; - } - // Whether this command pushed an undo step (a no-op command's step is dropped below, - // leaving its undo group without one). - let mut pushed = false; - if spec.mutates && spec.id != "view.markdownSourceText" { - let label = if typing { "Typing" } else { spec.label }; - // A joined command merges into its group's step; a group whose first command - // changed nothing has none, so the next command in it starts one (#4001). - if !join || self.history.is_empty() || !self.join_anchor { - pushed = self.checkpoint(label); - } - } else if !spec.id.starts_with("caret.") && !spec.id.starts_with("view.") { - // Non-mutating commands other than caret movement keep the typing group. - } else if spec.id.starts_with("caret.") { - self.typing_open = false; - } - // Only the paste right after a tracked cut makes it a move. - if (spec.mutates || matches!(id, "edit.undo" | "edit.undoTo" | "edit.redo")) && !matches!(id, "edit.cut" | "edit.paste" | "edit.pasteSpecial") - { - self.pending_move = None; - } - let sel_before = self.sel.clone(); - // A rejected command must not persist a new proofing default, including a rejection - // by the protected-region check after its handler has succeeded. Undo stays document-only. - let default_language_before = self.prefs.default_language.clone(); - let run = spec.run; - let mutates = spec.mutates; - let ime_caret_before = self.current_ime_caret_state(); - let rev_before = self.rev; - let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| match crate::cmd::canvas::member_command(self, id, params) { - Some(r) => r, - None => crate::cmd::column::dispatch(self, id, mutates, run, params), - })); - let mut result = match result { - Ok(r) => r, - Err(p) => { - let msg = p.downcast_ref::<&str>().map(ToString::to_string).or_else(|| p.downcast_ref::().cloned()).unwrap_or_default(); - Err(CmdError::Failed(format!("{id} panicked: {msg}"))) - } - }; - if self.document_id != home || (result.is_err() && self.order.iter().any(|d| *d >= mark)) { - return self.finish_elsewhere( - ElsewhereContext { home, mark, before_doc, blocks_before, default_language_before, pending_application_change_before }, - result, - ); - } - // Restrict Editing: outside the exceptions nothing may change. - if result.is_ok() - && guard - && let Some((before, ..)) = &before_doc - && !crate::cmd::protect::outside_unchanged(before, &self.doc, &self.author) - { - result = Err(CmdError::Disabled(format!("{id}: only the editable regions of this protected document can change"))); - } - match &result { - Ok(_) => self.finish_success(SuccessContext { - id, - spec, - before_doc: &before_doc, - pending_before: &pending_before, - document_id_before, - replaces_document, - dirty_before, - rev_restore, - pushed, - mutates, - ime_caret_before, - rev_before, - sel_before: &sel_before, - }), - Err(e) => { - self.ime_edit_outcome = ImeEditOutcome::default(); - self.pending_ime_application_change = pending_application_change_before; - self.autocap = None; - self.prefs.default_language = default_language_before; - if let Some(epoch) = ime_epoch_before { - self.ime_body_epoch = epoch; - } - if let Some(math) = math_before { - self.math = math; - } - if let Some(versions) = versions_before { - self.versions = versions; - } - if let Some((path, file_name, document_id, password, dirty, bib_style, merge_preview)) = identity_before { - self.path = path; - self.file_name = file_name; - self.document_id = document_id; - self.password = password; - self.dirty = dirty; - self.bib_style = bib_style; - self.merge.preview = merge_preview; - } - if let Some((d, s, h, r, t, ev, c)) = before_doc { - self.doc = d; - self.sel = s; - self.history = h; - self.redo = r; - self.typing_open = t; - // The steps the command pushed out are back, so they no longer count as gone. - self.undo_evicted = ev; - self.column = c; - // Its step is gone with them: its undo group has none to merge into. - if pushed { - self.join_anchor = false; - } - } - if let Some(dirty) = dirty_before { - self.dirty = dirty; - } - if let Some(rev) = rev_restore { - self.restore_rev(rev); - } - if let Some(b) = blocks_before { - self.building_blocks = b; - } - self.status = e.to_string(); - } - } - result - } - - fn finish_success(&mut self, context: SuccessContext<'_>) { - let SuccessContext { - id, - spec, - before_doc, - pending_before, - document_id_before, - replaces_document, - dirty_before, - rev_restore, - pushed, - mutates, - ime_caret_before, - rev_before, - sel_before, - } = context; - let mut no_op = false; - if spec.mutates { - // Formatting changed while tracking becomes a formatting revision. - if let Some((before, ..)) = before_doc { - crate::cmd::fmt_revisions::record(self, id, before); - } - self.doc.ensure_nonempty(); - self.doc.prune_text_boxes(); - // Whether the command left the document, the selection and the formatting - // waiting at the caret as they were. A command that replaces the document — - // a mail merge, a comparison — is not an edit of this one, whatever the new - // document holds: `replaces_document` names the commands that may replace - // it, and `document_id` catches any other that does (through - // `set_document`, which starts a new history the old one must not overwrite). - let unchanged = |s: &Session| match (before_doc, pending_before) { - (Some((doc, sel, _, _, _, _, column)), Some(pending)) => { - !replaces_document && s.document_id == document_id_before && s.unchanged(doc, sel, column, pending) - } - _ => false, - }; - let changed = !unchanged(self); - if changed { - // The layout the connectors are rerouted against is the new one. - self.touch(); - } - // Glued connectors follow the shapes they join. - let rerouted = crate::cmd::connectors::reroutes_after(id) && crate::cmd::connectors::reroute(self); - if rerouted { - self.touch(); - } - // A command that changed nothing is not an edit (#23): it adds no undo - // step, marks no unsaved changes, forces no relayout and leaves the redo - // stack alone. What it left behind is compared with what it started from, - // after the reroute: a reroute that moved a connector is an edit of its own. - no_op = !changed && !rerouted && unchanged(self); - if no_op { - // The step the dispatch pushed is not an edit: put the stacks back as - // they were, so the redo stack survives and no step is evicted. The - // group it belonged to is left without a step of its own, so the next - // command in it starts one. - if let Some((_, _, history, redo, typing_open, evicted, _)) = before_doc { - self.history = history.clone(); - self.redo = redo.clone(); - self.typing_open = *typing_open; - self.undo_evicted = *evicted; - } - if let Some(dirty) = dirty_before { - self.dirty = dirty; - } - if let Some(rev) = rev_restore { - let touched = self.rev; - self.restore_rev(rev); - crate::cmd::draw::rekey_lasso(self, touched, rev); - } - if pushed { - self.join_anchor = false; - } - } - } - self.clamp_selection(); - let mut ime_outcome = self.ime_edit_outcome; - if ime_outcome.content_changed { - ime_outcome.state_changed = true; - } - // An unclassified mutating command is conservatively a state change. It can make - // a frame Other, but it can never mint a receipt because `classified` is false. - // A command that changed nothing is not an edit (#23), so it is not an IME - // state change either: otherwise Backspace at the document start would still - // signal an application change and move the settled IME revision. - if !ime_outcome.classified && mutates && !no_op { - ime_outcome.state_changed = true; - } - ime_outcome.selection_changed = self.current_ime_caret_state() != ime_caret_before; - self.ime_edit_outcome = ime_outcome; - if ime_outcome.state_changed { - let caret = self.current_ime_caret_state(); - self.ime_state_seen = Some(caret); - self.reset_ime_state_revision(); - } - if !self.ime_origin && !no_op && (ime_outcome.content_changed || ime_outcome.state_changed || (!ime_outcome.classified && mutates)) { - self.pending_ime_application_change = true; - } - if sel_before.focus.story == StoryRef::Body && self.in_header_footer() { - self.hf_return = Some(sel_before.clone()); - } - // A selection command that moves the insertion point ends the typing group, like - // caret movement: typing, Select, typing are two undo steps (#419). - if !spec.mutates && self.sel != *sel_before { - self.typing_open = false; - } - if spec.mutates && !no_op && !matches!(id, "edit.undo" | "edit.redo") { - self.note_edit(); - } - // Outline view: collapsed headings follow edits; the caret stays where it shows. - crate::cmd::outline::after_command(self, sel_before, self.rev != rev_before); - // Extra selected objects last until something else is edited or selected; formatting - // them keeps them selected, as in Word. - if !matches!(id, "select.addObject" | "arrange.ungroup" | "shape.fill" | "shape.effects") && (spec.mutates || self.sel != *sel_before) { - self.also_selected.clear(); - } - // A canvas member stays picked while its canvas does and nothing else is selected. - if id != "select.member" - && (self.sel != *sel_before - || id.starts_with("select.") - || id.starts_with("caret.") - || matches!(id, "edit.undo" | "edit.undoTo" | "edit.redo")) - { - self.member = None; - } - self.note_typing(id, before_doc.as_ref().map(|b| &b.0), sel_before); - } - - /// The end of [`Session::run`] for a command that ended up in another document than it - /// started in (it opened, made, switched to or closed one) or that failed after opening one. - /// The checks that compare the document before and after the command don't apply. - /// Succeeded: the first document loses the undo step made for the command when its text is - /// as it was (opening a document is no edit to undo there). Failed: the documents the command opened close again and the first document is - /// active and as it was. - fn finish_elsewhere(&mut self, context: ElsewhereContext, result: Result) -> Result { - let ElsewhereContext { home, mark, before_doc, blocks_before, default_language_before, pending_application_change_before } = context; - // Another document is a state change the input method has to see, never an IME edit. - self.ime_edit_outcome = ImeEditOutcome::default(); - match &result { - Ok(_) => { - self.put_back_parked(home, before_doc); - self.clamp_selection(); - self.ime_state_seen = Some(self.current_ime_caret_state()); - self.reset_ime_state_revision(); - if !self.ime_origin { - self.pending_ime_application_change = true; - } - } - Err(e) => { - self.pending_ime_application_change = pending_application_change_before; - self.autocap = None; - self.prefs.default_language = default_language_before; - if self.is_open(home) { - let _ = self.switch_to(home); - let opened: Vec = self.order.iter().copied().filter(|d| *d >= mark && *d != home).collect(); - for d in opened { - let _ = self.close_document(d); - } - if let Some((d, s, h, r, t, ev, c)) = before_doc { - self.doc = d; - self.sel = s; - self.history = h; - self.redo = r; - self.typing_open = t; - self.undo_evicted = ev; - self.column = c; - } - } - if let Some(b) = blocks_before { - self.building_blocks = b; - } - self.status = e.to_string(); - } - } - result - } -} +use super::doc_state::{ColumnBlock, Selection}; +use super::ime_state::ImeCaretState; +use super::snapshot_types::RunSnapshot; +use super::{CharProps, CmdError, Document, Fragment, ImeEditOutcome, Pos, Session, StoryRef, Value, document_id_mark}; +use crate::CommandSpec; + +struct SuccessContext<'a> { + id: &'a str, + spec: &'a CommandSpec, + before_doc: &'a Option, + pending_before: &'a Option>, + document_id_before: u64, + replaces_document: bool, + dirty_before: Option, + rev_restore: Option, + pushed: bool, + mutates: bool, + ime_caret_before: ImeCaretState, + rev_before: u64, + sel_before: &'a Selection, +} + +struct ElsewhereContext { + home: u64, + mark: u64, + before_doc: Option, + blocks_before: Option>, + default_language_before: Option, + pending_application_change_before: bool, +} + +impl Session { + fn leave_math_for_command(&mut self, id: &str) { + if self.math.is_some() + && (id.starts_with("text.") || id.starts_with("caret.") || id.starts_with("select.") || id.starts_with("edit.paste")) + && let Some(m) = self.math.take() + { + let off = m.at.off + wordcraft_doc::para::OBJ.len_utf8(); + self.sel = Selection::caret(Pos { off, ..m.at }); + } + } + + /// Whether the command changed nothing: the document, the selection, the formatting + /// waiting at the caret and the column selection are as they were before it ran. + /// + /// A command that changes nothing is not an edit: it adds no undo step, marks no + /// unsaved changes, forces no relayout and leaves the redo stack alone (#23). + fn unchanged(&self, doc: &Document, sel: &Selection, column: &Option, pending: &Option) -> bool { + doc.same_content(&self.doc) + && *sel == self.sel + && *column == self.column + && *pending == self.pending + && !self.md_source.as_ref().is_some_and(|src| src.is_pending()) + } + + /// A navigated style change is for the Accept or Reject that follows: a command that edits + /// or moves the selection forgets it. + fn forget_review_style(&mut self, id: &str, mutates: bool) { + if (mutates && !matches!(id, "review.accept" | "review.reject")) || id.starts_with("caret.") || id.starts_with("select.") { + self.review_style = None; + } + } + + /// Run a command by id with JSON params (the single entry point for the UI, CLI, MCP and + /// control channel). Mutating commands are undoable; a failed command leaves the document as + /// it was; a panic inside a command becomes an error. + pub fn run(&mut self, id: &str, params: &Value) -> Result { + if self.md_source.is_some() + && let Some(mutates) = self.registry.get(id).map(|spec| spec.mutates) + { + crate::cmd::md_source::before_command(self, id, mutates)?; + } + self.run_command(id, params) + } + + /// Dispatches `id` through the command registry with transactional rollback on failure, + /// tracking history, undo checkpoints, and IME outcomes. + fn run_command(&mut self, id: &str, params: &Value) -> Result { + // The document the command starts in, and the first id a document it opens can have. + let home = self.document_id; + let mark = document_id_mark(); + // Every dispatch starts from an unclassified outcome. Early returns (unknown, disabled, + // protected) leave it at the default. + self.ime_edit_outcome = ImeEditOutcome::default(); + // A rolled-back transaction must not leave a successful application-change signal from a + // nested command that ran before the failure. + let pending_application_change_before = self.pending_ime_application_change; + let join = std::mem::take(&mut self.join_next); + // The equation replacement promise is not a generic undo join: only the command that + // actually types into the equation may consume it. Any other command discards it. + let equation_join = std::mem::take(&mut self.equation_join_next); + // The body replacement promise is likewise scoped: only the `text.insert` at the + // selection the deletion left may consume it. + let ime_text_join = self.ime_text_join.take(); + let reg = self.registry.clone(); + let Some(spec) = reg.get(id) else { return Err(CmdError::Unknown(id.to_string())) }; + self.forget_review_style(id, spec.mutates); + let join = join + || (equation_join && matches!(id, "equation.type" | "equation.backspace")) + || (id == "text.insert" && ime_text_join.as_ref().is_some_and(|sel| sel == &self.sel)); + if let Some(why) = (spec.enabled)(self) { + return Err(CmdError::Disabled(format!("{id}: {why}"))); + } + // Restrict Editing: refused outright, or allowed as long as it only edits the exceptions. + let guard = if spec.mutates { crate::cmd::protect::check_before(self, id, params)? } else { false }; + // Quick Parts aren't part of the document, so a refused edit puts them back itself (the Spike). + let blocks_before = guard.then(|| self.building_blocks.clone()); + if spec.mutates && self.doc.settings.protection.as_deref() == Some("trackedChanges") { + self.doc.settings.track_changes = true; + } + // Macro recording and Repeat. + let record = !matches!(id, "tools.recordMacro" | "tools.macros" | "edit.undo" | "edit.redo" | "edit.repeat") + && (spec.mutates || id.starts_with("caret.") || id.starts_with("select.")); + // A password never lands in a recorded macro or Repeat. + let kept = || { + let mut p = params.clone(); + if let Some(m) = p.as_object_mut() { + m.remove("password"); + } + p + }; + if record && let Some((_, steps)) = self.recording.as_mut() { + steps.push((id.to_string(), kept())); + } + if spec.mutates && !matches!(id, "edit.undo" | "edit.redo" | "edit.repeat") { + self.last_command = Some((id.to_string(), kept())); + } + // Typing, caret movement and selection outside the equation commands leave the equation. + self.leave_math_for_command(id); + // The undo stacks are snapshotted whole, not as a length: commands run nested commands, + // whose checkpoints, undos and redos (and evictions at the limit) must be put back too. + let ime_epoch_before = spec.mutates.then_some(self.ime_body_epoch); + // Commands may leave an equation (a whole-body replacement) even after they fail; put + // the editing session back with the document. + let math_before = spec.mutates.then(|| self.math.clone()); + let before_doc: Option = if spec.mutates { + Some(( + self.doc.clone(), + self.sel.clone(), + self.history.clone(), + self.redo.clone(), + self.typing_open, + self.undo_evicted, + self.column.clone(), + )) + } else { + None + }; + // The formatting waiting at the caret (⌘B with nothing selected): an undo step restores + // it, so a command that only changes it is an edit. + let pending_before = spec.mutates.then(|| self.pending.clone()); + // Which document is open. A command that replaces the document (a mail merge) opens a + // new one, with a new undo history: it is not an edit of this document, whatever the + // new document holds. + let document_id_before = self.document_id; + // Whether the document has unsaved changes, and the revision its layout is keyed on: a + // command that changes nothing leaves both as they were, and a failed one leaves no + // trace. + let dirty_before = spec.mutates.then_some(self.dirty); + let rev_restore = spec.mutates.then_some(self.rev); + // These mutating commands can replace the open document and discard its saved versions. + // Keep the versions until the command and the protected-region post-check have both + // succeeded; rejected commands must leave the old document and its snapshots together. + // `mailings.finish` with a path saves the merged copy there and leaves the open + // document alone, so it replaces nothing. + let replaces_document = matches!(id, "review.compare" | "review.combine" | "mailings.envelopes" | "mailings.labels") + || (id == "mailings.finish" && crate::p::str(params, "path").is_none()); + let versions_before = replaces_document.then(|| self.versions.clone()); + let identity_before = replaces_document.then(|| { + ( + self.path.clone(), + self.file_name.clone(), + self.document_id, + self.password.clone(), + self.dirty, + self.bib_style.clone(), + self.merge.preview, + ) + }); + let typing = spec.id == "text.insert" || spec.id == "equation.type"; + if spec.mutates && !typing { + self.typing_open = false; + } + // Whether this command pushed an undo step (a no-op command's step is dropped below, + // leaving its undo group without one). + let mut pushed = false; + if spec.mutates && spec.id != "view.markdownSourceText" { + let label = if typing { "Typing" } else { spec.label }; + // A joined command merges into its group's step; a group whose first command + // changed nothing has none, so the next command in it starts one (#4001). + if !join || self.history.is_empty() || !self.join_anchor { + pushed = self.checkpoint(label); + } + } else if !spec.id.starts_with("caret.") && !spec.id.starts_with("view.") { + // Non-mutating commands other than caret movement keep the typing group. + } else if spec.id.starts_with("caret.") { + self.typing_open = false; + } + // Only the paste right after a tracked cut makes it a move. + if (spec.mutates || matches!(id, "edit.undo" | "edit.undoTo" | "edit.redo")) && !matches!(id, "edit.cut" | "edit.paste" | "edit.pasteSpecial") + { + self.pending_move = None; + } + let sel_before = self.sel.clone(); + // A rejected command must not persist a new proofing default, including a rejection + // by the protected-region check after its handler has succeeded. Undo stays document-only. + let default_language_before = self.prefs.default_language.clone(); + let run = spec.run; + let mutates = spec.mutates; + let ime_caret_before = self.current_ime_caret_state(); + let rev_before = self.rev; + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| match crate::cmd::canvas::member_command(self, id, params) { + Some(r) => r, + None => crate::cmd::column::dispatch(self, id, mutates, run, params), + })); + let mut result = match result { + Ok(r) => r, + Err(p) => { + let msg = p.downcast_ref::<&str>().map(ToString::to_string).or_else(|| p.downcast_ref::().cloned()).unwrap_or_default(); + Err(CmdError::Failed(format!("{id} panicked: {msg}"))) + } + }; + if self.document_id != home || (result.is_err() && self.order.iter().any(|d| *d >= mark)) { + return self.finish_elsewhere( + ElsewhereContext { home, mark, before_doc, blocks_before, default_language_before, pending_application_change_before }, + result, + ); + } + // Restrict Editing: outside the exceptions nothing may change. + if result.is_ok() + && guard + && let Some((before, ..)) = &before_doc + && !crate::cmd::protect::outside_unchanged(before, &self.doc, &self.author) + { + result = Err(CmdError::Disabled(format!("{id}: only the editable regions of this protected document can change"))); + } + match &result { + Ok(_) => self.finish_success(SuccessContext { + id, + spec, + before_doc: &before_doc, + pending_before: &pending_before, + document_id_before, + replaces_document, + dirty_before, + rev_restore, + pushed, + mutates, + ime_caret_before, + rev_before, + sel_before: &sel_before, + }), + Err(e) => { + self.ime_edit_outcome = ImeEditOutcome::default(); + self.pending_ime_application_change = pending_application_change_before; + self.autocap = None; + self.prefs.default_language = default_language_before; + if let Some(epoch) = ime_epoch_before { + self.ime_body_epoch = epoch; + } + if let Some(math) = math_before { + self.math = math; + } + if let Some(versions) = versions_before { + self.versions = versions; + } + if let Some((path, file_name, document_id, password, dirty, bib_style, merge_preview)) = identity_before { + self.path = path; + self.file_name = file_name; + self.document_id = document_id; + self.password = password; + self.dirty = dirty; + self.bib_style = bib_style; + self.merge.preview = merge_preview; + } + if let Some((d, s, h, r, t, ev, c)) = before_doc { + self.doc = d; + self.sel = s; + self.history = h; + self.redo = r; + self.typing_open = t; + // The steps the command pushed out are back, so they no longer count as gone. + self.undo_evicted = ev; + self.column = c; + // Its step is gone with them: its undo group has none to merge into. + if pushed { + self.join_anchor = false; + } + } + if let Some(dirty) = dirty_before { + self.dirty = dirty; + } + if let Some(rev) = rev_restore { + self.restore_rev(rev); + } + if let Some(b) = blocks_before { + self.building_blocks = b; + } + self.status = e.to_string(); + } + } + result + } + + fn finish_success(&mut self, context: SuccessContext<'_>) { + let SuccessContext { + id, + spec, + before_doc, + pending_before, + document_id_before, + replaces_document, + dirty_before, + rev_restore, + pushed, + mutates, + ime_caret_before, + rev_before, + sel_before, + } = context; + let mut no_op = false; + if spec.mutates { + // Formatting changed while tracking becomes a formatting revision. + if let Some((before, ..)) = before_doc { + crate::cmd::fmt_revisions::record(self, id, before); + } + self.doc.ensure_nonempty(); + self.doc.prune_text_boxes(); + // Whether the command left the document, the selection and the formatting + // waiting at the caret as they were. A command that replaces the document — + // a mail merge, a comparison — is not an edit of this one, whatever the new + // document holds: `replaces_document` names the commands that may replace + // it, and `document_id` catches any other that does (through + // `set_document`, which starts a new history the old one must not overwrite). + let unchanged = |s: &Session| match (before_doc, pending_before) { + (Some((doc, sel, _, _, _, _, column)), Some(pending)) => { + !replaces_document && s.document_id == document_id_before && s.unchanged(doc, sel, column, pending) + } + _ => false, + }; + let changed = !unchanged(self); + if changed { + // The layout the connectors are rerouted against is the new one. + self.touch(); + } + // Glued connectors follow the shapes they join. + let rerouted = crate::cmd::connectors::reroutes_after(id) && crate::cmd::connectors::reroute(self); + if rerouted { + self.touch(); + } + // A command that changed nothing is not an edit (#23): it adds no undo + // step, marks no unsaved changes, forces no relayout and leaves the redo + // stack alone. What it left behind is compared with what it started from, + // after the reroute: a reroute that moved a connector is an edit of its own. + no_op = !changed && !rerouted && unchanged(self); + if no_op { + // The step the dispatch pushed is not an edit: put the stacks back as + // they were, so the redo stack survives and no step is evicted. The + // group it belonged to is left without a step of its own, so the next + // command in it starts one. + if let Some((_, _, history, redo, typing_open, evicted, _)) = before_doc { + self.history = history.clone(); + self.redo = redo.clone(); + self.typing_open = *typing_open; + self.undo_evicted = *evicted; + } + if let Some(dirty) = dirty_before { + self.dirty = dirty; + } + if let Some(rev) = rev_restore { + let touched = self.rev; + self.restore_rev(rev); + crate::cmd::draw::rekey_lasso(self, touched, rev); + } + if pushed { + self.join_anchor = false; + } + } + } + self.clamp_selection(); + let mut ime_outcome = self.ime_edit_outcome; + if ime_outcome.content_changed { + ime_outcome.state_changed = true; + } + // An unclassified mutating command is conservatively a state change. It can make + // a frame Other, but it can never mint a receipt because `classified` is false. + // A command that changed nothing is not an edit (#23), so it is not an IME + // state change either: otherwise Backspace at the document start would still + // signal an application change and move the settled IME revision. + if !ime_outcome.classified && mutates && !no_op { + ime_outcome.state_changed = true; + } + ime_outcome.selection_changed = self.current_ime_caret_state() != ime_caret_before; + self.ime_edit_outcome = ime_outcome; + if ime_outcome.state_changed { + let caret = self.current_ime_caret_state(); + self.ime_state_seen = Some(caret); + self.reset_ime_state_revision(); + } + if !self.ime_origin && !no_op && (ime_outcome.content_changed || ime_outcome.state_changed || (!ime_outcome.classified && mutates)) { + self.pending_ime_application_change = true; + } + if sel_before.focus.story == StoryRef::Body && self.in_header_footer() { + self.hf_return = Some(sel_before.clone()); + } + // A selection command that moves the insertion point ends the typing group, like + // caret movement: typing, Select, typing are two undo steps (#419). + if !spec.mutates && self.sel != *sel_before { + self.typing_open = false; + } + if spec.mutates && !no_op && !matches!(id, "edit.undo" | "edit.redo") { + self.note_edit(); + } + // Outline view: collapsed headings follow edits; the caret stays where it shows. + crate::cmd::outline::after_command(self, sel_before, self.rev != rev_before); + // Extra selected objects last until something else is edited or selected; formatting + // them keeps them selected, as in Word. + if !matches!(id, "select.addObject" | "arrange.ungroup" | "shape.fill" | "shape.effects") && (spec.mutates || self.sel != *sel_before) { + self.also_selected.clear(); + } + // A canvas member stays picked while its canvas does and nothing else is selected. + if id != "select.member" + && (self.sel != *sel_before + || id.starts_with("select.") + || id.starts_with("caret.") + || matches!(id, "edit.undo" | "edit.undoTo" | "edit.redo")) + { + self.member = None; + } + self.note_typing(id, before_doc.as_ref().map(|b| &b.0), sel_before); + } + + /// The end of [`Session::run`] for a command that ended up in another document than it + /// started in (it opened, made, switched to or closed one) or that failed after opening one. + /// The checks that compare the document before and after the command don't apply. + /// Succeeded: the first document loses the undo step made for the command when its text is + /// as it was (opening a document is no edit to undo there). Failed: the documents the command opened close again and the first document is + /// active and as it was. + fn finish_elsewhere(&mut self, context: ElsewhereContext, result: Result) -> Result { + let ElsewhereContext { home, mark, before_doc, blocks_before, default_language_before, pending_application_change_before } = context; + // Another document is a state change the input method has to see, never an IME edit. + self.ime_edit_outcome = ImeEditOutcome::default(); + match &result { + Ok(_) => { + self.put_back_parked(home, before_doc); + self.clamp_selection(); + self.ime_state_seen = Some(self.current_ime_caret_state()); + self.reset_ime_state_revision(); + if !self.ime_origin { + self.pending_ime_application_change = true; + } + } + Err(e) => { + self.pending_ime_application_change = pending_application_change_before; + self.autocap = None; + self.prefs.default_language = default_language_before; + if self.is_open(home) { + let _ = self.switch_to(home); + let opened: Vec = self.order.iter().copied().filter(|d| *d >= mark && *d != home).collect(); + for d in opened { + let _ = self.close_document(d); + } + if let Some((d, s, h, r, t, ev, c)) = before_doc { + self.doc = d; + self.sel = s; + self.history = h; + self.redo = r; + self.typing_open = t; + self.undo_evicted = ev; + self.column = c; + } + } + if let Some(b) = blocks_before { + self.building_blocks = b; + } + self.status = e.to_string(); + } + } + result + } +} diff --git a/crates/engine/src/session/snapshot.rs b/crates/engine/src/session/snapshot.rs index 311cb3ea92..90209edc57 100644 --- a/crates/engine/src/session/snapshot.rs +++ b/crates/engine/src/session/snapshot.rs @@ -1,302 +1,302 @@ -use super::doc_state::{MAX_UNDO, Selection, doc_bib_style}; -use super::snapshot_types::{EditSnapshot, SNAPSHOT_HEAD}; -use super::{Document, Session, StoryRef, next_document_id}; - -impl Session { - /// Replace the document (open/new). - pub fn set_document(&mut self, doc: Document) { - self.autocap = None; - let replaced = self.document_id; - self.md_source = None; - self.md_file = Default::default(); - self.document_id = next_document_id(); - if let Some(at) = self.order.iter_mut().find(|d| **d == replaced) { - *at = self.document_id; - } - self.note_body_replaced(); - self.bib_style = doc_bib_style(&doc); - self.doc = doc; - self.doc.ensure_nonempty(); - self.password = None; - self.file_name = None; - // The old document's equation is gone: its editing session cannot carry over. - self.math = None; - self.sel = Selection::caret(self.doc.start_of(StoryRef::Body)); - self.pending = None; - self.typing = None; - self.also_selected.clear(); - self.member = None; - self.column = None; - self.column_mode = false; - self.widened = None; - self.edit_marks.clear(); - self.new_header_parts.clear(); - self.hf_return = None; - self.go_back_step = 0; - self.view.outline.collapsed.clear(); - self.view.follow_prefs(&self.prefs); - // A copy the replaced document kept stays in the recovery list. - #[cfg(not(target_arch = "wasm32"))] - { - self.recovery_slot = Default::default(); - } - self.reset_history(); - self.clear_versions(); - self.touch(); - self.dirty = false; - self.made_rev = self.rev; - self.relayout(); - } - - /// A snapshot to put back with [`Session::restore`], for a caller that runs a command and - /// may want to take it back without a trace (cheap: the document's blocks are shared). - /// - /// Covered: `doc`, `sel`, the undo stack (its length, and its oldest steps when it is near - /// the limit), `redo`, the open typing group, the pending `join_next_undo`, - /// `join_next_equation_edit` and body replacement promises, whether the typing group has a - /// step of its own, and `dirty`. `rev`, - /// `cache` and `layout` are derived (`restore` bumps `rev`). The other fields are not - /// covered: a caller that lets the command change them (the view, the find state, the - /// clipboard, macros, the file path…) saves and puts them back itself. - /// - /// `restore` is exact when the commands in between only add undo steps (every mutating - /// command adds at most one; up to [`SNAPSHOT_HEAD`] may push old steps out of a full - /// stack). Taking steps off the stack (`edit.undo`), clearing it (`file.open` into - /// a blank document) or switching documents in between is not covered. - /// - /// The destructuring lists every field, so a new field does not build until someone decides - /// whether a snapshot must cover it. - pub fn edit_snapshot(&self) -> EditSnapshot { - let Session { - doc, - sel, - view: _, - pending: _, - last_highlight: _, - path: _, - file_name: _, - // The custom dictionary file, like the file path: host wiring, not an edit. - user_dict_path: _, - dirty, - clipboard: _, - // Like the clipboard: what the next paste can make a move of. - pending_move: _, - clipboard_text: _, - clipboard_html: _, - clipboard_rtf: _, - clip_history: _, - find: _, - goal_x: _, - page_hint: _, - registry: _, - author: _, - initials: _, - painter: _, - status: _, - // Headers just created to edit: closing checks they still exist and are empty. - new_header_parts: _, - hf_return: _, - // Which navigated style change Accept/Reject acts on: it checks the selection itself. - review_style: _, - history, - undo_evicted, - redo, - typing_open, - join_next, - equation_join_next, - ime_text_join, - join_anchor, - autocap: _, - rev: _, - protect_facts: _, - cache: _, - layout: _, - // The word being typed, like `column`: valid only while `sel` is where typing left it. - typing: _, - originals: _, - md_source: _, - md_file: _, - picture_looks: _, - last_command: _, - recording: _, - macros: _, - versions: _, - building_blocks: _, - autosave: _, - bib_style: _, - merge: _, - ui_requests: _, - document_id: _, - // The untitled name and when the document was made: not edits. - untitled: _, - made_rev: _, - // The other open documents: a snapshot covers the active one. - others: _, - order: _, - untitled_count: _, - ime_body_epoch, - // The IME revision and its caret token are process-global evidence, not document - // state: a rollback must not reuse a revision. - ime_state_revision: _, - ime_state_seen: _, - ime_edit_outcome: _, - #[cfg(any(test, feature = "test-support"))] - ime_state_exhausted: _, - pending_ime_application_change: _, - ime_origin: _, - read_aloud: _, - // The other windows, like `view`: not part of the document or its history. - windows: _, - prefs: _, - system_region: _, - keymap: _, - ribbon: _, - // A preference for new documents, like `prefs`: not part of this document. - editing_lang: _, - // Equation editing mode, like `view`: not part of the document or its history. - math: _, - math_latex: _, - math_normal_text: _, - // The save password, like the file path: not an edit to the document. - password: _, - #[cfg(not(target_arch = "wasm32"))] - recovery: _, - #[cfg(not(target_arch = "wasm32"))] - recovery_slot: _, - also_selected: _, - member: _, - // Column selection and a widened selection's ends, like `sel`'s shape: valid only while - // `sel` matches them. - column: _, - column_mode: _, - widened: _, - // Go Back's places, like the caret history: not part of the document. - edit_marks: _, - go_back_step: _, - } = self; - let head = if history.len() + SNAPSHOT_HEAD > MAX_UNDO { history.iter().take(SNAPSHOT_HEAD).cloned().collect() } else { Vec::new() }; - EditSnapshot { - doc: doc.clone(), - sel: sel.clone(), - history_len: history.len(), - evicted: *undo_evicted, - head, - redo: redo.clone(), - typing_open: *typing_open, - join_next: *join_next, - equation_join_next: *equation_join_next, - ime_text_join: ime_text_join.clone(), - join_anchor: *join_anchor, - dirty: *dirty, - ime_body_epoch: *ime_body_epoch, - } - } - - /// Put a snapshot back: the commands run since leave no trace in the document, the - /// selection or undo/redo (undo steps the limit pushed out meanwhile come back). The layout - /// is recomputed. - pub fn restore(&mut self, snap: EditSnapshot) { - let EditSnapshot { - doc, - sel, - history_len, - evicted, - head, - redo, - typing_open, - join_next, - equation_join_next, - ime_text_join, - join_anchor, - dirty, - ime_body_epoch, - } = snap; - let gone = usize::try_from(self.undo_evicted.saturating_sub(evicted)).unwrap_or(usize::MAX); - if gone > 0 { - let back = gone.min(head.len()); - self.history.splice(0..0, head.into_iter().take(back)); - } - self.undo_evicted = evicted; - self.autocap = None; - self.doc = doc; - self.sel = sel; - self.also_selected.clear(); - self.member = None; - self.history.truncate(history_len); - self.redo = redo; - self.typing_open = typing_open; - self.join_next = join_next; - self.equation_join_next = equation_join_next; - self.ime_text_join = ime_text_join; - self.ime_body_epoch = ime_body_epoch; - self.join_anchor = join_anchor; - self.touch(); - self.dirty = dirty; - } - - /// Make the selection valid for the current document. - pub fn clamp_selection(&mut self) { - self.sel.anchor = self.doc.clamp(&self.sel.anchor); - self.sel.focus = self.doc.clamp(&self.sel.focus); - // The equation being edited must still be there (undo, edits elsewhere). - if let Some(m) = &self.math { - let eq = self.doc.para_at(&m.at).and_then(|p| match p.object_at(m.at.off) { - Some(wordcraft_doc::InlineObject::Equation { math, .. }) => Some(wordcraft_doc::math_edit::clamp(&math.nodes, &m.pos)), - _ => None, - }); - match eq { - Some(pos) => { - if let Some(m) = self.math.as_mut() { - m.pos = pos; - } - } - None => self.math = None, - } - } - } - - /// Why command `id` can't run now, for greying it out: its own `enabled` check, then what - /// Restrict Editing refuses outright. `None` when it can run (or doesn't exist). - pub fn why_disabled(&self, id: &str) -> Option<&'static str> { - let spec = self.registry.get(id)?; - (spec.enabled)(self) - .or_else(|| crate::cmd::md_source::refusal(self, id, spec.mutates)) - .or_else(|| if spec.mutates { crate::cmd::protect::greyed_out(self, id, || self.protect_facts()) } else { None }) - } - - /// Put the revision back to `rev` after a command that changed nothing or failed. What the - /// command cached at the revision it touched to is dropped: that number comes round again - /// with the next edit, for a different document. - pub(super) fn restore_rev(&mut self, rev: u64) { - if self.rev == rev { - return; - } - self.rev = rev; - for slot in &mut self.layout { - if slot.as_ref().is_some_and(|l| l.0.0 != rev) { - *slot = None; - } - } - if self.find.highlights.as_ref().is_some_and(|(r, _)| *r != rev) { - self.find.highlights = None; - } - let facts = self.protect_facts.get_mut().unwrap_or_else(std::sync::PoisonError::into_inner); - if facts.as_ref().is_some_and(|((_, r, _), _)| *r != rev) { - *facts = None; - } - } - - /// [`crate::cmd::protect::Facts`] for the document as it is now, cached per revision. - fn protect_facts(&self) -> crate::cmd::protect::Facts { - let key = (self.document_id, self.rev, self.author.clone()); - let mut cache = self.protect_facts.lock().unwrap_or_else(std::sync::PoisonError::into_inner); - if let Some((k, f)) = cache.as_ref() - && *k == key - { - return *f; - } - let f = crate::cmd::protect::Facts::of(self); - *cache = Some((key, f)); - f - } -} +use super::doc_state::{MAX_UNDO, Selection, doc_bib_style}; +use super::snapshot_types::{EditSnapshot, SNAPSHOT_HEAD}; +use super::{Document, Session, StoryRef, next_document_id}; + +impl Session { + /// Replace the document (open/new). + pub fn set_document(&mut self, doc: Document) { + self.autocap = None; + let replaced = self.document_id; + self.md_source = None; + self.md_file = Default::default(); + self.document_id = next_document_id(); + if let Some(at) = self.order.iter_mut().find(|d| **d == replaced) { + *at = self.document_id; + } + self.note_body_replaced(); + self.bib_style = doc_bib_style(&doc); + self.doc = doc; + self.doc.ensure_nonempty(); + self.password = None; + self.file_name = None; + // The old document's equation is gone: its editing session cannot carry over. + self.math = None; + self.sel = Selection::caret(self.doc.start_of(StoryRef::Body)); + self.pending = None; + self.typing = None; + self.also_selected.clear(); + self.member = None; + self.column = None; + self.column_mode = false; + self.widened = None; + self.edit_marks.clear(); + self.new_header_parts.clear(); + self.hf_return = None; + self.go_back_step = 0; + self.view.outline.collapsed.clear(); + self.view.follow_prefs(&self.prefs); + // A copy the replaced document kept stays in the recovery list. + #[cfg(not(target_arch = "wasm32"))] + { + self.recovery_slot = Default::default(); + } + self.reset_history(); + self.clear_versions(); + self.touch(); + self.dirty = false; + self.made_rev = self.rev; + self.relayout(); + } + + /// A snapshot to put back with [`Session::restore`], for a caller that runs a command and + /// may want to take it back without a trace (cheap: the document's blocks are shared). + /// + /// Covered: `doc`, `sel`, the undo stack (its length, and its oldest steps when it is near + /// the limit), `redo`, the open typing group, the pending `join_next_undo`, + /// `join_next_equation_edit` and body replacement promises, whether the typing group has a + /// step of its own, and `dirty`. `rev`, + /// `cache` and `layout` are derived (`restore` bumps `rev`). The other fields are not + /// covered: a caller that lets the command change them (the view, the find state, the + /// clipboard, macros, the file path…) saves and puts them back itself. + /// + /// `restore` is exact when the commands in between only add undo steps (every mutating + /// command adds at most one; up to [`SNAPSHOT_HEAD`] may push old steps out of a full + /// stack). Taking steps off the stack (`edit.undo`), clearing it (`file.open` into + /// a blank document) or switching documents in between is not covered. + /// + /// The destructuring lists every field, so a new field does not build until someone decides + /// whether a snapshot must cover it. + pub fn edit_snapshot(&self) -> EditSnapshot { + let Session { + doc, + sel, + view: _, + pending: _, + last_highlight: _, + path: _, + file_name: _, + // The custom dictionary file, like the file path: host wiring, not an edit. + user_dict_path: _, + dirty, + clipboard: _, + // Like the clipboard: what the next paste can make a move of. + pending_move: _, + clipboard_text: _, + clipboard_html: _, + clipboard_rtf: _, + clip_history: _, + find: _, + goal_x: _, + page_hint: _, + registry: _, + author: _, + initials: _, + painter: _, + status: _, + // Headers just created to edit: closing checks they still exist and are empty. + new_header_parts: _, + hf_return: _, + // Which navigated style change Accept/Reject acts on: it checks the selection itself. + review_style: _, + history, + undo_evicted, + redo, + typing_open, + join_next, + equation_join_next, + ime_text_join, + join_anchor, + autocap: _, + rev: _, + protect_facts: _, + cache: _, + layout: _, + // The word being typed, like `column`: valid only while `sel` is where typing left it. + typing: _, + originals: _, + md_source: _, + md_file: _, + picture_looks: _, + last_command: _, + recording: _, + macros: _, + versions: _, + building_blocks: _, + autosave: _, + bib_style: _, + merge: _, + ui_requests: _, + document_id: _, + // The untitled name and when the document was made: not edits. + untitled: _, + made_rev: _, + // The other open documents: a snapshot covers the active one. + others: _, + order: _, + untitled_count: _, + ime_body_epoch, + // The IME revision and its caret token are process-global evidence, not document + // state: a rollback must not reuse a revision. + ime_state_revision: _, + ime_state_seen: _, + ime_edit_outcome: _, + #[cfg(any(test, feature = "test-support"))] + ime_state_exhausted: _, + pending_ime_application_change: _, + ime_origin: _, + read_aloud: _, + // The other windows, like `view`: not part of the document or its history. + windows: _, + prefs: _, + system_region: _, + keymap: _, + ribbon: _, + // A preference for new documents, like `prefs`: not part of this document. + editing_lang: _, + // Equation editing mode, like `view`: not part of the document or its history. + math: _, + math_latex: _, + math_normal_text: _, + // The save password, like the file path: not an edit to the document. + password: _, + #[cfg(not(target_arch = "wasm32"))] + recovery: _, + #[cfg(not(target_arch = "wasm32"))] + recovery_slot: _, + also_selected: _, + member: _, + // Column selection and a widened selection's ends, like `sel`'s shape: valid only while + // `sel` matches them. + column: _, + column_mode: _, + widened: _, + // Go Back's places, like the caret history: not part of the document. + edit_marks: _, + go_back_step: _, + } = self; + let head = if history.len() + SNAPSHOT_HEAD > MAX_UNDO { history.iter().take(SNAPSHOT_HEAD).cloned().collect() } else { Vec::new() }; + EditSnapshot { + doc: doc.clone(), + sel: sel.clone(), + history_len: history.len(), + evicted: *undo_evicted, + head, + redo: redo.clone(), + typing_open: *typing_open, + join_next: *join_next, + equation_join_next: *equation_join_next, + ime_text_join: ime_text_join.clone(), + join_anchor: *join_anchor, + dirty: *dirty, + ime_body_epoch: *ime_body_epoch, + } + } + + /// Put a snapshot back: the commands run since leave no trace in the document, the + /// selection or undo/redo (undo steps the limit pushed out meanwhile come back). The layout + /// is recomputed. + pub fn restore(&mut self, snap: EditSnapshot) { + let EditSnapshot { + doc, + sel, + history_len, + evicted, + head, + redo, + typing_open, + join_next, + equation_join_next, + ime_text_join, + join_anchor, + dirty, + ime_body_epoch, + } = snap; + let gone = usize::try_from(self.undo_evicted.saturating_sub(evicted)).unwrap_or(usize::MAX); + if gone > 0 { + let back = gone.min(head.len()); + self.history.splice(0..0, head.into_iter().take(back)); + } + self.undo_evicted = evicted; + self.autocap = None; + self.doc = doc; + self.sel = sel; + self.also_selected.clear(); + self.member = None; + self.history.truncate(history_len); + self.redo = redo; + self.typing_open = typing_open; + self.join_next = join_next; + self.equation_join_next = equation_join_next; + self.ime_text_join = ime_text_join; + self.ime_body_epoch = ime_body_epoch; + self.join_anchor = join_anchor; + self.touch(); + self.dirty = dirty; + } + + /// Make the selection valid for the current document. + pub fn clamp_selection(&mut self) { + self.sel.anchor = self.doc.clamp(&self.sel.anchor); + self.sel.focus = self.doc.clamp(&self.sel.focus); + // The equation being edited must still be there (undo, edits elsewhere). + if let Some(m) = &self.math { + let eq = self.doc.para_at(&m.at).and_then(|p| match p.object_at(m.at.off) { + Some(wordcraft_doc::InlineObject::Equation { math, .. }) => Some(wordcraft_doc::math_edit::clamp(&math.nodes, &m.pos)), + _ => None, + }); + match eq { + Some(pos) => { + if let Some(m) = self.math.as_mut() { + m.pos = pos; + } + } + None => self.math = None, + } + } + } + + /// Why command `id` can't run now, for greying it out: its own `enabled` check, then what + /// Restrict Editing refuses outright. `None` when it can run (or doesn't exist). + pub fn why_disabled(&self, id: &str) -> Option<&'static str> { + let spec = self.registry.get(id)?; + (spec.enabled)(self) + .or_else(|| crate::cmd::md_source::refusal(self, id, spec.mutates)) + .or_else(|| if spec.mutates { crate::cmd::protect::greyed_out(self, id, || self.protect_facts()) } else { None }) + } + + /// Put the revision back to `rev` after a command that changed nothing or failed. What the + /// command cached at the revision it touched to is dropped: that number comes round again + /// with the next edit, for a different document. + pub(super) fn restore_rev(&mut self, rev: u64) { + if self.rev == rev { + return; + } + self.rev = rev; + for slot in &mut self.layout { + if slot.as_ref().is_some_and(|l| l.0.0 != rev) { + *slot = None; + } + } + if self.find.highlights.as_ref().is_some_and(|(r, _)| *r != rev) { + self.find.highlights = None; + } + let facts = self.protect_facts.get_mut().unwrap_or_else(std::sync::PoisonError::into_inner); + if facts.as_ref().is_some_and(|((_, r, _), _)| *r != rev) { + *facts = None; + } + } + + /// [`crate::cmd::protect::Facts`] for the document as it is now, cached per revision. + fn protect_facts(&self) -> crate::cmd::protect::Facts { + let key = (self.document_id, self.rev, self.author.clone()); + let mut cache = self.protect_facts.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some((k, f)) = cache.as_ref() + && *k == key + { + return *f; + } + let f = crate::cmd::protect::Facts::of(self); + *cache = Some((key, f)); + f + } +} diff --git a/crates/engine/src/session/views.rs b/crates/engine/src/session/views.rs index 9be8596052..6442df1b65 100644 --- a/crates/engine/src/session/views.rs +++ b/crates/engine/src/session/views.rs @@ -1,170 +1,170 @@ -//! Several windows on one document. The document, its undo history, dirty flag, path and save -//! state are shared; each window has its own [`WindowView`]: where its caret is and how it shows -//! the document. The session holds one view per document (the drawn window's, for the active -//! document); a front end keeps the other windows' views and moves them in and out with -//! [`Session::take_view`] and [`Session::put_view`] as it draws each window. - -use wordcraft_doc::props::CharProps; -use wordcraft_doc::{Pos, StoryRef}; - -use super::{ColumnBlock, MathEdit, Selection, Session, TypedWord, ViewState, Widened, next_math_session}; - -/// One window's view of a document: its selection and caret state, and how it shows the page -/// (zoom, view mode, panes, ruler…). Positions in it may be stale after edits made through -/// another window; [`Session::put_view`] makes them valid again. -pub struct WindowView { - sel: Selection, - view: ViewState, - pending: Option, - goal_x: Option, - page_hint: usize, - hf_return: Option, - typing_open: bool, - join_next: bool, - equation_join_next: bool, - ime_text_join: Option, - typing: Option, - math: Option, - also_selected: Vec, - column: Option, - column_mode: bool, - widened: Option, - /// The document and its revision when the view was taken out. - document_id: u64, - rev: u64, -} - -impl WindowView { - /// The document this view is of. - pub fn document_id(&self) -> u64 { - self.document_id - } - - /// The view's selection. - pub fn selection(&self) -> &Selection { - &self.sel - } - - /// How the view shows the document. - pub fn view(&self) -> &ViewState { - &self.view - } -} - -impl Session { - /// Take the active document's view out of the session, for a window that stops being drawn. - /// The session is left with a caret at the start of the document and the default view - /// until [`Session::put_view`] puts a view back. - pub fn take_view(&mut self) -> WindowView { - use std::mem::{replace, take}; - let start = self.doc.start_of(StoryRef::Body); - WindowView { - sel: replace(&mut self.sel, Selection::caret(start)), - view: take(&mut self.view), - pending: self.pending.take(), - goal_x: self.goal_x.take(), - page_hint: take(&mut self.page_hint), - hf_return: self.hf_return.take(), - typing_open: take(&mut self.typing_open), - join_next: take(&mut self.join_next), - equation_join_next: take(&mut self.equation_join_next), - ime_text_join: self.ime_text_join.take(), - typing: self.typing.take(), - math: self.math.take(), - also_selected: take(&mut self.also_selected), - column: self.column.take(), - column_mode: take(&mut self.column_mode), - widened: self.widened.take(), - document_id: self.document_id, - rev: self.rev, - } - } - - /// A copy of the active document's view, for a new window on it (View › New Window). - pub fn copy_view(&self) -> WindowView { - WindowView { - sel: self.sel.clone(), - view: self.view.clone(), - pending: self.pending.clone(), - goal_x: self.goal_x, - page_hint: self.page_hint, - hf_return: self.hf_return.clone(), - // The new window hasn't typed: its first key starts an undo step of its own. - typing_open: false, - join_next: false, - equation_join_next: false, - ime_text_join: None, - typing: None, - // An equation editing session is an input method's owner: the new window's is its own. - math: self.math.clone().and_then(|m| Some(MathEdit { session: next_math_session()?, ..m })), - also_selected: self.also_selected.clone(), - column: self.column.clone(), - column_mode: self.column_mode, - widened: self.widened.clone(), - document_id: self.document_id, - rev: self.rev, - } - } - - /// Make `v` the active document's view. When the document changed since `v` was taken - /// (another window edited it, or `v` is of another document), its positions are clamped into - /// the document, what only made sense for the text it was taken from (a column block, a - /// widened table selection, more selected objects, the word being typed) is dropped, and the - /// window's typing no longer joins its last undo step. - pub fn put_view(&mut self, v: WindowView) { - let WindowView { - sel, - view, - pending, - goal_x, - page_hint, - hf_return, - typing_open, - join_next, - equation_join_next, - ime_text_join, - typing, - math, - also_selected, - column, - column_mode, - widened, - document_id, - rev, - } = v; - let current = document_id == self.document_id && rev == self.rev; - self.sel = sel; - self.view = view; - self.view.follow_prefs(&self.prefs); - self.pending = pending; - self.goal_x = goal_x; - self.page_hint = page_hint; - self.column_mode = column_mode; - self.math = math; - // A picked Drawing Canvas member (#2637) is not part of a view's saved selection. - self.member = None; - if current { - self.hf_return = hf_return; - self.typing_open = typing_open; - self.join_next = join_next; - self.equation_join_next = equation_join_next; - self.ime_text_join = ime_text_join; - self.typing = typing; - self.also_selected = also_selected; - self.column = column; - self.widened = widened; - return; - } - self.hf_return = hf_return.map(|s| Selection { anchor: self.doc.clamp(&s.anchor), focus: self.doc.clamp(&s.focus) }); - self.typing_open = false; - self.join_next = false; - self.equation_join_next = false; - self.ime_text_join = None; - self.typing = None; - self.also_selected = Vec::new(); - self.column = None; - self.widened = None; - self.goal_x = None; - self.clamp_selection(); - } -} +//! Several windows on one document. The document, its undo history, dirty flag, path and save +//! state are shared; each window has its own [`WindowView`]: where its caret is and how it shows +//! the document. The session holds one view per document (the drawn window's, for the active +//! document); a front end keeps the other windows' views and moves them in and out with +//! [`Session::take_view`] and [`Session::put_view`] as it draws each window. + +use wordcraft_doc::props::CharProps; +use wordcraft_doc::{Pos, StoryRef}; + +use super::{ColumnBlock, MathEdit, Selection, Session, TypedWord, ViewState, Widened, next_math_session}; + +/// One window's view of a document: its selection and caret state, and how it shows the page +/// (zoom, view mode, panes, ruler…). Positions in it may be stale after edits made through +/// another window; [`Session::put_view`] makes them valid again. +pub struct WindowView { + sel: Selection, + view: ViewState, + pending: Option, + goal_x: Option, + page_hint: usize, + hf_return: Option, + typing_open: bool, + join_next: bool, + equation_join_next: bool, + ime_text_join: Option, + typing: Option, + math: Option, + also_selected: Vec, + column: Option, + column_mode: bool, + widened: Option, + /// The document and its revision when the view was taken out. + document_id: u64, + rev: u64, +} + +impl WindowView { + /// The document this view is of. + pub fn document_id(&self) -> u64 { + self.document_id + } + + /// The view's selection. + pub fn selection(&self) -> &Selection { + &self.sel + } + + /// How the view shows the document. + pub fn view(&self) -> &ViewState { + &self.view + } +} + +impl Session { + /// Take the active document's view out of the session, for a window that stops being drawn. + /// The session is left with a caret at the start of the document and the default view + /// until [`Session::put_view`] puts a view back. + pub fn take_view(&mut self) -> WindowView { + use std::mem::{replace, take}; + let start = self.doc.start_of(StoryRef::Body); + WindowView { + sel: replace(&mut self.sel, Selection::caret(start)), + view: take(&mut self.view), + pending: self.pending.take(), + goal_x: self.goal_x.take(), + page_hint: take(&mut self.page_hint), + hf_return: self.hf_return.take(), + typing_open: take(&mut self.typing_open), + join_next: take(&mut self.join_next), + equation_join_next: take(&mut self.equation_join_next), + ime_text_join: self.ime_text_join.take(), + typing: self.typing.take(), + math: self.math.take(), + also_selected: take(&mut self.also_selected), + column: self.column.take(), + column_mode: take(&mut self.column_mode), + widened: self.widened.take(), + document_id: self.document_id, + rev: self.rev, + } + } + + /// A copy of the active document's view, for a new window on it (View › New Window). + pub fn copy_view(&self) -> WindowView { + WindowView { + sel: self.sel.clone(), + view: self.view.clone(), + pending: self.pending.clone(), + goal_x: self.goal_x, + page_hint: self.page_hint, + hf_return: self.hf_return.clone(), + // The new window hasn't typed: its first key starts an undo step of its own. + typing_open: false, + join_next: false, + equation_join_next: false, + ime_text_join: None, + typing: None, + // An equation editing session is an input method's owner: the new window's is its own. + math: self.math.clone().and_then(|m| Some(MathEdit { session: next_math_session()?, ..m })), + also_selected: self.also_selected.clone(), + column: self.column.clone(), + column_mode: self.column_mode, + widened: self.widened.clone(), + document_id: self.document_id, + rev: self.rev, + } + } + + /// Make `v` the active document's view. When the document changed since `v` was taken + /// (another window edited it, or `v` is of another document), its positions are clamped into + /// the document, what only made sense for the text it was taken from (a column block, a + /// widened table selection, more selected objects, the word being typed) is dropped, and the + /// window's typing no longer joins its last undo step. + pub fn put_view(&mut self, v: WindowView) { + let WindowView { + sel, + view, + pending, + goal_x, + page_hint, + hf_return, + typing_open, + join_next, + equation_join_next, + ime_text_join, + typing, + math, + also_selected, + column, + column_mode, + widened, + document_id, + rev, + } = v; + let current = document_id == self.document_id && rev == self.rev; + self.sel = sel; + self.view = view; + self.view.follow_prefs(&self.prefs); + self.pending = pending; + self.goal_x = goal_x; + self.page_hint = page_hint; + self.column_mode = column_mode; + self.math = math; + // A picked Drawing Canvas member (#2637) is not part of a view's saved selection. + self.member = None; + if current { + self.hf_return = hf_return; + self.typing_open = typing_open; + self.join_next = join_next; + self.equation_join_next = equation_join_next; + self.ime_text_join = ime_text_join; + self.typing = typing; + self.also_selected = also_selected; + self.column = column; + self.widened = widened; + return; + } + self.hf_return = hf_return.map(|s| Selection { anchor: self.doc.clamp(&s.anchor), focus: self.doc.clamp(&s.focus) }); + self.typing_open = false; + self.join_next = false; + self.equation_join_next = false; + self.ime_text_join = None; + self.typing = None; + self.also_selected = Vec::new(); + self.column = None; + self.widened = None; + self.goal_x = None; + self.clamp_selection(); + } +} diff --git a/crates/engine/src/snapshot.rs b/crates/engine/src/snapshot.rs index dcd60a38da..1e10420b00 100644 --- a/crates/engine/src/snapshot.rs +++ b/crates/engine/src/snapshot.rs @@ -1,80 +1,80 @@ -use crate::doc_state::{ColumnBlock, Selection, Undo}; -use std::sync::Arc; - -use wordcraft_doc::Document; -use wordcraft_layout::{DocLayout, NumeralMode, ViewMode}; - -/// What [`Session::restore`] needs to take commands back exactly: the document, the selection, -/// the undo and redo stacks (also the oldest undo steps the undo limit pushes out meanwhile), the -/// open typing group, the pending -/// [`Session::join_next_undo`]/[`Session::join_next_equation_edit`]/body IME replacement promises, -/// whether the group has a step of its own, and the dirty flag. See -/// [`Session::edit_snapshot`]. -pub struct EditSnapshot { - pub(super) doc: Document, - pub(super) sel: Selection, - pub(super) history_len: usize, - /// `Session::undo_evicted` when the snapshot was taken. - pub(super) evicted: u64, - /// The oldest undo steps, kept only when the stack is near its limit (the commands that - /// follow may push them out); at most [`SNAPSHOT_HEAD`]. - pub(super) head: Vec>, - pub(super) redo: Vec>, - pub(super) typing_open: bool, - pub(super) join_next: bool, - pub(super) equation_join_next: bool, - /// The body `DeleteSurrounding` replacement promise then, with the exact caret it expects. - pub(super) ime_text_join: Option, - pub(super) join_anchor: bool, - pub(super) dirty: bool, - /// The body's replacement epoch then: a previewed replacement that is rolled back is not a - /// replacement after all. - pub(super) ime_body_epoch: u64, -} - -/// Undo steps that the commands run between [`Session::edit_snapshot`] and [`Session::restore`] -/// may push out of a full stack and still come back. -pub(super) const SNAPSHOT_HEAD: usize = 4; - -impl EditSnapshot { - /// The document when the snapshot was taken. - pub fn doc(&self) -> &Document { - &self.doc - } - /// The dirty flag then. - pub fn dirty(&self) -> bool { - self.dirty - } - /// The number of undo steps then. - pub fn undo_depth(&self) -> usize { - self.history_len - } -} - -/// What `run` keeps to take a failed mutating command back: the document, the selection, the -/// undo and redo stacks, the open typing group, the undo steps dropped so far and the column -/// block. -pub(super) type RunSnapshot = (Document, Selection, Vec>, Vec>, bool, u64, Option); - -/// A layout made and what it was made for: ((revision, proofing dictionaries' generation), width, read height, view, read line -/// spacing, layout, proofing, outline, (deletions left out, deletions marked Hidden, body -/// deletions in balloons), numeral display, the word being typed, (hidden text shown, field -/// codes shown)). -pub(super) type LayoutEntry = ( - (u64, u64), - f32, - f32, - ViewMode, - Option, - Arc, - bool, - Option, - (bool, wordcraft_layout::HideDeleted, bool), - NumeralMode, - Option, - (bool, bool), -); - -/// The layouts last made, newest first. Two, so two windows on one document with different -/// views ([`WindowView`]) each find theirs as they draw in turn. -pub(super) type LayoutSlots = [Option; 2]; +use crate::doc_state::{ColumnBlock, Selection, Undo}; +use std::sync::Arc; + +use wordcraft_doc::Document; +use wordcraft_layout::{DocLayout, NumeralMode, ViewMode}; + +/// What [`Session::restore`] needs to take commands back exactly: the document, the selection, +/// the undo and redo stacks (also the oldest undo steps the undo limit pushes out meanwhile), the +/// open typing group, the pending +/// [`Session::join_next_undo`]/[`Session::join_next_equation_edit`]/body IME replacement promises, +/// whether the group has a step of its own, and the dirty flag. See +/// [`Session::edit_snapshot`]. +pub struct EditSnapshot { + pub(super) doc: Document, + pub(super) sel: Selection, + pub(super) history_len: usize, + /// `Session::undo_evicted` when the snapshot was taken. + pub(super) evicted: u64, + /// The oldest undo steps, kept only when the stack is near its limit (the commands that + /// follow may push them out); at most [`SNAPSHOT_HEAD`]. + pub(super) head: Vec>, + pub(super) redo: Vec>, + pub(super) typing_open: bool, + pub(super) join_next: bool, + pub(super) equation_join_next: bool, + /// The body `DeleteSurrounding` replacement promise then, with the exact caret it expects. + pub(super) ime_text_join: Option, + pub(super) join_anchor: bool, + pub(super) dirty: bool, + /// The body's replacement epoch then: a previewed replacement that is rolled back is not a + /// replacement after all. + pub(super) ime_body_epoch: u64, +} + +/// Undo steps that the commands run between [`Session::edit_snapshot`] and [`Session::restore`] +/// may push out of a full stack and still come back. +pub(super) const SNAPSHOT_HEAD: usize = 4; + +impl EditSnapshot { + /// The document when the snapshot was taken. + pub fn doc(&self) -> &Document { + &self.doc + } + /// The dirty flag then. + pub fn dirty(&self) -> bool { + self.dirty + } + /// The number of undo steps then. + pub fn undo_depth(&self) -> usize { + self.history_len + } +} + +/// What `run` keeps to take a failed mutating command back: the document, the selection, the +/// undo and redo stacks, the open typing group, the undo steps dropped so far and the column +/// block. +pub(super) type RunSnapshot = (Document, Selection, Vec>, Vec>, bool, u64, Option); + +/// A layout made and what it was made for: ((revision, proofing dictionaries' generation), width, read height, view, read line +/// spacing, layout, proofing, outline, (deletions left out, deletions marked Hidden, body +/// deletions in balloons), numeral display, the word being typed, (hidden text shown, field +/// codes shown)). +pub(super) type LayoutEntry = ( + (u64, u64), + f32, + f32, + ViewMode, + Option, + Arc, + bool, + Option, + (bool, wordcraft_layout::HideDeleted, bool), + NumeralMode, + Option, + (bool, bool), +); + +/// The layouts last made, newest first. Two, so two windows on one document with different +/// views ([`WindowView`]) each find theirs as they draw in turn. +pub(super) type LayoutSlots = [Option; 2]; diff --git a/crates/engine/src/speech.rs b/crates/engine/src/speech.rs index 233b13c5c1..d4d2c26645 100644 --- a/crates/engine/src/speech.rs +++ b/crates/engine/src/speech.rs @@ -1,1082 +1,1082 @@ -//! Read Aloud: the document as a queue of sentences that the system's speech program speaks one -//! at a time, so reading can be paused, skipped forwards and backwards, and sped up. -//! -//! Citation-manager fields can be left out: Zotero, Mendeley and EndNote citations and -//! bibliographies (range fields with an `ADDIN …` code) and Word's own `CITATION` and -//! `BIBLIOGRAPHY` fields. The citation's text simply isn't in the queue, so every citation -//! style is skipped the same way: "(Smith et al., 2020)", "[3]" or a superscript number. -//! -//! Each sentence is spoken in its own language: the run's language tag, or else the script of its -//! letters (Cyrillic, Greek, Arabic, Hebrew, CJK, Thai…), so a Russian sentence gets a Russian voice -//! where one is installed instead of being spelled out letter by letter. The system's own voice -//! stays whenever it already speaks the language (English text on an English system keeps the -//! voice the user chose), and without a voice for the language the default voice speaks. Speech -//! Dispatcher is the exception: it can't be asked for its default language, so it is told the -//! language of every sentence. -//! -//! The speech itself runs on a worker thread (desktop only): it starts `say` (macOS), `espeak-ng` -//! / `espeak` (Linux, BSD) or the Windows speech synthesizer (PowerShell) for the current sentence, -//! or talks to Speech Dispatcher over its socket when neither espeak is installed, and moves on when -//! the sentence ends. Commands only change the shared state and wake the worker, which stops the -//! running sentence when the state changed under it. - -use std::sync::{Arc, Condvar, Mutex, MutexGuard, PoisonError}; - -use serde_json::{Value, json}; -use wordcraft_doc::para::{COLUMN_BREAK, LINE_BREAK, OBJ, PAGE_BREAK}; -use wordcraft_doc::{Document, InlineObject, Pos}; - -/// Longest sentence handed to the speech program (longer ones are split at a space). -const MAX_SENTENCE: usize = 600; -/// Longest utterance, even with no space to split at (a long URL, a run of letters): it is cut -/// there, mid-word. -const MAX_UTTERANCE: usize = 2 * MAX_SENTENCE; -/// Most sentences in one queue. -const MAX_SENTENCES: usize = 20_000; -/// Speed limits (1.0 = the normal speed). -pub const MIN_RATE: f32 = 0.5; -pub const MAX_RATE: f32 = 3.0; -/// Words per minute at speed 1.0 (`say` and `espeak` both default to about this). -#[cfg(not(target_arch = "wasm32"))] -const BASE_WPM: f32 = 180.0; - -/// Words that end with a full stop without ending the sentence. -const ABBREVIATIONS: &[&str] = &[ - "al", "e.g", "i.e", "eg", "ie", "cf", "vs", "etc", "fig", "figs", "eq", "eqs", "no", "nos", "p", "pp", "vol", "vols", "ch", "chap", "sec", "tab", - "ref", "refs", "dr", "mr", "mrs", "ms", "prof", "st", "approx", "ca", "resp", "ed", "eds", "suppl", "jr", "sr", "inc", "ltd", "co", "dept", - "est", "min", "max", "viz", "ibid", -]; - -/// Is this field code a citation or a bibliography (Zotero, Mendeley, EndNote, Word)? -pub fn is_citation_code(instr: &str) -> bool { - let code = instr.trim_start(); - let word = code.split_whitespace().next().unwrap_or("").to_ascii_uppercase(); - if word == "CITATION" || word == "BIBLIOGRAPHY" { - return true; - } - if word != "ADDIN" { - return false; - } - let rest = code.get(5..).unwrap_or("").trim_start().to_ascii_uppercase(); - ["ZOTERO_ITEM", "ZOTERO_BIBL", "CSL_CITATION", "CSL_BIBLIOGRAPHY", "MENDELEY", "EN.CITE", "EN.REFLIST"].iter().any(|k| rest.starts_with(k)) -} - -/// One sentence to speak: its text and where it is in the document. -#[derive(Clone, Debug, PartialEq)] -pub struct Utterance { - pub text: String, - pub start: Pos, - pub end: Pos, - /// The language to speak it in (a BCP 47 tag), or `None` for the system's default voice. - pub lang: Option, -} - -impl Utterance { - pub fn to_json(&self) -> Value { - json!({"text": self.text, "start": crate::cmd::pos_json(&self.start), "end": crate::cmd::pos_json(&self.end)}) - } -} - -/// The sentences from `from` (to `to`, if given) in `from`'s story, in reading order. -/// With `skip_citations`, citation and bibliography fields are left out. At most -/// [`MAX_SENTENCES`]; when that cuts the text short a warning is logged, so the stop is not silent. -pub fn sentences(doc: &Document, from: &Pos, to: Option<&Pos>, skip_citations: bool) -> Vec { - let story = from.story; - // Spans to leave out: whole range fields, both markers included. - let skipped = Spans::new(if skip_citations { - doc.field_ranges(story).into_iter().filter(|r| is_citation_code(&r.instr)).map(|r| (r.start, r.end)).collect() - } else { - Vec::new() - }); - let in_skipped = |p: &Pos| skipped.contains(p); - let mut out = Vec::new(); - for path in doc.para_paths(story) { - if path < from.path { - continue; - } - if let Some(to) = to - && path > to.path - { - break; - } - let Some(para) = doc.para(story, &path) else { continue }; - // The spoken text, and for each char the paragraph offset it came from. - let mut text = String::new(); - let mut map: Vec<(usize, usize)> = Vec::new(); // (start, end) offsets in the paragraph - let mut k = 0; - for (off, c) in para.text.char_indices() { - let end = off + c.len_utf8(); - let obj = if c == OBJ { - k += 1; - para.objects.get(k - 1) - } else { - None - }; - if (path == from.path && off < from.off) || to.is_some_and(|t| path == t.path && off >= t.off) { - continue; - } - let pos = Pos::new(story, path.clone(), off); - if in_skipped(&pos) { - continue; - } - match obj { - Some(InlineObject::Field { instr, .. }) if skip_citations && is_citation_code(instr) => {} - Some(o) => { - for ch in o.plain_text().chars() { - text.push(ch); - map.push((off, end)); - } - } - None if c == OBJ => {} - None => { - let ch = if matches!(c, LINE_BREAK | PAGE_BREAK | COLUMN_BREAK | '\t') { ' ' } else { c }; - text.push(ch); - map.push((off, end)); - } - } - } - // Sliced per sentence; skipping from the start each time was quadratic in the paragraph. - let text_chars: Vec = text.chars().collect(); - for (a, b) in split_sentences(&text) { - if out.len() >= MAX_SENTENCES { - log::warn!( - "Read Aloud: the queue holds at most {MAX_SENTENCES} sentences; the rest of the text is not read (start again from where it stops)" - ); - return out; - } - let spoken = tidy(&text_chars.get(a..b).unwrap_or(&[]).iter().collect::()); - if !spoken.chars().any(char::is_alphanumeric) { - continue; - } - let (Some(first), Some(last)) = (map.get(a), map.get(b.saturating_sub(1))) else { continue }; - // The language of the sentence's first character, through styles and the defaults. - let run = para.props_of_char(first.0); - let explicit = doc.styles.resolve_char(para.props.style.as_deref(), run).lang; - let lang = speech_language(&spoken, explicit.as_deref(), run.lang_bidi.as_deref()); - out.push(Utterance { text: spoken, start: Pos::new(story, path.clone(), first.0), end: Pos::new(story, path.clone(), last.1), lang }); - } - } - out -} - -/// Spans of the document (both ends included), sorted and merged so a position is looked up by -/// binary search: checking every character against every citation was quadratic in a document -/// with many of them. -struct Spans(Vec<(Pos, Pos)>); - -impl Spans { - fn new(mut spans: Vec<(Pos, Pos)>) -> Self { - spans.retain(|(a, b)| a <= b); - spans.sort(); - let mut merged: Vec<(Pos, Pos)> = Vec::with_capacity(spans.len()); - for (a, b) in spans { - match merged.last_mut() { - Some(last) if a <= last.1 => { - if b > last.1 { - last.1 = b; - } - } - _ => merged.push((a, b)), - } - } - Spans(merged) - } - - /// Is `p` inside one of the spans? - fn contains(&self, p: &Pos) -> bool { - let after = self.0.partition_point(|(a, _)| a <= p); - after.checked_sub(1).and_then(|i| self.0.get(i)).is_some_and(|(_, b)| p <= b) - } -} - -/// The language of `text` from its script, as a BCP 47 primary tag: Cyrillic is Russian (Ukrainian -/// with one of its letters `іїєґ`), Greek, Arabic, Hebrew, Thai and Hangul map to `el`, `ar`, `he`, `th` -/// and `ko`, kana to Japanese, Han letters alone to Chinese. `None` for Latin text (the script -/// doesn't say which language) and text without letters. Mixed text goes to its largest non-Latin -/// script once that script has a real share of the letters (a fifth), even when Latin letters -/// outnumber it ("Я открыл Microsoft Word and PowerPoint"): the default voice would spell those -/// letters out, while a Russian voice reads the English words well enough. Ties go to the script -/// listed first. -pub fn detect_language(text: &str) -> Option<&'static str> { - // Letters per script: Cyrillic, Greek, Arabic, Hebrew, Thai, Hangul, kana, Han, Latin and others. - let mut n = [0usize; 10]; - let mut ukrainian = false; - for c in text.chars().filter(|c| c.is_alphabetic()) { - let i = match c { - '\u{0400}'..='\u{052F}' | '\u{1C80}'..='\u{1C8F}' | '\u{2DE0}'..='\u{2DFF}' | '\u{A640}'..='\u{A69F}' => { - ukrainian |= matches!(c, 'і' | 'ї' | 'є' | 'ґ' | 'І' | 'Ї' | 'Є' | 'Ґ'); - 0 - } - '\u{0370}'..='\u{03FF}' | '\u{1F00}'..='\u{1FFF}' => 1, - '\u{0600}'..='\u{06FF}' | '\u{0750}'..='\u{077F}' | '\u{08A0}'..='\u{08FF}' | '\u{FB50}'..='\u{FDFF}' | '\u{FE70}'..='\u{FEFF}' => 2, - '\u{0590}'..='\u{05FF}' | '\u{FB1D}'..='\u{FB4F}' => 3, - '\u{0E00}'..='\u{0E7F}' => 4, - '\u{1100}'..='\u{11FF}' | '\u{3130}'..='\u{318F}' | '\u{AC00}'..='\u{D7AF}' => 5, - '\u{3040}'..='\u{30FF}' | '\u{31F0}'..='\u{31FF}' => 6, - '\u{3400}'..='\u{4DBF}' | '\u{4E00}'..='\u{9FFF}' | '\u{F900}'..='\u{FAFF}' | '\u{20000}'..='\u{2FFFF}' => 7, - _ => 8, - }; - if let Some(k) = n.get_mut(i) { - *k += 1; - } - } - // Japanese text mixes kana and Han letters; any kana makes it Japanese. - if n[6] > 0 { - n[6] += n[7]; - n[7] = 0; - } - // The first script with the most letters (`max_by_key` would keep the last of a tie). - let (best, count) = n.iter().take(8).copied().enumerate().fold((0, 0), |b, (i, k)| if k > b.1 { (i, k) } else { b }); - let total: usize = n.iter().sum(); - if count == 0 || count.saturating_mul(5) < total { - return None; - } - Some(match best { - 0 if ukrainian => "uk", - 0 => "ru", - 1 => "el", - 2 => "ar", - 3 => "he", - 4 => "th", - 5 => "ko", - 6 => "ja", - _ => "zh", - }) -} - -/// A language tag that is safe to hand to a speech program or put in a script: letters, digits -/// and hyphens only, starting with a letter, at most 12 characters (a tag from a document is -/// hostile input). -fn plain_tag(tag: &str) -> Option<&str> { - let t = tag.trim(); - (!t.is_empty() && t.len() <= 12 && t.starts_with(|c: char| c.is_ascii_alphabetic()) && t.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')) - .then_some(t) -} - -/// The script group a language is written in, for the languages [`detect_language`] can tell -/// apart by script (the primary subtag in, the detected tag out). -fn script_language(primary: &str) -> Option<&'static str> { - Some(match primary { - "ru" | "uk" | "be" | "bg" | "sr" | "mk" | "kk" | "ky" | "mn" | "tg" => "ru", - "el" => "el", - "ar" | "fa" | "ur" | "ps" | "ku" | "sd" | "ug" => "ar", - "he" | "yi" => "he", - "th" => "th", - "ko" => "ko", - "ja" => "ja", - "zh" => "zh", - _ => return None, - }) -} - -/// The language to speak `text` in: the document's `explicit` tag when it fits the text, else the -/// language of its script, else `None` (the default voice). When the text is in a script -/// [`detect_language`] recognises, a tag written in another script (the `en-US` Word writes on -/// every run, on Cyrillic text) gives way to the script, or the voice would spell the letters out; -/// right-to-left text tries the run's complex-script tag (`w:bidi`, e.g. `fa-IR`) first, and Han -/// letters alone keep a Japanese or Korean tag. Latin text keeps any tag, as in Word, which reads -/// text in its proofing language: English words in a run tagged `ru-RU` get the Russian voice. -pub fn speech_language(text: &str, explicit: Option<&str>, bidi: Option<&str>) -> Option { - let explicit = explicit.and_then(plain_tag); - let Some(d) = detect_language(text) else { return explicit.map(str::to_string) }; - let fits = |tag: &&str| { - let primary = tag.split('-').next().unwrap_or("").to_ascii_lowercase(); - let script = script_language(&primary); - script == script_language(d) || (d == "zh" && matches!(script, Some("ja" | "ko"))) - }; - let bidi = bidi.and_then(plain_tag).filter(|_| matches!(d, "ar" | "he")); - Some(bidi.into_iter().chain(explicit).find(fits).unwrap_or(d).to_string()) -} - -/// The primary language subtag of a plain tag, lowercased (`ru-RU` gives `ru`). `None` for tags -/// without a two- or three-letter language, such as Word's `x-none`. -#[cfg(any(unix, windows, test))] -fn primary_tag(tag: &str) -> Option { - let t = plain_tag(tag)?; - t.split('-').next().filter(|p| (2..=3).contains(&p.len()) && p.chars().all(|c| c.is_ascii_alphabetic())).map(str::to_ascii_lowercase) -} - -/// The language to ask the speech program for, or `None` to keep its default voice: only when -/// `lang` isn't the language the default voice speaks (`default`, a primary subtag) already. -#[cfg(any(unix, test))] -fn voice_language(lang: Option<&str>, default: &str) -> Option { - lang.and_then(primary_tag).filter(|p| !p.eq_ignore_ascii_case(default)) -} - -/// The arguments of `espeak-ng`/`espeak`: the speed, the voice for `lang` unless it's English -/// (espeak's default voice), and `--stdin`. -#[cfg(any(all(unix, not(target_os = "macos")), test))] -fn espeak_args(wpm: &str, lang: Option<&str>) -> Vec { - let mut a = vec!["-s".to_string(), wpm.to_string()]; - if let Some(p) = voice_language(lang, "en") { - a.push("-v".into()); - a.push(p); - } - a.push("--stdin".into()); - a -} - -/// The voices in `say -v '?'`'s `listing`: each line is `Name ll_RR # sample sentence`, and -/// names may contain spaces. Gives (name, lowercased `ll_rr` code) pairs. -#[cfg(any(target_os = "macos", test))] -fn mac_voices(listing: &str) -> impl Iterator + '_ { - listing.lines().filter_map(|line| { - let line = line.split('#').next().unwrap_or(""); - let words: Vec<&str> = line.split_whitespace().collect(); - let at = words.iter().skip(1).position(|w| { - let mut p = w.split('_'); - p.next().is_some_and(|l| (2..=3).contains(&l.len()) && l.chars().all(|c| c.is_ascii_lowercase())) - && p.next().is_some_and(|r| !r.is_empty()) - })? + 1; - Some((words.get(..at)?.join(" "), words.get(at)?.to_ascii_lowercase())) - }) -} - -/// macOS's novelty voices (sound effects and singing), never chosen to read a document. -#[cfg(any(target_os = "macos", test))] -const MAC_NOVELTY_VOICES: &[&str] = &[ - "Albert", - "Bad News", - "Bahh", - "Bells", - "Boing", - "Bubbles", - "Cellos", - "Deranged", - "Good News", - "Hysterical", - "Jester", - "Organ", - "Pipe Organ", - "Superstar", - "Trinoids", - "Whisper", - "Wobble", - "Zarvox", -]; - -/// The voice `say` should use for `lang`, or `None` to keep the system voice: `None` when the -/// system voice already speaks the language (`default`, a primary subtag) or no voice does, else -/// the first voice in `listing` for the language, one of the same region first, never a novelty voice. -#[cfg(any(target_os = "macos", test))] -fn mac_voice(lang: &str, listing: &str, default: &str) -> Option { - let primary = voice_language(Some(lang), default)?; - let tag = plain_tag(lang)?.replace('-', "_").to_ascii_lowercase(); - let mut first = None; - for (name, code) in mac_voices(listing) { - if code.split('_').next() != Some(primary.as_str()) || MAC_NOVELTY_VOICES.contains(&name.as_str()) { - continue; - } - if code == tag { - return Some(name); - } - first.get_or_insert(name); - } - first -} - -/// The language (primary subtag) of the system voice: the `selected` voice's in `listing`, else -/// the `locale`'s (`ru_RU`, `ru_RU.UTF-8`), else English. -#[cfg(any(target_os = "macos", test))] -fn mac_default_language(selected: Option<&str>, listing: &str, locale: Option<&str>) -> String { - let voice = selected - .and_then(|v| mac_voices(listing).find(|(name, _)| name == v.trim())) - .and_then(|(_, code)| code.split('_').next().map(str::to_string)); - let locale = || locale.and_then(|l| l.split(['_', '-', '.', '@']).next()).and_then(primary_tag); - voice.or_else(locale).unwrap_or_else(|| "en".into()) -} - -/// The language of the voice `say` uses by default (asked once). -#[cfg(target_os = "macos")] -fn mac_default_voice_language() -> &'static str { - static LANG: std::sync::OnceLock = std::sync::OnceLock::new(); - LANG.get_or_init(|| { - let read = |args: &[&str]| { - std::process::Command::new("defaults") - .args(args) - .output() - .ok() - .filter(|o| o.status.success()) - .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) - }; - let voice = read(&["read", "com.apple.speech.voice.prefs", "SelectedVoiceName"]); - let locale = read(&["read", "-g", "AppleLocale"]).or_else(|| std::env::var("LANG").ok()); - mac_default_language(voice.as_deref(), mac_voice_listing(), locale.as_deref()) - }) -} - -#[cfg(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")))] -mod ssip; - -/// What Read Aloud says when the system has none of the speech programs it knows. -#[cfg(not(target_arch = "wasm32"))] -const MISSING_PROGRAM: &str = if cfg!(all(unix, not(target_os = "macos"))) { - "Read Aloud needs a speech program: install espeak-ng or Speech Dispatcher from your distribution's packages" -} else { - "Read Aloud couldn't find the system's speech program" -}; - -/// Read Aloud's speed (1 = normal) as Speech Dispatcher's rate, -100 to 100 with 0 the voice's -/// normal speed: the slowest speed is -50 and the fastest 100. -#[cfg(any(all(unix, not(target_os = "macos")), test))] -fn spd_rate(rate: f32) -> i32 { - let rate = if rate.is_finite() { rate.clamp(MIN_RATE, MAX_RATE) } else { 1.0 }; - let scaled = if rate >= 1.0 { (rate - 1.0) / (MAX_RATE - 1.0) * 100.0 } else { (rate - 1.0) * 100.0 }; - scaled.round().clamp(-100.0, 100.0) as i32 -} - -/// Say one sentence through Speech Dispatcher ([`ssip`]), in the sentence's language whenever it -/// has one, region included (its voices are per region: `en-GB`, `pt-BR`). Its default language is -/// its own setting (`en-US` unless configured), which SSIP can't ask for, so English is named too. -/// `None` when Speech Dispatcher isn't installed. -#[cfg(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")))] -fn speak_dispatcher(text: &str, lang: Option<&str>, rate: f32, changed: &dyn Fn() -> bool) -> Option { - let stream = match ssip::connect(changed) { - ssip::Connection::Open(s) => s, - ssip::Connection::Missing => return None, - ssip::Connection::Interrupted => return Some(Spoken::Interrupted), - ssip::Connection::Unreachable(why) => return Some(Spoken::Failed(format!("Read Aloud couldn't reach Speech Dispatcher: {why}"))), - }; - let lang = dispatcher_language(lang); - let req = ssip::Request { text, rate: spd_rate(rate), lang: lang.as_deref() }; - Some(dispatcher_spoken(ssip::converse(&stream, &req, changed))) -} - -/// How a sentence Speech Dispatcher handled ended, for the worker. -#[cfg(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")))] -fn dispatcher_spoken(outcome: std::io::Result) -> Spoken { - match outcome { - Ok(ssip::Outcome::Done) => Spoken::Done, - Ok(ssip::Outcome::Interrupted) => Spoken::Interrupted, - Ok(ssip::Outcome::Halted) => Spoken::Halted(HALTED_BY_OTHER.into()), - Ok(ssip::Outcome::NotStarted) => Spoken::Halted(NOT_STARTED.into()), - Err(e) => Spoken::Failed(format!("Read Aloud couldn't use Speech Dispatcher: {e}")), - } -} - -/// Why reading paused when another program cancelled or paused the sentence. -#[cfg(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")))] -const HALTED_BY_OTHER: &str = - "Speech Dispatcher kept stopping the sentence for another program (a screen reader, or a stop for every program): press Play to go on"; - -/// Why reading paused when Speech Dispatcher never began the sentence. -#[cfg(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")))] -const NOT_STARTED: &str = - "Speech Dispatcher didn't start the sentence (other speech may be queued before it, or its speech module may be stuck): press Play to try again"; - -#[cfg(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")))] -thread_local! { - /// Neither espeak command was found during this reading (the worker thread lives as long as - /// one reading): the next sentences go straight to Speech Dispatcher. - static NO_ESPEAK: std::cell::Cell = const { std::cell::Cell::new(false) }; -} - -/// The language tag Speech Dispatcher is given for a sentence in `lang`: its language and region, -/// as SSIP's `SET LANGUAGE` takes them (`sr-Latn-RS` gives `sr-RS`, `zh-Hant-TW` gives `zh-TW`), -/// when it names a language (Word's `x-none` doesn't). -#[cfg(any(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")), test))] -fn dispatcher_language(lang: Option<&str>) -> Option { - let tag = lang.and_then(plain_tag)?; - let primary = primary_tag(tag)?; - let region = tag - .split('-') - .skip(1) - .take(2) - .find(|t| (t.len() == 2 && t.chars().all(|c| c.is_ascii_alphabetic())) || (t.len() == 3 && t.chars().all(|c| c.is_ascii_digit()))); - Some(match region { - Some(r) => format!("{primary}-{}", r.to_ascii_uppercase()), - None => primary, - }) -} - -/// Should `speak` say the sentence again with the default voice? When it gave the program a voice -/// and the program then exited with a failure: espeak-ng does that, silently, for a voice it -/// doesn't know, and a missing voice must never mean silence. `exited_ok` is `None` when the -/// program didn't exit on its own (stopped, or never started). -#[cfg(any(not(target_arch = "wasm32"), test))] -fn retry_without_voice(gave_voice: bool, exited_ok: Option) -> bool { - gave_voice && exited_ok == Some(false) -} - -/// The names `say` can voice, as `say -v '?'` lists them (asked once). -#[cfg(target_os = "macos")] -fn mac_voice_listing() -> &'static str { - static LISTING: std::sync::OnceLock = std::sync::OnceLock::new(); - LISTING.get_or_init(|| { - std::process::Command::new("say").args(["-v", "?"]).output().map(|o| String::from_utf8_lossy(&o.stdout).into_owned()).unwrap_or_default() - }) -} - -/// Sentence spans in `text` as char index ranges, without surrounding whitespace. -fn split_sentences(text: &str) -> Vec<(usize, usize)> { - let chars: Vec = text.chars().collect(); - let mut out = Vec::new(); - let mut start = 0; - let mut i = 0; - while i < chars.len() { - let c = chars.get(i).copied().unwrap_or(' '); - let mut end = None; - if matches!(c, '.' | '!' | '?' | '…' | '。' | '!' | '?') { - // Include closing quotes and brackets. - let mut j = i + 1; - while chars.get(j).is_some_and(|c| matches!(c, '"' | '\'' | '”' | '’' | ')' | ']' | '»' | '.' | '!' | '?')) { - j += 1; - } - let at_end = j >= chars.len(); - let space_after = chars.get(j).is_some_and(|c| c.is_whitespace()); - if (at_end || space_after) && (c != '.' || !is_abbreviation(&chars, start, i)) { - end = Some(j); - } - i = j.max(i + 1); - } else { - i += 1; - } - // Very long sentences are split at a space so skipping stays fine-grained. - if end.is_none() && i - start >= MAX_SENTENCE && chars.get(i).is_some_and(|c| c.is_whitespace()) { - end = Some(i); - } - if let Some(e) = end { - push_trimmed(&chars, start, e, &mut out); - start = e; - } - } - push_trimmed(&chars, start, chars.len(), &mut out); - out -} - -fn push_trimmed(chars: &[char], mut a: usize, mut b: usize, out: &mut Vec<(usize, usize)>) { - while a < b && chars.get(a).is_some_and(|c| c.is_whitespace()) { - a += 1; - } - while b > a && chars.get(b - 1).is_some_and(|c| c.is_whitespace()) { - b -= 1; - } - // No space to split at (a long URL, a run of letters or full stops): cut the span hard. - while b - a > MAX_UTTERANCE { - out.push((a, a + MAX_UTTERANCE)); - a += MAX_UTTERANCE; - while a < b && chars.get(a).is_some_and(|c| c.is_whitespace()) { - a += 1; - } - } - if a < b { - out.push((a, b)); - } -} - -/// Does the full stop at `dot` end an abbreviation ("et al.", "Fig.", "J. Smith") rather than a sentence? -fn is_abbreviation(chars: &[char], start: usize, dot: usize) -> bool { - let mut w = dot; - while w > start && chars.get(w - 1).is_some_and(|c| c.is_alphanumeric() || *c == '.') { - w -= 1; - } - let word: String = chars.get(w..dot).unwrap_or(&[]).iter().collect::().to_lowercase(); - if word.is_empty() { - return false; - } - // An initial: "J. Smith", "A. B. Jones". - let letters: Vec = word.chars().filter(|c| *c != '.').collect(); - if letters.len() == 1 && letters.first().is_some_and(|c| c.is_alphabetic()) { - return true; - } - ABBREVIATIONS.contains(&word.as_str()) -} - -/// Collapse whitespace and tidy what removed citations leave behind: "( )", "[]", " ." → ".". -/// One pass, linear in `s` (#3680): a closing bracket right after its opening one (or after it -/// and a space) removes the pair, so nested empty brackets "( [ ] )" go too. -fn tidy(s: &str) -> String { - let mut t = String::with_capacity(s.len()); - for c in s.chars() { - if c.is_whitespace() { - if !t.is_empty() && !t.ends_with(' ') { - t.push(' '); - } - continue; - } - let open = match c { - ')' => Some('('), - ']' => Some('['), - _ => None, - }; - if let Some(open) = open { - let body = t.strip_suffix(' ').unwrap_or(&t); - if let Some(rest) = body.strip_suffix(open) { - let keep = rest.len(); - t.truncate(keep); - continue; - } - } - if matches!(c, '.' | ',' | ';' | ':') && t.ends_with(' ') { - t.pop(); - } - t.push(c); - } - if t.ends_with(' ') { - t.pop(); - } - t -} - -/// Reading state. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum State { - Stopped, - Playing, - Paused, -} - -impl State { - pub fn name(self) -> &'static str { - match self { - State::Stopped => "stopped", - State::Playing => "playing", - State::Paused => "paused", - } - } -} - -/// How sentences are spoken. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum Backend { - /// The system's speech program. - System, - /// Silent; a sentence lasts until something interrupts it (tests, `WORDCRAFT_SPEECH=off`). - Hold, - /// Silent; every sentence finishes at once (tests). - Instant, -} - -struct Shared { - queue: Vec, - index: usize, - state: State, - rate: f32, - /// Bumped on every change that should stop the sentence being spoken. - generation: u64, - error: Option, - /// A worker thread is running (cleared under the lock as it exits). - #[cfg_attr(target_arch = "wasm32", allow(dead_code))] - running: bool, - /// Bumped when a reading starts: a worker thread can outlive a reading (a pause, then a new - /// Read Aloud), and what it learnt about the system's speech programs is per reading. - #[cfg_attr(target_arch = "wasm32", allow(dead_code))] - readings: u64, -} - -/// The Read Aloud player. -pub struct ReadAloud { - shared: Arc<(Mutex, Condvar)>, - pub backend: Backend, - /// Leave citations and bibliographies out of the next reading. - pub skip_citations: bool, - /// The player window is open (it stays open after the last sentence until closed). - pub open: bool, - /// The document being read ([`crate::Session::document_id`]): the sentences' positions are - /// in it. - pub document: Option, -} - -impl Default for ReadAloud { - fn default() -> Self { - let off = cfg!(test) || std::env::var("WORDCRAFT_SPEECH").is_ok_and(|v| v == "off"); - ReadAloud { - shared: Arc::new(( - Mutex::new(Shared { - queue: Vec::new(), - index: 0, - state: State::Stopped, - rate: 1.0, - generation: 0, - error: None, - running: false, - readings: 0, - }), - Condvar::new(), - )), - backend: if off { Backend::Hold } else { Backend::System }, - skip_citations: true, - open: false, - document: None, - } - } -} - -fn lock(m: &Mutex) -> MutexGuard<'_, Shared> { - m.lock().unwrap_or_else(PoisonError::into_inner) -} - -impl ReadAloud { - /// Change the shared state, stop the current sentence and wake the worker. - fn change(&mut self, f: impl FnOnce(&mut Shared)) { - { - let (m, cv) = &*self.shared; - let mut s = lock(m); - f(&mut s); - s.generation = s.generation.wrapping_add(1); - cv.notify_all(); - } - self.ensure_worker(); - } - - /// Start reading `queue` from its first sentence. - pub fn start(&mut self, queue: Vec) { - self.open = true; - self.change(|s| { - s.state = if queue.is_empty() { State::Stopped } else { State::Playing }; - s.queue = queue; - s.index = 0; - s.error = None; - s.readings = s.readings.wrapping_add(1); - }); - } - - /// Pause, or carry on (a finished reading starts again from the top). - pub fn play_pause(&mut self) { - self.change(|s| { - s.state = match s.state { - State::Playing => State::Paused, - _ if s.queue.is_empty() => State::Stopped, - _ => { - if s.index >= s.queue.len() { - s.index = 0; - } - s.error = None; - State::Playing - } - }; - }); - } - - /// Move by `delta` sentences; a stopped player starts playing there. - pub fn skip(&mut self, delta: i64) { - self.change(|s| { - let last = s.queue.len().saturating_sub(1) as i64; - let at = (s.index.min(s.queue.len()) as i64).saturating_add(delta).clamp(0, last.max(0)); - s.index = at as usize; - if s.state == State::Stopped && !s.queue.is_empty() { - s.state = State::Playing; - } - }); - } - - /// Set the speed (1.0 = normal); the current sentence restarts at the new speed. - pub fn set_rate(&mut self, rate: f32) { - let r = if rate.is_finite() { rate.clamp(MIN_RATE, MAX_RATE) } else { 1.0 }; - let playing = self.state() == State::Playing; - if playing { - self.change(|s| s.rate = r); - } else { - lock(&self.shared.0).rate = r; - } - } - - /// Stop reading and close the player. - pub fn stop(&mut self) { - self.open = false; - self.change(|s| s.state = State::Stopped); - } - - pub fn state(&self) -> State { - lock(&self.shared.0).state - } - pub fn rate(&self) -> f32 { - lock(&self.shared.0).rate - } - /// The sentence being read (or paused on). - pub fn current(&self) -> Option { - let s = lock(&self.shared.0); - if s.state == State::Stopped { None } else { s.queue.get(s.index).cloned() } - } - /// (index, number of sentences). - pub fn progress(&self) -> (usize, usize) { - let s = lock(&self.shared.0); - (s.index, s.queue.len()) - } - /// The last speech error (no speech program…), if any. - pub fn error(&self) -> Option { - lock(&self.shared.0).error.clone() - } - - pub fn status(&self) -> Value { - let s = lock(&self.shared.0); - let current = if s.state == State::Stopped { None } else { s.queue.get(s.index) }; - json!({ - "state": s.state.name(), - "open": self.open, - "index": s.index, - "count": s.queue.len(), - "rate": s.rate, - "skipCitations": self.skip_citations, - "current": current.map(Utterance::to_json), - "error": s.error, - }) - } - - #[cfg(target_arch = "wasm32")] - fn ensure_worker(&mut self) { - let (m, _) = &*self.shared; - let mut s = lock(m); - if s.state == State::Playing && self.backend == Backend::System { - s.state = State::Stopped; - s.error = Some("Read Aloud isn't available in the browser".into()); - } - } - - #[cfg(not(target_arch = "wasm32"))] - fn ensure_worker(&mut self) { - let mut s = lock(&self.shared.0); - if s.state != State::Playing || s.running { - return; - } - s.running = true; - let shared = Arc::clone(&self.shared); - let backend = self.backend; - if let Err(e) = std::thread::Builder::new().name("read-aloud".into()).spawn(move || worker(&shared, backend)) { - s.running = false; - s.state = State::Stopped; - s.error = Some(format!("couldn't start Read Aloud: {e}")); - } - } -} - -impl Drop for ReadAloud { - fn drop(&mut self) { - let (m, cv) = &*self.shared; - let mut s = lock(m); - s.state = State::Stopped; - s.generation = s.generation.wrapping_add(1); - cv.notify_all(); - } -} - -/// How a sentence ended. -#[cfg(not(target_arch = "wasm32"))] -enum Spoken { - Done, - Interrupted, - /// Another program stopped the sentence: reading pauses on it, saying why. - #[cfg_attr(not(all(unix, not(target_os = "macos"))), allow(dead_code))] - Halted(String), - Failed(String), -} - -#[cfg(not(target_arch = "wasm32"))] -fn worker(shared: &(Mutex, Condvar), backend: Backend) { - let (m, cv) = shared; - let mut reading = None; - loop { - let (text, lang, generation, rate, readings) = { - let mut s = lock(m); - while s.state == State::Paused { - s = cv.wait(s).unwrap_or_else(PoisonError::into_inner); - } - if s.state == State::Stopped { - s.running = false; - return; - } - match s.queue.get(s.index) { - Some(u) => (u.text.clone(), u.lang.clone(), s.generation, s.rate, s.readings), - None => { - s.state = State::Stopped; - s.running = false; - return; - } - } - }; - // A new reading asks the system afresh which speech programs it has. - if reading != Some(readings) { - reading = Some(readings); - #[cfg(all(unix, not(target_os = "macos")))] - NO_ESPEAK.set(false); - } - let changed = || lock(m).generation != generation; - let outcome = match backend { - Backend::System => speak(&text, lang.as_deref(), rate, &changed), - Backend::Instant => Spoken::Done, - Backend::Hold => { - let mut s = lock(m); - while s.generation == generation { - s = cv.wait(s).unwrap_or_else(PoisonError::into_inner); - } - Spoken::Interrupted - } - }; - let mut s = lock(m); - if s.generation != generation { - continue; - } - match outcome { - Spoken::Done => { - s.index += 1; - if s.index >= s.queue.len() { - s.state = State::Stopped; - s.running = false; - return; - } - } - Spoken::Interrupted => {} - Spoken::Halted(why) => { - s.state = State::Paused; - s.error = Some(why); - } - Spoken::Failed(e) => { - s.state = State::Stopped; - s.error = Some(e); - s.running = false; - return; - } - } - } -} - -/// Windows PowerShell by its full path, `\System32\WindowsPowerShell\v1.0\powershell.exe` -/// (`C:\Windows` when `SystemRoot` is unset), rather than whichever `powershell` the program search -/// finds first. -#[cfg(any(target_os = "windows", test))] -fn windows_powershell(system_root: Option) -> std::path::PathBuf { - let root = system_root.filter(|r| !r.is_empty()).unwrap_or_else(|| "C:\\Windows".into()); - std::path::Path::new(&root).join("System32").join("WindowsPowerShell").join("v1.0").join("powershell.exe") -} - -/// Win32 `CREATE_NO_WINDOW` process creation flag: a console program starts without a console window. -#[cfg(any(target_os = "windows", test))] -const CREATE_NO_WINDOW: u32 = 0x0800_0000; - -/// How Read Aloud starts Windows PowerShell to speak at `rate`: the program, its arguments and -/// its process creation flags. `speak` builds its `Command` from exactly this; std can't read -/// creation flags back from a `Command`, so the tests check this value instead. -#[cfg(any(target_os = "windows", test))] -struct WindowsSpeech { - program: std::path::PathBuf, - args: [String; 4], - creation_flags: u32, -} - -#[cfg(any(target_os = "windows", test))] -fn windows_speech(system_root: Option, rate: f32, lang: Option<&str>) -> WindowsSpeech { - // SAPI rate: -10 (a third of normal speed) … 10 (three times). - let r = ((rate.ln() / 3f32.ln()) * 10.0).round().clamp(-10.0, 10.0) as i64; - // The voice of the sentence's language, when one is installed (the default voice would spell - // out Cyrillic, Greek… letters one by one), but only when the user's default voice doesn't - // speak that language already, in any region. The tag is `primary_tag`-clean, so it can't end - // the quote. - let voice = lang.and_then(primary_tag).map_or_else(String::new, |p| { - format!( - "if ($s.Voice.Culture.TwoLetterISOLanguageName -ne '{p}') {{ $v = $s.GetInstalledVoices() | Where-Object {{ $_.Enabled -and $_.VoiceInfo.Culture.TwoLetterISOLanguageName -eq '{p}' }} | Select-Object -First 1; if ($v) {{ $s.SelectVoice($v.VoiceInfo.Name) }} }}; " - ) - }); - // The sentence arrives on stdin as UTF-8 bytes; `[Console]::In` would decode them with the - // console's code page (Cyrillic became `?` and the voice read single letters), so read the raw - // stream and decode it as UTF-8. The text itself is never part of the script. - let script = format!( - "Add-Type -AssemblyName System.Speech; $s = New-Object System.Speech.Synthesis.SpeechSynthesizer; $s.Rate = {r}; {voice}$m = New-Object System.IO.MemoryStream; [Console]::OpenStandardInput().CopyTo($m); $s.Speak([System.Text.Encoding]::UTF8.GetString($m.ToArray()))" - ); - WindowsSpeech { - program: windows_powershell(system_root), - args: ["-NoProfile".into(), "-NonInteractive".into(), "-Command".into(), script], - // PowerShell is a console program: without this, every sentence flashes a console window - // over the document (#1122). - creation_flags: CREATE_NO_WINDOW, - } -} - -/// Speak one sentence with the system's speech program, stopping early when `changed()`. When -/// the program fails with the sentence's voice, the default voice says it instead. -#[cfg(not(target_arch = "wasm32"))] -fn speak(text: &str, lang: Option<&str>, rate: f32, changed: &dyn Fn() -> bool) -> Spoken { - let (spoken, gave_voice, exited_ok) = speak_with(text, lang, rate, changed); - if retry_without_voice(gave_voice, exited_ok) { - return speak_with(text, None, rate, changed).0; - } - spoken -} - -/// [`speak`] in `lang`'s voice once: how it ended, whether the program was given a voice, and -/// whether it exited successfully (`None` when it didn't exit on its own). -#[cfg(not(target_arch = "wasm32"))] -fn speak_with(text: &str, lang: Option<&str>, rate: f32, changed: &dyn Fn() -> bool) -> (Spoken, bool, Option) { - use std::io::Write; - use std::process::{Command, Stdio}; - let wpm = ((BASE_WPM * rate).round() as i64).to_string(); - // The text goes in on stdin so it can never be taken for an option. Each try is a program - // and whether it was given a voice. - let mut tries: Vec<(Command, bool)> = Vec::new(); - #[cfg(target_os = "macos")] - { - let mut c = Command::new("say"); - c.args(["-r", &wpm]); - let voice = lang.and_then(|l| mac_voice(l, mac_voice_listing(), mac_default_voice_language())); - if let Some(voice) = &voice { - c.args(["-v", voice.as_str()]); - } - tries.push((c, voice.is_some())); - } - #[cfg(target_os = "windows")] - { - let w = windows_speech(std::env::var_os("SystemRoot"), rate, lang); - let mut c = Command::new(&w.program); - c.args(&w.args); - std::os::windows::process::CommandExt::creation_flags(&mut c, w.creation_flags); - tries.push((c, lang.and_then(primary_tag).is_some())); - } - #[cfg(all(unix, not(target_os = "macos")))] - { - let programs: &[&str] = if NO_ESPEAK.get() { &[] } else { &["espeak-ng", "espeak"] }; - for prog in programs { - let args = espeak_args(&wpm, lang); - let voiced = args.iter().any(|a| a == "-v"); - let mut c = Command::new(prog); - c.args(args); - tries.push((c, voiced)); - } - } - let _ = (&wpm, lang); - let mut child = None; - let mut not_found = true; - let mut last_err = String::from("no speech program on this system"); - for (mut c, voiced) in tries { - match c.stdin(Stdio::piped()).stdout(Stdio::null()).stderr(Stdio::null()).spawn() { - Ok(ch) => { - child = Some((ch, voiced)); - break; - } - Err(e) => { - not_found &= e.kind() == std::io::ErrorKind::NotFound; - last_err = e.to_string(); - } - } - } - let Some((mut child, voiced)) = child else { - // Speech Dispatcher, which many desktops install without the espeak command. - #[cfg(all(unix, not(target_os = "macos")))] - if not_found { - NO_ESPEAK.set(true); - if let Some(spoken) = speak_dispatcher(text, lang, rate, changed) { - return (spoken, false, None); - } - } - let message = if not_found { MISSING_PROGRAM.to_string() } else { format!("Read Aloud couldn't start the speech program: {last_err}") }; - return (Spoken::Failed(message), false, None); - }; - if let Some(mut stdin) = child.stdin.take() { - let _ = stdin.write_all(text.as_bytes()); - // Dropping stdin closes it, which tells the program the text is complete. - } - loop { - match child.try_wait() { - Ok(Some(status)) => return (Spoken::Done, voiced, Some(status.success())), - Ok(None) => {} - Err(e) => return (Spoken::Failed(e.to_string()), voiced, None), - } - if changed() { - let _ = child.kill(); - let _ = child.wait(); - return (Spoken::Interrupted, voiced, None); - } - std::thread::sleep(std::time::Duration::from_millis(30)); - } -} - -#[cfg(test)] -mod tests; +//! Read Aloud: the document as a queue of sentences that the system's speech program speaks one +//! at a time, so reading can be paused, skipped forwards and backwards, and sped up. +//! +//! Citation-manager fields can be left out: Zotero, Mendeley and EndNote citations and +//! bibliographies (range fields with an `ADDIN …` code) and Word's own `CITATION` and +//! `BIBLIOGRAPHY` fields. The citation's text simply isn't in the queue, so every citation +//! style is skipped the same way: "(Smith et al., 2020)", "[3]" or a superscript number. +//! +//! Each sentence is spoken in its own language: the run's language tag, or else the script of its +//! letters (Cyrillic, Greek, Arabic, Hebrew, CJK, Thai…), so a Russian sentence gets a Russian voice +//! where one is installed instead of being spelled out letter by letter. The system's own voice +//! stays whenever it already speaks the language (English text on an English system keeps the +//! voice the user chose), and without a voice for the language the default voice speaks. Speech +//! Dispatcher is the exception: it can't be asked for its default language, so it is told the +//! language of every sentence. +//! +//! The speech itself runs on a worker thread (desktop only): it starts `say` (macOS), `espeak-ng` +//! / `espeak` (Linux, BSD) or the Windows speech synthesizer (PowerShell) for the current sentence, +//! or talks to Speech Dispatcher over its socket when neither espeak is installed, and moves on when +//! the sentence ends. Commands only change the shared state and wake the worker, which stops the +//! running sentence when the state changed under it. + +use std::sync::{Arc, Condvar, Mutex, MutexGuard, PoisonError}; + +use serde_json::{Value, json}; +use wordcraft_doc::para::{COLUMN_BREAK, LINE_BREAK, OBJ, PAGE_BREAK}; +use wordcraft_doc::{Document, InlineObject, Pos}; + +/// Longest sentence handed to the speech program (longer ones are split at a space). +const MAX_SENTENCE: usize = 600; +/// Longest utterance, even with no space to split at (a long URL, a run of letters): it is cut +/// there, mid-word. +const MAX_UTTERANCE: usize = 2 * MAX_SENTENCE; +/// Most sentences in one queue. +const MAX_SENTENCES: usize = 20_000; +/// Speed limits (1.0 = the normal speed). +pub const MIN_RATE: f32 = 0.5; +pub const MAX_RATE: f32 = 3.0; +/// Words per minute at speed 1.0 (`say` and `espeak` both default to about this). +#[cfg(not(target_arch = "wasm32"))] +const BASE_WPM: f32 = 180.0; + +/// Words that end with a full stop without ending the sentence. +const ABBREVIATIONS: &[&str] = &[ + "al", "e.g", "i.e", "eg", "ie", "cf", "vs", "etc", "fig", "figs", "eq", "eqs", "no", "nos", "p", "pp", "vol", "vols", "ch", "chap", "sec", "tab", + "ref", "refs", "dr", "mr", "mrs", "ms", "prof", "st", "approx", "ca", "resp", "ed", "eds", "suppl", "jr", "sr", "inc", "ltd", "co", "dept", + "est", "min", "max", "viz", "ibid", +]; + +/// Is this field code a citation or a bibliography (Zotero, Mendeley, EndNote, Word)? +pub fn is_citation_code(instr: &str) -> bool { + let code = instr.trim_start(); + let word = code.split_whitespace().next().unwrap_or("").to_ascii_uppercase(); + if word == "CITATION" || word == "BIBLIOGRAPHY" { + return true; + } + if word != "ADDIN" { + return false; + } + let rest = code.get(5..).unwrap_or("").trim_start().to_ascii_uppercase(); + ["ZOTERO_ITEM", "ZOTERO_BIBL", "CSL_CITATION", "CSL_BIBLIOGRAPHY", "MENDELEY", "EN.CITE", "EN.REFLIST"].iter().any(|k| rest.starts_with(k)) +} + +/// One sentence to speak: its text and where it is in the document. +#[derive(Clone, Debug, PartialEq)] +pub struct Utterance { + pub text: String, + pub start: Pos, + pub end: Pos, + /// The language to speak it in (a BCP 47 tag), or `None` for the system's default voice. + pub lang: Option, +} + +impl Utterance { + pub fn to_json(&self) -> Value { + json!({"text": self.text, "start": crate::cmd::pos_json(&self.start), "end": crate::cmd::pos_json(&self.end)}) + } +} + +/// The sentences from `from` (to `to`, if given) in `from`'s story, in reading order. +/// With `skip_citations`, citation and bibliography fields are left out. At most +/// [`MAX_SENTENCES`]; when that cuts the text short a warning is logged, so the stop is not silent. +pub fn sentences(doc: &Document, from: &Pos, to: Option<&Pos>, skip_citations: bool) -> Vec { + let story = from.story; + // Spans to leave out: whole range fields, both markers included. + let skipped = Spans::new(if skip_citations { + doc.field_ranges(story).into_iter().filter(|r| is_citation_code(&r.instr)).map(|r| (r.start, r.end)).collect() + } else { + Vec::new() + }); + let in_skipped = |p: &Pos| skipped.contains(p); + let mut out = Vec::new(); + for path in doc.para_paths(story) { + if path < from.path { + continue; + } + if let Some(to) = to + && path > to.path + { + break; + } + let Some(para) = doc.para(story, &path) else { continue }; + // The spoken text, and for each char the paragraph offset it came from. + let mut text = String::new(); + let mut map: Vec<(usize, usize)> = Vec::new(); // (start, end) offsets in the paragraph + let mut k = 0; + for (off, c) in para.text.char_indices() { + let end = off + c.len_utf8(); + let obj = if c == OBJ { + k += 1; + para.objects.get(k - 1) + } else { + None + }; + if (path == from.path && off < from.off) || to.is_some_and(|t| path == t.path && off >= t.off) { + continue; + } + let pos = Pos::new(story, path.clone(), off); + if in_skipped(&pos) { + continue; + } + match obj { + Some(InlineObject::Field { instr, .. }) if skip_citations && is_citation_code(instr) => {} + Some(o) => { + for ch in o.plain_text().chars() { + text.push(ch); + map.push((off, end)); + } + } + None if c == OBJ => {} + None => { + let ch = if matches!(c, LINE_BREAK | PAGE_BREAK | COLUMN_BREAK | '\t') { ' ' } else { c }; + text.push(ch); + map.push((off, end)); + } + } + } + // Sliced per sentence; skipping from the start each time was quadratic in the paragraph. + let text_chars: Vec = text.chars().collect(); + for (a, b) in split_sentences(&text) { + if out.len() >= MAX_SENTENCES { + log::warn!( + "Read Aloud: the queue holds at most {MAX_SENTENCES} sentences; the rest of the text is not read (start again from where it stops)" + ); + return out; + } + let spoken = tidy(&text_chars.get(a..b).unwrap_or(&[]).iter().collect::()); + if !spoken.chars().any(char::is_alphanumeric) { + continue; + } + let (Some(first), Some(last)) = (map.get(a), map.get(b.saturating_sub(1))) else { continue }; + // The language of the sentence's first character, through styles and the defaults. + let run = para.props_of_char(first.0); + let explicit = doc.styles.resolve_char(para.props.style.as_deref(), run).lang; + let lang = speech_language(&spoken, explicit.as_deref(), run.lang_bidi.as_deref()); + out.push(Utterance { text: spoken, start: Pos::new(story, path.clone(), first.0), end: Pos::new(story, path.clone(), last.1), lang }); + } + } + out +} + +/// Spans of the document (both ends included), sorted and merged so a position is looked up by +/// binary search: checking every character against every citation was quadratic in a document +/// with many of them. +struct Spans(Vec<(Pos, Pos)>); + +impl Spans { + fn new(mut spans: Vec<(Pos, Pos)>) -> Self { + spans.retain(|(a, b)| a <= b); + spans.sort(); + let mut merged: Vec<(Pos, Pos)> = Vec::with_capacity(spans.len()); + for (a, b) in spans { + match merged.last_mut() { + Some(last) if a <= last.1 => { + if b > last.1 { + last.1 = b; + } + } + _ => merged.push((a, b)), + } + } + Spans(merged) + } + + /// Is `p` inside one of the spans? + fn contains(&self, p: &Pos) -> bool { + let after = self.0.partition_point(|(a, _)| a <= p); + after.checked_sub(1).and_then(|i| self.0.get(i)).is_some_and(|(_, b)| p <= b) + } +} + +/// The language of `text` from its script, as a BCP 47 primary tag: Cyrillic is Russian (Ukrainian +/// with one of its letters `іїєґ`), Greek, Arabic, Hebrew, Thai and Hangul map to `el`, `ar`, `he`, `th` +/// and `ko`, kana to Japanese, Han letters alone to Chinese. `None` for Latin text (the script +/// doesn't say which language) and text without letters. Mixed text goes to its largest non-Latin +/// script once that script has a real share of the letters (a fifth), even when Latin letters +/// outnumber it ("Я открыл Microsoft Word and PowerPoint"): the default voice would spell those +/// letters out, while a Russian voice reads the English words well enough. Ties go to the script +/// listed first. +pub fn detect_language(text: &str) -> Option<&'static str> { + // Letters per script: Cyrillic, Greek, Arabic, Hebrew, Thai, Hangul, kana, Han, Latin and others. + let mut n = [0usize; 10]; + let mut ukrainian = false; + for c in text.chars().filter(|c| c.is_alphabetic()) { + let i = match c { + '\u{0400}'..='\u{052F}' | '\u{1C80}'..='\u{1C8F}' | '\u{2DE0}'..='\u{2DFF}' | '\u{A640}'..='\u{A69F}' => { + ukrainian |= matches!(c, 'і' | 'ї' | 'є' | 'ґ' | 'І' | 'Ї' | 'Є' | 'Ґ'); + 0 + } + '\u{0370}'..='\u{03FF}' | '\u{1F00}'..='\u{1FFF}' => 1, + '\u{0600}'..='\u{06FF}' | '\u{0750}'..='\u{077F}' | '\u{08A0}'..='\u{08FF}' | '\u{FB50}'..='\u{FDFF}' | '\u{FE70}'..='\u{FEFF}' => 2, + '\u{0590}'..='\u{05FF}' | '\u{FB1D}'..='\u{FB4F}' => 3, + '\u{0E00}'..='\u{0E7F}' => 4, + '\u{1100}'..='\u{11FF}' | '\u{3130}'..='\u{318F}' | '\u{AC00}'..='\u{D7AF}' => 5, + '\u{3040}'..='\u{30FF}' | '\u{31F0}'..='\u{31FF}' => 6, + '\u{3400}'..='\u{4DBF}' | '\u{4E00}'..='\u{9FFF}' | '\u{F900}'..='\u{FAFF}' | '\u{20000}'..='\u{2FFFF}' => 7, + _ => 8, + }; + if let Some(k) = n.get_mut(i) { + *k += 1; + } + } + // Japanese text mixes kana and Han letters; any kana makes it Japanese. + if n[6] > 0 { + n[6] += n[7]; + n[7] = 0; + } + // The first script with the most letters (`max_by_key` would keep the last of a tie). + let (best, count) = n.iter().take(8).copied().enumerate().fold((0, 0), |b, (i, k)| if k > b.1 { (i, k) } else { b }); + let total: usize = n.iter().sum(); + if count == 0 || count.saturating_mul(5) < total { + return None; + } + Some(match best { + 0 if ukrainian => "uk", + 0 => "ru", + 1 => "el", + 2 => "ar", + 3 => "he", + 4 => "th", + 5 => "ko", + 6 => "ja", + _ => "zh", + }) +} + +/// A language tag that is safe to hand to a speech program or put in a script: letters, digits +/// and hyphens only, starting with a letter, at most 12 characters (a tag from a document is +/// hostile input). +fn plain_tag(tag: &str) -> Option<&str> { + let t = tag.trim(); + (!t.is_empty() && t.len() <= 12 && t.starts_with(|c: char| c.is_ascii_alphabetic()) && t.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')) + .then_some(t) +} + +/// The script group a language is written in, for the languages [`detect_language`] can tell +/// apart by script (the primary subtag in, the detected tag out). +fn script_language(primary: &str) -> Option<&'static str> { + Some(match primary { + "ru" | "uk" | "be" | "bg" | "sr" | "mk" | "kk" | "ky" | "mn" | "tg" => "ru", + "el" => "el", + "ar" | "fa" | "ur" | "ps" | "ku" | "sd" | "ug" => "ar", + "he" | "yi" => "he", + "th" => "th", + "ko" => "ko", + "ja" => "ja", + "zh" => "zh", + _ => return None, + }) +} + +/// The language to speak `text` in: the document's `explicit` tag when it fits the text, else the +/// language of its script, else `None` (the default voice). When the text is in a script +/// [`detect_language`] recognises, a tag written in another script (the `en-US` Word writes on +/// every run, on Cyrillic text) gives way to the script, or the voice would spell the letters out; +/// right-to-left text tries the run's complex-script tag (`w:bidi`, e.g. `fa-IR`) first, and Han +/// letters alone keep a Japanese or Korean tag. Latin text keeps any tag, as in Word, which reads +/// text in its proofing language: English words in a run tagged `ru-RU` get the Russian voice. +pub fn speech_language(text: &str, explicit: Option<&str>, bidi: Option<&str>) -> Option { + let explicit = explicit.and_then(plain_tag); + let Some(d) = detect_language(text) else { return explicit.map(str::to_string) }; + let fits = |tag: &&str| { + let primary = tag.split('-').next().unwrap_or("").to_ascii_lowercase(); + let script = script_language(&primary); + script == script_language(d) || (d == "zh" && matches!(script, Some("ja" | "ko"))) + }; + let bidi = bidi.and_then(plain_tag).filter(|_| matches!(d, "ar" | "he")); + Some(bidi.into_iter().chain(explicit).find(fits).unwrap_or(d).to_string()) +} + +/// The primary language subtag of a plain tag, lowercased (`ru-RU` gives `ru`). `None` for tags +/// without a two- or three-letter language, such as Word's `x-none`. +#[cfg(any(unix, windows, test))] +fn primary_tag(tag: &str) -> Option { + let t = plain_tag(tag)?; + t.split('-').next().filter(|p| (2..=3).contains(&p.len()) && p.chars().all(|c| c.is_ascii_alphabetic())).map(str::to_ascii_lowercase) +} + +/// The language to ask the speech program for, or `None` to keep its default voice: only when +/// `lang` isn't the language the default voice speaks (`default`, a primary subtag) already. +#[cfg(any(unix, test))] +fn voice_language(lang: Option<&str>, default: &str) -> Option { + lang.and_then(primary_tag).filter(|p| !p.eq_ignore_ascii_case(default)) +} + +/// The arguments of `espeak-ng`/`espeak`: the speed, the voice for `lang` unless it's English +/// (espeak's default voice), and `--stdin`. +#[cfg(any(all(unix, not(target_os = "macos")), test))] +fn espeak_args(wpm: &str, lang: Option<&str>) -> Vec { + let mut a = vec!["-s".to_string(), wpm.to_string()]; + if let Some(p) = voice_language(lang, "en") { + a.push("-v".into()); + a.push(p); + } + a.push("--stdin".into()); + a +} + +/// The voices in `say -v '?'`'s `listing`: each line is `Name ll_RR # sample sentence`, and +/// names may contain spaces. Gives (name, lowercased `ll_rr` code) pairs. +#[cfg(any(target_os = "macos", test))] +fn mac_voices(listing: &str) -> impl Iterator + '_ { + listing.lines().filter_map(|line| { + let line = line.split('#').next().unwrap_or(""); + let words: Vec<&str> = line.split_whitespace().collect(); + let at = words.iter().skip(1).position(|w| { + let mut p = w.split('_'); + p.next().is_some_and(|l| (2..=3).contains(&l.len()) && l.chars().all(|c| c.is_ascii_lowercase())) + && p.next().is_some_and(|r| !r.is_empty()) + })? + 1; + Some((words.get(..at)?.join(" "), words.get(at)?.to_ascii_lowercase())) + }) +} + +/// macOS's novelty voices (sound effects and singing), never chosen to read a document. +#[cfg(any(target_os = "macos", test))] +const MAC_NOVELTY_VOICES: &[&str] = &[ + "Albert", + "Bad News", + "Bahh", + "Bells", + "Boing", + "Bubbles", + "Cellos", + "Deranged", + "Good News", + "Hysterical", + "Jester", + "Organ", + "Pipe Organ", + "Superstar", + "Trinoids", + "Whisper", + "Wobble", + "Zarvox", +]; + +/// The voice `say` should use for `lang`, or `None` to keep the system voice: `None` when the +/// system voice already speaks the language (`default`, a primary subtag) or no voice does, else +/// the first voice in `listing` for the language, one of the same region first, never a novelty voice. +#[cfg(any(target_os = "macos", test))] +fn mac_voice(lang: &str, listing: &str, default: &str) -> Option { + let primary = voice_language(Some(lang), default)?; + let tag = plain_tag(lang)?.replace('-', "_").to_ascii_lowercase(); + let mut first = None; + for (name, code) in mac_voices(listing) { + if code.split('_').next() != Some(primary.as_str()) || MAC_NOVELTY_VOICES.contains(&name.as_str()) { + continue; + } + if code == tag { + return Some(name); + } + first.get_or_insert(name); + } + first +} + +/// The language (primary subtag) of the system voice: the `selected` voice's in `listing`, else +/// the `locale`'s (`ru_RU`, `ru_RU.UTF-8`), else English. +#[cfg(any(target_os = "macos", test))] +fn mac_default_language(selected: Option<&str>, listing: &str, locale: Option<&str>) -> String { + let voice = selected + .and_then(|v| mac_voices(listing).find(|(name, _)| name == v.trim())) + .and_then(|(_, code)| code.split('_').next().map(str::to_string)); + let locale = || locale.and_then(|l| l.split(['_', '-', '.', '@']).next()).and_then(primary_tag); + voice.or_else(locale).unwrap_or_else(|| "en".into()) +} + +/// The language of the voice `say` uses by default (asked once). +#[cfg(target_os = "macos")] +fn mac_default_voice_language() -> &'static str { + static LANG: std::sync::OnceLock = std::sync::OnceLock::new(); + LANG.get_or_init(|| { + let read = |args: &[&str]| { + std::process::Command::new("defaults") + .args(args) + .output() + .ok() + .filter(|o| o.status.success()) + .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) + }; + let voice = read(&["read", "com.apple.speech.voice.prefs", "SelectedVoiceName"]); + let locale = read(&["read", "-g", "AppleLocale"]).or_else(|| std::env::var("LANG").ok()); + mac_default_language(voice.as_deref(), mac_voice_listing(), locale.as_deref()) + }) +} + +#[cfg(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")))] +mod ssip; + +/// What Read Aloud says when the system has none of the speech programs it knows. +#[cfg(not(target_arch = "wasm32"))] +const MISSING_PROGRAM: &str = if cfg!(all(unix, not(target_os = "macos"))) { + "Read Aloud needs a speech program: install espeak-ng or Speech Dispatcher from your distribution's packages" +} else { + "Read Aloud couldn't find the system's speech program" +}; + +/// Read Aloud's speed (1 = normal) as Speech Dispatcher's rate, -100 to 100 with 0 the voice's +/// normal speed: the slowest speed is -50 and the fastest 100. +#[cfg(any(all(unix, not(target_os = "macos")), test))] +fn spd_rate(rate: f32) -> i32 { + let rate = if rate.is_finite() { rate.clamp(MIN_RATE, MAX_RATE) } else { 1.0 }; + let scaled = if rate >= 1.0 { (rate - 1.0) / (MAX_RATE - 1.0) * 100.0 } else { (rate - 1.0) * 100.0 }; + scaled.round().clamp(-100.0, 100.0) as i32 +} + +/// Say one sentence through Speech Dispatcher ([`ssip`]), in the sentence's language whenever it +/// has one, region included (its voices are per region: `en-GB`, `pt-BR`). Its default language is +/// its own setting (`en-US` unless configured), which SSIP can't ask for, so English is named too. +/// `None` when Speech Dispatcher isn't installed. +#[cfg(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")))] +fn speak_dispatcher(text: &str, lang: Option<&str>, rate: f32, changed: &dyn Fn() -> bool) -> Option { + let stream = match ssip::connect(changed) { + ssip::Connection::Open(s) => s, + ssip::Connection::Missing => return None, + ssip::Connection::Interrupted => return Some(Spoken::Interrupted), + ssip::Connection::Unreachable(why) => return Some(Spoken::Failed(format!("Read Aloud couldn't reach Speech Dispatcher: {why}"))), + }; + let lang = dispatcher_language(lang); + let req = ssip::Request { text, rate: spd_rate(rate), lang: lang.as_deref() }; + Some(dispatcher_spoken(ssip::converse(&stream, &req, changed))) +} + +/// How a sentence Speech Dispatcher handled ended, for the worker. +#[cfg(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")))] +fn dispatcher_spoken(outcome: std::io::Result) -> Spoken { + match outcome { + Ok(ssip::Outcome::Done) => Spoken::Done, + Ok(ssip::Outcome::Interrupted) => Spoken::Interrupted, + Ok(ssip::Outcome::Halted) => Spoken::Halted(HALTED_BY_OTHER.into()), + Ok(ssip::Outcome::NotStarted) => Spoken::Halted(NOT_STARTED.into()), + Err(e) => Spoken::Failed(format!("Read Aloud couldn't use Speech Dispatcher: {e}")), + } +} + +/// Why reading paused when another program cancelled or paused the sentence. +#[cfg(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")))] +const HALTED_BY_OTHER: &str = + "Speech Dispatcher kept stopping the sentence for another program (a screen reader, or a stop for every program): press Play to go on"; + +/// Why reading paused when Speech Dispatcher never began the sentence. +#[cfg(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")))] +const NOT_STARTED: &str = + "Speech Dispatcher didn't start the sentence (other speech may be queued before it, or its speech module may be stuck): press Play to try again"; + +#[cfg(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")))] +thread_local! { + /// Neither espeak command was found during this reading (the worker thread lives as long as + /// one reading): the next sentences go straight to Speech Dispatcher. + static NO_ESPEAK: std::cell::Cell = const { std::cell::Cell::new(false) }; +} + +/// The language tag Speech Dispatcher is given for a sentence in `lang`: its language and region, +/// as SSIP's `SET LANGUAGE` takes them (`sr-Latn-RS` gives `sr-RS`, `zh-Hant-TW` gives `zh-TW`), +/// when it names a language (Word's `x-none` doesn't). +#[cfg(any(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")), test))] +fn dispatcher_language(lang: Option<&str>) -> Option { + let tag = lang.and_then(plain_tag)?; + let primary = primary_tag(tag)?; + let region = tag + .split('-') + .skip(1) + .take(2) + .find(|t| (t.len() == 2 && t.chars().all(|c| c.is_ascii_alphabetic())) || (t.len() == 3 && t.chars().all(|c| c.is_ascii_digit()))); + Some(match region { + Some(r) => format!("{primary}-{}", r.to_ascii_uppercase()), + None => primary, + }) +} + +/// Should `speak` say the sentence again with the default voice? When it gave the program a voice +/// and the program then exited with a failure: espeak-ng does that, silently, for a voice it +/// doesn't know, and a missing voice must never mean silence. `exited_ok` is `None` when the +/// program didn't exit on its own (stopped, or never started). +#[cfg(any(not(target_arch = "wasm32"), test))] +fn retry_without_voice(gave_voice: bool, exited_ok: Option) -> bool { + gave_voice && exited_ok == Some(false) +} + +/// The names `say` can voice, as `say -v '?'` lists them (asked once). +#[cfg(target_os = "macos")] +fn mac_voice_listing() -> &'static str { + static LISTING: std::sync::OnceLock = std::sync::OnceLock::new(); + LISTING.get_or_init(|| { + std::process::Command::new("say").args(["-v", "?"]).output().map(|o| String::from_utf8_lossy(&o.stdout).into_owned()).unwrap_or_default() + }) +} + +/// Sentence spans in `text` as char index ranges, without surrounding whitespace. +fn split_sentences(text: &str) -> Vec<(usize, usize)> { + let chars: Vec = text.chars().collect(); + let mut out = Vec::new(); + let mut start = 0; + let mut i = 0; + while i < chars.len() { + let c = chars.get(i).copied().unwrap_or(' '); + let mut end = None; + if matches!(c, '.' | '!' | '?' | '…' | '。' | '!' | '?') { + // Include closing quotes and brackets. + let mut j = i + 1; + while chars.get(j).is_some_and(|c| matches!(c, '"' | '\'' | '”' | '’' | ')' | ']' | '»' | '.' | '!' | '?')) { + j += 1; + } + let at_end = j >= chars.len(); + let space_after = chars.get(j).is_some_and(|c| c.is_whitespace()); + if (at_end || space_after) && (c != '.' || !is_abbreviation(&chars, start, i)) { + end = Some(j); + } + i = j.max(i + 1); + } else { + i += 1; + } + // Very long sentences are split at a space so skipping stays fine-grained. + if end.is_none() && i - start >= MAX_SENTENCE && chars.get(i).is_some_and(|c| c.is_whitespace()) { + end = Some(i); + } + if let Some(e) = end { + push_trimmed(&chars, start, e, &mut out); + start = e; + } + } + push_trimmed(&chars, start, chars.len(), &mut out); + out +} + +fn push_trimmed(chars: &[char], mut a: usize, mut b: usize, out: &mut Vec<(usize, usize)>) { + while a < b && chars.get(a).is_some_and(|c| c.is_whitespace()) { + a += 1; + } + while b > a && chars.get(b - 1).is_some_and(|c| c.is_whitespace()) { + b -= 1; + } + // No space to split at (a long URL, a run of letters or full stops): cut the span hard. + while b - a > MAX_UTTERANCE { + out.push((a, a + MAX_UTTERANCE)); + a += MAX_UTTERANCE; + while a < b && chars.get(a).is_some_and(|c| c.is_whitespace()) { + a += 1; + } + } + if a < b { + out.push((a, b)); + } +} + +/// Does the full stop at `dot` end an abbreviation ("et al.", "Fig.", "J. Smith") rather than a sentence? +fn is_abbreviation(chars: &[char], start: usize, dot: usize) -> bool { + let mut w = dot; + while w > start && chars.get(w - 1).is_some_and(|c| c.is_alphanumeric() || *c == '.') { + w -= 1; + } + let word: String = chars.get(w..dot).unwrap_or(&[]).iter().collect::().to_lowercase(); + if word.is_empty() { + return false; + } + // An initial: "J. Smith", "A. B. Jones". + let letters: Vec = word.chars().filter(|c| *c != '.').collect(); + if letters.len() == 1 && letters.first().is_some_and(|c| c.is_alphabetic()) { + return true; + } + ABBREVIATIONS.contains(&word.as_str()) +} + +/// Collapse whitespace and tidy what removed citations leave behind: "( )", "[]", " ." → ".". +/// One pass, linear in `s` (#3680): a closing bracket right after its opening one (or after it +/// and a space) removes the pair, so nested empty brackets "( [ ] )" go too. +fn tidy(s: &str) -> String { + let mut t = String::with_capacity(s.len()); + for c in s.chars() { + if c.is_whitespace() { + if !t.is_empty() && !t.ends_with(' ') { + t.push(' '); + } + continue; + } + let open = match c { + ')' => Some('('), + ']' => Some('['), + _ => None, + }; + if let Some(open) = open { + let body = t.strip_suffix(' ').unwrap_or(&t); + if let Some(rest) = body.strip_suffix(open) { + let keep = rest.len(); + t.truncate(keep); + continue; + } + } + if matches!(c, '.' | ',' | ';' | ':') && t.ends_with(' ') { + t.pop(); + } + t.push(c); + } + if t.ends_with(' ') { + t.pop(); + } + t +} + +/// Reading state. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum State { + Stopped, + Playing, + Paused, +} + +impl State { + pub fn name(self) -> &'static str { + match self { + State::Stopped => "stopped", + State::Playing => "playing", + State::Paused => "paused", + } + } +} + +/// How sentences are spoken. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Backend { + /// The system's speech program. + System, + /// Silent; a sentence lasts until something interrupts it (tests, `WORDCRAFT_SPEECH=off`). + Hold, + /// Silent; every sentence finishes at once (tests). + Instant, +} + +struct Shared { + queue: Vec, + index: usize, + state: State, + rate: f32, + /// Bumped on every change that should stop the sentence being spoken. + generation: u64, + error: Option, + /// A worker thread is running (cleared under the lock as it exits). + #[cfg_attr(target_arch = "wasm32", allow(dead_code))] + running: bool, + /// Bumped when a reading starts: a worker thread can outlive a reading (a pause, then a new + /// Read Aloud), and what it learnt about the system's speech programs is per reading. + #[cfg_attr(target_arch = "wasm32", allow(dead_code))] + readings: u64, +} + +/// The Read Aloud player. +pub struct ReadAloud { + shared: Arc<(Mutex, Condvar)>, + pub backend: Backend, + /// Leave citations and bibliographies out of the next reading. + pub skip_citations: bool, + /// The player window is open (it stays open after the last sentence until closed). + pub open: bool, + /// The document being read ([`crate::Session::document_id`]): the sentences' positions are + /// in it. + pub document: Option, +} + +impl Default for ReadAloud { + fn default() -> Self { + let off = cfg!(test) || std::env::var("WORDCRAFT_SPEECH").is_ok_and(|v| v == "off"); + ReadAloud { + shared: Arc::new(( + Mutex::new(Shared { + queue: Vec::new(), + index: 0, + state: State::Stopped, + rate: 1.0, + generation: 0, + error: None, + running: false, + readings: 0, + }), + Condvar::new(), + )), + backend: if off { Backend::Hold } else { Backend::System }, + skip_citations: true, + open: false, + document: None, + } + } +} + +fn lock(m: &Mutex) -> MutexGuard<'_, Shared> { + m.lock().unwrap_or_else(PoisonError::into_inner) +} + +impl ReadAloud { + /// Change the shared state, stop the current sentence and wake the worker. + fn change(&mut self, f: impl FnOnce(&mut Shared)) { + { + let (m, cv) = &*self.shared; + let mut s = lock(m); + f(&mut s); + s.generation = s.generation.wrapping_add(1); + cv.notify_all(); + } + self.ensure_worker(); + } + + /// Start reading `queue` from its first sentence. + pub fn start(&mut self, queue: Vec) { + self.open = true; + self.change(|s| { + s.state = if queue.is_empty() { State::Stopped } else { State::Playing }; + s.queue = queue; + s.index = 0; + s.error = None; + s.readings = s.readings.wrapping_add(1); + }); + } + + /// Pause, or carry on (a finished reading starts again from the top). + pub fn play_pause(&mut self) { + self.change(|s| { + s.state = match s.state { + State::Playing => State::Paused, + _ if s.queue.is_empty() => State::Stopped, + _ => { + if s.index >= s.queue.len() { + s.index = 0; + } + s.error = None; + State::Playing + } + }; + }); + } + + /// Move by `delta` sentences; a stopped player starts playing there. + pub fn skip(&mut self, delta: i64) { + self.change(|s| { + let last = s.queue.len().saturating_sub(1) as i64; + let at = (s.index.min(s.queue.len()) as i64).saturating_add(delta).clamp(0, last.max(0)); + s.index = at as usize; + if s.state == State::Stopped && !s.queue.is_empty() { + s.state = State::Playing; + } + }); + } + + /// Set the speed (1.0 = normal); the current sentence restarts at the new speed. + pub fn set_rate(&mut self, rate: f32) { + let r = if rate.is_finite() { rate.clamp(MIN_RATE, MAX_RATE) } else { 1.0 }; + let playing = self.state() == State::Playing; + if playing { + self.change(|s| s.rate = r); + } else { + lock(&self.shared.0).rate = r; + } + } + + /// Stop reading and close the player. + pub fn stop(&mut self) { + self.open = false; + self.change(|s| s.state = State::Stopped); + } + + pub fn state(&self) -> State { + lock(&self.shared.0).state + } + pub fn rate(&self) -> f32 { + lock(&self.shared.0).rate + } + /// The sentence being read (or paused on). + pub fn current(&self) -> Option { + let s = lock(&self.shared.0); + if s.state == State::Stopped { None } else { s.queue.get(s.index).cloned() } + } + /// (index, number of sentences). + pub fn progress(&self) -> (usize, usize) { + let s = lock(&self.shared.0); + (s.index, s.queue.len()) + } + /// The last speech error (no speech program…), if any. + pub fn error(&self) -> Option { + lock(&self.shared.0).error.clone() + } + + pub fn status(&self) -> Value { + let s = lock(&self.shared.0); + let current = if s.state == State::Stopped { None } else { s.queue.get(s.index) }; + json!({ + "state": s.state.name(), + "open": self.open, + "index": s.index, + "count": s.queue.len(), + "rate": s.rate, + "skipCitations": self.skip_citations, + "current": current.map(Utterance::to_json), + "error": s.error, + }) + } + + #[cfg(target_arch = "wasm32")] + fn ensure_worker(&mut self) { + let (m, _) = &*self.shared; + let mut s = lock(m); + if s.state == State::Playing && self.backend == Backend::System { + s.state = State::Stopped; + s.error = Some("Read Aloud isn't available in the browser".into()); + } + } + + #[cfg(not(target_arch = "wasm32"))] + fn ensure_worker(&mut self) { + let mut s = lock(&self.shared.0); + if s.state != State::Playing || s.running { + return; + } + s.running = true; + let shared = Arc::clone(&self.shared); + let backend = self.backend; + if let Err(e) = std::thread::Builder::new().name("read-aloud".into()).spawn(move || worker(&shared, backend)) { + s.running = false; + s.state = State::Stopped; + s.error = Some(format!("couldn't start Read Aloud: {e}")); + } + } +} + +impl Drop for ReadAloud { + fn drop(&mut self) { + let (m, cv) = &*self.shared; + let mut s = lock(m); + s.state = State::Stopped; + s.generation = s.generation.wrapping_add(1); + cv.notify_all(); + } +} + +/// How a sentence ended. +#[cfg(not(target_arch = "wasm32"))] +enum Spoken { + Done, + Interrupted, + /// Another program stopped the sentence: reading pauses on it, saying why. + #[cfg_attr(not(all(unix, not(target_os = "macos"))), allow(dead_code))] + Halted(String), + Failed(String), +} + +#[cfg(not(target_arch = "wasm32"))] +fn worker(shared: &(Mutex, Condvar), backend: Backend) { + let (m, cv) = shared; + let mut reading = None; + loop { + let (text, lang, generation, rate, readings) = { + let mut s = lock(m); + while s.state == State::Paused { + s = cv.wait(s).unwrap_or_else(PoisonError::into_inner); + } + if s.state == State::Stopped { + s.running = false; + return; + } + match s.queue.get(s.index) { + Some(u) => (u.text.clone(), u.lang.clone(), s.generation, s.rate, s.readings), + None => { + s.state = State::Stopped; + s.running = false; + return; + } + } + }; + // A new reading asks the system afresh which speech programs it has. + if reading != Some(readings) { + reading = Some(readings); + #[cfg(all(unix, not(target_os = "macos")))] + NO_ESPEAK.set(false); + } + let changed = || lock(m).generation != generation; + let outcome = match backend { + Backend::System => speak(&text, lang.as_deref(), rate, &changed), + Backend::Instant => Spoken::Done, + Backend::Hold => { + let mut s = lock(m); + while s.generation == generation { + s = cv.wait(s).unwrap_or_else(PoisonError::into_inner); + } + Spoken::Interrupted + } + }; + let mut s = lock(m); + if s.generation != generation { + continue; + } + match outcome { + Spoken::Done => { + s.index += 1; + if s.index >= s.queue.len() { + s.state = State::Stopped; + s.running = false; + return; + } + } + Spoken::Interrupted => {} + Spoken::Halted(why) => { + s.state = State::Paused; + s.error = Some(why); + } + Spoken::Failed(e) => { + s.state = State::Stopped; + s.error = Some(e); + s.running = false; + return; + } + } + } +} + +/// Windows PowerShell by its full path, `\System32\WindowsPowerShell\v1.0\powershell.exe` +/// (`C:\Windows` when `SystemRoot` is unset), rather than whichever `powershell` the program search +/// finds first. +#[cfg(any(target_os = "windows", test))] +fn windows_powershell(system_root: Option) -> std::path::PathBuf { + let root = system_root.filter(|r| !r.is_empty()).unwrap_or_else(|| "C:\\Windows".into()); + std::path::Path::new(&root).join("System32").join("WindowsPowerShell").join("v1.0").join("powershell.exe") +} + +/// Win32 `CREATE_NO_WINDOW` process creation flag: a console program starts without a console window. +#[cfg(any(target_os = "windows", test))] +const CREATE_NO_WINDOW: u32 = 0x0800_0000; + +/// How Read Aloud starts Windows PowerShell to speak at `rate`: the program, its arguments and +/// its process creation flags. `speak` builds its `Command` from exactly this; std can't read +/// creation flags back from a `Command`, so the tests check this value instead. +#[cfg(any(target_os = "windows", test))] +struct WindowsSpeech { + program: std::path::PathBuf, + args: [String; 4], + creation_flags: u32, +} + +#[cfg(any(target_os = "windows", test))] +fn windows_speech(system_root: Option, rate: f32, lang: Option<&str>) -> WindowsSpeech { + // SAPI rate: -10 (a third of normal speed) … 10 (three times). + let r = ((rate.ln() / 3f32.ln()) * 10.0).round().clamp(-10.0, 10.0) as i64; + // The voice of the sentence's language, when one is installed (the default voice would spell + // out Cyrillic, Greek… letters one by one), but only when the user's default voice doesn't + // speak that language already, in any region. The tag is `primary_tag`-clean, so it can't end + // the quote. + let voice = lang.and_then(primary_tag).map_or_else(String::new, |p| { + format!( + "if ($s.Voice.Culture.TwoLetterISOLanguageName -ne '{p}') {{ $v = $s.GetInstalledVoices() | Where-Object {{ $_.Enabled -and $_.VoiceInfo.Culture.TwoLetterISOLanguageName -eq '{p}' }} | Select-Object -First 1; if ($v) {{ $s.SelectVoice($v.VoiceInfo.Name) }} }}; " + ) + }); + // The sentence arrives on stdin as UTF-8 bytes; `[Console]::In` would decode them with the + // console's code page (Cyrillic became `?` and the voice read single letters), so read the raw + // stream and decode it as UTF-8. The text itself is never part of the script. + let script = format!( + "Add-Type -AssemblyName System.Speech; $s = New-Object System.Speech.Synthesis.SpeechSynthesizer; $s.Rate = {r}; {voice}$m = New-Object System.IO.MemoryStream; [Console]::OpenStandardInput().CopyTo($m); $s.Speak([System.Text.Encoding]::UTF8.GetString($m.ToArray()))" + ); + WindowsSpeech { + program: windows_powershell(system_root), + args: ["-NoProfile".into(), "-NonInteractive".into(), "-Command".into(), script], + // PowerShell is a console program: without this, every sentence flashes a console window + // over the document (#1122). + creation_flags: CREATE_NO_WINDOW, + } +} + +/// Speak one sentence with the system's speech program, stopping early when `changed()`. When +/// the program fails with the sentence's voice, the default voice says it instead. +#[cfg(not(target_arch = "wasm32"))] +fn speak(text: &str, lang: Option<&str>, rate: f32, changed: &dyn Fn() -> bool) -> Spoken { + let (spoken, gave_voice, exited_ok) = speak_with(text, lang, rate, changed); + if retry_without_voice(gave_voice, exited_ok) { + return speak_with(text, None, rate, changed).0; + } + spoken +} + +/// [`speak`] in `lang`'s voice once: how it ended, whether the program was given a voice, and +/// whether it exited successfully (`None` when it didn't exit on its own). +#[cfg(not(target_arch = "wasm32"))] +fn speak_with(text: &str, lang: Option<&str>, rate: f32, changed: &dyn Fn() -> bool) -> (Spoken, bool, Option) { + use std::io::Write; + use std::process::{Command, Stdio}; + let wpm = ((BASE_WPM * rate).round() as i64).to_string(); + // The text goes in on stdin so it can never be taken for an option. Each try is a program + // and whether it was given a voice. + let mut tries: Vec<(Command, bool)> = Vec::new(); + #[cfg(target_os = "macos")] + { + let mut c = Command::new("say"); + c.args(["-r", &wpm]); + let voice = lang.and_then(|l| mac_voice(l, mac_voice_listing(), mac_default_voice_language())); + if let Some(voice) = &voice { + c.args(["-v", voice.as_str()]); + } + tries.push((c, voice.is_some())); + } + #[cfg(target_os = "windows")] + { + let w = windows_speech(std::env::var_os("SystemRoot"), rate, lang); + let mut c = Command::new(&w.program); + c.args(&w.args); + std::os::windows::process::CommandExt::creation_flags(&mut c, w.creation_flags); + tries.push((c, lang.and_then(primary_tag).is_some())); + } + #[cfg(all(unix, not(target_os = "macos")))] + { + let programs: &[&str] = if NO_ESPEAK.get() { &[] } else { &["espeak-ng", "espeak"] }; + for prog in programs { + let args = espeak_args(&wpm, lang); + let voiced = args.iter().any(|a| a == "-v"); + let mut c = Command::new(prog); + c.args(args); + tries.push((c, voiced)); + } + } + let _ = (&wpm, lang); + let mut child = None; + let mut not_found = true; + let mut last_err = String::from("no speech program on this system"); + for (mut c, voiced) in tries { + match c.stdin(Stdio::piped()).stdout(Stdio::null()).stderr(Stdio::null()).spawn() { + Ok(ch) => { + child = Some((ch, voiced)); + break; + } + Err(e) => { + not_found &= e.kind() == std::io::ErrorKind::NotFound; + last_err = e.to_string(); + } + } + } + let Some((mut child, voiced)) = child else { + // Speech Dispatcher, which many desktops install without the espeak command. + #[cfg(all(unix, not(target_os = "macos")))] + if not_found { + NO_ESPEAK.set(true); + if let Some(spoken) = speak_dispatcher(text, lang, rate, changed) { + return (spoken, false, None); + } + } + let message = if not_found { MISSING_PROGRAM.to_string() } else { format!("Read Aloud couldn't start the speech program: {last_err}") }; + return (Spoken::Failed(message), false, None); + }; + if let Some(mut stdin) = child.stdin.take() { + let _ = stdin.write_all(text.as_bytes()); + // Dropping stdin closes it, which tells the program the text is complete. + } + loop { + match child.try_wait() { + Ok(Some(status)) => return (Spoken::Done, voiced, Some(status.success())), + Ok(None) => {} + Err(e) => return (Spoken::Failed(e.to_string()), voiced, None), + } + if changed() { + let _ = child.kill(); + let _ = child.wait(); + return (Spoken::Interrupted, voiced, None); + } + std::thread::sleep(std::time::Duration::from_millis(30)); + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/engine/src/speech/ssip.rs b/crates/engine/src/speech/ssip.rs index 979b144453..a0cf365495 100644 --- a/crates/engine/src/speech/ssip.rs +++ b/crates/engine/src/speech/ssip.rs @@ -1,927 +1,927 @@ -//! Speech Dispatcher through its own protocol (SSIP) on the user's socket, for systems that have -//! Speech Dispatcher but not the `espeak-ng` command. -//! -//! Talking SSIP directly, instead of starting `spd-say`, keeps two promises `spd-say` can't: the -//! sentence is always data (pipe mode runs a line starting with `!-!` as an SSIP command, and a -//! document must never send commands), and stopping cancels only WordCraft's own speech -//! (`CANCEL self`), where `spd-say -C` would silence every client, a screen reader included. -//! -//! One connection speaks one sentence: it names the client, asks for the begin, end, cancel and -//! pause events, sets the rate and language, sends the text, and waits for the sentence's end -//! event. A sentence another client's message cancels (a screen reader's announcement) is sent -//! again after a pause, twice at most; a sentence cancelled once more or paused by another -//! client halts reading on it ([`Outcome::Halted`]), and so does one that never began -//! ([`Outcome::NotStarted`]). - -use std::io::{ErrorKind, Read, Write}; -use std::net::{TcpStream, ToSocketAddrs}; -use std::os::unix::net::UnixStream; -use std::path::PathBuf; -use std::time::{Duration, Instant}; - -/// How often a wait for the end event looks at whether reading moved on. -const POLL: Duration = Duration::from_millis(30); -/// Longest wait for a reply to a command (the server answers at once; this only bounds a stuck one). -const REPLY_TIMEOUT: Duration = if cfg!(test) { Duration::from_secs(1) } else { Duration::from_secs(5) }; -/// Longest reply line kept (a server sends short lines; this bounds a broken one). -const MAX_LINE: usize = 4096; -/// Speech Dispatcher's TCP port when `SPEECHD_ADDRESS` names none. -const DEFAULT_PORT: u16 = 6560; -/// How long a freshly started server gets to open its socket (it loads its speech modules first). -const START_WAIT: Duration = Duration::from_secs(5); -/// Longest wait for a TCP connection to one address. -const CONNECT_TIMEOUT: Duration = Duration::from_secs(1); -/// The end of a sentence is awaited this long after it began, plus [`SENTENCE_PER_CHAR`] for each -/// character: far more than the slowest reading takes, so only a stuck server reaches it. -const SENTENCE_BASE: Duration = if cfg!(test) { Duration::from_secs(1) } else { Duration::from_secs(30) }; -const SENTENCE_PER_CHAR: Duration = Duration::from_millis(400); -/// Longest wait for a queued sentence to begin (behind other clients' speech, or paused before it -/// began, which sends no pause event). -const QUEUE_WAIT: Duration = if cfg!(test) { Duration::from_secs(1) } else { Duration::from_secs(120) }; -/// The pauses before a sentence another client cancelled is sent again: the other client's -/// message gets time to end, and the server holds ours back while a higher-priority one speaks. -const RESEND_AFTER: [Duration; 2] = - if cfg!(test) { [Duration::from_millis(50), Duration::from_millis(50)] } else { [Duration::from_secs(1), Duration::from_secs(3)] }; - -/// What to say and how. -pub(super) struct Request<'a> { - pub text: &'a str, - /// Speech Dispatcher's rate, -100 to 100. - pub rate: i32, - /// A plain language tag (`hu`, `pt-BR`); anything else is left out. - pub lang: Option<&'a str>, -} - -/// How a sentence ended. -#[derive(Debug, PartialEq, Eq)] -pub(super) enum Outcome { - /// Spoken to the end. - Done, - /// Reading moved on: the sentence was cancelled. - Interrupted, - /// Another client stopped it: cancelled it again after it was sent again, or paused it. - /// Reading halts on it instead of fighting the other client. - Halted, - /// The sentence never began: queued too long, behind other clients' speech, paused before it - /// began (which sends no event), or with a speech module that doesn't start. Reading halts on it. - NotStarted, -} - -/// Where Speech Dispatcher listens. -#[derive(Debug, PartialEq, Eq)] -pub(super) enum Address { - Unix(PathBuf), - /// Host and port. - Inet(String, u16), -} - -/// The server's address, found the way Speech Dispatcher's own client library finds it: -/// `SPEECHD_ADDRESS` when set (`unix_socket[:path]`, `inet_socket[:host[:port]]`), else the socket -/// `speech-dispatcher/speechd.sock` in the runtime folder, else in the cache folder -/// (`XDG_CACHE_HOME`, else `~/.cache`). `None` for an address method it doesn't know, a port that -/// isn't a number, or no folder to look in. -pub(super) fn address(spec: Option<&str>, runtime_dir: Option, cache_home: Option, home: Option) -> Option
{ - let default_socket = || { - let set = |p: Option| p.filter(|p| !p.as_os_str().is_empty()); - set(runtime_dir) - .or_else(|| set(cache_home)) - .or_else(|| set(home).map(|h| h.join(".cache"))) - .map(|d| d.join("speech-dispatcher").join("speechd.sock")) - }; - let Some(spec) = spec.map(str::trim).filter(|a| !a.is_empty()) else { return default_socket().map(Address::Unix) }; - let (method, rest) = spec.split_once(':').unwrap_or((spec, "")); - match method { - "unix_socket" => Some(Address::Unix(if rest.is_empty() { default_socket()? } else { PathBuf::from(rest) })), - "inet_socket" => { - let mut p = rest.split(':'); - let host = p.next().filter(|h| !h.is_empty()).unwrap_or("127.0.0.1"); - let port = p.next().filter(|p| !p.is_empty()).map_or(Some(DEFAULT_PORT), |p| p.parse().ok())?; - Some(Address::Inet(host.to_string(), port)) - } - _ => None, - } -} - -/// Is `host` this machine (the only place a server may be started)? -fn is_local(host: &str) -> bool { - host.eq_ignore_ascii_case("localhost") || host.parse::().is_ok_and(|ip| ip.is_loopback()) -} - -/// A connection to Speech Dispatcher. -pub(super) enum Stream { - Unix(UnixStream), - Tcp(TcpStream), -} - -impl Stream { - fn try_clone(&self) -> std::io::Result { - Ok(match self { - Stream::Unix(s) => Stream::Unix(s.try_clone()?), - Stream::Tcp(s) => Stream::Tcp(s.try_clone()?), - }) - } - - /// Reads give up after [`POLL`] (so a wait can look at whether reading moved on), writes after - /// [`REPLY_TIMEOUT`]. - fn set_timeouts(&self) -> std::io::Result<()> { - match self { - Stream::Unix(s) => { - s.set_read_timeout(Some(POLL))?; - s.set_write_timeout(Some(REPLY_TIMEOUT)) - } - Stream::Tcp(s) => { - s.set_read_timeout(Some(POLL))?; - s.set_write_timeout(Some(REPLY_TIMEOUT)) - } - } - } -} - -impl Read for Stream { - fn read(&mut self, buf: &mut [u8]) -> std::io::Result { - match self { - Stream::Unix(s) => s.read(buf), - Stream::Tcp(s) => s.read(buf), - } - } -} - -impl Write for Stream { - fn write(&mut self, buf: &[u8]) -> std::io::Result { - match self { - Stream::Unix(s) => s.write(buf), - Stream::Tcp(s) => s.write(buf), - } - } - fn flush(&mut self) -> std::io::Result<()> { - match self { - Stream::Unix(s) => s.flush(), - Stream::Tcp(s) => s.flush(), - } - } -} - -/// Connect to `address`; between the addresses a host name resolves to, give up once `changed()`. -fn open(address: &Address, changed: &dyn Fn() -> bool) -> std::io::Result { - match address { - Address::Unix(path) => UnixStream::connect(path).map(Stream::Unix), - Address::Inet(host, port) => { - let mut last = std::io::Error::from(ErrorKind::NotFound); - for a in (host.as_str(), *port).to_socket_addrs()? { - if changed() { - return Err(moved_on()); - } - match TcpStream::connect_timeout(&a, CONNECT_TIMEOUT) { - Ok(s) => return Ok(Stream::Tcp(s)), - Err(e) => last = e, - } - } - Err(last) - } - } -} - -/// How [`connect`] went. -pub(super) enum Connection { - Open(Stream), - /// Speech Dispatcher isn't installed. - Missing, - /// It is installed but can't be reached: why. - Unreachable(String), - /// Reading moved on while the server was starting. - Interrupted, -} - -/// Connect to the user's Speech Dispatcher, starting it once if it isn't running (as `spd-say` -/// does), unless the address is another machine's. -pub(super) fn connect(changed: &dyn Fn() -> bool) -> Connection { - let env = |k: &str| std::env::var_os(k).map(PathBuf::from); - let spec = std::env::var("SPEECHD_ADDRESS").ok().filter(|s| !s.trim().is_empty()); - let Some(address) = address(spec.as_deref(), env("XDG_RUNTIME_DIR"), env("XDG_CACHE_HOME"), env("HOME")) else { - return if spec.is_some() { Connection::Unreachable("SPEECHD_ADDRESS isn't an address it understands".into()) } else { Connection::Missing }; - }; - match open(&address, changed) { - Ok(s) => return Connection::Open(s), - Err(e) if e.kind() == ErrorKind::Interrupted => return Connection::Interrupted, - Err(_) => {} - } - let mut start = std::process::Command::new("speech-dispatcher"); - start.arg("--spawn"); - // A server started for an address of its own must open it there. - match (&address, spec.is_some()) { - (_, false) => {} - (Address::Unix(path), true) => { - start.args(["--communication-method", "unix_socket", "--socket-path"]).arg(path); - } - (Address::Inet(host, port), true) if is_local(host) => { - start.args(["--communication-method", "inet_socket", "--port", &port.to_string()]); - } - (Address::Inet(..), true) => return Connection::Unreachable("nothing answers at SPEECHD_ADDRESS".into()), - } - // `--spawn` exits once it has forked the server; the wait for it is still bounded and gives way - // to Stop, so a start that hangs can't keep the reading thread (and Read Aloud with it) busy. - let until = Instant::now() + START_WAIT; - match start_server(&mut start, until, changed) { - Started::Exited(true) => {} - Started::Exited(false) => { - return Connection::Unreachable("it isn't running and didn't start (its configuration may turn starting on demand off)".into()); - } - Started::Missing => return Connection::Missing, - Started::Failed(e) => return Connection::Unreachable(format!("it couldn't be started: {e}")), - Started::Interrupted => return Connection::Interrupted, - Started::TimedOut => { - return open(&address, changed).map_or_else(|_| Connection::Unreachable("starting it didn't finish in time".into()), Connection::Open); - } - } - // The server opens its socket a moment after it starts. - loop { - if changed() { - return Connection::Interrupted; - } - if let Ok(s) = open(&address, changed) { - return Connection::Open(s); - } - if Instant::now() >= until { - return Connection::Unreachable("it started but doesn't answer".into()); - } - std::thread::sleep(Duration::from_millis(50)); - } -} - -/// How running the server's start command went. -#[derive(Debug)] -enum Started { - /// It exited, successfully or not. - Exited(bool), - /// The command isn't installed. - Missing, - /// It couldn't be run or waited for. - Failed(std::io::Error), - /// Reading moved on before it exited; it was stopped. - Interrupted, - /// It was still running at the deadline; it was stopped. - TimedOut, -} - -/// Run `command` (output discarded) and wait for it to exit, at most until `until` and only while -/// reading stays put; a command still running then is stopped and reaped. -fn start_server(command: &mut std::process::Command, until: Instant, changed: &dyn Fn() -> bool) -> Started { - use std::process::Stdio; - let mut child = match command.stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null()).spawn() { - Ok(child) => child, - Err(e) if e.kind() == ErrorKind::NotFound => return Started::Missing, - Err(e) => return Started::Failed(e), - }; - let stopped = loop { - match child.try_wait() { - Ok(Some(status)) => return Started::Exited(status.success()), - Ok(None) if changed() => break Started::Interrupted, - Ok(None) if Instant::now() >= until => break Started::TimedOut, - Ok(None) => std::thread::sleep(Duration::from_millis(20)), - Err(e) => break Started::Failed(e), - } - }; - let _ = child.kill(); - let _ = child.wait(); - stopped -} - -/// The sentence as SSIP message data: one line (a line break is a space when read aloud anyway), -/// a leading `.` doubled so it can't end the message, and the terminating `.` line. -pub(super) fn message_data(text: &str) -> String { - let line: String = text.chars().map(|c| if c == '\r' || c == '\n' { ' ' } else { c }).collect(); - let dot = if line.starts_with('.') { "." } else { "" }; - format!("{dot}{line}\r\n.\r\n") -} - -/// Reads reply lines. Bytes wait in `pending` until their line is complete, so a character split -/// across reads stays whole, and a line longer than [`MAX_LINE`] is an error, never a growing buffer. -struct Lines { - reader: R, - pending: Vec, -} - -impl Lines { - fn new(reader: R) -> Self { - Lines { reader, pending: Vec::new() } - } - - /// The next complete line without its line ending, or `Ok(None)` when none is complete after - /// one read (a timeout, or part of a line): the caller looks at its deadline and whether reading - /// moved on between calls, however slowly the bytes trickle in. - fn next(&mut self) -> std::io::Result> { - if let Some(line) = self.complete_line() { - return Ok(Some(line)); - } - if self.pending.len() > MAX_LINE { - return Err(ErrorKind::InvalidData.into()); - } - let mut buf = [0u8; 512]; - match self.reader.read(&mut buf) { - Ok(0) => Err(ErrorKind::UnexpectedEof.into()), - Ok(n) => { - self.pending.extend_from_slice(buf.get(..n).unwrap_or_default()); - Ok(self.complete_line()) - } - Err(e) if matches!(e.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut | ErrorKind::Interrupted) => Ok(None), - Err(e) => Err(e), - } - } - - /// The first complete line in `pending`, taken out of it. - fn complete_line(&mut self) -> Option { - let end = self.pending.iter().position(|&b| b == b'\n')?; - let line: Vec = self.pending.drain(..=end).collect(); - Some(String::from_utf8_lossy(&line).trim_end_matches(['\r', '\n']).to_string()) - } -} - -/// The three-digit code of a final reply line (`225 OK MESSAGE QUEUED`); `None` for a -/// continuation line (`225-12`). -fn final_code(line: &str) -> Option { - let code = line.get(..3)?.parse().ok()?; - (line.as_bytes().get(3) == Some(&b' ') || line.len() == 3).then_some(code) -} - -/// A `2xx` reply, or an error naming the code. -fn ok(code: u16) -> std::io::Result<()> { - if (200..300).contains(&code) { Ok(()) } else { Err(std::io::Error::other(format!("Speech Dispatcher refused a command ({code})"))) } -} - -/// The error a conversation stops with when reading moved on. -fn moved_on() -> std::io::Error { - std::io::Error::new(ErrorKind::Interrupted, "reading moved on") -} - -/// The begin (701), end (702), cancel (703) and pause (704) events. -const BEGIN: u16 = 701; -const END: u16 = 702; -const CANCEL: u16 = 703; -const PAUSE: u16 = 704; - -/// One conversation with the server. -struct Conversation<'a, W, R> { - w: W, - lines: Lines, - /// The last end, cancel or pause event, until it's taken. - event: Option, - /// The message just queued began. - begun: bool, - /// Whether reading moved on (checked between lines and at every read timeout). - changed: &'a dyn Fn() -> bool, -} - -impl Conversation<'_, W, R> { - /// The reply to the command just sent: its final code. An end, cancel or pause event that arrives - /// meanwhile is kept. Gives up after [`REPLY_TIMEOUT`], also when the server keeps sending - /// other lines, and as soon as reading moves on. - fn reply(&mut self) -> std::io::Result { - let start = Instant::now(); - loop { - if let Some(line) = self.lines.next()? { - match final_code(&line) { - Some(BEGIN) => self.begun = true, - Some(code @ (END | CANCEL | PAUSE)) => self.event = Some(code), - Some(code) if (700..800).contains(&code) => {} - Some(code) => return Ok(code), - None => {} - } - } - if start.elapsed() > REPLY_TIMEOUT { - return Err(ErrorKind::TimedOut.into()); - } - if (self.changed)() { - return Err(moved_on()); - } - } - } - - /// Send one command line and read its reply code. - fn command(&mut self, cmd: &str) -> std::io::Result { - self.w.write_all(cmd.as_bytes())?; - self.w.write_all(b"\r\n")?; - self.reply() - } - - /// What became of the message just queued: its end, cancel or pause event, or - /// [`Fate::NotStarted`] when it didn't begin within [`QUEUE_WAIT`]. Once it began, its end is - /// waited for until `limit` has passed (a stuck server: an error). An error as soon as reading - /// moves on. - fn fate(&mut self, limit: Duration) -> std::io::Result { - let queued = Instant::now(); - let mut began = self.begun.then(Instant::now); - loop { - match self.event.take() { - Some(END) => return Ok(Fate::End), - Some(CANCEL) => return Ok(Fate::Cancelled), - Some(_) => return Ok(Fate::Paused), - None => {} - } - if (self.changed)() { - return Err(moved_on()); - } - match began { - None if queued.elapsed() > QUEUE_WAIT => return Ok(Fate::NotStarted), - Some(t) if t.elapsed() > limit => return Err(std::io::Error::new(ErrorKind::TimedOut, "the sentence didn't end")), - _ => {} - } - if let Some(line) = self.lines.next()? { - match final_code(&line) { - Some(BEGIN) => { - began.get_or_insert_with(Instant::now); - } - Some(code @ (END | CANCEL | PAUSE)) => self.event = Some(code), - _ => {} - } - } - } - } - - /// Wait `pause`, reading what the server sends meanwhile; an error as soon as reading moves on. - fn wait(&mut self, pause: Duration) -> std::io::Result<()> { - let start = Instant::now(); - while start.elapsed() < pause { - if (self.changed)() { - return Err(moved_on()); - } - self.lines.next()?; - } - Ok(()) - } - - /// Set up the client, say the sentence, and wait for it to end. Only [`converse`] cancels our - /// own speech, and it stops waiting first, so a cancel or pause event comes from another - /// client. A cancel is most often another client's message, a screen reader's announcement: - /// the sentence is sent again after a pause ([`RESEND_AFTER`]), when the announcement has - /// ended or the server holds ours back until it has. Cancelled again after that, paused, or - /// never begun, the sentence halts reading. - fn speak(&mut self, req: &Request<'_>) -> std::io::Result { - ok(self.command("SET SELF CLIENT_NAME user:wordcraft:read-aloud")?)?; - ok(self.command("SET SELF NOTIFICATION BEGIN on")?)?; - ok(self.command("SET SELF NOTIFICATION END on")?)?; - ok(self.command("SET SELF NOTIFICATION CANCEL on")?)?; - ok(self.command("SET SELF NOTIFICATION PAUSE on")?)?; - ok(self.command(&format!("SET SELF RATE {}", req.rate))?)?; - // A language the server doesn't know leaves its default: no error. The tag is checked - // again here, at the edge of the protocol: it goes on a command line. - if let Some(lang) = req.lang.and_then(super::plain_tag) { - self.command(&format!("SET SELF LANGUAGE {lang}"))?; - } - let chars = u32::try_from(req.text.chars().count()).unwrap_or(u32::MAX); - let limit = SENTENCE_BASE.saturating_add(SENTENCE_PER_CHAR.saturating_mul(chars)); - let mut resends = RESEND_AFTER.iter(); - loop { - self.event = None; - self.begun = false; - ok(self.command("SPEAK")?)?; - self.w.write_all(message_data(req.text).as_bytes())?; - ok(self.reply()?)?; - match self.fate(limit)? { - Fate::End => return Ok(Outcome::Done), - Fate::Cancelled => match resends.next() { - Some(pause) => self.wait(*pause)?, - None => return Ok(Outcome::Halted), - }, - Fate::Paused => return Ok(Outcome::Halted), - Fate::NotStarted => return Ok(Outcome::NotStarted), - } - } - } -} - -/// What became of a queued message. -enum Fate { - End, - Cancelled, - Paused, - NotStarted, -} - -/// The last words after a sentence that didn't finish. -const CLOSE: &[u8] = b"\r\n.\r\nCANCEL self\r\nQUIT\r\n"; - -/// Speak `req` over `stream`, cancelling it when `changed()`. -pub(super) fn converse(stream: &Stream, req: &Request<'_>, changed: &dyn Fn() -> bool) -> std::io::Result { - stream.set_timeouts()?; - let mut c = Conversation { w: stream.try_clone()?, lines: Lines::new(stream.try_clone()?), event: None, begun: false, changed }; - let result = c.speak(req); - // The last words wait for no answer: a server that stopped answering mustn't hold up Stop. - // After anything but a finished sentence our message may still be queued, or the server may - // be reading message data: an empty line and `.` end a message being sent (outside one they - // are two refused commands), and `CANCEL self` drops ours. - let done = matches!(result, Ok(Outcome::Done)); - let _ = c.w.write_all(if done { b"QUIT\r\n" } else { CLOSE }); - match result { - Err(e) if e.kind() == ErrorKind::Interrupted => Ok(Outcome::Interrupted), - r => r, - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::io::{BufRead, BufReader}; - - #[test] - fn the_address_follows_the_environment() { - let unix = |p: &str| Some(Address::Unix(PathBuf::from(p))); - let run = || Some(PathBuf::from("/run/user/1")); - assert_eq!(address(Some("unix_socket:/tmp/s.sock"), None, None, None), unix("/tmp/s.sock")); - assert_eq!(address(None, run(), None, None), unix("/run/user/1/speech-dispatcher/speechd.sock")); - // `unix_socket` alone is the default socket, as in Speech Dispatcher's client library. - assert_eq!(address(Some("unix_socket"), run(), None, None), unix("/run/user/1/speech-dispatcher/speechd.sock")); - assert_eq!(address(Some("unix_socket:"), None, None, Some("/home/u".into())), unix("/home/u/.cache/speech-dispatcher/speechd.sock")); - // Without a runtime folder: the cache folder, `XDG_CACHE_HOME` first. - assert_eq!(address(Some(" "), None, None, Some("/home/u".into())), unix("/home/u/.cache/speech-dispatcher/speechd.sock")); - assert_eq!(address(None, Some("".into()), Some("/c".into()), Some("/home/u".into())), unix("/c/speech-dispatcher/speechd.sock")); - // A TCP address, with the default host and port filled in. - assert_eq!(address(Some("inet_socket:localhost:6561"), run(), None, None), Some(Address::Inet("localhost".into(), 6561))); - assert_eq!(address(Some("inet_socket"), None, None, None), Some(Address::Inet("127.0.0.1".into(), DEFAULT_PORT))); - assert_eq!(address(Some("inet_socket:host"), None, None, None), Some(Address::Inet("host".into(), DEFAULT_PORT))); - assert_eq!(address(Some("inet_socket:host:port"), None, None, None), None); - assert_eq!(address(Some("inet_socket:host:99999"), None, None, None), None); - assert_eq!(address(Some("carrier_pigeon:x"), run(), None, None), None); - assert_eq!(address(None, None, None, None), None); - assert!(is_local("localhost") && is_local("127.0.0.1") && is_local("::1")); - assert!(!is_local("example.org") && !is_local("192.0.2.1")); - } - - #[test] - fn document_text_is_always_message_data() { - // `spd-say -e` would run this line as an SSIP command; here it's spoken text. - assert_eq!(message_data("!-!SET SELF RATE 50"), "!-!SET SELF RATE 50\r\n.\r\n"); - // A line of a single `.` would end the message early, and a line break would start a - // new line that could be one. - assert_eq!(message_data(".\r\nQUIT"), ".. QUIT\r\n.\r\n"); - assert_eq!(message_data("a\n.\nb"), "a . b\r\n.\r\n"); - assert_eq!(final_code("225 OK MESSAGE QUEUED"), Some(225)); - assert_eq!(final_code("225-17"), None); - assert_eq!(final_code("x"), None); - } - - /// Hands out the given reads one by one, then reports a timeout. - struct Reads(std::collections::VecDeque>); - - impl Read for Reads { - fn read(&mut self, buf: &mut [u8]) -> std::io::Result { - match self.0.pop_front() { - Some(chunk) if !chunk.is_empty() => { - let n = chunk.len().min(buf.len()); - buf[..n].copy_from_slice(&chunk[..n]); - if n < chunk.len() { - self.0.push_front(chunk[n..].to_vec()); - } - Ok(n) - } - _ => Err(ErrorKind::WouldBlock.into()), - } - } - } - - #[test] - fn a_character_split_by_a_read_timeout_stays_whole() { - // `é` is C3 A9; the read times out (the empty read) between its two bytes. - let mut lines = Lines::new(Reads(vec![b"249-Caf\xC3".to_vec(), Vec::new(), b"\xA9\tfr\tnone\r\n702 END\r\n".to_vec()].into())); - assert_eq!(lines.next().unwrap(), None, "half a line"); - assert_eq!(lines.next().unwrap(), None, "the timeout"); - assert_eq!(lines.next().unwrap().as_deref(), Some("249-Café\tfr\tnone")); - assert_eq!(lines.next().unwrap().as_deref(), Some("702 END")); - assert_eq!(lines.next().unwrap(), None); - } - - #[test] - fn a_line_without_an_end_is_an_error_not_a_growing_buffer() { - // A peer that never stops sending and never ends the line. - let mut lines = Lines::new(std::io::repeat(b'x')); - let mut calls = 0; - let err = loop { - calls += 1; - assert!(calls < 100, "never gave up"); - match lines.next() { - Ok(None) => {} - Ok(Some(l)) => panic!("{l}"), - Err(e) => break e, - } - }; - assert_eq!(err.kind(), ErrorKind::InvalidData); - assert!(lines.pending.len() <= MAX_LINE + 512); - } - - #[test] - fn a_trickle_of_bytes_hands_control_back_after_each_read() { - // One byte per read and never a line end: every call returns, so the caller can look at - // its deadline and at whether reading moved on. - let mut lines = Lines::new(Reads(vec![b"2".to_vec(), b"0".to_vec(), b"0".to_vec(), b" OK\r\n".to_vec()].into())); - assert_eq!(lines.next().unwrap(), None); - assert_eq!(lines.next().unwrap(), None); - assert_eq!(lines.next().unwrap(), None); - assert_eq!(lines.next().unwrap().as_deref(), Some("200 OK")); - } - - /// How the stand-in server ends a message. - #[derive(Clone, Copy)] - enum Ending { - /// The end event. - End, - /// Nothing until `CANCEL self`. - Hold, - /// Cancelled by another client every time. - CancelledByOther, - /// Cancelled by another client the first time only. - CancelledOnce, - /// Queued and never begun (paused before it began: no event at all). - NeverBegins, - /// Paused by another client (`PAUSE all`). - PausedByOther, - } - - /// A Speech Dispatcher stand-in: answers every command with `2xx` and ends each message as - /// `ending` says. Returns the lines it received. - fn server(mut s: UnixStream, ending: Ending) -> std::thread::JoinHandle> { - std::thread::spawn(move || { - let mut got = Vec::new(); - let mut r = BufReader::new(s.try_clone().unwrap()); - let mut in_data = false; - let mut messages = 0; - loop { - let mut line = String::new(); - if r.read_line(&mut line).unwrap_or(0) == 0 { - return got; - } - let line = line.trim_end_matches(['\r', '\n']).to_string(); - got.push(line.clone()); - let reply: &[u8] = if in_data { - if line != "." { - continue; - } - in_data = false; - messages += 1; - match ending { - Ending::CancelledOnce if messages == 1 => { - b"225-1\r\n225 OK MESSAGE QUEUED\r\n701 BEGIN\r\n703-1\r\n703-7\r\n703 CANCELED\r\n" - } - Ending::CancelledOnce => b"225-1\r\n225 OK MESSAGE QUEUED\r\n701 BEGIN\r\n702-1\r\n702-7\r\n702 END\r\n", - Ending::NeverBegins => b"225-1\r\n225 OK MESSAGE QUEUED\r\n", - Ending::CancelledByOther => b"225-1\r\n225 OK MESSAGE QUEUED\r\n701 BEGIN\r\n703-1\r\n703-7\r\n703 CANCELED\r\n", - Ending::PausedByOther => b"225-1\r\n225 OK MESSAGE QUEUED\r\n701 BEGIN\r\n704-1\r\n704-7\r\n704 PAUSED\r\n", - Ending::End => b"225-1\r\n225 OK MESSAGE QUEUED\r\n702-1\r\n702-7\r\n702 END\r\n", - Ending::Hold => b"225-1\r\n225 OK MESSAGE QUEUED\r\n701 BEGIN\r\n", - } - } else if line == "SPEAK" { - in_data = true; - b"230 OK RECEIVING DATA\r\n" - } else if line == "CANCEL self" { - b"703-1\r\n703-7\r\n703 CANCELED\r\n210 OK CANCELLED\r\n" - } else if line == "QUIT" { - let _ = s.write_all(b"231 HAPPY HACKING\r\n"); - return got; - } else { - b"200 OK\r\n" - }; - // The client doesn't wait for the answers to its last words. - let _ = s.write_all(reply); - } - }) - } - - fn speaks(got: &[String]) -> usize { - got.iter().filter(|l| *l == "SPEAK").count() - } - - #[test] - fn a_sentence_is_spoken_to_its_end() { - let (client, srv) = UnixStream::pair().unwrap(); - let h = server(srv, Ending::End); - let req = Request { text: "!-!QUIT", rate: 50, lang: Some("pt-BR") }; - assert_eq!(converse(&Stream::Unix(client), &req, &|| false).unwrap(), Outcome::Done); - let got = h.join().unwrap(); - assert!(got.contains(&"SET SELF RATE 50".to_string()), "{got:?}"); - // The region stays: Speech Dispatcher's voices are per region (`en-GB`, `pt-BR`). - assert!(got.contains(&"SET SELF LANGUAGE pt-BR".to_string()), "{got:?}"); - let at = got.iter().position(|l| l == "SPEAK").unwrap(); - assert_eq!(got[at + 1..at + 3], ["!-!QUIT".to_string(), ".".to_string()], "the text went as data"); - assert_eq!(speaks(&got), 1); - assert!(!got.iter().any(|l| l.starts_with("CANCEL")), "nothing cancelled"); - assert_eq!(got.last().map(String::as_str), Some("QUIT")); - } - - #[test] - fn a_sentence_a_screen_reader_cuts_off_is_said_again_after_a_pause() { - // One announcement: the sentence is said again once it has had time to end. - let (client, srv) = UnixStream::pair().unwrap(); - let h = server(srv, Ending::CancelledOnce); - let req = Request { text: "Interrupted by a screen reader.", rate: 0, lang: None }; - let start = Instant::now(); - assert_eq!(converse(&Stream::Unix(client), &req, &|| false).unwrap(), Outcome::Done); - assert!(start.elapsed() >= RESEND_AFTER[0], "it waited before saying it again"); - let got = h.join().unwrap(); - assert_eq!(speaks(&got), 2, "{got:?}"); - assert!(got.contains(&"SET SELF NOTIFICATION BEGIN on".to_string()), "{got:?}"); - assert!(!got.iter().any(|l| l.starts_with("CANCEL")), "{got:?}"); - } - - #[test] - fn a_sentence_another_client_keeps_stopping_halts_reading() { - // Cancelled every time (another client stopping everything, a failing module): two - // tries more, then reading halts, never moving on silently. A pause halts at once. - for (ending, sent) in [(Ending::CancelledByOther, 1 + RESEND_AFTER.len()), (Ending::PausedByOther, 1)] { - let (client, srv) = UnixStream::pair().unwrap(); - let h = server(srv, ending); - let req = Request { text: "Interrupted by a screen reader.", rate: 0, lang: None }; - assert_eq!(converse(&Stream::Unix(client), &req, &|| false).unwrap(), Outcome::Halted); - let got = h.join().unwrap(); - assert_eq!(speaks(&got), sent, "{got:?}"); - assert!(got.contains(&"SET SELF NOTIFICATION PAUSE on".to_string()), "{got:?}"); - // Only our own message is dropped. - assert!(got.contains(&"CANCEL self".to_string()) && !got.iter().any(|l| l.eq_ignore_ascii_case("CANCEL all")), "{got:?}"); - } - } - - #[test] - fn a_withheld_end_times_out_and_the_next_sentence_plays() { - // The server accepts the sentence, begins it, stays connected and never reports its end: - // the wait gives up with an error, drops the message (`CANCEL self`), and closes. - let (client, srv) = UnixStream::pair().unwrap(); - let h = server(srv, Ending::Hold); - let req = Request { text: "Withheld.", rate: 0, lang: None }; - let start = Instant::now(); - let err = converse(&Stream::Unix(client), &req, &|| false).unwrap_err(); - assert_eq!(err.kind(), ErrorKind::TimedOut, "{err}"); - assert!(start.elapsed() < SENTENCE_BASE + SENTENCE_PER_CHAR * 9 + REPLY_TIMEOUT * 2, "{:?}", start.elapsed()); - let got = h.join().unwrap(); - assert_eq!(speaks(&got), 1); - assert!(got.contains(&"CANCEL self".to_string()) && got.last().map(String::as_str) == Some("QUIT"), "{got:?}"); - // Playback recovers: the next sentence, on a new connection as every sentence gets, - // is spoken to its end. - let (client, srv) = UnixStream::pair().unwrap(); - let h = server(srv, Ending::End); - assert_eq!(converse(&Stream::Unix(client), &Request { text: "Next.", rate: 0, lang: None }, &|| false).unwrap(), Outcome::Done); - assert_eq!(speaks(&h.join().unwrap()), 1); - } - - #[test] - fn a_sentence_that_never_begins_halts_reading_instead_of_failing() { - // `PAUSE all` before our message began sends no event: the time queued has its own - // bound, and reaching it halts reading (Play carries on) rather than stopping with an error. - let (client, srv) = UnixStream::pair().unwrap(); - let h = server(srv, Ending::NeverBegins); - let req = Request { text: "Queued behind another client.", rate: 0, lang: None }; - let start = Instant::now(); - assert_eq!(converse(&Stream::Unix(client), &req, &|| false).unwrap(), Outcome::NotStarted); - assert!(start.elapsed() >= QUEUE_WAIT && start.elapsed() < QUEUE_WAIT + REPLY_TIMEOUT * 2, "{:?}", start.elapsed()); - assert!(h.join().unwrap().contains(&"CANCEL self".to_string())); - } - - #[test] - fn a_sentence_that_never_ends_times_out() { - // A stuck speech module: no end event ever. The wait is bounded by the sentence's length. - let (client, srv) = UnixStream::pair().unwrap(); - let h = server(srv, Ending::Hold); - let req = Request { text: "x", rate: 0, lang: None }; - let start = Instant::now(); - assert_eq!(converse(&Stream::Unix(client), &req, &|| false).unwrap_err().kind(), ErrorKind::TimedOut); - assert!(start.elapsed() < SENTENCE_BASE + SENTENCE_PER_CHAR + REPLY_TIMEOUT * 2, "{:?}", start.elapsed()); - assert!(h.join().unwrap().contains(&"CANCEL self".to_string()), "the stuck message is dropped"); - } - - #[test] - fn moving_on_cancels_only_our_own_speech() { - let (client, srv) = UnixStream::pair().unwrap(); - let h = server(srv, Ending::Hold); - let req = Request { text: "Long sentence.", rate: 0, lang: None }; - let start = Instant::now(); - let changed = || start.elapsed() > Duration::from_millis(300); - assert_eq!(converse(&Stream::Unix(client), &req, &changed).unwrap(), Outcome::Interrupted); - let got = h.join().unwrap(); - assert_eq!(speaks(&got), 1, "{got:?}"); - assert!(got.contains(&"CANCEL self".to_string()), "{got:?}"); - assert!(!got.iter().any(|l| l.eq_ignore_ascii_case("CANCEL all"))); - assert!(!got.iter().any(|l| l.starts_with("SET SELF LANGUAGE")), "no language, no command"); - } - - #[test] - fn moving_on_stops_waiting_for_a_slow_server() { - // A server that reads but never answers: Stop acts at once, not after the reply timeout. - let (client, mut srv) = UnixStream::pair().unwrap(); - let h = std::thread::spawn(move || { - let mut all = String::new(); - let _ = srv.read_to_string(&mut all); - all - }); - let req = Request { text: "x", rate: 0, lang: None }; - let start = Instant::now(); - assert_eq!(converse(&Stream::Unix(client), &req, &|| true).unwrap(), Outcome::Interrupted); - assert!(start.elapsed() < REPLY_TIMEOUT, "{:?}", start.elapsed()); - // Stopped while the server may be about to read message data: the empty line and `.` end - // any message first, so `CANCEL self` is never taken for text. - let all = h.join().unwrap(); - assert!(all.ends_with("\r\n\r\n.\r\nCANCEL self\r\nQUIT\r\n"), "{all:?}"); - } - - #[test] - fn a_server_that_only_sends_events_times_out() { - // Events and continuation lines are no reply: they can't keep a command waiting forever. - let (client, mut srv) = UnixStream::pair().unwrap(); - let h = std::thread::spawn(move || while srv.write_all(b"701 BEGIN\r\n225-1\r\n").is_ok() {}); - let req = Request { text: "x", rate: 0, lang: None }; - let start = Instant::now(); - let client = Stream::Unix(client); - assert_eq!(converse(&client, &req, &|| false).unwrap_err().kind(), ErrorKind::TimedOut); - assert!(start.elapsed() < REPLY_TIMEOUT * 3, "{:?}", start.elapsed()); - drop(client); - h.join().unwrap(); - } - - #[test] - fn a_hostile_language_tag_is_never_sent() { - let (client, srv) = UnixStream::pair().unwrap(); - let h = server(srv, Ending::End); - let req = Request { text: "x", rate: 0, lang: Some("hu\r\nCANCEL all") }; - assert_eq!(converse(&Stream::Unix(client), &req, &|| false).unwrap(), Outcome::Done); - let got = h.join().unwrap(); - assert!(!got.iter().any(|l| l.starts_with("SET SELF LANGUAGE") || l.starts_with("CANCEL")), "{got:?}"); - } - - #[test] - fn a_server_that_hangs_up_is_an_error_not_a_hang() { - let (client, mut srv) = UnixStream::pair().unwrap(); - let h = std::thread::spawn(move || { - let mut buf = [0u8; 64]; - let _ = srv.read(&mut buf); - }); - let req = Request { text: "x", rate: 0, lang: None }; - assert!(converse(&Stream::Unix(client), &req, &|| false).is_err()); - h.join().unwrap(); - } - - #[test] - fn a_start_command_that_exits_reports_how() { - let far = Instant::now() + Duration::from_secs(30); - let run = |script: &str| start_server(std::process::Command::new("sh").args(["-c", script]), far, &|| false); - assert!(matches!(run("exit 0"), Started::Exited(true))); - assert!(matches!(run("exit 1"), Started::Exited(false))); - let missing = start_server(&mut std::process::Command::new("no-such-program-for-wordcraft"), far, &|| false); - assert!(matches!(missing, Started::Missing), "{missing:?}"); - } - - /// A start command that never exits, writing its process id to `pid` first (whole, by renaming). - fn hanging_start(pid: &std::path::Path) -> std::process::Command { - let mut c = std::process::Command::new("sh"); - c.args(["-c", "echo $$ > \"$1.part\" && mv \"$1.part\" \"$1\" && exec sleep 30", "sh"]).arg(pid); - c - } - - /// A fresh path for a start command's process id. - fn pid_file(name: &str) -> std::path::PathBuf { - let p = std::env::temp_dir().join(format!("wordcraft-ssip-{}-{name}.pid", std::process::id())); - let _ = std::fs::remove_file(&p); - let _ = std::fs::remove_file(p.with_extension("pid.part")); - p - } - - /// The process id the start command wrote (whole, or still in its `.part` file when it was - /// stopped before renaming it); `None` when it was stopped before writing any. - fn written_pid(pid: &std::path::Path) -> Option { - let part = pid.with_extension("pid.part"); - let id = std::fs::read_to_string(pid).or_else(|_| std::fs::read_to_string(&part)).ok(); - let _ = std::fs::remove_file(pid); - let _ = std::fs::remove_file(part); - id.map(|id| id.trim().to_string()).filter(|id| !id.is_empty()) - } - - /// Is process `id` gone: reaped, not merely killed and left a zombie (which `kill -0` still - /// finds)? - fn gone(id: &str) -> bool { - let probe = std::process::Command::new("sh").args(["-c", "kill -0 \"$1\" 2>/dev/null", "sh", id]).status().unwrap(); - !probe.success() - } - - #[test] - fn a_start_command_that_hangs_is_stopped_at_the_deadline() { - // A start stopped before the stand-in wrote its id is tried again with a longer deadline - // (a loaded machine may not run it at once); one that never wrote it left nothing to reap. - for deadline in [Duration::from_secs(1), Duration::from_secs(3), Duration::from_secs(10)] { - let pid = pid_file("deadline"); - let begun = Instant::now(); - let got = start_server(&mut hanging_start(&pid), begun + deadline, &|| false); - assert!(matches!(got, Started::TimedOut), "{got:?}"); - assert!(begun.elapsed() < deadline + Duration::from_secs(4), "{:?}", begun.elapsed()); - if let Some(id) = written_pid(&pid) { - assert!(gone(&id), "the start command was reaped"); - return; - } - } - } - - #[test] - fn stop_ends_the_wait_for_a_start_command_at_once() { - let pid = pid_file("stop"); - let begun = Instant::now(); - // Stop once the command is surely running. - let got = start_server(&mut hanging_start(&pid), begun + Duration::from_secs(30), &|| pid.exists()); - assert!(matches!(got, Started::Interrupted), "{got:?}"); - assert!(begun.elapsed() < Duration::from_secs(5), "{:?}", begun.elapsed()); - let id = written_pid(&pid).expect("stopped only once the id was written"); - assert!(gone(&id), "the start command was reaped"); - } -} +//! Speech Dispatcher through its own protocol (SSIP) on the user's socket, for systems that have +//! Speech Dispatcher but not the `espeak-ng` command. +//! +//! Talking SSIP directly, instead of starting `spd-say`, keeps two promises `spd-say` can't: the +//! sentence is always data (pipe mode runs a line starting with `!-!` as an SSIP command, and a +//! document must never send commands), and stopping cancels only WordCraft's own speech +//! (`CANCEL self`), where `spd-say -C` would silence every client, a screen reader included. +//! +//! One connection speaks one sentence: it names the client, asks for the begin, end, cancel and +//! pause events, sets the rate and language, sends the text, and waits for the sentence's end +//! event. A sentence another client's message cancels (a screen reader's announcement) is sent +//! again after a pause, twice at most; a sentence cancelled once more or paused by another +//! client halts reading on it ([`Outcome::Halted`]), and so does one that never began +//! ([`Outcome::NotStarted`]). + +use std::io::{ErrorKind, Read, Write}; +use std::net::{TcpStream, ToSocketAddrs}; +use std::os::unix::net::UnixStream; +use std::path::PathBuf; +use std::time::{Duration, Instant}; + +/// How often a wait for the end event looks at whether reading moved on. +const POLL: Duration = Duration::from_millis(30); +/// Longest wait for a reply to a command (the server answers at once; this only bounds a stuck one). +const REPLY_TIMEOUT: Duration = if cfg!(test) { Duration::from_secs(1) } else { Duration::from_secs(5) }; +/// Longest reply line kept (a server sends short lines; this bounds a broken one). +const MAX_LINE: usize = 4096; +/// Speech Dispatcher's TCP port when `SPEECHD_ADDRESS` names none. +const DEFAULT_PORT: u16 = 6560; +/// How long a freshly started server gets to open its socket (it loads its speech modules first). +const START_WAIT: Duration = Duration::from_secs(5); +/// Longest wait for a TCP connection to one address. +const CONNECT_TIMEOUT: Duration = Duration::from_secs(1); +/// The end of a sentence is awaited this long after it began, plus [`SENTENCE_PER_CHAR`] for each +/// character: far more than the slowest reading takes, so only a stuck server reaches it. +const SENTENCE_BASE: Duration = if cfg!(test) { Duration::from_secs(1) } else { Duration::from_secs(30) }; +const SENTENCE_PER_CHAR: Duration = Duration::from_millis(400); +/// Longest wait for a queued sentence to begin (behind other clients' speech, or paused before it +/// began, which sends no pause event). +const QUEUE_WAIT: Duration = if cfg!(test) { Duration::from_secs(1) } else { Duration::from_secs(120) }; +/// The pauses before a sentence another client cancelled is sent again: the other client's +/// message gets time to end, and the server holds ours back while a higher-priority one speaks. +const RESEND_AFTER: [Duration; 2] = + if cfg!(test) { [Duration::from_millis(50), Duration::from_millis(50)] } else { [Duration::from_secs(1), Duration::from_secs(3)] }; + +/// What to say and how. +pub(super) struct Request<'a> { + pub text: &'a str, + /// Speech Dispatcher's rate, -100 to 100. + pub rate: i32, + /// A plain language tag (`hu`, `pt-BR`); anything else is left out. + pub lang: Option<&'a str>, +} + +/// How a sentence ended. +#[derive(Debug, PartialEq, Eq)] +pub(super) enum Outcome { + /// Spoken to the end. + Done, + /// Reading moved on: the sentence was cancelled. + Interrupted, + /// Another client stopped it: cancelled it again after it was sent again, or paused it. + /// Reading halts on it instead of fighting the other client. + Halted, + /// The sentence never began: queued too long, behind other clients' speech, paused before it + /// began (which sends no event), or with a speech module that doesn't start. Reading halts on it. + NotStarted, +} + +/// Where Speech Dispatcher listens. +#[derive(Debug, PartialEq, Eq)] +pub(super) enum Address { + Unix(PathBuf), + /// Host and port. + Inet(String, u16), +} + +/// The server's address, found the way Speech Dispatcher's own client library finds it: +/// `SPEECHD_ADDRESS` when set (`unix_socket[:path]`, `inet_socket[:host[:port]]`), else the socket +/// `speech-dispatcher/speechd.sock` in the runtime folder, else in the cache folder +/// (`XDG_CACHE_HOME`, else `~/.cache`). `None` for an address method it doesn't know, a port that +/// isn't a number, or no folder to look in. +pub(super) fn address(spec: Option<&str>, runtime_dir: Option, cache_home: Option, home: Option) -> Option
{ + let default_socket = || { + let set = |p: Option| p.filter(|p| !p.as_os_str().is_empty()); + set(runtime_dir) + .or_else(|| set(cache_home)) + .or_else(|| set(home).map(|h| h.join(".cache"))) + .map(|d| d.join("speech-dispatcher").join("speechd.sock")) + }; + let Some(spec) = spec.map(str::trim).filter(|a| !a.is_empty()) else { return default_socket().map(Address::Unix) }; + let (method, rest) = spec.split_once(':').unwrap_or((spec, "")); + match method { + "unix_socket" => Some(Address::Unix(if rest.is_empty() { default_socket()? } else { PathBuf::from(rest) })), + "inet_socket" => { + let mut p = rest.split(':'); + let host = p.next().filter(|h| !h.is_empty()).unwrap_or("127.0.0.1"); + let port = p.next().filter(|p| !p.is_empty()).map_or(Some(DEFAULT_PORT), |p| p.parse().ok())?; + Some(Address::Inet(host.to_string(), port)) + } + _ => None, + } +} + +/// Is `host` this machine (the only place a server may be started)? +fn is_local(host: &str) -> bool { + host.eq_ignore_ascii_case("localhost") || host.parse::().is_ok_and(|ip| ip.is_loopback()) +} + +/// A connection to Speech Dispatcher. +pub(super) enum Stream { + Unix(UnixStream), + Tcp(TcpStream), +} + +impl Stream { + fn try_clone(&self) -> std::io::Result { + Ok(match self { + Stream::Unix(s) => Stream::Unix(s.try_clone()?), + Stream::Tcp(s) => Stream::Tcp(s.try_clone()?), + }) + } + + /// Reads give up after [`POLL`] (so a wait can look at whether reading moved on), writes after + /// [`REPLY_TIMEOUT`]. + fn set_timeouts(&self) -> std::io::Result<()> { + match self { + Stream::Unix(s) => { + s.set_read_timeout(Some(POLL))?; + s.set_write_timeout(Some(REPLY_TIMEOUT)) + } + Stream::Tcp(s) => { + s.set_read_timeout(Some(POLL))?; + s.set_write_timeout(Some(REPLY_TIMEOUT)) + } + } + } +} + +impl Read for Stream { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + match self { + Stream::Unix(s) => s.read(buf), + Stream::Tcp(s) => s.read(buf), + } + } +} + +impl Write for Stream { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + match self { + Stream::Unix(s) => s.write(buf), + Stream::Tcp(s) => s.write(buf), + } + } + fn flush(&mut self) -> std::io::Result<()> { + match self { + Stream::Unix(s) => s.flush(), + Stream::Tcp(s) => s.flush(), + } + } +} + +/// Connect to `address`; between the addresses a host name resolves to, give up once `changed()`. +fn open(address: &Address, changed: &dyn Fn() -> bool) -> std::io::Result { + match address { + Address::Unix(path) => UnixStream::connect(path).map(Stream::Unix), + Address::Inet(host, port) => { + let mut last = std::io::Error::from(ErrorKind::NotFound); + for a in (host.as_str(), *port).to_socket_addrs()? { + if changed() { + return Err(moved_on()); + } + match TcpStream::connect_timeout(&a, CONNECT_TIMEOUT) { + Ok(s) => return Ok(Stream::Tcp(s)), + Err(e) => last = e, + } + } + Err(last) + } + } +} + +/// How [`connect`] went. +pub(super) enum Connection { + Open(Stream), + /// Speech Dispatcher isn't installed. + Missing, + /// It is installed but can't be reached: why. + Unreachable(String), + /// Reading moved on while the server was starting. + Interrupted, +} + +/// Connect to the user's Speech Dispatcher, starting it once if it isn't running (as `spd-say` +/// does), unless the address is another machine's. +pub(super) fn connect(changed: &dyn Fn() -> bool) -> Connection { + let env = |k: &str| std::env::var_os(k).map(PathBuf::from); + let spec = std::env::var("SPEECHD_ADDRESS").ok().filter(|s| !s.trim().is_empty()); + let Some(address) = address(spec.as_deref(), env("XDG_RUNTIME_DIR"), env("XDG_CACHE_HOME"), env("HOME")) else { + return if spec.is_some() { Connection::Unreachable("SPEECHD_ADDRESS isn't an address it understands".into()) } else { Connection::Missing }; + }; + match open(&address, changed) { + Ok(s) => return Connection::Open(s), + Err(e) if e.kind() == ErrorKind::Interrupted => return Connection::Interrupted, + Err(_) => {} + } + let mut start = std::process::Command::new("speech-dispatcher"); + start.arg("--spawn"); + // A server started for an address of its own must open it there. + match (&address, spec.is_some()) { + (_, false) => {} + (Address::Unix(path), true) => { + start.args(["--communication-method", "unix_socket", "--socket-path"]).arg(path); + } + (Address::Inet(host, port), true) if is_local(host) => { + start.args(["--communication-method", "inet_socket", "--port", &port.to_string()]); + } + (Address::Inet(..), true) => return Connection::Unreachable("nothing answers at SPEECHD_ADDRESS".into()), + } + // `--spawn` exits once it has forked the server; the wait for it is still bounded and gives way + // to Stop, so a start that hangs can't keep the reading thread (and Read Aloud with it) busy. + let until = Instant::now() + START_WAIT; + match start_server(&mut start, until, changed) { + Started::Exited(true) => {} + Started::Exited(false) => { + return Connection::Unreachable("it isn't running and didn't start (its configuration may turn starting on demand off)".into()); + } + Started::Missing => return Connection::Missing, + Started::Failed(e) => return Connection::Unreachable(format!("it couldn't be started: {e}")), + Started::Interrupted => return Connection::Interrupted, + Started::TimedOut => { + return open(&address, changed).map_or_else(|_| Connection::Unreachable("starting it didn't finish in time".into()), Connection::Open); + } + } + // The server opens its socket a moment after it starts. + loop { + if changed() { + return Connection::Interrupted; + } + if let Ok(s) = open(&address, changed) { + return Connection::Open(s); + } + if Instant::now() >= until { + return Connection::Unreachable("it started but doesn't answer".into()); + } + std::thread::sleep(Duration::from_millis(50)); + } +} + +/// How running the server's start command went. +#[derive(Debug)] +enum Started { + /// It exited, successfully or not. + Exited(bool), + /// The command isn't installed. + Missing, + /// It couldn't be run or waited for. + Failed(std::io::Error), + /// Reading moved on before it exited; it was stopped. + Interrupted, + /// It was still running at the deadline; it was stopped. + TimedOut, +} + +/// Run `command` (output discarded) and wait for it to exit, at most until `until` and only while +/// reading stays put; a command still running then is stopped and reaped. +fn start_server(command: &mut std::process::Command, until: Instant, changed: &dyn Fn() -> bool) -> Started { + use std::process::Stdio; + let mut child = match command.stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null()).spawn() { + Ok(child) => child, + Err(e) if e.kind() == ErrorKind::NotFound => return Started::Missing, + Err(e) => return Started::Failed(e), + }; + let stopped = loop { + match child.try_wait() { + Ok(Some(status)) => return Started::Exited(status.success()), + Ok(None) if changed() => break Started::Interrupted, + Ok(None) if Instant::now() >= until => break Started::TimedOut, + Ok(None) => std::thread::sleep(Duration::from_millis(20)), + Err(e) => break Started::Failed(e), + } + }; + let _ = child.kill(); + let _ = child.wait(); + stopped +} + +/// The sentence as SSIP message data: one line (a line break is a space when read aloud anyway), +/// a leading `.` doubled so it can't end the message, and the terminating `.` line. +pub(super) fn message_data(text: &str) -> String { + let line: String = text.chars().map(|c| if c == '\r' || c == '\n' { ' ' } else { c }).collect(); + let dot = if line.starts_with('.') { "." } else { "" }; + format!("{dot}{line}\r\n.\r\n") +} + +/// Reads reply lines. Bytes wait in `pending` until their line is complete, so a character split +/// across reads stays whole, and a line longer than [`MAX_LINE`] is an error, never a growing buffer. +struct Lines { + reader: R, + pending: Vec, +} + +impl Lines { + fn new(reader: R) -> Self { + Lines { reader, pending: Vec::new() } + } + + /// The next complete line without its line ending, or `Ok(None)` when none is complete after + /// one read (a timeout, or part of a line): the caller looks at its deadline and whether reading + /// moved on between calls, however slowly the bytes trickle in. + fn next(&mut self) -> std::io::Result> { + if let Some(line) = self.complete_line() { + return Ok(Some(line)); + } + if self.pending.len() > MAX_LINE { + return Err(ErrorKind::InvalidData.into()); + } + let mut buf = [0u8; 512]; + match self.reader.read(&mut buf) { + Ok(0) => Err(ErrorKind::UnexpectedEof.into()), + Ok(n) => { + self.pending.extend_from_slice(buf.get(..n).unwrap_or_default()); + Ok(self.complete_line()) + } + Err(e) if matches!(e.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut | ErrorKind::Interrupted) => Ok(None), + Err(e) => Err(e), + } + } + + /// The first complete line in `pending`, taken out of it. + fn complete_line(&mut self) -> Option { + let end = self.pending.iter().position(|&b| b == b'\n')?; + let line: Vec = self.pending.drain(..=end).collect(); + Some(String::from_utf8_lossy(&line).trim_end_matches(['\r', '\n']).to_string()) + } +} + +/// The three-digit code of a final reply line (`225 OK MESSAGE QUEUED`); `None` for a +/// continuation line (`225-12`). +fn final_code(line: &str) -> Option { + let code = line.get(..3)?.parse().ok()?; + (line.as_bytes().get(3) == Some(&b' ') || line.len() == 3).then_some(code) +} + +/// A `2xx` reply, or an error naming the code. +fn ok(code: u16) -> std::io::Result<()> { + if (200..300).contains(&code) { Ok(()) } else { Err(std::io::Error::other(format!("Speech Dispatcher refused a command ({code})"))) } +} + +/// The error a conversation stops with when reading moved on. +fn moved_on() -> std::io::Error { + std::io::Error::new(ErrorKind::Interrupted, "reading moved on") +} + +/// The begin (701), end (702), cancel (703) and pause (704) events. +const BEGIN: u16 = 701; +const END: u16 = 702; +const CANCEL: u16 = 703; +const PAUSE: u16 = 704; + +/// One conversation with the server. +struct Conversation<'a, W, R> { + w: W, + lines: Lines, + /// The last end, cancel or pause event, until it's taken. + event: Option, + /// The message just queued began. + begun: bool, + /// Whether reading moved on (checked between lines and at every read timeout). + changed: &'a dyn Fn() -> bool, +} + +impl Conversation<'_, W, R> { + /// The reply to the command just sent: its final code. An end, cancel or pause event that arrives + /// meanwhile is kept. Gives up after [`REPLY_TIMEOUT`], also when the server keeps sending + /// other lines, and as soon as reading moves on. + fn reply(&mut self) -> std::io::Result { + let start = Instant::now(); + loop { + if let Some(line) = self.lines.next()? { + match final_code(&line) { + Some(BEGIN) => self.begun = true, + Some(code @ (END | CANCEL | PAUSE)) => self.event = Some(code), + Some(code) if (700..800).contains(&code) => {} + Some(code) => return Ok(code), + None => {} + } + } + if start.elapsed() > REPLY_TIMEOUT { + return Err(ErrorKind::TimedOut.into()); + } + if (self.changed)() { + return Err(moved_on()); + } + } + } + + /// Send one command line and read its reply code. + fn command(&mut self, cmd: &str) -> std::io::Result { + self.w.write_all(cmd.as_bytes())?; + self.w.write_all(b"\r\n")?; + self.reply() + } + + /// What became of the message just queued: its end, cancel or pause event, or + /// [`Fate::NotStarted`] when it didn't begin within [`QUEUE_WAIT`]. Once it began, its end is + /// waited for until `limit` has passed (a stuck server: an error). An error as soon as reading + /// moves on. + fn fate(&mut self, limit: Duration) -> std::io::Result { + let queued = Instant::now(); + let mut began = self.begun.then(Instant::now); + loop { + match self.event.take() { + Some(END) => return Ok(Fate::End), + Some(CANCEL) => return Ok(Fate::Cancelled), + Some(_) => return Ok(Fate::Paused), + None => {} + } + if (self.changed)() { + return Err(moved_on()); + } + match began { + None if queued.elapsed() > QUEUE_WAIT => return Ok(Fate::NotStarted), + Some(t) if t.elapsed() > limit => return Err(std::io::Error::new(ErrorKind::TimedOut, "the sentence didn't end")), + _ => {} + } + if let Some(line) = self.lines.next()? { + match final_code(&line) { + Some(BEGIN) => { + began.get_or_insert_with(Instant::now); + } + Some(code @ (END | CANCEL | PAUSE)) => self.event = Some(code), + _ => {} + } + } + } + } + + /// Wait `pause`, reading what the server sends meanwhile; an error as soon as reading moves on. + fn wait(&mut self, pause: Duration) -> std::io::Result<()> { + let start = Instant::now(); + while start.elapsed() < pause { + if (self.changed)() { + return Err(moved_on()); + } + self.lines.next()?; + } + Ok(()) + } + + /// Set up the client, say the sentence, and wait for it to end. Only [`converse`] cancels our + /// own speech, and it stops waiting first, so a cancel or pause event comes from another + /// client. A cancel is most often another client's message, a screen reader's announcement: + /// the sentence is sent again after a pause ([`RESEND_AFTER`]), when the announcement has + /// ended or the server holds ours back until it has. Cancelled again after that, paused, or + /// never begun, the sentence halts reading. + fn speak(&mut self, req: &Request<'_>) -> std::io::Result { + ok(self.command("SET SELF CLIENT_NAME user:wordcraft:read-aloud")?)?; + ok(self.command("SET SELF NOTIFICATION BEGIN on")?)?; + ok(self.command("SET SELF NOTIFICATION END on")?)?; + ok(self.command("SET SELF NOTIFICATION CANCEL on")?)?; + ok(self.command("SET SELF NOTIFICATION PAUSE on")?)?; + ok(self.command(&format!("SET SELF RATE {}", req.rate))?)?; + // A language the server doesn't know leaves its default: no error. The tag is checked + // again here, at the edge of the protocol: it goes on a command line. + if let Some(lang) = req.lang.and_then(super::plain_tag) { + self.command(&format!("SET SELF LANGUAGE {lang}"))?; + } + let chars = u32::try_from(req.text.chars().count()).unwrap_or(u32::MAX); + let limit = SENTENCE_BASE.saturating_add(SENTENCE_PER_CHAR.saturating_mul(chars)); + let mut resends = RESEND_AFTER.iter(); + loop { + self.event = None; + self.begun = false; + ok(self.command("SPEAK")?)?; + self.w.write_all(message_data(req.text).as_bytes())?; + ok(self.reply()?)?; + match self.fate(limit)? { + Fate::End => return Ok(Outcome::Done), + Fate::Cancelled => match resends.next() { + Some(pause) => self.wait(*pause)?, + None => return Ok(Outcome::Halted), + }, + Fate::Paused => return Ok(Outcome::Halted), + Fate::NotStarted => return Ok(Outcome::NotStarted), + } + } + } +} + +/// What became of a queued message. +enum Fate { + End, + Cancelled, + Paused, + NotStarted, +} + +/// The last words after a sentence that didn't finish. +const CLOSE: &[u8] = b"\r\n.\r\nCANCEL self\r\nQUIT\r\n"; + +/// Speak `req` over `stream`, cancelling it when `changed()`. +pub(super) fn converse(stream: &Stream, req: &Request<'_>, changed: &dyn Fn() -> bool) -> std::io::Result { + stream.set_timeouts()?; + let mut c = Conversation { w: stream.try_clone()?, lines: Lines::new(stream.try_clone()?), event: None, begun: false, changed }; + let result = c.speak(req); + // The last words wait for no answer: a server that stopped answering mustn't hold up Stop. + // After anything but a finished sentence our message may still be queued, or the server may + // be reading message data: an empty line and `.` end a message being sent (outside one they + // are two refused commands), and `CANCEL self` drops ours. + let done = matches!(result, Ok(Outcome::Done)); + let _ = c.w.write_all(if done { b"QUIT\r\n" } else { CLOSE }); + match result { + Err(e) if e.kind() == ErrorKind::Interrupted => Ok(Outcome::Interrupted), + r => r, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::{BufRead, BufReader}; + + #[test] + fn the_address_follows_the_environment() { + let unix = |p: &str| Some(Address::Unix(PathBuf::from(p))); + let run = || Some(PathBuf::from("/run/user/1")); + assert_eq!(address(Some("unix_socket:/tmp/s.sock"), None, None, None), unix("/tmp/s.sock")); + assert_eq!(address(None, run(), None, None), unix("/run/user/1/speech-dispatcher/speechd.sock")); + // `unix_socket` alone is the default socket, as in Speech Dispatcher's client library. + assert_eq!(address(Some("unix_socket"), run(), None, None), unix("/run/user/1/speech-dispatcher/speechd.sock")); + assert_eq!(address(Some("unix_socket:"), None, None, Some("/home/u".into())), unix("/home/u/.cache/speech-dispatcher/speechd.sock")); + // Without a runtime folder: the cache folder, `XDG_CACHE_HOME` first. + assert_eq!(address(Some(" "), None, None, Some("/home/u".into())), unix("/home/u/.cache/speech-dispatcher/speechd.sock")); + assert_eq!(address(None, Some("".into()), Some("/c".into()), Some("/home/u".into())), unix("/c/speech-dispatcher/speechd.sock")); + // A TCP address, with the default host and port filled in. + assert_eq!(address(Some("inet_socket:localhost:6561"), run(), None, None), Some(Address::Inet("localhost".into(), 6561))); + assert_eq!(address(Some("inet_socket"), None, None, None), Some(Address::Inet("127.0.0.1".into(), DEFAULT_PORT))); + assert_eq!(address(Some("inet_socket:host"), None, None, None), Some(Address::Inet("host".into(), DEFAULT_PORT))); + assert_eq!(address(Some("inet_socket:host:port"), None, None, None), None); + assert_eq!(address(Some("inet_socket:host:99999"), None, None, None), None); + assert_eq!(address(Some("carrier_pigeon:x"), run(), None, None), None); + assert_eq!(address(None, None, None, None), None); + assert!(is_local("localhost") && is_local("127.0.0.1") && is_local("::1")); + assert!(!is_local("example.org") && !is_local("192.0.2.1")); + } + + #[test] + fn document_text_is_always_message_data() { + // `spd-say -e` would run this line as an SSIP command; here it's spoken text. + assert_eq!(message_data("!-!SET SELF RATE 50"), "!-!SET SELF RATE 50\r\n.\r\n"); + // A line of a single `.` would end the message early, and a line break would start a + // new line that could be one. + assert_eq!(message_data(".\r\nQUIT"), ".. QUIT\r\n.\r\n"); + assert_eq!(message_data("a\n.\nb"), "a . b\r\n.\r\n"); + assert_eq!(final_code("225 OK MESSAGE QUEUED"), Some(225)); + assert_eq!(final_code("225-17"), None); + assert_eq!(final_code("x"), None); + } + + /// Hands out the given reads one by one, then reports a timeout. + struct Reads(std::collections::VecDeque>); + + impl Read for Reads { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + match self.0.pop_front() { + Some(chunk) if !chunk.is_empty() => { + let n = chunk.len().min(buf.len()); + buf[..n].copy_from_slice(&chunk[..n]); + if n < chunk.len() { + self.0.push_front(chunk[n..].to_vec()); + } + Ok(n) + } + _ => Err(ErrorKind::WouldBlock.into()), + } + } + } + + #[test] + fn a_character_split_by_a_read_timeout_stays_whole() { + // `é` is C3 A9; the read times out (the empty read) between its two bytes. + let mut lines = Lines::new(Reads(vec![b"249-Caf\xC3".to_vec(), Vec::new(), b"\xA9\tfr\tnone\r\n702 END\r\n".to_vec()].into())); + assert_eq!(lines.next().unwrap(), None, "half a line"); + assert_eq!(lines.next().unwrap(), None, "the timeout"); + assert_eq!(lines.next().unwrap().as_deref(), Some("249-Café\tfr\tnone")); + assert_eq!(lines.next().unwrap().as_deref(), Some("702 END")); + assert_eq!(lines.next().unwrap(), None); + } + + #[test] + fn a_line_without_an_end_is_an_error_not_a_growing_buffer() { + // A peer that never stops sending and never ends the line. + let mut lines = Lines::new(std::io::repeat(b'x')); + let mut calls = 0; + let err = loop { + calls += 1; + assert!(calls < 100, "never gave up"); + match lines.next() { + Ok(None) => {} + Ok(Some(l)) => panic!("{l}"), + Err(e) => break e, + } + }; + assert_eq!(err.kind(), ErrorKind::InvalidData); + assert!(lines.pending.len() <= MAX_LINE + 512); + } + + #[test] + fn a_trickle_of_bytes_hands_control_back_after_each_read() { + // One byte per read and never a line end: every call returns, so the caller can look at + // its deadline and at whether reading moved on. + let mut lines = Lines::new(Reads(vec![b"2".to_vec(), b"0".to_vec(), b"0".to_vec(), b" OK\r\n".to_vec()].into())); + assert_eq!(lines.next().unwrap(), None); + assert_eq!(lines.next().unwrap(), None); + assert_eq!(lines.next().unwrap(), None); + assert_eq!(lines.next().unwrap().as_deref(), Some("200 OK")); + } + + /// How the stand-in server ends a message. + #[derive(Clone, Copy)] + enum Ending { + /// The end event. + End, + /// Nothing until `CANCEL self`. + Hold, + /// Cancelled by another client every time. + CancelledByOther, + /// Cancelled by another client the first time only. + CancelledOnce, + /// Queued and never begun (paused before it began: no event at all). + NeverBegins, + /// Paused by another client (`PAUSE all`). + PausedByOther, + } + + /// A Speech Dispatcher stand-in: answers every command with `2xx` and ends each message as + /// `ending` says. Returns the lines it received. + fn server(mut s: UnixStream, ending: Ending) -> std::thread::JoinHandle> { + std::thread::spawn(move || { + let mut got = Vec::new(); + let mut r = BufReader::new(s.try_clone().unwrap()); + let mut in_data = false; + let mut messages = 0; + loop { + let mut line = String::new(); + if r.read_line(&mut line).unwrap_or(0) == 0 { + return got; + } + let line = line.trim_end_matches(['\r', '\n']).to_string(); + got.push(line.clone()); + let reply: &[u8] = if in_data { + if line != "." { + continue; + } + in_data = false; + messages += 1; + match ending { + Ending::CancelledOnce if messages == 1 => { + b"225-1\r\n225 OK MESSAGE QUEUED\r\n701 BEGIN\r\n703-1\r\n703-7\r\n703 CANCELED\r\n" + } + Ending::CancelledOnce => b"225-1\r\n225 OK MESSAGE QUEUED\r\n701 BEGIN\r\n702-1\r\n702-7\r\n702 END\r\n", + Ending::NeverBegins => b"225-1\r\n225 OK MESSAGE QUEUED\r\n", + Ending::CancelledByOther => b"225-1\r\n225 OK MESSAGE QUEUED\r\n701 BEGIN\r\n703-1\r\n703-7\r\n703 CANCELED\r\n", + Ending::PausedByOther => b"225-1\r\n225 OK MESSAGE QUEUED\r\n701 BEGIN\r\n704-1\r\n704-7\r\n704 PAUSED\r\n", + Ending::End => b"225-1\r\n225 OK MESSAGE QUEUED\r\n702-1\r\n702-7\r\n702 END\r\n", + Ending::Hold => b"225-1\r\n225 OK MESSAGE QUEUED\r\n701 BEGIN\r\n", + } + } else if line == "SPEAK" { + in_data = true; + b"230 OK RECEIVING DATA\r\n" + } else if line == "CANCEL self" { + b"703-1\r\n703-7\r\n703 CANCELED\r\n210 OK CANCELLED\r\n" + } else if line == "QUIT" { + let _ = s.write_all(b"231 HAPPY HACKING\r\n"); + return got; + } else { + b"200 OK\r\n" + }; + // The client doesn't wait for the answers to its last words. + let _ = s.write_all(reply); + } + }) + } + + fn speaks(got: &[String]) -> usize { + got.iter().filter(|l| *l == "SPEAK").count() + } + + #[test] + fn a_sentence_is_spoken_to_its_end() { + let (client, srv) = UnixStream::pair().unwrap(); + let h = server(srv, Ending::End); + let req = Request { text: "!-!QUIT", rate: 50, lang: Some("pt-BR") }; + assert_eq!(converse(&Stream::Unix(client), &req, &|| false).unwrap(), Outcome::Done); + let got = h.join().unwrap(); + assert!(got.contains(&"SET SELF RATE 50".to_string()), "{got:?}"); + // The region stays: Speech Dispatcher's voices are per region (`en-GB`, `pt-BR`). + assert!(got.contains(&"SET SELF LANGUAGE pt-BR".to_string()), "{got:?}"); + let at = got.iter().position(|l| l == "SPEAK").unwrap(); + assert_eq!(got[at + 1..at + 3], ["!-!QUIT".to_string(), ".".to_string()], "the text went as data"); + assert_eq!(speaks(&got), 1); + assert!(!got.iter().any(|l| l.starts_with("CANCEL")), "nothing cancelled"); + assert_eq!(got.last().map(String::as_str), Some("QUIT")); + } + + #[test] + fn a_sentence_a_screen_reader_cuts_off_is_said_again_after_a_pause() { + // One announcement: the sentence is said again once it has had time to end. + let (client, srv) = UnixStream::pair().unwrap(); + let h = server(srv, Ending::CancelledOnce); + let req = Request { text: "Interrupted by a screen reader.", rate: 0, lang: None }; + let start = Instant::now(); + assert_eq!(converse(&Stream::Unix(client), &req, &|| false).unwrap(), Outcome::Done); + assert!(start.elapsed() >= RESEND_AFTER[0], "it waited before saying it again"); + let got = h.join().unwrap(); + assert_eq!(speaks(&got), 2, "{got:?}"); + assert!(got.contains(&"SET SELF NOTIFICATION BEGIN on".to_string()), "{got:?}"); + assert!(!got.iter().any(|l| l.starts_with("CANCEL")), "{got:?}"); + } + + #[test] + fn a_sentence_another_client_keeps_stopping_halts_reading() { + // Cancelled every time (another client stopping everything, a failing module): two + // tries more, then reading halts, never moving on silently. A pause halts at once. + for (ending, sent) in [(Ending::CancelledByOther, 1 + RESEND_AFTER.len()), (Ending::PausedByOther, 1)] { + let (client, srv) = UnixStream::pair().unwrap(); + let h = server(srv, ending); + let req = Request { text: "Interrupted by a screen reader.", rate: 0, lang: None }; + assert_eq!(converse(&Stream::Unix(client), &req, &|| false).unwrap(), Outcome::Halted); + let got = h.join().unwrap(); + assert_eq!(speaks(&got), sent, "{got:?}"); + assert!(got.contains(&"SET SELF NOTIFICATION PAUSE on".to_string()), "{got:?}"); + // Only our own message is dropped. + assert!(got.contains(&"CANCEL self".to_string()) && !got.iter().any(|l| l.eq_ignore_ascii_case("CANCEL all")), "{got:?}"); + } + } + + #[test] + fn a_withheld_end_times_out_and_the_next_sentence_plays() { + // The server accepts the sentence, begins it, stays connected and never reports its end: + // the wait gives up with an error, drops the message (`CANCEL self`), and closes. + let (client, srv) = UnixStream::pair().unwrap(); + let h = server(srv, Ending::Hold); + let req = Request { text: "Withheld.", rate: 0, lang: None }; + let start = Instant::now(); + let err = converse(&Stream::Unix(client), &req, &|| false).unwrap_err(); + assert_eq!(err.kind(), ErrorKind::TimedOut, "{err}"); + assert!(start.elapsed() < SENTENCE_BASE + SENTENCE_PER_CHAR * 9 + REPLY_TIMEOUT * 2, "{:?}", start.elapsed()); + let got = h.join().unwrap(); + assert_eq!(speaks(&got), 1); + assert!(got.contains(&"CANCEL self".to_string()) && got.last().map(String::as_str) == Some("QUIT"), "{got:?}"); + // Playback recovers: the next sentence, on a new connection as every sentence gets, + // is spoken to its end. + let (client, srv) = UnixStream::pair().unwrap(); + let h = server(srv, Ending::End); + assert_eq!(converse(&Stream::Unix(client), &Request { text: "Next.", rate: 0, lang: None }, &|| false).unwrap(), Outcome::Done); + assert_eq!(speaks(&h.join().unwrap()), 1); + } + + #[test] + fn a_sentence_that_never_begins_halts_reading_instead_of_failing() { + // `PAUSE all` before our message began sends no event: the time queued has its own + // bound, and reaching it halts reading (Play carries on) rather than stopping with an error. + let (client, srv) = UnixStream::pair().unwrap(); + let h = server(srv, Ending::NeverBegins); + let req = Request { text: "Queued behind another client.", rate: 0, lang: None }; + let start = Instant::now(); + assert_eq!(converse(&Stream::Unix(client), &req, &|| false).unwrap(), Outcome::NotStarted); + assert!(start.elapsed() >= QUEUE_WAIT && start.elapsed() < QUEUE_WAIT + REPLY_TIMEOUT * 2, "{:?}", start.elapsed()); + assert!(h.join().unwrap().contains(&"CANCEL self".to_string())); + } + + #[test] + fn a_sentence_that_never_ends_times_out() { + // A stuck speech module: no end event ever. The wait is bounded by the sentence's length. + let (client, srv) = UnixStream::pair().unwrap(); + let h = server(srv, Ending::Hold); + let req = Request { text: "x", rate: 0, lang: None }; + let start = Instant::now(); + assert_eq!(converse(&Stream::Unix(client), &req, &|| false).unwrap_err().kind(), ErrorKind::TimedOut); + assert!(start.elapsed() < SENTENCE_BASE + SENTENCE_PER_CHAR + REPLY_TIMEOUT * 2, "{:?}", start.elapsed()); + assert!(h.join().unwrap().contains(&"CANCEL self".to_string()), "the stuck message is dropped"); + } + + #[test] + fn moving_on_cancels_only_our_own_speech() { + let (client, srv) = UnixStream::pair().unwrap(); + let h = server(srv, Ending::Hold); + let req = Request { text: "Long sentence.", rate: 0, lang: None }; + let start = Instant::now(); + let changed = || start.elapsed() > Duration::from_millis(300); + assert_eq!(converse(&Stream::Unix(client), &req, &changed).unwrap(), Outcome::Interrupted); + let got = h.join().unwrap(); + assert_eq!(speaks(&got), 1, "{got:?}"); + assert!(got.contains(&"CANCEL self".to_string()), "{got:?}"); + assert!(!got.iter().any(|l| l.eq_ignore_ascii_case("CANCEL all"))); + assert!(!got.iter().any(|l| l.starts_with("SET SELF LANGUAGE")), "no language, no command"); + } + + #[test] + fn moving_on_stops_waiting_for_a_slow_server() { + // A server that reads but never answers: Stop acts at once, not after the reply timeout. + let (client, mut srv) = UnixStream::pair().unwrap(); + let h = std::thread::spawn(move || { + let mut all = String::new(); + let _ = srv.read_to_string(&mut all); + all + }); + let req = Request { text: "x", rate: 0, lang: None }; + let start = Instant::now(); + assert_eq!(converse(&Stream::Unix(client), &req, &|| true).unwrap(), Outcome::Interrupted); + assert!(start.elapsed() < REPLY_TIMEOUT, "{:?}", start.elapsed()); + // Stopped while the server may be about to read message data: the empty line and `.` end + // any message first, so `CANCEL self` is never taken for text. + let all = h.join().unwrap(); + assert!(all.ends_with("\r\n\r\n.\r\nCANCEL self\r\nQUIT\r\n"), "{all:?}"); + } + + #[test] + fn a_server_that_only_sends_events_times_out() { + // Events and continuation lines are no reply: they can't keep a command waiting forever. + let (client, mut srv) = UnixStream::pair().unwrap(); + let h = std::thread::spawn(move || while srv.write_all(b"701 BEGIN\r\n225-1\r\n").is_ok() {}); + let req = Request { text: "x", rate: 0, lang: None }; + let start = Instant::now(); + let client = Stream::Unix(client); + assert_eq!(converse(&client, &req, &|| false).unwrap_err().kind(), ErrorKind::TimedOut); + assert!(start.elapsed() < REPLY_TIMEOUT * 3, "{:?}", start.elapsed()); + drop(client); + h.join().unwrap(); + } + + #[test] + fn a_hostile_language_tag_is_never_sent() { + let (client, srv) = UnixStream::pair().unwrap(); + let h = server(srv, Ending::End); + let req = Request { text: "x", rate: 0, lang: Some("hu\r\nCANCEL all") }; + assert_eq!(converse(&Stream::Unix(client), &req, &|| false).unwrap(), Outcome::Done); + let got = h.join().unwrap(); + assert!(!got.iter().any(|l| l.starts_with("SET SELF LANGUAGE") || l.starts_with("CANCEL")), "{got:?}"); + } + + #[test] + fn a_server_that_hangs_up_is_an_error_not_a_hang() { + let (client, mut srv) = UnixStream::pair().unwrap(); + let h = std::thread::spawn(move || { + let mut buf = [0u8; 64]; + let _ = srv.read(&mut buf); + }); + let req = Request { text: "x", rate: 0, lang: None }; + assert!(converse(&Stream::Unix(client), &req, &|| false).is_err()); + h.join().unwrap(); + } + + #[test] + fn a_start_command_that_exits_reports_how() { + let far = Instant::now() + Duration::from_secs(30); + let run = |script: &str| start_server(std::process::Command::new("sh").args(["-c", script]), far, &|| false); + assert!(matches!(run("exit 0"), Started::Exited(true))); + assert!(matches!(run("exit 1"), Started::Exited(false))); + let missing = start_server(&mut std::process::Command::new("no-such-program-for-wordcraft"), far, &|| false); + assert!(matches!(missing, Started::Missing), "{missing:?}"); + } + + /// A start command that never exits, writing its process id to `pid` first (whole, by renaming). + fn hanging_start(pid: &std::path::Path) -> std::process::Command { + let mut c = std::process::Command::new("sh"); + c.args(["-c", "echo $$ > \"$1.part\" && mv \"$1.part\" \"$1\" && exec sleep 30", "sh"]).arg(pid); + c + } + + /// A fresh path for a start command's process id. + fn pid_file(name: &str) -> std::path::PathBuf { + let p = std::env::temp_dir().join(format!("wordcraft-ssip-{}-{name}.pid", std::process::id())); + let _ = std::fs::remove_file(&p); + let _ = std::fs::remove_file(p.with_extension("pid.part")); + p + } + + /// The process id the start command wrote (whole, or still in its `.part` file when it was + /// stopped before renaming it); `None` when it was stopped before writing any. + fn written_pid(pid: &std::path::Path) -> Option { + let part = pid.with_extension("pid.part"); + let id = std::fs::read_to_string(pid).or_else(|_| std::fs::read_to_string(&part)).ok(); + let _ = std::fs::remove_file(pid); + let _ = std::fs::remove_file(part); + id.map(|id| id.trim().to_string()).filter(|id| !id.is_empty()) + } + + /// Is process `id` gone: reaped, not merely killed and left a zombie (which `kill -0` still + /// finds)? + fn gone(id: &str) -> bool { + let probe = std::process::Command::new("sh").args(["-c", "kill -0 \"$1\" 2>/dev/null", "sh", id]).status().unwrap(); + !probe.success() + } + + #[test] + fn a_start_command_that_hangs_is_stopped_at_the_deadline() { + // A start stopped before the stand-in wrote its id is tried again with a longer deadline + // (a loaded machine may not run it at once); one that never wrote it left nothing to reap. + for deadline in [Duration::from_secs(1), Duration::from_secs(3), Duration::from_secs(10)] { + let pid = pid_file("deadline"); + let begun = Instant::now(); + let got = start_server(&mut hanging_start(&pid), begun + deadline, &|| false); + assert!(matches!(got, Started::TimedOut), "{got:?}"); + assert!(begun.elapsed() < deadline + Duration::from_secs(4), "{:?}", begun.elapsed()); + if let Some(id) = written_pid(&pid) { + assert!(gone(&id), "the start command was reaped"); + return; + } + } + } + + #[test] + fn stop_ends_the_wait_for_a_start_command_at_once() { + let pid = pid_file("stop"); + let begun = Instant::now(); + // Stop once the command is surely running. + let got = start_server(&mut hanging_start(&pid), begun + Duration::from_secs(30), &|| pid.exists()); + assert!(matches!(got, Started::Interrupted), "{got:?}"); + assert!(begun.elapsed() < Duration::from_secs(5), "{:?}", begun.elapsed()); + let id = written_pid(&pid).expect("stopped only once the id was written"); + assert!(gone(&id), "the start command was reaped"); + } +} diff --git a/crates/engine/src/speech/tests.rs b/crates/engine/src/speech/tests.rs index 9433326a78..26d8481c94 100644 --- a/crates/engine/src/speech/tests.rs +++ b/crates/engine/src/speech/tests.rs @@ -1,500 +1,500 @@ -use super::*; - -fn texts(v: &[Utterance]) -> Vec<&str> { - v.iter().map(|u| u.text.as_str()).collect() -} - -#[test] -fn powershell_is_launched_from_the_system_folder() { - let tail = std::path::Path::new("System32").join("WindowsPowerShell").join("v1.0").join("powershell.exe"); - let p = windows_powershell(Some("D:\\Win".into())); - assert!(p.starts_with("D:\\Win") && p.ends_with(&tail), "{}", p.display()); - // No SystemRoot, or an empty one: the default Windows folder. - for root in [None, Some("".into())] { - let p = windows_powershell(root); - assert!(p.starts_with("C:\\Windows") && p.ends_with(&tail), "{}", p.display()); - } -} - -#[test] -fn powershell_starts_without_a_console_window() { - // The flags `speak` passes to `creation_flags` (std can't read them back from the - // `Command`): Win32's CREATE_NO_WINDOW (0x0800_0000), or every sentence flashes a - // console (#1122). - let w = windows_speech(Some("D:\\Win".into()), 1.0, None); - assert_eq!(w.creation_flags, 0x0800_0000); - assert_eq!(w.program, windows_powershell(Some("D:\\Win".into()))); - assert_eq!(w.args[..3], ["-NoProfile", "-NonInteractive", "-Command"]); - assert!(w.args[3].contains("$s.Rate = 0;"), "{}", w.args[3]); - assert!(windows_speech(None, 3.0, None).args[3].contains("$s.Rate = 10;")); -} - -#[test] -fn splits_sentences_but_not_abbreviations() { - let t = "Smith et al. found 3.5 times more (see Fig. 2). J. Smith agreed! Did they? Yes… Pick one, e.g. this one."; - let spans = split_sentences(t); - let got: Vec = spans.iter().map(|(a, b)| t.chars().skip(*a).take(b - a).collect()).collect(); - assert_eq!(got, ["Smith et al. found 3.5 times more (see Fig. 2).", "J. Smith agreed!", "Did they?", "Yes…", "Pick one, e.g. this one."]); -} - -#[test] -fn long_sentences_are_split() { - let t = "word ".repeat(400); - let spans = split_sentences(&t); - assert!(spans.len() >= 3); - assert!(spans.iter().all(|(a, b)| b - a <= MAX_SENTENCE + 5)); -} - -/// A word with no space to split at, or a run of punctuation, is still cut into utterances of -/// at most `MAX_UTTERANCE` characters, covering all of it (#3680). -#[test] -fn sentences_without_spaces_are_split() { - for t in ["x".repeat(10_000), "!".repeat(10_000), format!("Go {} now.", "a.".repeat(5_000))] { - let spans = split_sentences(&t); - assert!(spans.len() >= 8, "{} spans", spans.len()); - assert!(spans.iter().all(|(a, b)| a < b && b - a <= MAX_UTTERANCE)); - assert_eq!(spans.first().map(|s| s.0), Some(0)); - assert_eq!(spans.last().map(|s| s.1), Some(t.chars().count())); - assert!(spans.windows(2).all(|w| w[0].1 <= w[1].0)); - } -} - -#[test] -fn tidy_removes_citation_leftovers() { - assert_eq!(tidy("as shown ( ) ."), "as shown."); - assert_eq!(tidy("as shown [] , and"), "as shown, and"); - assert_eq!(tidy(" a ( ( [ ] ) ) b ( x ) [ ) ] ; "), "a b ( x ) [ ) ];"); -} - -/// The tidy that ran replacements until nothing changed, kept to check the one-pass tidy gives -/// the same text. -fn tidy_by_replacing(s: &str) -> String { - let mut t: String = s.split_whitespace().collect::>().join(" "); - loop { - let before = t.len(); - for (from, to) in [("()", ""), ("[]", ""), ("( )", ""), ("[ ]", ""), (" .", "."), (" ,", ","), (" ;", ";"), (" :", ":"), (" ", " ")] { - t = t.replace(from, to); - } - if t.len() == before { - break; - } - } - t.trim().to_string() -} - -#[test] -fn tidy_matches_replacing_until_nothing_changes() { - let alphabet = ['(', ')', '[', ']', ' ', ' ', '\t', '\u{a0}', '.', ',', ';', ':', 'a', 'é']; - let mut seed: u64 = 0x3680; - for _ in 0..20_000 { - seed = seed.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1_442_695_040_888_963_407); - let len = (seed >> 59) as usize + 1; - let s: String = (0..len) - .map(|i| { - let r = seed.rotate_left(5 * i as u32 + 1) ^ (i as u64).wrapping_mul(0x9e37_79b9_7f4a_7c15); - alphabet[(r % alphabet.len() as u64) as usize] - }) - .collect(); - assert_eq!(tidy(&s), tidy_by_replacing(&s), "{s:?}"); - } -} - -/// Nested empty brackets are removed in one pass: 40,000 pairs took seconds when each pass -/// removed one level (#3680). -#[test] -fn tidy_is_linear_on_nested_brackets() { - let n = 40_000; - let started = std::time::Instant::now(); - assert_eq!(tidy(&format!("a {}{} b", "( [".repeat(n), "] )".repeat(n))), "a b"); - assert_eq!(tidy(&format!("{}x{}", "(".repeat(n), ")".repeat(n))).len(), 2 * n + 1); - // Milliseconds here; `tidy_by_replacing` (the old tidy) takes about 5 minutes on this input. - assert!(started.elapsed() < std::time::Duration::from_secs(10), "{:?}", started.elapsed()); -} - -#[test] -fn citation_codes() { - assert!(is_citation_code(" ADDIN ZOTERO_ITEM CSL_CITATION {}")); - assert!(is_citation_code("ADDIN ZOTERO_BIBL {\"uncited\":[]} CSL_BIBLIOGRAPHY")); - assert!(is_citation_code("ADDIN EN.CITE ")); - assert!(is_citation_code("ADDIN CSL_CITATION {}")); - assert!(is_citation_code("CITATION Smi20 \\l 1033")); - assert!(is_citation_code("BIBLIOGRAPHY")); - assert!(!is_citation_code("ADDIN ZOTERO_PREF_1")); - assert!(!is_citation_code("PAGE")); - assert!(!is_citation_code("")); - assert!(!is_citation_code("ADD")); -} - -#[test] -fn plain_document_sentences() { - let doc = Document::from_text("First one. Second one.\nThird paragraph"); - let s = sentences(&doc, &Pos::body(0, 0), None, true); - assert_eq!(texts(&s), ["First one.", "Second one.", "Third paragraph"]); - assert_eq!(s[1].start, Pos::body(0, 11)); - assert_eq!(s[1].end, Pos::body(0, 22)); - // From the caret, and only up to the end of a selection. - let s = sentences(&doc, &Pos::body(0, 11), Some(&Pos::body(1, 5)), true); - assert_eq!(texts(&s), ["Second one.", "Third"]); -} - -#[test] -fn player_moves_through_sentences() { - let doc = Document::from_text("One. Two. Three."); - let mut r = ReadAloud::default(); - r.backend = Backend::Hold; - r.start(sentences(&doc, &Pos::body(0, 0), None, true)); - assert_eq!(r.state(), State::Playing); - assert_eq!(r.current().map(|u| u.text), Some("One.".into())); - r.skip(1); - r.skip(5); - assert_eq!(r.progress(), (2, 3)); - r.skip(-10); - assert_eq!(r.progress(), (0, 3)); - r.play_pause(); - assert_eq!(r.state(), State::Paused); - r.set_rate(99.0); - assert_eq!(r.rate(), MAX_RATE); - r.set_rate(f32::NAN); - assert_eq!(r.rate(), 1.0); - r.play_pause(); - assert_eq!(r.state(), State::Playing); - r.stop(); - assert_eq!(r.state(), State::Stopped); - assert!(!r.open); - assert!(r.current().is_none()); -} - -#[test] -fn player_finishes_on_its_own() { - let doc = Document::from_text("One. Two. Three."); - let mut r = ReadAloud::default(); - r.backend = Backend::Instant; - r.start(sentences(&doc, &Pos::body(0, 0), None, true)); - for _ in 0..200 { - if r.state() == State::Stopped { - break; - } - std::thread::sleep(std::time::Duration::from_millis(5)); - } - assert_eq!(r.state(), State::Stopped); - assert_eq!(r.progress(), (3, 3)); - assert!(r.open, "the player stays open after the last sentence"); - // Play again starts from the top. - r.backend = Backend::Hold; - r.play_pause(); - assert_eq!(r.progress().0, 0); - assert_eq!(r.state(), State::Playing); -} - -#[test] -fn every_reading_counts_as_a_new_one() { - // The worker forgets what it learnt about the speech programs when a reading starts, even - // when the same worker thread carries on (a pause, then Read Aloud again). - let mut r = ReadAloud::default(); - r.backend = Backend::Hold; - let readings = |r: &ReadAloud| lock(&r.shared.0).readings; - let doc = Document::from_text("One. Two."); - r.start(sentences(&doc, &Pos::body(0, 0), None, true)); - let first = readings(&r); - r.play_pause(); - r.start(sentences(&doc, &Pos::body(0, 0), None, true)); - assert_eq!(readings(&r), first + 1); - r.play_pause(); - r.play_pause(); - assert_eq!(readings(&r), first + 1, "pause and play are the same reading"); - r.stop(); -} - -#[cfg(all(unix, not(target_os = "macos")))] -#[test] -fn a_sentence_that_never_began_says_so_not_another_program() { - // No begin event within the queue bound: no other program need be involved, so the - // message names the sentence not starting, not another program stopping it. - let halted = |o| match dispatcher_spoken(Ok(o)) { - Spoken::Halted(m) => m, - _ => String::new(), - }; - assert_eq!(halted(ssip::Outcome::NotStarted), NOT_STARTED); - assert!(!NOT_STARTED.contains("another program")); - assert_eq!(halted(ssip::Outcome::Halted), HALTED_BY_OTHER); -} - -#[test] -fn play_after_a_failed_sentence_plays_it_again() { - // A sentence whose end never came stops reading with an error (as the worker does on a - // timeout); Play starts it again and the message goes. - let mut r = ReadAloud::default(); - r.backend = Backend::Hold; - r.start(sentences(&Document::from_text("One. Two."), &Pos::body(0, 0), None, true)); - r.skip(1); - { - let mut s = lock(&r.shared.0); - s.state = State::Stopped; - s.error = Some("Read Aloud couldn't use Speech Dispatcher: the sentence didn't end".into()); - } - r.play_pause(); - assert_eq!((r.state(), r.progress().0, r.error()), (State::Playing, 1, None)); - r.stop(); -} - -#[test] -fn play_after_a_halt_carries_on_and_clears_the_message() { - // Another program stopped the sentence: reading paused on it with a message, and Play - // goes on from the same sentence. - let mut r = ReadAloud::default(); - r.backend = Backend::Hold; - r.start(sentences(&Document::from_text("One. Two."), &Pos::body(0, 0), None, true)); - { - let mut s = lock(&r.shared.0); - s.state = State::Paused; - s.error = Some("stopped by another program".into()); - } - r.play_pause(); - assert_eq!(r.state(), State::Playing); - assert_eq!(r.progress().0, 0); - assert_eq!(r.error(), None); - r.stop(); -} - -#[test] -fn empty_queue_does_not_play() { - let mut r = ReadAloud::default(); - r.start(Vec::new()); - assert_eq!(r.state(), State::Stopped); - r.skip(1); - r.skip(-1); - r.play_pause(); - assert_eq!(r.state(), State::Stopped); -} -#[test] -fn languages_are_detected_from_the_script() { - for (text, tag) in [ - ("Привет как твои дела?", "ru"), - ("Привіт, як справи? Їжак і ґанок", "uk"), - ("Γειά σου κόσμε", "el"), - ("مرحبا بالعالم", "ar"), - ("שלום עולם", "he"), - ("こんにちは、世界", "ja"), - ("你好,世界", "zh"), - ("안녕하세요 세계", "ko"), - ("สวัสดีชาวโลก", "th"), - ("Hello Привет мир как дела", "ru"), - ("Hello world and Привет", "ru"), - ] { - assert_eq!(detect_language(text), Some(tag), "{text}"); - } - // Latin text, nothing to read, or only punctuation: no guess. - for text in ["Hello, world", "", " ", "?!…", "123"] { - assert_eq!(detect_language(text), None, "{text:?}"); - } -} - -#[test] -fn an_explicit_language_wins_over_detection() { - // The document says so (a Russian run, even of Latin letters). - assert_eq!(speech_language("Privet", Some("ru-RU"), None).as_deref(), Some("ru-RU")); - assert_eq!(speech_language("Привет", Some("uk-UA"), None).as_deref(), Some("uk-UA")); - assert_eq!(speech_language("Hallo Welt", Some("de-DE"), None).as_deref(), Some("de-DE")); - // The default English tag on Cyrillic text (Word writes it on every run): the script wins. - assert_eq!(speech_language("Привет", Some("en-US"), None).as_deref(), Some("ru")); - // Nothing to go on: the default voice. - assert_eq!(speech_language("Hello", None, None), None); - assert_eq!(speech_language("Привет", None, None).as_deref(), Some("ru")); - // A hostile tag is never passed on. - assert_eq!(speech_language("Hello", Some("en'; calc; '"), None), None); -} - -#[test] -fn sentences_carry_the_run_language() { - let mut doc = Document::from_text("Привет как твои дела?\nPrivet."); - let s = sentences(&doc, &Pos::body(0, 0), None, true); - assert_eq!(s.iter().map(|u| u.lang.as_deref()).collect::>(), [Some("ru"), Some("en-US")]); - if let wordcraft_doc::Block::Para(p) = Arc::make_mut(&mut doc.body[1]) { - for r in &mut p.runs { - r.props.lang = Some("ru-RU".into()); - } - } - let s = sentences(&doc, &Pos::body(0, 0), None, true); - assert_eq!(s.iter().map(|u| u.lang.as_deref()).collect::>(), [Some("ru"), Some("ru-RU")]); -} - -#[test] -fn windows_script_reads_utf8_and_picks_the_voice() { - let w = windows_speech(None, 1.0, Some("ru")); - let script = &w.args[3]; - assert!(script.contains("UTF8"), "{script}"); - assert!(script.contains("GetInstalledVoices") && script.contains("SelectVoice"), "{script}"); - assert!(script.contains("'ru'"), "{script}"); - // The default voice when the language is unknown, and no voice code at all. - let d = windows_speech(None, 1.0, None); - assert!(d.args[3].contains("UTF8") && !d.args[3].contains("SelectVoice"), "{}", d.args[3]); - // The sentence is never in the script: it arrives on stdin. - assert!(!windows_speech(None, 1.0, Some("ru-RU")).args[3].contains("Привет")); - // A tag that isn't a plain language tag is dropped, not put in the script. - let h = windows_speech(None, 1.0, Some("ru'; Remove-Item x; '")); - assert!(!h.args[3].contains("Remove-Item"), "{}", h.args[3]); -} - -#[test] -fn espeak_gets_the_language() { - assert_eq!(espeak_args("180", Some("ru-RU")), ["-s", "180", "-v", "ru", "--stdin"]); - assert_eq!(espeak_args("180", Some("uk")), ["-s", "180", "-v", "uk", "--stdin"]); - assert_eq!(espeak_args("180", None), ["-s", "180", "--stdin"]); - assert_eq!(espeak_args("180", Some("--evil")), ["-s", "180", "--stdin"]); - assert_eq!(espeak_args("180", Some("hu-HU")), ["-s", "180", "-v", "hu", "--stdin"]); -} - -#[test] -fn speech_dispatcher_rate_follows_the_speed() { - assert_eq!(spd_rate(1.0), 0); - assert_eq!(spd_rate(MIN_RATE), -50); - assert_eq!(spd_rate(MAX_RATE), 100); - assert_eq!(spd_rate(2.0), 50); - assert_eq!(spd_rate(10.0), 100); - assert_eq!(spd_rate(0.0), -50); - assert_eq!(spd_rate(f32::NAN), 0); - assert_eq!(spd_rate(1.5), 25); -} - -#[test] -fn speech_dispatcher_keeps_the_region() { - let tag = |t| dispatcher_language(Some(t)); - assert_eq!(tag("en-GB").as_deref(), Some("en-GB")); - assert_eq!(tag(" pt-BR ").as_deref(), Some("pt-BR")); - assert_eq!(tag("zh-TW").as_deref(), Some("zh-TW")); - assert_eq!(tag("hu").as_deref(), Some("hu")); - assert_eq!(tag("HU-hu").as_deref(), Some("hu-HU")); - // SSIP takes language and region: script and variant subtags are left out. - assert_eq!(tag("sr-Latn-RS").as_deref(), Some("sr-RS")); - assert_eq!(tag("zh-Hant-TW").as_deref(), Some("zh-TW")); - assert_eq!(tag("es-419").as_deref(), Some("es-419")); - assert_eq!(tag("de-CH-1996").as_deref(), Some("de-CH")); - assert_eq!(tag("sr-Cyrl").as_deref(), Some("sr")); - assert_eq!(dispatcher_language(Some("x-none")), None); - assert_eq!(dispatcher_language(Some("hu\r\nQUIT")), None); - assert_eq!(dispatcher_language(None), None); -} - -#[test] -fn english_text_keeps_the_default_voice() { - // Word writes `en-US` on almost every run: espeak's default voice is English already, so - // no `-v` (as before #1843), and Word's language-less `x-none` is no voice either. - assert_eq!(espeak_args("180", Some("en-US")), ["-s", "180", "--stdin"]); - assert_eq!(espeak_args("180", Some("en-GB")), ["-s", "180", "--stdin"]); - assert_eq!(espeak_args("180", Some("x-none")), ["-s", "180", "--stdin"]); - // Windows: the user's default voice stays when it already speaks the language, any region. - let script = &windows_speech(None, 1.0, Some("en-US")).args[3]; - let guard = script.find("if ($s.Voice.Culture.TwoLetterISOLanguageName -ne 'en')"); - let pick = script.find("GetInstalledVoices"); - assert!(guard.is_some() && pick.is_some() && guard < pick, "{script}"); -} - -#[test] -fn macos_keeps_the_system_voice_for_its_language() { - // Current macOS lists its novelty voices first, in alphabetical order. - let list = "Albert en_US # Hello! My name is Albert.\n\ - Bad News en_US # The light you see at the end of the tunnel.\n\ - Bahh en_US # Do not pull the wool over my eyes.\n\ - Samantha en_US # Hello! My name is Samantha.\n\ - Milena ru_RU # Здравствуйте!\n"; - // English text with an English system voice: no `-v`, so `say` keeps the user's voice. - assert_eq!(mac_voice("en-US", list, "en"), None); - assert_eq!(mac_voice("en", list, "en"), None); - // English text on a Russian system: an English voice, never a novelty one. - assert_eq!(mac_voice("en-US", list, "ru").as_deref(), Some("Samantha")); - assert_eq!(mac_voice("en-US", "Albert en_US # Hi\nBahh en_US # Hi\n", "ru"), None); - // The system voice's language: the chosen voice's, else the locale's, else English. - assert_eq!(mac_default_language(Some("Milena"), list, Some("en_US")), "ru"); - assert_eq!(mac_default_language(Some("Gone"), list, Some("de_DE")), "de"); - assert_eq!(mac_default_language(None, list, Some("ru_RU.UTF-8")), "ru"); - assert_eq!(mac_default_language(None, list, Some("C")), "en"); - assert_eq!(mac_default_language(None, "", None), "en"); -} - -#[test] -fn a_voice_the_program_lacks_falls_back_to_the_default() { - // espeak-ng exits with a failure, saying nothing, for a `-v` it doesn't know. - assert!(retry_without_voice(true, Some(false))); - // It spoke, it was given no voice, or it was stopped (or never exited on its own). - assert!(!retry_without_voice(true, Some(true))); - assert!(!retry_without_voice(false, Some(false))); - assert!(!retry_without_voice(true, None)); -} - -#[test] -fn right_to_left_text_uses_the_complex_script_tag() { - // Persian tagged `w:bidi="fa-IR"` on a `w:val="en-US"` run: Persian, not Arabic. - assert_eq!(speech_language("سلام دنیا", Some("en-US"), Some("fa-IR")).as_deref(), Some("fa-IR")); - assert_eq!(speech_language("שלום עולם", Some("en-US"), Some("he-IL")).as_deref(), Some("he-IL")); - // The complex-script tag is for complex-script text only. - assert_eq!(speech_language("Hello", Some("en-US"), Some("fa-IR")).as_deref(), Some("en-US")); - assert_eq!(speech_language("سلام", Some("en-US"), Some("he-IL")).as_deref(), Some("ar")); -} - -#[test] -fn mixed_sentences_follow_the_other_script() { - // More English letters than Cyrillic ones: the English voice would spell the Russian out. - assert_eq!(detect_language("Я открыл Microsoft Word and PowerPoint"), Some("ru")); - // A single foreign word in an English sentence isn't a real share: the default voice. - assert_eq!(detect_language("The word дом means house"), None); - // Ties between scripts always go the same way, the first script in the list. - assert_eq!(detect_language("αβ дг"), Some("ru")); - assert_eq!(detect_language("дг αβ"), Some("ru")); -} - -#[test] -fn han_letters_keep_a_japanese_or_korean_tag() { - assert_eq!(speech_language("東京都。", Some("ja-JP"), None).as_deref(), Some("ja-JP")); - assert_eq!(speech_language("東京都。", Some("ko-KR"), None).as_deref(), Some("ko-KR")); - assert_eq!(speech_language("東京都。", Some("en-US"), None).as_deref(), Some("zh")); -} - -#[test] -fn macos_voice_matches_the_language() { - let list = "Alex en_US # Most people recognize me by my voice.\n\ - Eddy (Russian (Russia)) ru_RU # Привет!\n\ - Milena ru_RU # Здравствуйте!\n\ - Samantha en_US # Hello!\n\ - Lesya uk_UA # Привіт!\n\ - Daniel en_GB # Hello!\n"; - assert_eq!(mac_voice("ru", list, "en").as_deref(), Some("Eddy (Russian (Russia))")); - assert_eq!(mac_voice("ru-RU", list, "en").as_deref(), Some("Eddy (Russian (Russia))")); - assert_eq!(mac_voice("uk", list, "en").as_deref(), Some("Lesya")); - assert_eq!(mac_voice("en-GB", list, "ru").as_deref(), Some("Daniel")); - // No voice for the language, or nothing listed: the default voice. - assert_eq!(mac_voice("el", list, "en"), None); - assert_eq!(mac_voice("ru", "", "en"), None); - assert_eq!(mac_voice("ru", "garbage line\n\n \n", "en"), None); -} - -#[test] -fn skipped_spans_match_a_scan_of_every_span() { - // Overlapping, nested, touching, empty-range and reversed spans. - let raw = vec![ - (Pos::body(0, 5), Pos::body(0, 9)), - (Pos::body(0, 7), Pos::body(0, 12)), - (Pos::body(0, 20), Pos::body(0, 20)), - (Pos::body(0, 30), Pos::body(2, 3)), - (Pos::body(1, 4), Pos::body(1, 8)), - (Pos::body(3, 9), Pos::body(3, 2)), - (Pos::body(4, 0), Pos::body(4, 1)), - (Pos::body(4, 2), Pos::body(4, 3)), - ]; - let spans = Spans::new(raw.clone()); - for block in 0..6 { - for off in 0..40 { - let p = Pos::body(block, off); - let scan = raw.iter().any(|(a, b)| *a <= p && p <= *b); - assert_eq!(spans.contains(&p), scan, "{p:?}"); - } - } -} - -#[test] -fn many_citations_are_looked_up_quickly() { - // 50,000 disjoint spans and 200,000 lookups: a scan of every span per position would take - // ten billion comparisons. - let spans = Spans::new((0..50_000).map(|i| (Pos::body(0, i * 4), Pos::body(0, i * 4 + 1))).collect()); - let hits = (0..200_000).filter(|&off| spans.contains(&Pos::body(0, off))).count(); - assert_eq!(hits, 100_000); -} +use super::*; + +fn texts(v: &[Utterance]) -> Vec<&str> { + v.iter().map(|u| u.text.as_str()).collect() +} + +#[test] +fn powershell_is_launched_from_the_system_folder() { + let tail = std::path::Path::new("System32").join("WindowsPowerShell").join("v1.0").join("powershell.exe"); + let p = windows_powershell(Some("D:\\Win".into())); + assert!(p.starts_with("D:\\Win") && p.ends_with(&tail), "{}", p.display()); + // No SystemRoot, or an empty one: the default Windows folder. + for root in [None, Some("".into())] { + let p = windows_powershell(root); + assert!(p.starts_with("C:\\Windows") && p.ends_with(&tail), "{}", p.display()); + } +} + +#[test] +fn powershell_starts_without_a_console_window() { + // The flags `speak` passes to `creation_flags` (std can't read them back from the + // `Command`): Win32's CREATE_NO_WINDOW (0x0800_0000), or every sentence flashes a + // console (#1122). + let w = windows_speech(Some("D:\\Win".into()), 1.0, None); + assert_eq!(w.creation_flags, 0x0800_0000); + assert_eq!(w.program, windows_powershell(Some("D:\\Win".into()))); + assert_eq!(w.args[..3], ["-NoProfile", "-NonInteractive", "-Command"]); + assert!(w.args[3].contains("$s.Rate = 0;"), "{}", w.args[3]); + assert!(windows_speech(None, 3.0, None).args[3].contains("$s.Rate = 10;")); +} + +#[test] +fn splits_sentences_but_not_abbreviations() { + let t = "Smith et al. found 3.5 times more (see Fig. 2). J. Smith agreed! Did they? Yes… Pick one, e.g. this one."; + let spans = split_sentences(t); + let got: Vec = spans.iter().map(|(a, b)| t.chars().skip(*a).take(b - a).collect()).collect(); + assert_eq!(got, ["Smith et al. found 3.5 times more (see Fig. 2).", "J. Smith agreed!", "Did they?", "Yes…", "Pick one, e.g. this one."]); +} + +#[test] +fn long_sentences_are_split() { + let t = "word ".repeat(400); + let spans = split_sentences(&t); + assert!(spans.len() >= 3); + assert!(spans.iter().all(|(a, b)| b - a <= MAX_SENTENCE + 5)); +} + +/// A word with no space to split at, or a run of punctuation, is still cut into utterances of +/// at most `MAX_UTTERANCE` characters, covering all of it (#3680). +#[test] +fn sentences_without_spaces_are_split() { + for t in ["x".repeat(10_000), "!".repeat(10_000), format!("Go {} now.", "a.".repeat(5_000))] { + let spans = split_sentences(&t); + assert!(spans.len() >= 8, "{} spans", spans.len()); + assert!(spans.iter().all(|(a, b)| a < b && b - a <= MAX_UTTERANCE)); + assert_eq!(spans.first().map(|s| s.0), Some(0)); + assert_eq!(spans.last().map(|s| s.1), Some(t.chars().count())); + assert!(spans.windows(2).all(|w| w[0].1 <= w[1].0)); + } +} + +#[test] +fn tidy_removes_citation_leftovers() { + assert_eq!(tidy("as shown ( ) ."), "as shown."); + assert_eq!(tidy("as shown [] , and"), "as shown, and"); + assert_eq!(tidy(" a ( ( [ ] ) ) b ( x ) [ ) ] ; "), "a b ( x ) [ ) ];"); +} + +/// The tidy that ran replacements until nothing changed, kept to check the one-pass tidy gives +/// the same text. +fn tidy_by_replacing(s: &str) -> String { + let mut t: String = s.split_whitespace().collect::>().join(" "); + loop { + let before = t.len(); + for (from, to) in [("()", ""), ("[]", ""), ("( )", ""), ("[ ]", ""), (" .", "."), (" ,", ","), (" ;", ";"), (" :", ":"), (" ", " ")] { + t = t.replace(from, to); + } + if t.len() == before { + break; + } + } + t.trim().to_string() +} + +#[test] +fn tidy_matches_replacing_until_nothing_changes() { + let alphabet = ['(', ')', '[', ']', ' ', ' ', '\t', '\u{a0}', '.', ',', ';', ':', 'a', 'é']; + let mut seed: u64 = 0x3680; + for _ in 0..20_000 { + seed = seed.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1_442_695_040_888_963_407); + let len = (seed >> 59) as usize + 1; + let s: String = (0..len) + .map(|i| { + let r = seed.rotate_left(5 * i as u32 + 1) ^ (i as u64).wrapping_mul(0x9e37_79b9_7f4a_7c15); + alphabet[(r % alphabet.len() as u64) as usize] + }) + .collect(); + assert_eq!(tidy(&s), tidy_by_replacing(&s), "{s:?}"); + } +} + +/// Nested empty brackets are removed in one pass: 40,000 pairs took seconds when each pass +/// removed one level (#3680). +#[test] +fn tidy_is_linear_on_nested_brackets() { + let n = 40_000; + let started = std::time::Instant::now(); + assert_eq!(tidy(&format!("a {}{} b", "( [".repeat(n), "] )".repeat(n))), "a b"); + assert_eq!(tidy(&format!("{}x{}", "(".repeat(n), ")".repeat(n))).len(), 2 * n + 1); + // Milliseconds here; `tidy_by_replacing` (the old tidy) takes about 5 minutes on this input. + assert!(started.elapsed() < std::time::Duration::from_secs(10), "{:?}", started.elapsed()); +} + +#[test] +fn citation_codes() { + assert!(is_citation_code(" ADDIN ZOTERO_ITEM CSL_CITATION {}")); + assert!(is_citation_code("ADDIN ZOTERO_BIBL {\"uncited\":[]} CSL_BIBLIOGRAPHY")); + assert!(is_citation_code("ADDIN EN.CITE ")); + assert!(is_citation_code("ADDIN CSL_CITATION {}")); + assert!(is_citation_code("CITATION Smi20 \\l 1033")); + assert!(is_citation_code("BIBLIOGRAPHY")); + assert!(!is_citation_code("ADDIN ZOTERO_PREF_1")); + assert!(!is_citation_code("PAGE")); + assert!(!is_citation_code("")); + assert!(!is_citation_code("ADD")); +} + +#[test] +fn plain_document_sentences() { + let doc = Document::from_text("First one. Second one.\nThird paragraph"); + let s = sentences(&doc, &Pos::body(0, 0), None, true); + assert_eq!(texts(&s), ["First one.", "Second one.", "Third paragraph"]); + assert_eq!(s[1].start, Pos::body(0, 11)); + assert_eq!(s[1].end, Pos::body(0, 22)); + // From the caret, and only up to the end of a selection. + let s = sentences(&doc, &Pos::body(0, 11), Some(&Pos::body(1, 5)), true); + assert_eq!(texts(&s), ["Second one.", "Third"]); +} + +#[test] +fn player_moves_through_sentences() { + let doc = Document::from_text("One. Two. Three."); + let mut r = ReadAloud::default(); + r.backend = Backend::Hold; + r.start(sentences(&doc, &Pos::body(0, 0), None, true)); + assert_eq!(r.state(), State::Playing); + assert_eq!(r.current().map(|u| u.text), Some("One.".into())); + r.skip(1); + r.skip(5); + assert_eq!(r.progress(), (2, 3)); + r.skip(-10); + assert_eq!(r.progress(), (0, 3)); + r.play_pause(); + assert_eq!(r.state(), State::Paused); + r.set_rate(99.0); + assert_eq!(r.rate(), MAX_RATE); + r.set_rate(f32::NAN); + assert_eq!(r.rate(), 1.0); + r.play_pause(); + assert_eq!(r.state(), State::Playing); + r.stop(); + assert_eq!(r.state(), State::Stopped); + assert!(!r.open); + assert!(r.current().is_none()); +} + +#[test] +fn player_finishes_on_its_own() { + let doc = Document::from_text("One. Two. Three."); + let mut r = ReadAloud::default(); + r.backend = Backend::Instant; + r.start(sentences(&doc, &Pos::body(0, 0), None, true)); + for _ in 0..200 { + if r.state() == State::Stopped { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert_eq!(r.state(), State::Stopped); + assert_eq!(r.progress(), (3, 3)); + assert!(r.open, "the player stays open after the last sentence"); + // Play again starts from the top. + r.backend = Backend::Hold; + r.play_pause(); + assert_eq!(r.progress().0, 0); + assert_eq!(r.state(), State::Playing); +} + +#[test] +fn every_reading_counts_as_a_new_one() { + // The worker forgets what it learnt about the speech programs when a reading starts, even + // when the same worker thread carries on (a pause, then Read Aloud again). + let mut r = ReadAloud::default(); + r.backend = Backend::Hold; + let readings = |r: &ReadAloud| lock(&r.shared.0).readings; + let doc = Document::from_text("One. Two."); + r.start(sentences(&doc, &Pos::body(0, 0), None, true)); + let first = readings(&r); + r.play_pause(); + r.start(sentences(&doc, &Pos::body(0, 0), None, true)); + assert_eq!(readings(&r), first + 1); + r.play_pause(); + r.play_pause(); + assert_eq!(readings(&r), first + 1, "pause and play are the same reading"); + r.stop(); +} + +#[cfg(all(unix, not(target_os = "macos")))] +#[test] +fn a_sentence_that_never_began_says_so_not_another_program() { + // No begin event within the queue bound: no other program need be involved, so the + // message names the sentence not starting, not another program stopping it. + let halted = |o| match dispatcher_spoken(Ok(o)) { + Spoken::Halted(m) => m, + _ => String::new(), + }; + assert_eq!(halted(ssip::Outcome::NotStarted), NOT_STARTED); + assert!(!NOT_STARTED.contains("another program")); + assert_eq!(halted(ssip::Outcome::Halted), HALTED_BY_OTHER); +} + +#[test] +fn play_after_a_failed_sentence_plays_it_again() { + // A sentence whose end never came stops reading with an error (as the worker does on a + // timeout); Play starts it again and the message goes. + let mut r = ReadAloud::default(); + r.backend = Backend::Hold; + r.start(sentences(&Document::from_text("One. Two."), &Pos::body(0, 0), None, true)); + r.skip(1); + { + let mut s = lock(&r.shared.0); + s.state = State::Stopped; + s.error = Some("Read Aloud couldn't use Speech Dispatcher: the sentence didn't end".into()); + } + r.play_pause(); + assert_eq!((r.state(), r.progress().0, r.error()), (State::Playing, 1, None)); + r.stop(); +} + +#[test] +fn play_after_a_halt_carries_on_and_clears_the_message() { + // Another program stopped the sentence: reading paused on it with a message, and Play + // goes on from the same sentence. + let mut r = ReadAloud::default(); + r.backend = Backend::Hold; + r.start(sentences(&Document::from_text("One. Two."), &Pos::body(0, 0), None, true)); + { + let mut s = lock(&r.shared.0); + s.state = State::Paused; + s.error = Some("stopped by another program".into()); + } + r.play_pause(); + assert_eq!(r.state(), State::Playing); + assert_eq!(r.progress().0, 0); + assert_eq!(r.error(), None); + r.stop(); +} + +#[test] +fn empty_queue_does_not_play() { + let mut r = ReadAloud::default(); + r.start(Vec::new()); + assert_eq!(r.state(), State::Stopped); + r.skip(1); + r.skip(-1); + r.play_pause(); + assert_eq!(r.state(), State::Stopped); +} +#[test] +fn languages_are_detected_from_the_script() { + for (text, tag) in [ + ("Привет как твои дела?", "ru"), + ("Привіт, як справи? Їжак і ґанок", "uk"), + ("Γειά σου κόσμε", "el"), + ("مرحبا بالعالم", "ar"), + ("שלום עולם", "he"), + ("こんにちは、世界", "ja"), + ("你好,世界", "zh"), + ("안녕하세요 세계", "ko"), + ("สวัสดีชาวโลก", "th"), + ("Hello Привет мир как дела", "ru"), + ("Hello world and Привет", "ru"), + ] { + assert_eq!(detect_language(text), Some(tag), "{text}"); + } + // Latin text, nothing to read, or only punctuation: no guess. + for text in ["Hello, world", "", " ", "?!…", "123"] { + assert_eq!(detect_language(text), None, "{text:?}"); + } +} + +#[test] +fn an_explicit_language_wins_over_detection() { + // The document says so (a Russian run, even of Latin letters). + assert_eq!(speech_language("Privet", Some("ru-RU"), None).as_deref(), Some("ru-RU")); + assert_eq!(speech_language("Привет", Some("uk-UA"), None).as_deref(), Some("uk-UA")); + assert_eq!(speech_language("Hallo Welt", Some("de-DE"), None).as_deref(), Some("de-DE")); + // The default English tag on Cyrillic text (Word writes it on every run): the script wins. + assert_eq!(speech_language("Привет", Some("en-US"), None).as_deref(), Some("ru")); + // Nothing to go on: the default voice. + assert_eq!(speech_language("Hello", None, None), None); + assert_eq!(speech_language("Привет", None, None).as_deref(), Some("ru")); + // A hostile tag is never passed on. + assert_eq!(speech_language("Hello", Some("en'; calc; '"), None), None); +} + +#[test] +fn sentences_carry_the_run_language() { + let mut doc = Document::from_text("Привет как твои дела?\nPrivet."); + let s = sentences(&doc, &Pos::body(0, 0), None, true); + assert_eq!(s.iter().map(|u| u.lang.as_deref()).collect::>(), [Some("ru"), Some("en-US")]); + if let wordcraft_doc::Block::Para(p) = Arc::make_mut(&mut doc.body[1]) { + for r in &mut p.runs { + r.props.lang = Some("ru-RU".into()); + } + } + let s = sentences(&doc, &Pos::body(0, 0), None, true); + assert_eq!(s.iter().map(|u| u.lang.as_deref()).collect::>(), [Some("ru"), Some("ru-RU")]); +} + +#[test] +fn windows_script_reads_utf8_and_picks_the_voice() { + let w = windows_speech(None, 1.0, Some("ru")); + let script = &w.args[3]; + assert!(script.contains("UTF8"), "{script}"); + assert!(script.contains("GetInstalledVoices") && script.contains("SelectVoice"), "{script}"); + assert!(script.contains("'ru'"), "{script}"); + // The default voice when the language is unknown, and no voice code at all. + let d = windows_speech(None, 1.0, None); + assert!(d.args[3].contains("UTF8") && !d.args[3].contains("SelectVoice"), "{}", d.args[3]); + // The sentence is never in the script: it arrives on stdin. + assert!(!windows_speech(None, 1.0, Some("ru-RU")).args[3].contains("Привет")); + // A tag that isn't a plain language tag is dropped, not put in the script. + let h = windows_speech(None, 1.0, Some("ru'; Remove-Item x; '")); + assert!(!h.args[3].contains("Remove-Item"), "{}", h.args[3]); +} + +#[test] +fn espeak_gets_the_language() { + assert_eq!(espeak_args("180", Some("ru-RU")), ["-s", "180", "-v", "ru", "--stdin"]); + assert_eq!(espeak_args("180", Some("uk")), ["-s", "180", "-v", "uk", "--stdin"]); + assert_eq!(espeak_args("180", None), ["-s", "180", "--stdin"]); + assert_eq!(espeak_args("180", Some("--evil")), ["-s", "180", "--stdin"]); + assert_eq!(espeak_args("180", Some("hu-HU")), ["-s", "180", "-v", "hu", "--stdin"]); +} + +#[test] +fn speech_dispatcher_rate_follows_the_speed() { + assert_eq!(spd_rate(1.0), 0); + assert_eq!(spd_rate(MIN_RATE), -50); + assert_eq!(spd_rate(MAX_RATE), 100); + assert_eq!(spd_rate(2.0), 50); + assert_eq!(spd_rate(10.0), 100); + assert_eq!(spd_rate(0.0), -50); + assert_eq!(spd_rate(f32::NAN), 0); + assert_eq!(spd_rate(1.5), 25); +} + +#[test] +fn speech_dispatcher_keeps_the_region() { + let tag = |t| dispatcher_language(Some(t)); + assert_eq!(tag("en-GB").as_deref(), Some("en-GB")); + assert_eq!(tag(" pt-BR ").as_deref(), Some("pt-BR")); + assert_eq!(tag("zh-TW").as_deref(), Some("zh-TW")); + assert_eq!(tag("hu").as_deref(), Some("hu")); + assert_eq!(tag("HU-hu").as_deref(), Some("hu-HU")); + // SSIP takes language and region: script and variant subtags are left out. + assert_eq!(tag("sr-Latn-RS").as_deref(), Some("sr-RS")); + assert_eq!(tag("zh-Hant-TW").as_deref(), Some("zh-TW")); + assert_eq!(tag("es-419").as_deref(), Some("es-419")); + assert_eq!(tag("de-CH-1996").as_deref(), Some("de-CH")); + assert_eq!(tag("sr-Cyrl").as_deref(), Some("sr")); + assert_eq!(dispatcher_language(Some("x-none")), None); + assert_eq!(dispatcher_language(Some("hu\r\nQUIT")), None); + assert_eq!(dispatcher_language(None), None); +} + +#[test] +fn english_text_keeps_the_default_voice() { + // Word writes `en-US` on almost every run: espeak's default voice is English already, so + // no `-v` (as before #1843), and Word's language-less `x-none` is no voice either. + assert_eq!(espeak_args("180", Some("en-US")), ["-s", "180", "--stdin"]); + assert_eq!(espeak_args("180", Some("en-GB")), ["-s", "180", "--stdin"]); + assert_eq!(espeak_args("180", Some("x-none")), ["-s", "180", "--stdin"]); + // Windows: the user's default voice stays when it already speaks the language, any region. + let script = &windows_speech(None, 1.0, Some("en-US")).args[3]; + let guard = script.find("if ($s.Voice.Culture.TwoLetterISOLanguageName -ne 'en')"); + let pick = script.find("GetInstalledVoices"); + assert!(guard.is_some() && pick.is_some() && guard < pick, "{script}"); +} + +#[test] +fn macos_keeps_the_system_voice_for_its_language() { + // Current macOS lists its novelty voices first, in alphabetical order. + let list = "Albert en_US # Hello! My name is Albert.\n\ + Bad News en_US # The light you see at the end of the tunnel.\n\ + Bahh en_US # Do not pull the wool over my eyes.\n\ + Samantha en_US # Hello! My name is Samantha.\n\ + Milena ru_RU # Здравствуйте!\n"; + // English text with an English system voice: no `-v`, so `say` keeps the user's voice. + assert_eq!(mac_voice("en-US", list, "en"), None); + assert_eq!(mac_voice("en", list, "en"), None); + // English text on a Russian system: an English voice, never a novelty one. + assert_eq!(mac_voice("en-US", list, "ru").as_deref(), Some("Samantha")); + assert_eq!(mac_voice("en-US", "Albert en_US # Hi\nBahh en_US # Hi\n", "ru"), None); + // The system voice's language: the chosen voice's, else the locale's, else English. + assert_eq!(mac_default_language(Some("Milena"), list, Some("en_US")), "ru"); + assert_eq!(mac_default_language(Some("Gone"), list, Some("de_DE")), "de"); + assert_eq!(mac_default_language(None, list, Some("ru_RU.UTF-8")), "ru"); + assert_eq!(mac_default_language(None, list, Some("C")), "en"); + assert_eq!(mac_default_language(None, "", None), "en"); +} + +#[test] +fn a_voice_the_program_lacks_falls_back_to_the_default() { + // espeak-ng exits with a failure, saying nothing, for a `-v` it doesn't know. + assert!(retry_without_voice(true, Some(false))); + // It spoke, it was given no voice, or it was stopped (or never exited on its own). + assert!(!retry_without_voice(true, Some(true))); + assert!(!retry_without_voice(false, Some(false))); + assert!(!retry_without_voice(true, None)); +} + +#[test] +fn right_to_left_text_uses_the_complex_script_tag() { + // Persian tagged `w:bidi="fa-IR"` on a `w:val="en-US"` run: Persian, not Arabic. + assert_eq!(speech_language("سلام دنیا", Some("en-US"), Some("fa-IR")).as_deref(), Some("fa-IR")); + assert_eq!(speech_language("שלום עולם", Some("en-US"), Some("he-IL")).as_deref(), Some("he-IL")); + // The complex-script tag is for complex-script text only. + assert_eq!(speech_language("Hello", Some("en-US"), Some("fa-IR")).as_deref(), Some("en-US")); + assert_eq!(speech_language("سلام", Some("en-US"), Some("he-IL")).as_deref(), Some("ar")); +} + +#[test] +fn mixed_sentences_follow_the_other_script() { + // More English letters than Cyrillic ones: the English voice would spell the Russian out. + assert_eq!(detect_language("Я открыл Microsoft Word and PowerPoint"), Some("ru")); + // A single foreign word in an English sentence isn't a real share: the default voice. + assert_eq!(detect_language("The word дом means house"), None); + // Ties between scripts always go the same way, the first script in the list. + assert_eq!(detect_language("αβ дг"), Some("ru")); + assert_eq!(detect_language("дг αβ"), Some("ru")); +} + +#[test] +fn han_letters_keep_a_japanese_or_korean_tag() { + assert_eq!(speech_language("東京都。", Some("ja-JP"), None).as_deref(), Some("ja-JP")); + assert_eq!(speech_language("東京都。", Some("ko-KR"), None).as_deref(), Some("ko-KR")); + assert_eq!(speech_language("東京都。", Some("en-US"), None).as_deref(), Some("zh")); +} + +#[test] +fn macos_voice_matches_the_language() { + let list = "Alex en_US # Most people recognize me by my voice.\n\ + Eddy (Russian (Russia)) ru_RU # Привет!\n\ + Milena ru_RU # Здравствуйте!\n\ + Samantha en_US # Hello!\n\ + Lesya uk_UA # Привіт!\n\ + Daniel en_GB # Hello!\n"; + assert_eq!(mac_voice("ru", list, "en").as_deref(), Some("Eddy (Russian (Russia))")); + assert_eq!(mac_voice("ru-RU", list, "en").as_deref(), Some("Eddy (Russian (Russia))")); + assert_eq!(mac_voice("uk", list, "en").as_deref(), Some("Lesya")); + assert_eq!(mac_voice("en-GB", list, "ru").as_deref(), Some("Daniel")); + // No voice for the language, or nothing listed: the default voice. + assert_eq!(mac_voice("el", list, "en"), None); + assert_eq!(mac_voice("ru", "", "en"), None); + assert_eq!(mac_voice("ru", "garbage line\n\n \n", "en"), None); +} + +#[test] +fn skipped_spans_match_a_scan_of_every_span() { + // Overlapping, nested, touching, empty-range and reversed spans. + let raw = vec![ + (Pos::body(0, 5), Pos::body(0, 9)), + (Pos::body(0, 7), Pos::body(0, 12)), + (Pos::body(0, 20), Pos::body(0, 20)), + (Pos::body(0, 30), Pos::body(2, 3)), + (Pos::body(1, 4), Pos::body(1, 8)), + (Pos::body(3, 9), Pos::body(3, 2)), + (Pos::body(4, 0), Pos::body(4, 1)), + (Pos::body(4, 2), Pos::body(4, 3)), + ]; + let spans = Spans::new(raw.clone()); + for block in 0..6 { + for off in 0..40 { + let p = Pos::body(block, off); + let scan = raw.iter().any(|(a, b)| *a <= p && p <= *b); + assert_eq!(spans.contains(&p), scan, "{p:?}"); + } + } +} + +#[test] +fn many_citations_are_looked_up_quickly() { + // 50,000 disjoint spans and 200,000 lookups: a scan of every span per position would take + // ten billion comparisons. + let spans = Spans::new((0..50_000).map(|i| (Pos::body(0, i * 4), Pos::body(0, i * 4 + 1))).collect()); + let hits = (0..200_000).filter(|&off| spans.contains(&Pos::body(0, off))).count(); + assert_eq!(hits, 100_000); +} diff --git a/crates/engine/src/strict.rs b/crates/engine/src/strict.rs index 223366b81e..972fbedf23 100644 --- a/crates/engine/src/strict.rs +++ b/crates/engine/src/strict.rs @@ -1,233 +1,233 @@ -//! Strict export (#6005): the report of what a PDF or PNG would leave out silently (characters no -//! face can draw, pictures that are missing or can't be decoded), and the refusal built from it. -//! -//! The check runs on the layout and display options the export itself uses, so what it reports is -//! what the output lacks. - -use std::ops::RangeInclusive; - -use serde_json::{Value, json}; -use wordcraft_doc::{Document, MAX_MISSING_PICTURES, MissingPicture}; -use wordcraft_layout::display::DisplayOptions; -use wordcraft_layout::{DocLayout, LayoutCache, StrictOutput}; - -use crate::io::ExportOptions; - -/// The report's JSON schema version; fields may be added without raising it. -pub const SCHEMA: u32 = 1; - -/// What a strict export found missing. -#[derive(Clone, Debug, Default, PartialEq, Eq)] -pub struct StrictReport { - /// (code point, times drawn), sorted by code point. - pub glyphs: Vec<(char, u64)>, - pub pictures: Vec, - /// More distinct characters were missing than are listed. - pub glyphs_truncated: bool, - /// More pictures were missing than are listed. - pub pictures_truncated: bool, - /// Distinct characters found missing, those past the list's cap included. - pub total_glyphs: usize, - /// Pictures found missing, those past the list's cap included. - pub total_pictures: u64, -} - -impl StrictReport { - /// Nothing is missing: the strict export passes. - pub fn passed(&self) -> bool { - self.glyphs.is_empty() && self.pictures.is_empty() && !self.truncated() - } - - /// Either list was cut at its cap. - pub fn truncated(&self) -> bool { - self.glyphs_truncated || self.pictures_truncated - } - - /// The schema-1 JSON of the report. - pub fn to_json(&self) -> Value { - json!({ - "schema": SCHEMA, - "strict": if self.passed() { "passed" } else { "failed" }, - "missing_glyphs": self.glyphs.iter().map(|(c, n)| json!({"cp": code_point(*c), "count": n})).collect::>(), - "missing_pictures": self.pictures.iter().map(|p| json!({"part": p.part, "source_part": p.story, "reason": p.reason.code()})).collect::>(), - "truncated": self.truncated(), - "total_missing_glyphs": self.total_glyphs, - "total_missing_pictures": self.total_pictures, - "glyphs_truncated": self.glyphs_truncated, - "pictures_truncated": self.pictures_truncated, - }) - } - - /// The human lines (without the `strict: ` prefix): one for the characters, one for the - /// pictures; none when nothing is missing. - fn parts(&self) -> Vec { - let glyphs: Vec<(String, u64)> = self.glyphs.iter().map(|(c, n)| (code_point(*c), *n)).collect(); - let pictures: Vec<(String, String, String)> = - self.pictures.iter().map(|p| (p.part.clone(), story_label(&p.story).to_string(), p.reason.label().to_string())).collect(); - human_parts(&glyphs, &pictures, (self.glyphs_truncated, self.pictures_truncated), (self.total_glyphs as u64, self.total_pictures)) - } - - /// The human lines, each `strict: …`. - pub fn lines(&self) -> Vec { - self.parts().into_iter().map(|p| format!("strict: {p}")).collect() - } - - /// The refusal's message: `strict: …`, the lines joined. - pub fn refusal(&self) -> String { - format!("strict: {}", self.parts().join("; ")) - } -} - -/// The human text of a report: (code point text, count) and (part, story label, reason words). -fn human_parts(glyphs: &[(String, u64)], pictures: &[(String, String, String)], cut: (bool, bool), totals: (u64, u64)) -> Vec { - let (glyphs_cut, pictures_cut) = cut; - let mut out = Vec::new(); - if !glyphs.is_empty() { - let list: Vec = glyphs.iter().map(|(cp, n)| format!("{cp} (×{n})")).collect(); - let more = if glyphs_cut { ", …" } else { "" }; - let listed = glyphs.len() as u64; - let n = if glyphs_cut { totals.0.max(listed) } else { listed }; - let first = if n > listed { format!(" (first {listed} listed)") } else { String::new() }; - out.push(format!("{n} character{} cannot be drawn{first}: {}{more}", if n == 1 { "" } else { "s" }, list.join(", "))); - } - if !pictures.is_empty() { - let list: Vec = pictures - .iter() - .map(|(part, at, reason)| if at.is_empty() { format!("{part}: {reason}") } else { format!("{part} ({at}): {reason}") }) - .collect(); - let more = if pictures_cut { "; …" } else { "" }; - let listed = pictures.len() as u64; - let n = if pictures_cut { totals.1.max(listed) } else { listed }; - let first = if n > listed { format!(" (first {listed} listed)") } else { String::new() }; - out.push(format!("{n} picture(s) left out{first}: {}{more}", list.join("; "))); - } - out -} - -/// The `strict: …` human lines of a report in its JSON form (what `file.strictReport` returns). -/// Anything the JSON lacks is left out. -pub fn lines_from_json(report: &Value) -> Vec { - let text = |v: &Value, k: &str| v.get(k).and_then(Value::as_str).unwrap_or_default().to_string(); - let glyphs: Vec<(String, u64)> = report - .get("missing_glyphs") - .and_then(Value::as_array) - .map(|a| a.iter().map(|g| (text(g, "cp"), g.get("count").and_then(Value::as_u64).unwrap_or(0))).collect()) - .unwrap_or_default(); - let pictures: Vec<(String, String, String)> = report - .get("missing_pictures") - .and_then(Value::as_array) - .map(|a| a.iter().map(|p| (text(p, "part"), story_label(&text(p, "source_part")).to_string(), reason_label(&text(p, "reason")))).collect()) - .unwrap_or_default(); - let flag = |k: &str| report.get(k).and_then(Value::as_bool).unwrap_or(false); - // A report without the per-list flags (the top-level one alone) marks both lists. - let (glyphs_cut, pictures_cut) = match (report.get("glyphs_truncated"), report.get("pictures_truncated")) { - (None, None) => (flag("truncated"), flag("truncated")), - _ => (flag("glyphs_truncated"), flag("pictures_truncated")), - }; - let total = |k: &str, listed: usize| report.get(k).and_then(Value::as_u64).unwrap_or(listed as u64); - let totals = (total("total_missing_glyphs", glyphs.len()), total("total_missing_pictures", pictures.len())); - human_parts(&glyphs, &pictures, (glyphs_cut, pictures_cut), totals).into_iter().map(|p| format!("strict: {p}")).collect() -} - -/// The words for a JSON reason code; a code this version does not know is shown as it is. -fn reason_label(code: &str) -> String { - wordcraft_doc::MissingPictureReason::from_code(code).map_or_else(|| code.to_string(), |r| r.label().to_string()) -} - -/// `U+2D30`: upper-case hex, at least four digits. -fn code_point(c: char) -> String { - format!("U+{:04X}", c as u32) -} - -/// The part a picture is in, as the report names it: `header2.xml` for `word/header2.xml`. -fn story_label(story: &str) -> &str { - story.strip_prefix("word/").unwrap_or(story) -} - -/// The report of `doc` as drawn by `lay` with `display` into `output`, on `pages` (0-based; `None` -/// is every page). The watermark is checked with the page, and the pictures dropped when the -/// document was opened are added to those that can't be decoded now, capped like each. -pub fn strict_report( - doc: &Document, - lay: &DocLayout, - display: &DisplayOptions, - pages: Option>, - output: StrictOutput, -) -> StrictReport { - let mut missing = lay.missing_glyphs(doc, display, pages.clone()); - let any_page = lay.pages.iter().enumerate().any(|(n, _)| pages.as_ref().is_none_or(|r| r.contains(&n))); - if any_page && let Some(wm) = &doc.settings.watermark { - let max = match output { - StrictOutput::Pdf => wordcraft_pdf::WATERMARK_MAX_CHARS, - StrictOutput::Png => usize::MAX, - }; - missing.merge(wordcraft_layout::missing_watermark_glyphs(wm, max)); - } - let (undecodable, mut pictures_truncated, undecodable_total) = wordcraft_layout::undecodable_pictures(doc, lay, pages, display, output); - pictures_truncated |= doc.missing_pictures.truncated; - // The two lists overlap where the same picture is in both; those are counted once. - let mut overlap: u64 = 0; - let mut pictures: Vec = Vec::new(); - for p in doc.missing_pictures.list.iter().cloned().chain(undecodable) { - if pictures.contains(&p) { - overlap = overlap.saturating_add(1); - continue; - } - if pictures.len() >= MAX_MISSING_PICTURES { - pictures_truncated = true; - break; - } - pictures.push(p); - } - let total_pictures = doc.missing_pictures.total.saturating_add(undecodable_total).saturating_sub(overlap).max(pictures.len() as u64); - StrictReport { - glyphs: missing.chars, - pictures, - glyphs_truncated: missing.truncated, - pictures_truncated, - total_glyphs: missing.total, - total_pictures, - } -} - -/// The PDF export's options for `opts`: the one place they are built, shared by the export and -/// its strict check. -pub(crate) fn pdf_options(opts: &ExportOptions) -> wordcraft_pdf::PdfOptions { - wordcraft_pdf::PdfOptions { include_markup: opts.markup, comments: opts.markup && opts.comments, numeral: opts.numeral, ..Default::default() } -} - -/// What a page image shows: the display options `file.exportPng` renders with. -pub(crate) fn png_display() -> DisplayOptions { - // A page image is the printed page: headers and footers at full strength. - let mut opts = wordcraft_render::RenderOptions::default(); - opts.display.dim_header = false; - opts.display -} - -/// The strict report of a PDF export of `doc`: every page, laid out as the export lays it out. -pub(crate) fn pdf_report(doc: &Document, opts: &ExportOptions) -> StrictReport { - let pdf = pdf_options(opts); - let lay = wordcraft_layout::layout(doc, &mut LayoutCache::new(), &pdf.layout_options()); - strict_report(doc, &lay, &pdf.display_options(), None, StrictOutput::Pdf) -} - -/// The PDF of `doc`; with `opts.strict`, only if nothing is missing, laid out once for the check -/// and the export. -pub(crate) fn export_pdf(doc: &Document, opts: &ExportOptions) -> Result, String> { - let pdf = pdf_options(opts); - if !opts.strict { - return wordcraft_pdf::export(doc, &pdf).map_err(|e| e.to_string()); - } - let lay = wordcraft_layout::layout(doc, &mut LayoutCache::new(), &pdf.layout_options()); - let report = strict_report(doc, &lay, &pdf.display_options(), None, StrictOutput::Pdf); - if !report.passed() { - return Err(report.refusal()); - } - wordcraft_pdf::export_layout(doc, &lay, &pdf).map_err(|e| e.to_string()) -} - -/// Is `path` a PDF or PNG output (the only outputs a strict export applies to)? -pub(crate) fn is_strict_output(path: &str) -> bool { - let lower = path.to_ascii_lowercase(); - lower.ends_with(".pdf") || lower.ends_with(".png") -} +//! Strict export (#6005): the report of what a PDF or PNG would leave out silently (characters no +//! face can draw, pictures that are missing or can't be decoded), and the refusal built from it. +//! +//! The check runs on the layout and display options the export itself uses, so what it reports is +//! what the output lacks. + +use std::ops::RangeInclusive; + +use serde_json::{Value, json}; +use wordcraft_doc::{Document, MAX_MISSING_PICTURES, MissingPicture}; +use wordcraft_layout::display::DisplayOptions; +use wordcraft_layout::{DocLayout, LayoutCache, StrictOutput}; + +use crate::io::ExportOptions; + +/// The report's JSON schema version; fields may be added without raising it. +pub const SCHEMA: u32 = 1; + +/// What a strict export found missing. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct StrictReport { + /// (code point, times drawn), sorted by code point. + pub glyphs: Vec<(char, u64)>, + pub pictures: Vec, + /// More distinct characters were missing than are listed. + pub glyphs_truncated: bool, + /// More pictures were missing than are listed. + pub pictures_truncated: bool, + /// Distinct characters found missing, those past the list's cap included. + pub total_glyphs: usize, + /// Pictures found missing, those past the list's cap included. + pub total_pictures: u64, +} + +impl StrictReport { + /// Nothing is missing: the strict export passes. + pub fn passed(&self) -> bool { + self.glyphs.is_empty() && self.pictures.is_empty() && !self.truncated() + } + + /// Either list was cut at its cap. + pub fn truncated(&self) -> bool { + self.glyphs_truncated || self.pictures_truncated + } + + /// The schema-1 JSON of the report. + pub fn to_json(&self) -> Value { + json!({ + "schema": SCHEMA, + "strict": if self.passed() { "passed" } else { "failed" }, + "missing_glyphs": self.glyphs.iter().map(|(c, n)| json!({"cp": code_point(*c), "count": n})).collect::>(), + "missing_pictures": self.pictures.iter().map(|p| json!({"part": p.part, "source_part": p.story, "reason": p.reason.code()})).collect::>(), + "truncated": self.truncated(), + "total_missing_glyphs": self.total_glyphs, + "total_missing_pictures": self.total_pictures, + "glyphs_truncated": self.glyphs_truncated, + "pictures_truncated": self.pictures_truncated, + }) + } + + /// The human lines (without the `strict: ` prefix): one for the characters, one for the + /// pictures; none when nothing is missing. + fn parts(&self) -> Vec { + let glyphs: Vec<(String, u64)> = self.glyphs.iter().map(|(c, n)| (code_point(*c), *n)).collect(); + let pictures: Vec<(String, String, String)> = + self.pictures.iter().map(|p| (p.part.clone(), story_label(&p.story).to_string(), p.reason.label().to_string())).collect(); + human_parts(&glyphs, &pictures, (self.glyphs_truncated, self.pictures_truncated), (self.total_glyphs as u64, self.total_pictures)) + } + + /// The human lines, each `strict: …`. + pub fn lines(&self) -> Vec { + self.parts().into_iter().map(|p| format!("strict: {p}")).collect() + } + + /// The refusal's message: `strict: …`, the lines joined. + pub fn refusal(&self) -> String { + format!("strict: {}", self.parts().join("; ")) + } +} + +/// The human text of a report: (code point text, count) and (part, story label, reason words). +fn human_parts(glyphs: &[(String, u64)], pictures: &[(String, String, String)], cut: (bool, bool), totals: (u64, u64)) -> Vec { + let (glyphs_cut, pictures_cut) = cut; + let mut out = Vec::new(); + if !glyphs.is_empty() { + let list: Vec = glyphs.iter().map(|(cp, n)| format!("{cp} (×{n})")).collect(); + let more = if glyphs_cut { ", …" } else { "" }; + let listed = glyphs.len() as u64; + let n = if glyphs_cut { totals.0.max(listed) } else { listed }; + let first = if n > listed { format!(" (first {listed} listed)") } else { String::new() }; + out.push(format!("{n} character{} cannot be drawn{first}: {}{more}", if n == 1 { "" } else { "s" }, list.join(", "))); + } + if !pictures.is_empty() { + let list: Vec = pictures + .iter() + .map(|(part, at, reason)| if at.is_empty() { format!("{part}: {reason}") } else { format!("{part} ({at}): {reason}") }) + .collect(); + let more = if pictures_cut { "; …" } else { "" }; + let listed = pictures.len() as u64; + let n = if pictures_cut { totals.1.max(listed) } else { listed }; + let first = if n > listed { format!(" (first {listed} listed)") } else { String::new() }; + out.push(format!("{n} picture(s) left out{first}: {}{more}", list.join("; "))); + } + out +} + +/// The `strict: …` human lines of a report in its JSON form (what `file.strictReport` returns). +/// Anything the JSON lacks is left out. +pub fn lines_from_json(report: &Value) -> Vec { + let text = |v: &Value, k: &str| v.get(k).and_then(Value::as_str).unwrap_or_default().to_string(); + let glyphs: Vec<(String, u64)> = report + .get("missing_glyphs") + .and_then(Value::as_array) + .map(|a| a.iter().map(|g| (text(g, "cp"), g.get("count").and_then(Value::as_u64).unwrap_or(0))).collect()) + .unwrap_or_default(); + let pictures: Vec<(String, String, String)> = report + .get("missing_pictures") + .and_then(Value::as_array) + .map(|a| a.iter().map(|p| (text(p, "part"), story_label(&text(p, "source_part")).to_string(), reason_label(&text(p, "reason")))).collect()) + .unwrap_or_default(); + let flag = |k: &str| report.get(k).and_then(Value::as_bool).unwrap_or(false); + // A report without the per-list flags (the top-level one alone) marks both lists. + let (glyphs_cut, pictures_cut) = match (report.get("glyphs_truncated"), report.get("pictures_truncated")) { + (None, None) => (flag("truncated"), flag("truncated")), + _ => (flag("glyphs_truncated"), flag("pictures_truncated")), + }; + let total = |k: &str, listed: usize| report.get(k).and_then(Value::as_u64).unwrap_or(listed as u64); + let totals = (total("total_missing_glyphs", glyphs.len()), total("total_missing_pictures", pictures.len())); + human_parts(&glyphs, &pictures, (glyphs_cut, pictures_cut), totals).into_iter().map(|p| format!("strict: {p}")).collect() +} + +/// The words for a JSON reason code; a code this version does not know is shown as it is. +fn reason_label(code: &str) -> String { + wordcraft_doc::MissingPictureReason::from_code(code).map_or_else(|| code.to_string(), |r| r.label().to_string()) +} + +/// `U+2D30`: upper-case hex, at least four digits. +fn code_point(c: char) -> String { + format!("U+{:04X}", c as u32) +} + +/// The part a picture is in, as the report names it: `header2.xml` for `word/header2.xml`. +fn story_label(story: &str) -> &str { + story.strip_prefix("word/").unwrap_or(story) +} + +/// The report of `doc` as drawn by `lay` with `display` into `output`, on `pages` (0-based; `None` +/// is every page). The watermark is checked with the page, and the pictures dropped when the +/// document was opened are added to those that can't be decoded now, capped like each. +pub fn strict_report( + doc: &Document, + lay: &DocLayout, + display: &DisplayOptions, + pages: Option>, + output: StrictOutput, +) -> StrictReport { + let mut missing = lay.missing_glyphs(doc, display, pages.clone()); + let any_page = lay.pages.iter().enumerate().any(|(n, _)| pages.as_ref().is_none_or(|r| r.contains(&n))); + if any_page && let Some(wm) = &doc.settings.watermark { + let max = match output { + StrictOutput::Pdf => wordcraft_pdf::WATERMARK_MAX_CHARS, + StrictOutput::Png => usize::MAX, + }; + missing.merge(wordcraft_layout::missing_watermark_glyphs(wm, max)); + } + let (undecodable, mut pictures_truncated, undecodable_total) = wordcraft_layout::undecodable_pictures(doc, lay, pages, display, output); + pictures_truncated |= doc.missing_pictures.truncated; + // The two lists overlap where the same picture is in both; those are counted once. + let mut overlap: u64 = 0; + let mut pictures: Vec = Vec::new(); + for p in doc.missing_pictures.list.iter().cloned().chain(undecodable) { + if pictures.contains(&p) { + overlap = overlap.saturating_add(1); + continue; + } + if pictures.len() >= MAX_MISSING_PICTURES { + pictures_truncated = true; + break; + } + pictures.push(p); + } + let total_pictures = doc.missing_pictures.total.saturating_add(undecodable_total).saturating_sub(overlap).max(pictures.len() as u64); + StrictReport { + glyphs: missing.chars, + pictures, + glyphs_truncated: missing.truncated, + pictures_truncated, + total_glyphs: missing.total, + total_pictures, + } +} + +/// The PDF export's options for `opts`: the one place they are built, shared by the export and +/// its strict check. +pub(crate) fn pdf_options(opts: &ExportOptions) -> wordcraft_pdf::PdfOptions { + wordcraft_pdf::PdfOptions { include_markup: opts.markup, comments: opts.markup && opts.comments, numeral: opts.numeral, ..Default::default() } +} + +/// What a page image shows: the display options `file.exportPng` renders with. +pub(crate) fn png_display() -> DisplayOptions { + // A page image is the printed page: headers and footers at full strength. + let mut opts = wordcraft_render::RenderOptions::default(); + opts.display.dim_header = false; + opts.display +} + +/// The strict report of a PDF export of `doc`: every page, laid out as the export lays it out. +pub(crate) fn pdf_report(doc: &Document, opts: &ExportOptions) -> StrictReport { + let pdf = pdf_options(opts); + let lay = wordcraft_layout::layout(doc, &mut LayoutCache::new(), &pdf.layout_options()); + strict_report(doc, &lay, &pdf.display_options(), None, StrictOutput::Pdf) +} + +/// The PDF of `doc`; with `opts.strict`, only if nothing is missing, laid out once for the check +/// and the export. +pub(crate) fn export_pdf(doc: &Document, opts: &ExportOptions) -> Result, String> { + let pdf = pdf_options(opts); + if !opts.strict { + return wordcraft_pdf::export(doc, &pdf).map_err(|e| e.to_string()); + } + let lay = wordcraft_layout::layout(doc, &mut LayoutCache::new(), &pdf.layout_options()); + let report = strict_report(doc, &lay, &pdf.display_options(), None, StrictOutput::Pdf); + if !report.passed() { + return Err(report.refusal()); + } + wordcraft_pdf::export_layout(doc, &lay, &pdf).map_err(|e| e.to_string()) +} + +/// Is `path` a PDF or PNG output (the only outputs a strict export applies to)? +pub(crate) fn is_strict_output(path: &str) -> bool { + let lower = path.to_ascii_lowercase(); + lower.ends_with(".pdf") || lower.ends_with(".png") +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index a05053b78b..cc9dd55b0c 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1,13495 +1,13504 @@ -// A copy of craft-proof's hand-written Czech fixture (`crates/proof/src/cs/test_dict.rs` in -// craft-libs, which a dependency's tests can't share); its install helper resolves this parent import. -use wordcraft_proof::cs::install_dictionary; -mod czech_test_dict; - -use serde_json::json; -use std::sync::Arc; -use wordcraft_doc::props::BorderStyle; -use wordcraft_doc::{Pos, StoryRef}; - -use crate::{Session, cmd}; - -mod bidi; -mod documents; -mod find_apostrophes; -mod fit_one_page; -mod font_sizes; -mod fonts; -mod ime_noop; -mod para_tools; -mod revision_checks; -mod typing; -mod wordart; - -/// True, after saying so on stderr, when this build lacks the Language data the proofing tests -/// need (craft-proof built without `CRAFT_I18N_DIR`). The probes are craft-proof's public -/// `english_source` (the embedded English dictionary) and its American/British variant table -/// (`colour` → `color`): a craft-i18n checkout without some of the files embeds only the rest, -/// so both are asked. Without them English still spells from craft-proof's built-in word list, -/// but there are no hyphenation patterns and no variant table, so the tests that check those -/// return early, as craft-proof's own do; with the data (CI, releases) they all run. -pub(crate) fn no_language_data(test: &str) -> bool { - let missing = wordcraft_proof::english_source(wordcraft_proof::ProofLang::En) != Some(wordcraft_proof::EnglishSource::Embedded) - || wordcraft_proof::en_variants::preferred("colour", wordcraft_proof::ProofLang::En).as_deref() != Some("color"); - if missing { - eprintln!("{test}: skipped, needs the Language data (set CRAFT_I18N_DIR to a craft-i18n checkout)"); - } - missing -} - -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}"); -} - -/// Gap 9: AES-encrypted Word files — Standard encryption (Word 2007) and agile packages — open -/// with the right password whatever Word extension they carry, and ask for one or refuse a wrong -/// one the same way. -#[test] -fn aes_encrypted_packages_open_under_any_word_extension() { - let mut doc = wordcraft_doc::Document::from_text("Old secret minutes"); - doc.core.title = "Minutes".into(); - let pkg = wordcraft_docx::write(&doc).unwrap(); - let standard = wordcraft_docx::encrypt_standard_for_tests(&pkg, "letmein", 128).unwrap(); - let agile = wordcraft_docx::encrypt_with_spin_count(&pkg, "letmein", 8).unwrap(); - for (kind, bytes) in [("standard", &standard), ("agile", &agile)] { - for name in ["old.doc", "old.docx", "old.dot", "noext"] { - let (d, encrypted) = crate::io::open_bytes_with(name, bytes, Some("letmein")).unwrap_or_else(|e| panic!("{kind} {name}: {e}")); - assert!(encrypted, "{kind} {name}"); - assert!(d.plain_text(StoryRef::Body).contains("Old secret minutes"), "{kind} {name}"); - assert_eq!(d.core.title, "Minutes"); - let e = crate::io::open_bytes_with(name, bytes, None).unwrap_err(); - assert!(crate::io::needs_password(&e), "{kind} {name}: {e}"); - let e = crate::io::open_bytes_with(name, bytes, Some("letmeout")).unwrap_err(); - assert!(crate::io::wrong_password(&e), "{kind} {name}: {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")); -} - -/// The command a shortcut runs. -fn id(k: &str) -> Option<&'static str> { - cmd::registry().by_shortcut(k).map(|c| c.id) -} - -#[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). - 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")); -} - -#[test] -fn word_default_shortcuts_resolve_per_platform() { - 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")); - } -} - -#[test] -fn word_default_shortcuts_resolve_function_keys() { - 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 confined_sessions_neither_save_the_dictionary_nor_choose_folders_outside_their_roots() { - // #3737 (findings 34 and WC-35): the custom dictionary file and the folder settings are - // held to the automation roots like any other path. - let base = scratch_dir("dict-roots"); - for d in ["in", "out", "outside"] { - std::fs::create_dir_all(base.join(d)).unwrap(); - } - let roots = crate::file_access::AutomationRoots::new(Some(&base.join("in")), Some(&base.join("out"))).unwrap(); - let mut s = s(); - let outside = base.join("outside/custom-dictionary.txt"); - s.user_dict_path = Some(outside.clone()); - let confined = |s: &mut Session, cmd: &str, v: serde_json::Value| crate::file_access::confine(roots.as_ref(), || s.run(cmd, &v)); - assert!(confined(&mut s, "review.addToDictionary", json!({"word": "zxqengconfined"})).is_ok()); - assert!(wordcraft_proof::user_dictionary().contains(&"zxqengconfined".to_string()), "kept in memory"); - assert!(!outside.exists(), "nothing written outside the write root"); - for key in ["dictionariesFolder", "userTemplatesFolder"] { - assert!(confined(&mut s, "file.options", json!({key: base.join("outside").to_string_lossy()})).is_err(), "{key}"); - } - assert_eq!((s.prefs.dictionaries_folder.as_deref(), s.prefs.user_templates_folder.as_deref()), (None, None)); - // A folder inside the read root is still taken. - let inside = base.join("in"); - assert!(confined(&mut s, "file.options", json!({"userTemplatesFolder": inside.to_string_lossy()})).is_ok()); - let _ = std::fs::remove_dir_all(&base); - assert_eq!(s.prefs.user_templates_folder.as_deref(), Some(inside.to_string_lossy().as_ref())); -} - -#[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"); -} - -/// A command that changes nothing is not an edit (#23): no undo step, no unsaved changes -/// and no relayout. -#[test] -fn a_command_that_changes_nothing_is_not_an_edit() { - let mut s = s(); - let rev = s.rev(); - // Backspace at the start of the document: there is nothing to delete. - run(&mut s, "text.backspace", json!({})); - assert_eq!(s.undo_depth(), 0, "no undo step"); - assert!(!s.dirty, "no unsaved changes"); - assert_eq!(s.rev(), rev, "no relayout"); - // Typing nothing is the same. - run(&mut s, "text.insert", json!({"text": ""})); - assert_eq!(s.undo_depth(), 0, "no undo step"); - assert!(!s.dirty, "no unsaved changes"); - // A command that changes something is an edit. - run(&mut s, "text.insert", json!({"text": "Hello"})); - assert_eq!(s.undo_depth(), 1, "one undo step"); - assert!(s.dirty, "the document is edited"); - // Setting the alignment a paragraph already has changes nothing. - let depth = s.undo_depth(); - run(&mut s, "para.align", json!({"value": "center"})); - assert_eq!(s.undo_depth(), depth + 1, "one undo step"); - let depth = s.undo_depth(); - run(&mut s, "para.align", json!({"value": "center"})); - assert_eq!(s.undo_depth(), depth, "no undo step"); - // A command that changes nothing leaves the unsaved-changes flag and the layout revision as - // they were, even when it calls `touch` itself: a saved document is not marked edited for - // nothing, and its layout is not laid out again. - let mut clean = Session::new(wordcraft_doc::Document::new()); - run(&mut clean, "para.align", json!({"value": "left"})); - clean.dirty = false; - let rev = clean.rev(); - run(&mut clean, "para.align", json!({"value": "left"})); - assert!(!clean.dirty, "no unsaved changes"); - assert_eq!(clean.rev(), rev, "no relayout"); - assert_eq!(clean.undo_depth(), 1, "only the first align is a step"); -} - -/// Five hundred commands that change nothing used to fill the whole undo stack and push the -/// user's own edits out of it, so they could never be undone again (#23). -#[test] -fn no_op_commands_do_not_evict_the_undo_history() { - let mut s = s(); - run(&mut s, "text.insert", json!({"text": "Hello"})); - run(&mut s, "caret.docStart", json!({})); - for _ in 0..600 { - run(&mut s, "text.backspace", json!({})); - } - assert_eq!(s.undo_depth(), 1, "the typing step is still there"); - run(&mut s, "edit.undo", json!({})); - assert_eq!(text(&s), "", "the typed text is still undoable"); -} - -/// A command that changes nothing does not throw the redo stack away (#23). -#[test] -fn a_no_op_command_keeps_the_redo_stack() { - let mut s = s(); - run(&mut s, "text.insert", json!({"text": "Hello"})); - run(&mut s, "edit.undo", json!({})); - assert!(s.can_redo()); - run(&mut s, "text.backspace", json!({})); - assert!(s.can_redo(), "the redo stack survives a command that changed nothing"); - run(&mut s, "edit.redo", json!({})); - assert_eq!(text(&s), "Hello"); -} - -/// Undo and Redo move between steps even when a step's document is the one showing: they are -/// never taken back as commands that changed nothing, so Undo can't get stuck on such a step. -#[test] -fn undo_moves_past_a_step_that_changed_nothing() { - let mut s = s(); - run(&mut s, "text.insert", json!({"text": "Hello"})); - s.checkpoint("Nothing"); - let depth = s.undo_depth(); - run(&mut s, "edit.undo", json!({})); - assert_eq!(s.undo_depth(), depth - 1); - assert!(s.can_redo()); - run(&mut s, "edit.redo", json!({})); - assert_eq!(s.undo_depth(), depth); - run(&mut s, "edit.undo", json!({})); - run(&mut s, "edit.undo", json!({})); - assert_eq!(text(&s), ""); -} - -/// Commands that run as one undo step (`join_next_undo`: a dialog's OK, the frames of a drag): -/// a group whose first command changes nothing has no step to merge into, so the next command -/// in it starts the group's step instead of merging into an older edit (#23 follow-up). -#[test] -fn a_joined_command_starts_its_group_when_the_first_changed_nothing() { - let mut s = s(); - run(&mut s, "text.insert", json!({"text": "Hello"})); - let (depth, margins) = (s.undo_depth(), (s.doc.last_section.margin_left, s.doc.last_section.margin_top)); - // Portrait on a portrait page changes nothing. - run(&mut s, "layout.orientation", json!({"value": "portrait"})); - assert_eq!(s.undo_depth(), depth, "the first command of the group is no edit"); - // The next command of the group starts the step the group needs. - s.join_next_undo(); - run(&mut s, "layout.margins", json!({"left": 100.0})); - assert_eq!(s.doc.last_section.margin_left, 100.0); - assert_eq!(s.undo_depth(), depth + 1, "the group's one step"); - // A command that changes nothing in the middle of the group leaves that step alone, and - // the next one merges into it. - s.join_next_undo(); - run(&mut s, "layout.orientation", json!({"value": "portrait"})); - s.join_next_undo(); - run(&mut s, "layout.margins", json!({"top": 60.0})); - assert_eq!(s.undo_depth(), depth + 1, "still the group's one step"); - // Undo takes the whole group back and no more: the typing is still there. - run(&mut s, "edit.undo", json!({})); - assert_eq!((s.doc.last_section.margin_left, s.doc.last_section.margin_top), margins, "the margins the group started from"); - assert_eq!(text(&s), "Hello"); -} - -/// Bold with nothing selected arms the formatting for the next word: the document is the -/// same, so only the caret's formatting changed — and that is an edit (#23). -#[test] -fn a_command_that_only_arms_the_caret_formatting_is_an_edit() { - let mut s = s(); - run(&mut s, "text.insert", json!({"text": "Hello"})); - run(&mut s, "caret.docStart", json!({})); - let depth = s.undo_depth(); - run(&mut s, "format.bold", json!({})); - assert_eq!(s.undo_depth(), depth + 1, "one undo step"); - // Undo takes the armed formatting back, as Word does (docs/typing-parity.md). - run(&mut s, "edit.undo", json!({})); - run(&mut s, "text.insert", json!({"text": "x"})); - assert_eq!(s.doc.para_at(&s.sel.focus).map(|p| p.props_of_char(0).bold), Some(None)); -} - -/// 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(); - c.date_utc = None; - }); - 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 regex_replace_expands_groups() { - // #4177: with Regular expressions on, Replace with expands capture groups. - let rep = |text: &str, q: &str, with: &str| rep_mode(text, q, with, json!({"regex": true})); - let names = "Smith, John and Doe, Jane"; - // The issue's cases: `\n` and `$n` swap words; `${name}` names a group. - assert_eq!(rep(names, r"(\w+), (\w+)", r"\2 \1"), "John Smith and Jane Doe"); - assert_eq!(rep(names, r"(\w+), (\w+)", "$2 $1"), "John Smith and Jane Doe"); - assert_eq!(rep("2024-05", r"(?\d{4})-", "${y}/"), "2024/05"); - // Braced numbers, `$0` / `${0}` (whole match), `$$` (a dollar sign). - assert_eq!(rep("ab", "(a)(b)", "${2}${1}"), "ba"); - assert_eq!(rep("ab", "a", "[$0|${0}]"), "[a|a]b"); - assert_eq!(rep("5", r"(\d)", "$$$1"), "$5"); - // `$name` takes the longest run of letters, digits and `_` (the regex crate's rule). - assert_eq!(rep("ab", "(?a)", "$x_"), "b"); - assert_eq!(rep("ab", "(?a)", "${x}_"), "a_b"); - assert_eq!(rep("ab", "(a)", "$1a"), "b"); - // Missing, unknown and out-of-range groups expand to nothing. - assert_eq!(rep("ab", "(a)", "<$2>"), "<>b"); - assert_eq!(rep("ab", "(a)", r"<\9>"), "<>b"); - assert_eq!(rep("ab", "(a)", "<${nope}>"), "<>b"); - assert_eq!(rep("ab", "(a)", "<$99999999999999999999999>"), "<>b"); - assert_eq!(rep("ab", "(a)|(z)", "<$2>"), "<>b"); - // A `$` that starts no group, and an unclosed brace, stay as written. - assert_eq!(rep("ab", "a", "$ ${x"), "$ ${xb"); - // `\t` a tab, `\\` a backslash; other escapes stay as written. - assert_eq!(rep("ab", "a", r"\t\\\q"), "\t\\\\qb"); - // Word's `^` codes still work. - assert_eq!(rep("ab", "a", "^&^t^&"), "a\tab"); - // `\$` and `\^` keep the character as typed (CodeRabbit on #4177). - assert_eq!(rep("ab", "(a)", r"\$1\^p"), "$1^pb"); - // `special: false` turns off the `^` codes only; groups still expand (CodeRabbit on #4177). - assert_eq!(rep_mode("ab", "(a)", "$1^t", json!({"regex": true, "special": false})), "a^tb"); - // `\n` is a paragraph mark: it splits the paragraph, as `^p` does, in one undo step. - let mut s = s(); - run(&mut s, "document.setText", json!({"text": "one, two"})); - assert_eq!(run(&mut s, "edit.replaceAll", json!({"text": r"(\w+), (\w+)", "with": r"$1\n$2", "regex": true}))["replaced"], 1); - assert_eq!(text(&s), "one\ntwo"); - assert_eq!(s.doc.para_paths(StoryRef::Body).len(), 2); - run(&mut s, "edit.undo", json!({})); - assert_eq!(text(&s), "one, two"); - run(&mut s, "document.setText", json!({"text": "a-b"})); - run(&mut s, "edit.replaceAll", json!({"text": "-", "with": "^p", "regex": true})); - assert_eq!(s.doc.para_paths(StoryRef::Body).len(), 2); - // Plain and wildcard Replace are unchanged: `$1` is literal there. - assert_eq!(rep_mode("ab", "a", "$1", json!({})), "$1b"); - assert_eq!(rep_mode("ab", "(a)", "$1\\1", json!({"wildcards": true})), "$1ab"); - // Hostile Replace text never panics. - run(&mut s, "document.setText", json!({"text": "ab ab"})); - for with in ["$", "${", "${}", "${0", "$\u{e9}", "\\", "${\u{1F600}}", "$18446744073709551616", "${-1}", "$$$", "\\0\\n\\", "^"] { - let _ = s.run("edit.replaceAll", &json!({"text": "(a)(?b)?", "with": with, "regex": true})); - } -} - -/// The body text after Replace All of `q` with `with` in `text`, matching case, with `extra` options. -fn rep_mode(text: &str, q: &str, with: &str, extra: serde_json::Value) -> String { - let mut s = s(); - run(&mut s, "document.setText", json!({"text": text})); - let mut v = json!({"text": q, "with": with, "matchCase": true}); - if let (Some(o), Some(e)) = (v.as_object_mut(), extra.as_object()) { - o.extend(e.clone()); - } - run(&mut s, "edit.replaceAll", v); - self::text(&s) -} - -#[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"); -} - -/// Whether any run in the body is bold. -fn any_bold(s: &Session) -> bool { - s.doc.body.iter().filter_map(|b| b.as_para()).flat_map(|p| p.runs.iter()).any(|r| r.props.bold == Some(true)) -} - -#[test] -fn paste_from_another_program_does_not_bring_back_our_fragment() { - // Review on #6225: when another program owns the clipboard, matching text must not make - // Paste put WordCraft's own formatted fragment back. - let mut s = s(); - run(&mut s, "document.setText", json!({"text": "Bold"})); - run(&mut s, "select.all", json!({})); - run(&mut s, "format.bold", json!({})); - run(&mut s, "edit.copy", json!({})); - assert_eq!(s.clipboard_text, "Bold"); - - // The same text, copied by another program: paste it as plain text, not our bold fragment. - run(&mut s, "document.setText", json!({"text": "Plain "})); - run(&mut s, "caret.docEnd", json!({})); - run(&mut s, "edit.paste", json!({"text": "Bold", "privateClipboard": false})); - assert_eq!(text(&s), "Plain Bold"); - assert!(!any_bold(&s), "{:?}", s.doc.body); - - // Callers that omit the flag (an agent, the CLI) still get the session's own copy. - run(&mut s, "caret.docEnd", json!({})); - run(&mut s, "edit.paste", json!({"text": "Bold"})); - assert!(any_bold(&s), "{:?}", s.doc.body); -} - -#[test] -fn paste_special_does_not_offer_our_fragment_for_another_programs_copy() { - let mut s = s(); - run(&mut s, "document.setText", json!({"text": "Bold"})); - run(&mut s, "select.all", json!({})); - run(&mut s, "format.bold", json!({})); - run(&mut s, "edit.copy", json!({})); - - let r = run(&mut s, "edit.pasteSpecial", json!({"text": "Bold", "privateClipboard": false})); - let ids: Vec<&str> = r["formats"].as_array().map(|a| a.iter().filter_map(|f| f["id"].as_str()).collect()).unwrap_or_default(); - assert!(!ids.contains(&"formatted"), "{ids:?}"); - assert_eq!(s.ui_requests.last().and_then(|r| r.get("privateClipboard")), Some(&json!(false)), "{:?}", s.ui_requests); - - let e = s.run("edit.pasteSpecial", &json!({"as": "formatted", "text": "Bold", "privateClipboard": false})); - assert!(e.is_err(), "another program's copy cannot be pasted as our fragment"); -} - -/// 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); -} - -/// Bold and italic of a paragraph's character, in a document. -fn emphasis_at(d: &wordcraft_doc::Document, block: usize, off: usize) -> (bool, bool) { - match d.body.get(block).map(|b| &**b) { - Some(wordcraft_doc::Block::Para(p)) => { - let c = p.props_of_char(off); - (c.bold.unwrap_or(false), c.italic.unwrap_or(false)) - } - _ => (false, false), - } -} - -#[test] -fn copying_puts_rtf_on_the_clipboard() { - // gap-clipboard-rtf: a copy carries RTF beside its text and HTML, for programs that read RTF. - let mut s = s(); - run(&mut s, "document.setText", json!({"text": "plain bold\nsome italic"})); - s.doc.format_range(&Pos::body(0, 6), &Pos::body(0, 10), &|c| c.bold = Some(true)).unwrap(); - s.doc.format_range(&Pos::body(1, 5), &Pos::body(1, 11), &|c| c.italic = Some(true)).unwrap(); - run(&mut s, "select.all", json!({})); - let r = run(&mut s, "edit.copy", json!({})); - let rtf = r["rtf"].as_str().unwrap_or_default().to_string(); - assert!(rtf.starts_with("{\\rtf1"), "{r}"); - assert_eq!(s.clipboard_rtf, rtf); - let d = wordcraft_formats::rtf::import(rtf.as_bytes()).unwrap(); - let paras: Vec = - d.body.iter().filter_map(|b| if let wordcraft_doc::Block::Para(p) = &**b { Some(p.text.clone()) } else { None }).collect(); - assert_eq!(paras.get(..2), Some(&["plain bold".to_string(), "some italic".to_string()][..]), "{paras:?}"); - assert_eq!(emphasis_at(&d, 0, 0), (false, false)); - assert_eq!(emphasis_at(&d, 0, 7), (true, false)); - assert_eq!(emphasis_at(&d, 1, 7), (false, true)); - - // Pasting it elsewhere keeps the formatting. - let mut t = Session::new(wordcraft_doc::Document::new()); - run(&mut t, "edit.paste", json!({"rtf": rtf})); - assert!(text(&t).starts_with("plain bold\nsome italic"), "{}", text(&t)); - assert_eq!(emphasis_at(&t.doc, 0, 7), (true, false)); - assert_eq!(emphasis_at(&t.doc, 1, 7), (false, true)); - - // Cut carries it too. - run(&mut s, "select.range", json!({"anchor": {"block": 0, "off": 6}, "focus": {"block": 0, "off": 10}})); - let r = run(&mut s, "edit.cut", json!({})); - assert!(r["rtf"].as_str().is_some_and(|t| t.starts_with("{\\rtf1") && t.contains("bold")), "{r}"); - assert_eq!(text(&s).lines().next(), Some("plain ")); - - // Nothing selected: nothing copied, no RTF. - run(&mut s, "caret.docEnd", json!({})); - let r = run(&mut s, "edit.copy", json!({})); - assert!(r.get("rtf").is_none(), "{r}"); -} - -#[test] -fn pasting_hostile_rtf_never_panics() { - let deep = format!("{{\\rtf1 {}x{}", "{\\b ".repeat(5000), "}".repeat(5000)); - let junk = ["{\\rtf1", "{\\rtf1 {\\b bo", "{\\rtf1\\ansi\\u-99999999999? \\bin999999999 x}", "{\\rtf1 \\'zz\\fonttbl{", "{\\rtf\u{0}\u{FFFF}}"]; - for rtf in junk.iter().map(|j| j.to_string()).chain([deep]) { - for id in ["edit.paste", "edit.pasteMerge", "edit.pasteSpecial"] { - let mut t = Session::new(wordcraft_doc::Document::new()); - // `Session::run` turns a panic into an error: look for it. - for v in [json!({"rtf": rtf, "as": "rtf"}), json!({"rtf": rtf, "text": "fallback"})] { - if let Err(e) = t.run(id, &v) { - assert!(!e.to_string().contains("panicked"), "{id}: {e}"); - } - let _ = t.layout(); - } - } - } -} - -#[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() }; - Arc::make_mut(&mut 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::default(), "Numbering toggles off the paragraph's effective numbered level"); - run(&mut s, "para.numbering", json!({})); - run(&mut s, "para.listLevel", json!({"level": 1})); - 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 convert_text_to_table_keeps_inline_content() { - // Issue #3751: the cells were rebuilt from plain text, losing formatting, pictures, links - // and tracked deletions. - use wordcraft_doc::para::InlineObject; - let mut s = s(); - run(&mut s, "document.setText", json!({"text": "Name\t\tLink\tOld"})); - { - let p = s.doc.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap(); - p.format(0, 4, &|c| c.bold = Some(true)).unwrap(); - p.format(6, 10, &|c| c.link = Some("https://example.com".into())).unwrap(); - p.format(11, 14, &|c| c.del = Some(1)).unwrap(); - let img = InlineObject::Image { - media: "pic".into(), - w: 10.0, - h: 10.0, - alt: String::new(), - float: Default::default(), - crop: [0.0; 4], - fx: Default::default(), - ole: None, - }; - p.insert_object(5, img, &Default::default()).unwrap(); - } - run(&mut s, "select.all", json!({})); - run(&mut s, "table.fromText", json!({})); - let t = s.doc.body.iter().find_map(|b| b.as_table()).expect("a table"); - let cell = |c: usize| t.rows[0].cells[c].blocks.iter().find_map(|b| b.as_para()).expect("a paragraph"); - assert_eq!(cell(0).text, "Name"); - assert_eq!(cell(0).props_of_char(0).bold, Some(true), "cell 1 lost bold"); - assert!(matches!(cell(1).objects.as_slice(), [InlineObject::Image { media, .. }] if media == "pic"), "cell 2 lost its picture"); - assert_eq!(cell(2).text, "Link"); - assert_eq!(cell(2).props_of_char(0).link.as_deref(), Some("https://example.com"), "cell 3 lost its link"); - assert_eq!(cell(3).text, "Old"); - assert_eq!(cell(3).props_of_char(0).del, Some(1), "cell 4 lost its tracked deletion"); -} - -/// A table `cols` wide holding `cells` row by row, typed in with Tab; the caret ends in the -/// last cell. -fn typed_table(cols: usize, cells: &[&str]) -> Session { - let mut s = s(); - run(&mut s, "insert.table", json!({"rows": cells.len() / cols, "cols": cols})); - 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 -} - -/// The body as `T` for a table and the text of each paragraph. -fn body_outline(s: &Session) -> Vec { - s.doc.body.iter().map(|b| b.as_para().map_or_else(|| "T".to_string(), |p| p.text.clone())).collect() -} - -fn cell_at(table: u32, r: u32, c: u32) -> serde_json::Value { - json!({"story": "body", "path": [table, r, c, 0], "off": 0}) -} - -/// Convert to Text sets a row's cells apart with a tab, a comma, another character or -/// paragraph marks; rows are paragraphs. -#[test] -fn convert_to_text_takes_each_separator() { - let cases = [ - (json!({}), vec!["a\tb", "c\td"]), - (json!({"separator": "tab"}), vec!["a\tb", "c\td"]), - (json!({"separator": "comma"}), vec!["a,b", "c,d"]), - (json!({"separator": "other", "char": ";"}), vec!["a;b", "c;d"]), - (json!({"separator": "paragraph"}), vec!["a", "b", "c", "d"]), - ]; - for (params, want) in cases { - let mut s = typed_table(2, &["a", "b", "c", "d"]); - run(&mut s, "table.toText", params.clone()); - let got = body_outline(&s); - assert_eq!(got.get(..want.len()), Some(&want.iter().map(|x| x.to_string()).collect::>()[..]), "{params}: {got:?}"); - assert!(s.doc.body.iter().all(|b| b.as_table().is_none()), "{params}"); - } - // U+FFFC marks an object in a paragraph's text, so it cannot be the separator. - let obj = wordcraft_doc::para::OBJ.to_string(); - for bad in [ - json!({"separator": "other"}), - json!({"separator": "other", "char": "\n"}), - json!({"separator": "other", "char": obj}), - json!({"separator": "pipe"}), - ] { - let mut s = typed_table(2, &["a", "b", "c", "d"]); - let depth = s.undo_depth(); - assert!(s.run("table.toText", &bad).is_err(), "{bad}"); - assert!(s.doc.body.iter().any(|b| b.as_table().is_some()), "{bad}: the table stays"); - assert_eq!(s.undo_depth(), depth); - } -} - -/// With rows selected, only those become text; the rows above and below stay tables, and a -/// vertical merge cut by the selection starts again below it. -#[test] -fn convert_to_text_converts_the_selected_rows() { - let mut s = typed_table(2, &["a", "b", "c", "d", "e", "f", "g", "h"]); - let t = s.doc.body.iter().position(|b| b.as_table().is_some()).unwrap() as u32; - // Column 1 merged down rows 1 to 3. - run(&mut s, "select.range", json!({"anchor": cell_at(t, 1, 1), "focus": cell_at(t, 3, 1)})); - run(&mut s, "table.merge", json!({})); - run(&mut s, "select.range", json!({"anchor": cell_at(t, 1, 0), "focus": cell_at(t, 1, 1)})); - run(&mut s, "table.toText", json!({})); - let at = t as usize; - // The merged cell holds d, f and h, one paragraph each. - assert_eq!(body_outline(&s).get(at..at + 5), Some(&["T", "c\td", "f", "h", "T"].map(String::from)[..]), "{:?}", body_outline(&s)); - let head = s.doc.body[at].as_table().unwrap(); - let tail = s.doc.body[at + 4].as_table().unwrap(); - assert_eq!((head.rows.len(), tail.rows.len()), (1, 2)); - use wordcraft_doc::props::VMerge; - let merges: Vec = tail.rows.iter().map(|r| r.cells.last().unwrap().props.vmerge).collect(); - assert_eq!(merges, [VMerge::Restart, VMerge::Continue], "the merge starts again in the first row left"); - assert_eq!(s.sel.focus.path.0, [t + 1], "the caret goes to the converted text"); - - // A merge that went on into the converted rows ends in the last row above them. - let mut s = typed_table(2, &["a", "b", "c", "d", "e", "f"]); - let t = s.doc.body.iter().position(|b| b.as_table().is_some()).unwrap() as u32; - run(&mut s, "select.range", json!({"anchor": cell_at(t, 0, 0), "focus": cell_at(t, 1, 0)})); - run(&mut s, "table.merge", json!({})); - run(&mut s, "select.range", json!({"anchor": cell_at(t, 1, 1), "focus": cell_at(t, 2, 1)})); - run(&mut s, "table.toText", json!({})); - let head = s.doc.body[t as usize].as_table().unwrap(); - assert_eq!(head.rows.len(), 1); - assert_eq!(head.rows[0].cells[0].props.vmerge, VMerge::None, "no merge goes on below the table"); - - // A floating table keeps its place in the part above; the part below goes in the text flow. - let mut s = typed_table(2, &["a", "b", "c", "d", "e", "f"]); - let t = s.doc.body.iter().position(|b| b.as_table().is_some()).unwrap() as u32; - let float = wordcraft_doc::props::TableFloat { x: 100.0, y: 200.0, overlap: true, ..Default::default() }; - s.doc.table_mut(StoryRef::Body, &wordcraft_doc::Path(vec![t])).unwrap().props.float = Some(float); - run(&mut s, "select.range", json!({"anchor": cell_at(t, 1, 0), "focus": cell_at(t, 1, 1)})); - run(&mut s, "table.toText", json!({})); - let at = t as usize; - assert_eq!(s.doc.body[at].as_table().unwrap().props.float, Some(float)); - assert_eq!(s.doc.body[at + 2].as_table().unwrap().props.float, None, "the part below doesn't sit on the part above"); - - // The caret alone in a cell converts every row, as before. - let mut s = typed_table(2, &["a", "b", "c", "d", "e", "f"]); - run(&mut s, "table.toText", json!({})); - assert!(s.doc.body.iter().all(|b| b.as_table().is_none())); -} - -/// A content control around the table keeps both its ends when the first rows, the last rows -/// or every row become text. -#[test] -fn convert_to_text_keeps_a_content_control_around_the_table() { - use wordcraft_doc::{ContentControl, ControlWrap, InlineObject}; - let has = |p: &wordcraft_doc::Paragraph, start: bool| { - p.objects.iter().any(|o| if start { matches!(o, InlineObject::ControlStart { .. }) } else { matches!(o, InlineObject::ControlEnd) }) - }; - for rows in [Some((0, 0)), Some((2, 2)), None] { - let mut s = typed_table(2, &["a", "b", "c", "d", "e", "f"]); - let t = s.doc.body.iter().position(|b| b.as_table().is_some()).unwrap() as u32; - s.doc.table_mut(StoryRef::Body, &wordcraft_doc::Path(vec![t])).unwrap().controls = - ControlWrap { open: vec![ContentControl { tag: "wrap".into(), ..Default::default() }], close: 1 }; - assert!(!s.doc.has_unbalanced_controls()); - match rows { - Some((r0, r1)) => run(&mut s, "select.range", json!({"anchor": cell_at(t, r0, 0), "focus": cell_at(t, r1, 1)})), - None => run(&mut s, "select.range", json!({"anchor": cell_at(t, 1, 0), "focus": cell_at(t, 1, 0)})), - }; - run(&mut s, "table.toText", json!({})); - assert!(!s.doc.has_unbalanced_controls(), "{rows:?}: {:?}", body_outline(&s)); - let at = t as usize; - if rows != Some((2, 2)) { - assert!(s.doc.body[at].as_para().is_some_and(|p| has(p, true)), "{rows:?}: the control starts on the text"); - } - if rows != Some((0, 0)) { - let last = s.doc.body.iter().skip(at).rfind(|b| b.as_para().is_some_and(|p| !p.text.is_empty()) || b.as_table().is_some()).unwrap(); - assert!(last.as_para().is_some_and(|p| has(p, false)), "{rows:?}: the control ends on the text"); - } - } - - // A control around rows 0 to 3, row 1 converted: the table above closes it, the table below - // opens it again. - let mut s = typed_table(2, &["a", "b", "c", "d", "e", "f", "g", "h"]); - let t = s.doc.body.iter().position(|b| b.as_table().is_some()).unwrap() as u32; - { - let table = s.doc.table_mut(StoryRef::Body, &wordcraft_doc::Path(vec![t])).unwrap(); - table.rows[0].controls.open = vec![ContentControl { id: Some(7), tag: "rows".into(), ..Default::default() }]; - table.rows[3].controls.close = 1; - } - run(&mut s, "select.range", json!({"anchor": cell_at(t, 1, 0), "focus": cell_at(t, 1, 1)})); - run(&mut s, "table.toText", json!({})); - let at = t as usize; - let head = s.doc.body[at].as_table().unwrap(); - let tail = s.doc.body[at + 2].as_table().unwrap(); - assert_eq!((head.rows.len(), head.rows[0].controls.close), (1, 1), "the table above closes the control"); - assert_eq!(tail.rows[0].controls.open.iter().map(|c| c.tag.as_str()).collect::>(), ["rows"], "the table below opens it"); - assert_eq!((head.rows[0].controls.open[0].id, tail.rows[0].controls.open[0].id), (Some(7), None), "one w:id in the file"); - assert_eq!(tail.rows[1].controls.close, 1); - assert!(!s.doc.has_unbalanced_controls()); -} - -/// The text keeps its formatting, and a table nested in a cell stays a table. -#[test] -fn convert_to_text_keeps_formatting_and_nested_tables() { - let mut s = typed_table(2, &["Name", "X"]); - let t = s.doc.body.iter().position(|b| b.as_table().is_some()).unwrap() as u32; - let first = json!({"story": "body", "path": [t, 0, 0, 0], "off": 0}); - let end = json!({"story": "body", "path": [t, 0, 0, 0], "off": 4}); - run(&mut s, "select.range", json!({"anchor": first, "focus": end})); - run(&mut s, "format.bold", json!({})); - // A table nested in the second cell, after its text. - s.sel = crate::Selection::caret(wordcraft_doc::Pos { story: StoryRef::Body, path: wordcraft_doc::Path(vec![t, 0, 1, 0]), off: 1 }); - run(&mut s, "text.newParagraph", json!({})); - run(&mut s, "insert.table", json!({"rows": 1, "cols": 1})); - run(&mut s, "text.insert", json!({"text": "Inner"})); - s.sel = crate::Selection::caret(wordcraft_doc::Pos { story: StoryRef::Body, path: wordcraft_doc::Path(vec![t, 0, 0, 0]), off: 0 }); - run(&mut s, "table.toText", json!({})); - let at = t as usize; - let line = s.doc.body[at].as_para().unwrap(); - assert_eq!(line.text, "Name\tX"); - assert_eq!(line.props_of_char(0).bold, Some(true), "the text lost its bold"); - assert_ne!(line.props_of_char(4).bold, Some(true), "the tab takes no formatting"); - let inner = s.doc.body.iter().skip(at + 1).find_map(|b| b.as_table()).expect("the nested table stays a table"); - assert_eq!(table_cell_text(inner), "Inner"); - - // A selected row whose first cell starts with a nested table: the caret goes to the row's - // text, not back into the table above. - let mut s = typed_table(2, &["A", "B", "", "D"]); - let t = s.doc.body.iter().position(|b| b.as_table().is_some()).unwrap() as u32; - s.sel = crate::Selection::caret(wordcraft_doc::Pos { story: StoryRef::Body, path: wordcraft_doc::Path(vec![t, 1, 0, 0]), off: 0 }); - run(&mut s, "insert.table", json!({"rows": 1, "cols": 1})); - let row = &s.doc.body[t as usize].as_table().unwrap().rows[1]; - assert!(row.cells[0].blocks.first().is_some_and(|b| b.as_table().is_some()), "the cell starts with the nested table"); - run(&mut s, "select.range", json!({"anchor": cell_at(t, 1, 1), "focus": cell_at(t, 1, 1)})); - s.sel.anchor = wordcraft_doc::Pos { story: StoryRef::Body, path: wordcraft_doc::Path(vec![t, 1, 0, 1]), off: 0 }; - run(&mut s, "table.toText", json!({})); - let at = t as usize; - assert_eq!(body_outline(&s).get(at..at + 3), Some(&["T", "T", "\tD"].map(String::from)[..]), "{:?}", body_outline(&s)); - assert_eq!(s.sel.focus.path.0, [t + 2], "the caret is on the row's text"); -} - -fn table_cell_text(t: &wordcraft_doc::Table) -> String { - t.rows.iter().flat_map(|r| &r.cells).flat_map(|c| &c.blocks).filter_map(|b| b.as_para()).map(|p| p.text.clone()).collect() -} - -#[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); -} - -/// Tests that install a test dictionary wait for the dictionaries-folder test, which changes the -/// folder and then reads what each language reports: an install in between reports no file. -static DICTIONARIES: std::sync::Mutex<()> = std::sync::Mutex::new(()); - -fn dictionaries_turn() -> std::sync::MutexGuard<'static, ()> { - DICTIONARIES.lock().unwrap_or_else(std::sync::PoisonError::into_inner) -} - -/// 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() { - let _turn = dictionaries_turn(); - 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() { - let _turn = dictionaries_turn(); - 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 › Language › 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 _turn = dictionaries_turn(); - 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), - // The others the tests install, so reloading from the folder keeps their words. - ("it_IT", ITALIAN_TEST_AFF, ITALIAN_TEST_DIC), - ("es_ES", SPANISH_TEST_AFF, SPANISH_TEST_DIC), - ("es_UY", SPANISH_TEST_AFF, URUGUAYAN_TEST_DIC), - // #3252: set_dictionaries_folder() now reloads Czech too (it used to leave its cache - // alone), so a run of this test overlapping czech_proofing_through_the_review_commands - // would otherwise force a reload that finds no cs_CZ here and empties it mid-test. Same - // fixture content either way keeps such a race harmless, as it already does for Polish. - ("cs_CZ", czech_test_dict::AFF, czech_test_dict::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()); - // Each language reports the folder's file, which is searched first. - let reported = |got: Option<&serde_json::Value>, name: &str| { - let want = dir.join(format!("{name}.dic")); - assert_eq!(got, Some(&json!(want.to_string_lossy())), "{name}: {dicts:?}"); - assert_eq!(wordcraft_proof::hunspell::find(&[name]).map(|(_, dic)| dic), Some(want)); - }; - reported(file("pl").as_ref(), "pl_PL"); - reported(file("pt").as_ref(), "pt_BR"); - reported(file("id").as_ref(), "id_ID"); - // Each Spanish variety reports its own file (Spain, Mexico, Latin America, Uruguay, in order). - let spanish: Vec = dicts.iter().filter(|d| d["language"] == "es").map(|d| d["file"].clone()).collect(); - assert_eq!(spanish.len(), 4); - reported(spanish.first(), "es_ES"); - reported(spanish.last(), "es_UY"); - // 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() { - let _turn = dictionaries_turn(); - 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"); -} - -/// The spelling issues of the document: each word with its first suggestion. -fn spelling_with_first_suggestion(s: &mut Session) -> Vec<(String, String)> { - let issues = run(s, "review.issues", json!({})); - issues - .as_array() - .unwrap() - .iter() - .filter(|i| i["kind"] == "spelling") - .map(|i| (i["text"].as_str().unwrap().to_string(), i["suggestions"][0].as_str().unwrap_or("").to_string())) - .collect() -} - -fn pairs(v: &[(&str, &str)]) -> Vec<(String, String)> { - v.iter().map(|(a, b)| (a.to_string(), b.to_string())).collect() -} - -/// English (UK), (Canada) and (Australia) through Review › Language (#1281, #1045): each accepts -/// its own spellings and flags the other dialect's with its own form suggested first, F7 finds -/// it and Change fixes it. -#[test] -fn english_dialects_through_the_review_commands() { - if no_language_data("english_dialects_through_the_review_commands") { - return; - } - let mut s = s(); - run(&mut s, "document.setText", json!({"text": "The colour of the center."})); - // US English by default: the British spelling is flagged, the American one isn't. - assert_eq!(spelling_with_first_suggestion(&mut s), pairs(&[("colour", "color")])); - run(&mut s, "select.all", json!({})); - run(&mut s, "review.language", json!({"lang": "en_gb", "default": true})); - assert_eq!(s.doc.styles.default_chr.lang.as_deref(), Some("en-GB")); - assert_eq!(run(&mut s, "format.state", json!({}))["lang"], "en-GB"); - assert_eq!(spelling_with_first_suggestion(&mut s), pairs(&[("center", "centre")])); - run(&mut s, "caret.docStart", json!({})); - assert_eq!(run(&mut s, "review.spelling", json!({}))["text"], "center"); - run(&mut s, "review.applySuggestion", json!({"text": "centre"})); - assert_eq!(text(&s), "The colour of the centre."); - assert!(spelling_with_first_suggestion(&mut s).is_empty()); - // Canada: British -our and -re, American -ize. - run(&mut s, "document.setText", json!({"text": "We organise the colour and analyze the centre."})); - run(&mut s, "select.all", json!({})); - run(&mut s, "review.language", json!({"lang": "en-CA", "default": true})); - assert_eq!(spelling_with_first_suggestion(&mut s), pairs(&[("organise", "organize")])); - // Australia spells the British way: -ise is right, -yze isn't. - run(&mut s, "select.all", json!({})); - run(&mut s, "review.language", json!({"lang": "en-AU", "default": true})); - assert_eq!(spelling_with_first_suggestion(&mut s), pairs(&[("analyze", "analyse")])); - // Add to Dictionary overrules the dialect. - run(&mut s, "review.addToDictionary", json!({"word": "analyze"})); - assert!(spelling_with_first_suggestion(&mut s).is_empty()); - wordcraft_proof::remove_word("analyze"); -} - -/// A British document keeps its languages through .docx (`w:lang` in the defaults and on a run) -/// and is proofed the same way when it comes back. -#[test] -fn english_dialects_survive_a_docx_round_trip() { - if no_language_data("english_dialects_survive_a_docx_round_trip") { - return; - } - use std::io::Read; - let mut s = s(); - run(&mut s, "document.setText", json!({"text": "Colour and color. Centre and center."})); - run(&mut s, "select.all", json!({})); - run(&mut s, "review.language", json!({"lang": "en-GB", "default": true})); - run(&mut s, "select.text", json!({"text": "Centre and center."})); - run(&mut s, "review.language", json!({"lang": "en-US"})); - let before = spelling_with_first_suggestion(&mut s); - assert_eq!(before, pairs(&[("color", "colour"), ("Centre", "Center")])); - let bytes = crate::io::save_bytes("british.docx", &s.doc).unwrap(); - let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap(); - let mut part = |name: &str| { - let mut xml = String::new(); - zip.by_name(name).unwrap().read_to_string(&mut xml).unwrap(); - xml - }; - assert!(part("word/styles.xml").contains(r#"w:val="en-GB""#), "the default language is written"); - assert!(part("word/document.xml").contains(r#"w:val="en-US""#), "the run's language is written"); - let doc = crate::io::open_bytes("british.docx", &bytes).unwrap(); - assert_eq!(doc.styles.default_chr.lang.as_deref(), Some("en-GB")); - let mut back = Session::new(doc); - assert_eq!(spelling_with_first_suggestion(&mut back), before); -} - -/// An English interface writes new documents in the region's English (English (UK) on a British -/// system, #1045); another region's language leaves US English. -#[test] -fn new_documents_follow_the_regions_english() { - if no_language_data("new_documents_follow_the_regions_english") { - return; - } - let mut s = s(); - s.system_region = wordcraft_geom::Region::from_tag("en_GB.UTF-8"); - assert_eq!(s.region_english().as_deref(), Some("en-GB")); - let tag = s.region_english(); - s.set_editing_language(tag.as_deref()); - assert_eq!(s.doc.styles.default_chr.lang.as_deref(), Some("en-GB"), "the untouched document follows"); - run(&mut s, "document.setText", json!({"text": "Colour, color."})); - assert_eq!(spelling_with_first_suggestion(&mut s), pairs(&[("color", "colour")])); - // The region chosen in Options wins over the system's. - run(&mut s, "file.options", json!({"region": "en-CA"})); - assert_eq!(s.region_english().as_deref(), Some("en-CA")); - for (tag, english) in [("pl-PL", None), ("en-US", None), ("en-AU", Some("en-AU")), ("auto", Some("en-GB"))] { - run(&mut s, "file.options", json!({"region": tag})); - assert_eq!(s.region_english().as_deref(), english, "{tag}"); - } - s.system_region = wordcraft_geom::Region::from_tag("fr-FR"); - assert_eq!(s.region_english(), None); -} - -/// A Spanish interface writes new documents in the region's Spanish (#3360): Uruguay's on a -/// Uruguayan system, Mexico's on a Mexican one, Spain's elsewhere. -#[test] -fn new_documents_follow_the_regions_spanish() { - let mut s = s(); - s.system_region = wordcraft_geom::Region::from_tag("es_UY.UTF-8"); - assert_eq!(s.region_spanish(), "es-UY"); - // The ISO 639-2 code is Spanish too (CodeRabbit review on #3562). - s.system_region = wordcraft_geom::Region::from_tag("spa_MX.UTF-8"); - assert_eq!(s.region_spanish(), "es-MX"); - s.system_region = wordcraft_geom::Region::from_tag("es_UY.UTF-8"); - s.set_editing_language(Some(&s.region_spanish())); - assert_eq!(s.doc.styles.default_chr.lang.as_deref(), Some("es-UY"), "the untouched document follows"); - for (tag, spanish) in [("es-MX", "es-MX"), ("es-419", "es-419"), ("es-ES", "es-ES"), ("en-US", "es-ES"), ("fr-FR", "es-ES"), ("auto", "es-UY")] { - run(&mut s, "file.options", json!({"region": tag})); - assert_eq!(s.region_spanish(), spanish, "{tag}"); - } -} - -// Hand-written synthetic fixtures (no system dictionary data): Spain's has `vosotros` forms, -// Uruguay's has `vos` ones' verbs only (the forms come from the voseo rules). -const SPANISH_TEST_AFF: &str = - "SET UTF-8\nFLAG UTF-8\nTRY aeosrnidlctumpbgvyqhfzjñáéíóú\nMAP 2\nMAP eé\nMAP ií\nREP 2\nREP v b\nREP b v\nSFX S Y 1\nSFX S 0 s [aeiou]\n"; -const SPANISH_TEST_DIC: &str = "10\nesto\nes\nuna\nprueba/S\ndel\ncorrector\ntambién\nhabéis\nvenido\ndecir\n"; -const URUGUAYAN_TEST_DIC: &str = "10\nesto\nes\nuna\nprueba/S\ndel\ncorrector\ntambién\ntener\nvenir\ndecir\n"; - -fn spanish_test_dictionaries() { - use wordcraft_proof::ProofLang; - let _turn = dictionaries_turn(); - for (lang, dic) in [(ProofLang::Es, SPANISH_TEST_DIC), (ProofLang::EsUy, URUGUAYAN_TEST_DIC)] { - wordcraft_proof::es::install_dictionary(lang, SPANISH_TEST_AFF, dic).unwrap(); - } -} - -/// #3553: Spanish proofing through the same commands as Review, F7, Change and Add to -/// Dictionary: `prueva` and `corector` are flagged with `prueba` and `corrector` offered. -#[test] -fn spanish_proofing_through_the_review_commands() { - spanish_test_dictionaries(); - let mut s = s(); - run(&mut s, "document.setText", json!({"text": "esto es una prueva del corector. Habéis venido?"})); - run(&mut s, "select.all", json!({})); - run(&mut s, "review.language", json!({"lang": "es_es", "default": true})); - assert_eq!(s.doc.styles.default_chr.lang.as_deref(), Some("es-ES")); - let issues = run(&mut s, "review.issues", json!({})); - let arr = issues.as_array().unwrap(); - assert!(arr.iter().all(|i| i["lang"] == "es"), "{issues}"); - let has = |text: &str, kind: &str, sugg: &str| { - arr.iter().any(|i| i["text"] == text && i["kind"] == kind && i["suggestions"].as_array().unwrap().iter().any(|x| x == sugg)) - }; - assert!(has("prueva", "spelling", "prueba"), "{issues}"); - assert!(has("corector", "spelling", "corrector"), "{issues}"); - assert!(has("Habéis venido?", "grammar", "¿Habéis venido?"), "{issues}"); - assert_eq!(arr.len(), 3, "{issues}"); - assert!(arr.iter().any(|i| i["message"] == "Posible error ortográfico"), "{issues}"); - run(&mut s, "caret.docStart", json!({})); - assert_eq!(run(&mut s, "review.spelling", json!({}))["text"], "prueva"); - run(&mut s, "review.applySuggestion", json!({"text": "prueba"})); - assert!(text(&s).starts_with("esto es una prueba del"), "{}", text(&s)); - run(&mut s, "review.addToDictionary", json!({"word": "corector"})); - let left = run(&mut s, "review.issues", json!({})); - assert_eq!(left.as_array().unwrap().len(), 1, "{left}"); -} - -/// #3562 review: Add to Dictionary and Ignore All get the issue's own text, decomposed (NFD) as -/// typed; the word is then accepted decomposed and composed, and its issue range was the typed -/// bytes. -#[test] -fn add_and_ignore_all_accept_both_normalization_forms() { - spanish_test_dictionaries(); - let mut s = s(); - let text = "esto zqanade\u{301}do y zqignora\u{301}do"; - run(&mut s, "document.setText", json!({"text": text})); - run(&mut s, "select.all", json!({})); - run(&mut s, "review.language", json!({"lang": "es-ES"})); - let issues = run(&mut s, "review.issues", json!({})); - let words: Vec<(String, u64)> = issues - .as_array() - .unwrap() - .iter() - .filter(|i| i["kind"] == "spelling") - .map(|i| (i["text"].as_str().unwrap().to_string(), i["start"]["off"].as_u64().unwrap())) - .collect(); - assert_eq!(words, [("zqanade\u{301}do".to_string(), 5), ("zqignora\u{301}do".to_string(), text.find("zqign").unwrap() as u64)], "{issues}"); - run(&mut s, "review.addToDictionary", json!({"word": words[0].0})); - run(&mut s, "review.ignoreAll", json!({"word": words[1].0})); - assert!(run(&mut s, "review.issues", json!({})).as_array().unwrap().is_empty()); - // The composed spellings are the same words. - run(&mut s, "document.setText", json!({"text": "esto zqanadédo y zqignorádo"})); - run(&mut s, "select.all", json!({})); - run(&mut s, "review.language", json!({"lang": "es-ES"})); - assert!(run(&mut s, "review.issues", json!({})).as_array().unwrap().is_empty()); - wordcraft_proof::remove_word("zqanadédo"); -} - -/// Uruguayan Spanish accepts the `vos` forms, imperatives included, that Spain's flags. -#[test] -fn uruguayan_spanish_accepts_vos_forms() { - spanish_test_dictionaries(); - let mut s = s(); - run(&mut s, "document.setText", json!({"text": "Vení, decime qué tenés."})); - run(&mut s, "select.all", json!({})); - run(&mut s, "review.language", json!({"lang": "es-UY"})); - assert_eq!(run(&mut s, "format.state", json!({}))["lang"], "es-UY"); - let spelling = |s: &mut Session| -> Vec { - let issues = run(s, "review.issues", json!({})); - issues.as_array().unwrap().iter().filter(|i| i["kind"] == "spelling").filter_map(|i| i["text"].as_str().map(String::from)).collect() - }; - assert_eq!(spelling(&mut s), ["qué"], "only the word the fixture lacks"); - run(&mut s, "select.all", json!({})); - run(&mut s, "review.language", json!({"lang": "es-ES"})); - assert_eq!(spelling(&mut s), ["Vení", "decime", "qué", "tenés"]); -} - -/// 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. -const ITALIAN_TEST_AFF: &str = "SET UTF-8\nTRY aeiortnsclcaffèéè\nMAP 2\nMAP aà\nMAP eéè\nREP 1\nREP caffe caffè\n"; -const ITALIAN_TEST_DIC: &str = "6\ncasa\ncaffè\nroma\ndisse\ne\nper\n"; - -fn italian_test_dictionary() { - let _turn = dictionaries_turn(); - wordcraft_proof::it::install_dictionary(ITALIAN_TEST_AFF, ITALIAN_TEST_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); -} - -/// #2882: Set As Default survives preference serialization and overrides the interface language. -#[test] -fn default_proofing_language_survives_a_restart() { - let mut first = s(); - first.set_editing_language(Some("en-US")); - run(&mut first, "review.language", json!({"lang": "FR_fr", "default": true})); - let saved = serde_json::to_vec(&first.prefs).unwrap(); - run(&mut first, "review.language", json!({"lang": "en-GB"})); - run(&mut first, "edit.undo", json!({})); - run(&mut first, "file.new", json!({})); - assert_eq!(first.doc.styles.default_chr.lang.as_deref(), Some("fr-FR")); - - let mut second = s(); - second.prefs = serde_json::from_slice(&saved).unwrap(); - second.set_editing_language(Some("pl-PL")); - assert_eq!(second.doc.styles.default_chr.lang.as_deref(), Some("fr-FR")); - assert!(!second.dirty && !second.can_undo()); - run(&mut second, "file.new", json!({})); - assert_eq!(second.doc.styles.default_chr.lang.as_deref(), Some("fr-FR")); - let data = crate::cmd::insert::base64_encode(b"Bonjour"); - run(&mut second, "file.open", json!({"path": "note.txt", "data": data})); - assert_eq!(second.doc.styles.default_chr.lang.as_deref(), Some("fr-FR")); - - let mut english = wordcraft_doc::Document::from_text("English document"); - Arc::make_mut(&mut english.styles).default_chr.lang = Some("en-GB".into()); - let bytes = crate::io::save_bytes("english.docx", &english).unwrap(); - let data = crate::cmd::insert::base64_encode(&bytes); - run(&mut second, "file.open", json!({"path": "english.docx", "data": data})); - second.set_editing_language(Some("ru-RU")); - assert_eq!(second.doc.styles.default_chr.lang.as_deref(), Some("en-GB")); - run(&mut second, "file.new", json!({"template": "letter"})); - assert_eq!(second.doc.styles.default_chr.lang.as_deref(), Some("en-US")); -} - -/// Older or malformed settings retain the interface fallback and unrelated preferences. -#[test] -fn default_proofing_language_ignores_damaged_preferences() { - for value in [json!(null), json!("not a tag"), json!(17), json!([])] { - let mut session = s(); - session.prefs = serde_json::from_value(json!({"defaultLanguage": value, "detectLanguage": false})).unwrap(); - session.set_editing_language(Some("pl-PL")); - assert_eq!(session.doc.styles.default_chr.lang.as_deref(), Some("pl-PL")); - assert!(!session.prefs.detect_language); - } - let mut old = s(); - old.prefs = serde_json::from_str("{}").unwrap(); - old.set_editing_language(Some("ru-RU")); - assert_eq!(old.doc.styles.default_chr.lang.as_deref(), Some("ru-RU")); -} - -/// #2882: a final protection rejection rolls back the saved default as well as the document. -#[test] -fn rejected_default_proofing_language_does_not_survive_a_restart() { - for saved in [None, Some("pl-PL")] { - let mut session = Session::new(wordcraft_doc::Document::from_text("Locked intro.\nEditable text.\nLocked end.")); - session.prefs.default_language = saved.map(str::to_string); - session.set_editing_language(Some("en-US")); - run(&mut session, "select.text", json!({"text": "Editable text."})); - run(&mut session, "review.restrict", json!({"exception": "add"})); - run(&mut session, "review.restrict", json!({"mode": "readOnly"})); - run(&mut session, "select.text", json!({"text": "Editable"})); - // Selection-only language formatting is allowed inside the exception. - run(&mut session, "review.language", json!({"lang": "en-GB"})); - let before = session.doc.clone(); - let prefs = serde_json::to_value(&session.prefs).unwrap(); - let undo = session.undo_labels(); - let error = session.run("review.language", &json!({"lang": "fr-FR", "default": true})).unwrap_err(); - assert!(error.to_string().contains("only the editable regions"), "{error}"); - assert_eq!(session.doc, before); - assert_eq!(session.undo_labels(), undo); - assert_eq!(serde_json::to_value(&session.prefs).unwrap(), prefs); - let mut restarted = s(); - restarted.prefs = serde_json::from_value(prefs).unwrap(); - restarted.set_editing_language(Some("en-US")); - run(&mut restarted, "file.new", json!({})); - assert_eq!(restarted.doc.styles.default_chr.lang.as_deref(), Some(saved.unwrap_or("en-US"))); - } -} - -/// 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})); -} - -/// An item's own colour survives item edits and layout changes, and a style colour drops it. -#[test] -fn smart_art_item_colors_stay_until_a_style_is_picked() { - let mut s = s(); - run(&mut s, "insert.smartArt", json!({"layout": "process"})); - let colors = |s: &Session| cmd::smart_art::selected_smart_art(s).unwrap().items.iter().map(|i| i.color.map(|c| c.hex())).collect::>(); - assert!(!cmd::smart_art::selected_smart_art(&s).unwrap().has_custom_colors()); - run(&mut s, "smartArt.itemColor", json!({"index": 1, "color": "FF8800"})); - assert_eq!(colors(&s), [None, Some("FF8800".to_string()), None, None]); - run(&mut s, "smartArt.items", json!({"items": [{"text": "A"}, {"text": "B", "color": "#ff8800"}]})); - run(&mut s, "smartArt.layout", json!({"layout": "cycle"})); - run(&mut s, "smartArt.addShape", json!({"after": 0})); - assert_eq!(colors(&s), [None, None, Some("FF8800".to_string())]); - assert!(s.run("smartArt.itemColor", &json!({"index": 0, "color": "nope"})).is_err()); - run(&mut s, "smartArt.itemColor", json!({"index": 2})); - assert!(!cmd::smart_art::selected_smart_art(&s).unwrap().has_custom_colors()); - run(&mut s, "smartArt.itemColor", json!({"index": 0, "color": "00FF00"})); - run(&mut s, "smartArt.colors", json!({"variant": "accent2"})); - assert!(!cmd::smart_art::selected_smart_art(&s).unwrap().has_custom_colors()); - run(&mut s, "edit.undo", json!({})); - assert!(cmd::smart_art::selected_smart_art(&s).unwrap().has_custom_colors(), "undo brings the colours back"); -} - -/// A malformed colour must not silently replace a coloured item with its style colour. -#[test] -fn smart_art_item_colors_reject_invalid_values_without_changing_the_graphic() { - let mut s = s(); - run(&mut s, "insert.smartArt", json!({"items": [{"text": "Keep", "color": "123456"}]})); - let before = cmd::smart_art::selected_smart_art(&s).unwrap(); - for command in ["insert.smartArt", "smartArt.items"] { - for color in [json!("nope"), json!(42), json!(true), json!([]), json!({})] { - assert!(s.run(command, &json!({"items": [{"text": "Replace", "color": color}]})).is_err(), "{command}: {color}"); - assert_eq!(cmd::smart_art::selected_smart_art(&s).unwrap(), before); - } - run(&mut s, command, json!({"items": [{"text": "Default", "color": null}, {"text": "Omitted"}]})); - assert!(!cmd::smart_art::selected_smart_art(&s).unwrap().has_custom_colors()); - run(&mut s, "edit.undo", json!({})); - assert_eq!(cmd::smart_art::selected_smart_art(&s).unwrap(), before); - } -} - -/// #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.itemColor", - "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"); -} - -/// #3621: with thousands of collapsed headings, an Enter above them (which shifts every index) -/// finds them again without scanning the document once per heading, and a caret move doesn't -/// redo the matching. -#[test] -fn outline_many_collapsed_headings_stay_fast_after_edits() { - const N: usize = 8000; - let text: Vec = (0..N).flat_map(|i| [format!("Heading {i}"), format!("body {i}")]).collect(); - let mut d = wordcraft_doc::Document::from_text(&text.join("\n")); - for i in 0..N { - let p = d.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(2 * i)).unwrap(); - p.props.style = Some("Heading1".into()); - } - let mut s = Session::new(d); - run(&mut s, "view.outline", json!({})); - run(&mut s, "outline.showLevel1", json!({})); - s.sel = crate::Selection::caret(Pos::body(0, 0)); - run(&mut s, "outline.collapse", json!({})); - let collapsed_texts = - |s: &Session| -> Vec { s.view.outline.collapsed.iter().map(|(i, _)| s.doc.body[*i].as_para().unwrap().text.clone()).collect() }; - let before = collapsed_texts(&s); - assert_eq!(before.len(), N, "every heading is collapsed"); - s.sel = crate::Selection::caret(Pos::body(1, 0)); - for _ in 0..10 { - run(&mut s, "text.newParagraph", json!({})); - } - for _ in 0..20 { - run(&mut s, "caret.right", json!({})); - } - assert_eq!(collapsed_texts(&s), before, "the same headings stay collapsed"); -} - -/// #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 formula's `\*` switches format its result when the formula is inserted and when Update Field -/// works it out again. -#[test] -fn table_formula_results_take_the_format_switches() { - for (formula, inserted, updated) in [ - (r"=SUM(ABOVE) \* CardText", "twelve", "twenty-one"), - (r"=SUM(ABOVE) \* CardText \* Caps", "Twelve", "Twenty-one"), - (r"=SUM(ABOVE) \* OrdText \* Upper", "TWELFTH", "TWENTY-FIRST"), - (r"=SUM(ABOVE) \* MERGEFORMAT", "12", "21"), - ] { - let mut s = formula_table(); - let r = run(&mut s, "table.formula", json!({"formula": formula})); - assert_eq!((r["formula"].as_str(), r["result"].as_str()), (Some(formula), Some(inserted))); - 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(updated), "{formula}"); - } -} - -#[test] -fn table_formula_results_take_dollar_text_and_hex() { - for (formula, inserted, updated) in [ - (r"=SUM(ABOVE) \* DollarText", "twelve and 00/100", "twenty-one and 00/100"), - (r"=SUM(ABOVE) \* DollarText \* Upper", "TWELVE AND 00/100", "TWENTY-ONE AND 00/100"), - (r"=SUM(ABOVE) \* Hex", "C", "15"), - ] { - let mut s = formula_table(); - let r = run(&mut s, "table.formula", json!({"formula": formula})); - assert_eq!((r["formula"].as_str(), r["result"].as_str()), (Some(formula), Some(inserted))); - 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(updated), "{formula}"); - } -} - -/// 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() -} - -#[test] -fn table_sort_rejects_invalid_columns_without_changing_rows_or_history() { - for column in [ - json!(-1), - json!(0.5), - json!(0.0), - json!("0"), - json!(null), - json!(true), - json!([]), - json!({}), - json!(1), - json!(u32::MAX as u64 + 1), - json!(u64::MAX), - ] { - let mut s = column_table("en-US", &["Charlie", "Alpha", "Bravo"]); - s.close_typing(); - run(&mut s, "text.insert", json!({"text": "!"})); - let before = s.doc.clone(); - let selection = s.sel.clone(); - let dirty = s.dirty; - assert!(s.run("table.sort", &json!({"column": column, "header": false})).is_err(), "column {column}"); - assert_eq!(s.doc, before, "column {column}"); - assert_eq!(s.sel, selection, "column {column}"); - assert_eq!(s.dirty, dirty, "column {column}"); - run(&mut s, "edit.undo", json!({})); - assert_eq!(column_texts(&s), ["Charlie", "Alpha", "Bravo"], "column {column}: refusal must not consume Undo"); - run(&mut s, "edit.redo", json!({})); - assert_eq!(s.doc, before, "column {column}: Redo must restore the preceding edit"); - } -} - -#[test] -fn table_sort_keeps_default_and_valid_columns() { - let mut s = s(); - run(&mut s, "insert.table", json!({"rows": 3, "cols": 2})); - for (k, value) in ["Charlie", "1", "Alpha", "3", "Bravo", "2"].into_iter().enumerate() { - if k > 0 { - run(&mut s, "text.tab", json!({})); - } - run(&mut s, "text.insert", json!({"text": value})); - } - let original = s.doc.clone(); - for params in [json!({"header": false}), json!({"column": 0, "header": false})] { - run(&mut s, "table.sort", params); - assert_eq!(column_texts(&s), ["Alpha", "Bravo", "Charlie"]); - run(&mut s, "edit.undo", json!({})); - assert_eq!(s.doc, original); - } - run(&mut s, "table.sort", json!({"column": 1, "header": false})); - assert_eq!(column_texts(&s), ["Charlie", "Bravo", "Alpha"]); - run(&mut s, "table.sort", json!({"column": 1, "header": false, "descending": true})); - assert_eq!(column_texts(&s), ["Alpha", "Bravo", "Charlie"]); - // A column may be missing in some rows. Its present cells still sort normally. - let at = s.doc.body.iter().position(|b| b.as_table().is_some()).unwrap() as u32; - let table = s.doc.table_mut(StoryRef::Body, &wordcraft_doc::Path(vec![at])).unwrap(); - table.rows[0].cells.remove(1); - run(&mut s, "table.sort", json!({"column": 1, "header": false})); - assert_eq!(column_texts(&s), ["Charlie", "Bravo", "Alpha"]); - - let mut s = column_table("en-US", &["Title", "3", "1", "2"]); - let at = s.doc.body.iter().position(|b| b.as_table().is_some()).unwrap() as u32; - s.doc.table_mut(StoryRef::Body, &wordcraft_doc::Path(vec![at])).unwrap().rows[0].props.header = true; - run(&mut s, "table.sort", json!({"column": 0})); - assert_eq!(column_texts(&s), ["Title", "1", "2", "3"]); -} - -/// #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")); - Arc::make_mut(&mut 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"); - - // A replace with no `text` and no earlier search is a mistake, not "0 matches" (#3271). - let mut s = super::tests::s(); - for (id, params) in [("edit.replaceAll", json!({"find": "cat", "replace": "dog"})), ("edit.replace", json!({})), ("edit.replaceAll", json!({}))] { - let e = s.run(id, ¶ms).unwrap_err().to_string(); - assert!(e.contains("`text` (string) is required"), "{id}: {e}"); - } - run(&mut s, "edit.replaceAll", json!({"text": "", "with": "x"})); - - // 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); -} - -#[test] -fn window_commands_need_the_desktop_and_name_a_window() { - use crate::cmd::view::OtherWindow; - let mut s = s(); - // Web, CLI, MCP: one window, so View › Window is disabled. - for id in ["view.arrangeAll", "view.sideBySide", "view.syncScroll"] { - assert!(s.run(id, &json!({})).is_err(), "{id} without a window host"); - } - s.windows.available = true; - run(&mut s, "view.arrangeAll", json!({})); - assert_eq!(s.ui_requests.pop(), Some(json!({"windows": "arrange"}))); - assert!(s.run("view.sideBySide", &json!({})).is_err(), "no other window"); - s.windows.others = vec![OtherWindow { id: 7, title: "Notes".into() }, OtherWindow { id: 9, title: "Draft".into() }]; - let e = s.run("view.sideBySide", &json!({})).unwrap_err().to_string(); - assert!(e.contains("7 (Notes)") && e.contains("9 (Draft)"), "several windows: say which: {e}"); - assert!(s.run("view.sideBySide", &json!({"window": 8})).is_err(), "an unknown window"); - assert!(s.run("view.sideBySide", &json!({"window": "9"})).is_err()); - // Side by Side pairs with one window and scrolls together until turned off. - assert!(s.run("view.syncScroll", &json!({})).is_err(), "only while Side by Side is on"); - let r = run(&mut s, "view.sideBySide", json!({"window": 7})); - assert_eq!((r["value"].as_bool(), s.windows.side_by_side, s.windows.sync_scroll), (Some(true), Some(7), true)); - assert_eq!(s.ui_requests.pop(), Some(json!({"windows": "sideBySide", "with": 7}))); - run(&mut s, "view.syncScroll", json!({})); - assert!(!s.windows.sync_scroll); - assert_eq!(s.ui_requests.pop(), Some(json!({"windows": "syncScroll", "with": 7, "value": false}))); - // Asking again for the partner it already has keeps scrolling off and pairs nothing anew. - let r = run(&mut s, "view.sideBySide", json!({"window": 7, "value": true})); - assert_eq!((r["syncScroll"].as_bool(), s.windows.sync_scroll), (Some(false), false)); - assert!(s.ui_requests.is_empty()); - // Another partner replaces the first; the bare button turns it off. - run(&mut s, "view.sideBySide", json!({"window": 9})); - assert_eq!(std::mem::take(&mut s.ui_requests), [json!({"windows": "sideBySide", "off": 7}), json!({"windows": "sideBySide", "with": 9})]); - run(&mut s, "view.sideBySide", json!({})); - assert_eq!((s.windows.side_by_side, s.windows.sync_scroll), (None, false)); - assert_eq!(s.ui_requests.pop(), Some(json!({"windows": "sideBySide", "off": 9}))); - assert!(!s.dirty, "window commands never touch the document"); -} - -/// 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); -} - -/// Change Picture keeps the width and takes the replacement's aspect ratio: swapping a figure for -/// a revised one of a different shape must not stretch it into the old frame. -#[cfg(not(target_arch = "wasm32"))] -#[test] -fn picture_change_keeps_width_and_takes_new_aspect() { - let dir = scratch_dir("picture-change-aspect"); - let mut wide = Vec::new(); - image::RgbImage::from_pixel(40, 20, image::Rgb([200, 30, 30])).write_to(&mut std::io::Cursor::new(&mut wide), image::ImageFormat::Png).unwrap(); - let mut tall = Vec::new(); - image::RgbImage::from_pixel(30, 60, image::Rgb([30, 30, 200])).write_to(&mut std::io::Cursor::new(&mut tall), image::ImageFormat::Png).unwrap(); - let (first, second) = (dir.join("wide.png"), dir.join("tall.png")); - std::fs::write(&first, &wide).unwrap(); - std::fs::write(&second, &tall).unwrap(); - - let mut s = Session::new(wordcraft_doc::Document::new()); - run(&mut s, "insert.picture", json!({"path": first.to_string_lossy()})); - let size = |s: &Session| { - let mut out = (0.0f32, 0.0f32, [1.0f32; 4]); - s.doc.objects_in_reading_order(&mut |o| { - if let wordcraft_doc::para::InlineObject::Image { w, h, crop, .. } = o { - out = (*w, *h, *crop); - } - }); - out - }; - let (w0, h0, _) = size(&s); - assert!((w0 / h0 - 2.0).abs() < 1e-3, "inserted at its own 2:1 shape: {w0} x {h0}"); - run(&mut s, "picture.crop", json!({"left": 0.1})); - let (w_cropped, _, crop) = size(&s); - assert!(crop[0] > 0.0, "cropped before the change"); - run(&mut s, "picture.change", json!({"path": second.to_string_lossy()})); - let (w1, h1, crop) = size(&s); - assert!((w1 - w_cropped).abs() < 1e-3, "the width is kept: {w_cropped} -> {w1}"); - assert!((h1 / w1 - 2.0).abs() < 1e-3, "the height follows the new 1:2 picture: {w1} x {h1}"); - assert_eq!(crop, [0.0; 4], "the old picture's crop is dropped"); - 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"); - - // A name given with the document's extension is the whole file name: an agent or a script - // passes one, and appending the extension again made "Report.docm.docm" (#2983). - s.run("file.rename", &json!({"name": "Report.docm"})).unwrap(); - assert_eq!(s.path.as_ref(), Some(&dir.join("Report.docm"))); - assert!(!dir.join("Report.docm.docm").exists()); - // Another extension, or the extension alone, is a stem and still gets one. - s.run("file.rename", &json!({"name": "Report.2026"})).unwrap(); - assert_eq!(s.path.as_ref(), Some(&dir.join("Report.2026.docm"))); - s.run("file.rename", &json!({"name": ".docm"})).unwrap(); - assert_eq!(s.path.as_ref(), Some(&dir.join(".docm.docm"))); - assert_eq!(std::fs::read(dir.join(".docm.docm")).unwrap(), b"original bytes"); - // A name whose bytes do not line up with the extension's length: comparing by slicing at - // `len - ext.len()` could land inside a character and panic (#2983 review). - s.run("file.rename", &json!({"name": "abécde"})).unwrap(); - assert_eq!(s.path.as_ref(), Some(&dir.join("abécde.docm"))); - s.run("file.rename", &json!({"name": "résumé.docm"})).unwrap(); - assert_eq!(s.path.as_ref(), Some(&dir.join("résumé.docm"))); - // Whatever its case; the file is named as typed, so the extension follows the name. - s.run("file.rename", &json!({"name": "Report.DOCM"})).unwrap(); - assert_eq!(s.path.as_ref(), Some(&dir.join("Report.DOCM"))); - assert!(!dir.join("Report.DOCM.docm").exists()); - 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()); -} - -/// A version snapshot belongs to the document it was taken from: opening another document (or -/// starting a new one) starts a new version history, so a restore can't put the previous -/// document's content into the open one (#1327 follow-up). -#[test] -fn a_version_snapshot_does_not_survive_a_document_switch() { - let mut s = Session::new(wordcraft_doc::Document::from_text("First document.")); - s.run("file.versions", &json!({"save": "snapshot"})).unwrap(); - assert_eq!(s.versions.len(), 1); - - // A new document: the snapshot of the previous one is gone with it. - s.run("file.new", &json!({})).unwrap(); - assert!(s.versions.is_empty(), "the snapshot of the previous document survived the switch"); - assert!(s.run("file.versions", &json!({"restore": 0})).is_err(), "the old snapshot is still restorable"); - assert_ne!(s.doc.plain_text(StoryRef::Body), "First document.", "the old document's content came back"); - - // The same for an opened file. - s.run("file.versions", &json!({"save": "snapshot"})).unwrap(); - s.run("file.open", &json!({"path": "other.txt", "data": "aGk="})).unwrap(); - assert!(s.versions.is_empty(), "the snapshot of the previous document survived the switch"); - assert!(s.run("file.versions", &json!({"restore": 0})).is_err(), "the old snapshot is still restorable"); - assert_eq!(s.doc.plain_text(StoryRef::Body), "hi"); - - // A snapshot of the open document still restores into it. - s.run("file.versions", &json!({"save": "snapshot"})).unwrap(); - s.run("text.insert", &json!({"text": " there"})).unwrap(); - s.run("file.versions", &json!({"restore": 0})).unwrap(); - assert_eq!(s.doc.plain_text(StoryRef::Body), "hi"); -} - -/// A comparison that opens the original replaces the open document, so the version snapshots -/// of the document it replaces go with it: a restore must not put the previous document back -/// over the comparison result (#1327 follow-up). -#[test] -fn a_version_snapshot_does_not_survive_a_comparison() { - let dir = std::env::temp_dir().join(format!("wordcraft-compare-versions-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let original = dir.join("original.docx"); - let mut other = Session::new(wordcraft_doc::Document::from_text("Original document.")); - other.run("file.save", &json!({"path": original.to_string_lossy()})).unwrap(); - - let mut s = Session::new(wordcraft_doc::Document::from_text("Current document.")); - s.run("file.versions", &json!({"save": "snapshot"})).unwrap(); - assert_eq!(s.versions.len(), 1); - s.run("review.compare", &json!({"original": original.to_string_lossy(), "text": "Revised document."})).unwrap(); - assert!(s.versions.is_empty(), "the snapshot of the previous document survived the comparison"); - assert!(s.run("file.versions", &json!({"restore": 0})).is_err(), "the old snapshot is still restorable"); - let _ = std::fs::remove_dir_all(&dir); -} - -/// Compare's new and revised results are untitled documents even without an `original` path. -#[test] -fn a_version_snapshot_does_not_survive_an_untitled_comparison() { - for show_in in ["new", "revised"] { - let mut s = Session::new(wordcraft_doc::Document::from_text("Current document.")); - s.run("file.versions", &json!({"save": "snapshot"})).unwrap(); - s.run("review.compare", &json!({"text": "Revised document.", "showIn": show_in})).unwrap(); - assert!(s.versions.is_empty(), "old snapshots survived a {show_in} comparison"); - assert!(s.run("file.versions", &json!({"restore": 0})).is_err()); - assert!(s.path.is_none() && s.file_name.is_none()); - } -} - -/// A comparison with an `original` replaces the open document in place — the handler loads the -/// original into `s.doc` without `set_document` — so `document_id` does not move and two -/// identical comparisons produce identical documents. A comparison is an edit of its own all -/// the same: the `replaces_document` guard keeps its undo step (#4004 review). -#[test] -fn a_repeated_comparison_is_still_an_edit() { - let dir = std::env::temp_dir().join(format!("wordcraft-compare-twice-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let original = dir.join("original.docx"); - let mut other = Session::new(wordcraft_doc::Document::from_text("Original document.")); - other.run("file.save", &json!({"path": original.to_string_lossy()})).unwrap(); - - let mut s = Session::new(wordcraft_doc::Document::from_text("Current document.")); - let compare = json!({"original": original.to_string_lossy(), "text": "Revised document."}); - s.run("review.compare", &compare).unwrap(); - let depth = s.undo_depth(); - assert!(s.dirty); - s.run("review.compare", &compare).unwrap(); - assert_eq!(s.undo_depth(), depth + 1, "the second comparison is an edit of its own"); - assert!(s.dirty, "the comparison result is unsaved"); - let _ = std::fs::remove_dir_all(&dir); -} - -/// A rejected comparison leaves the current document and its snapshots together. -#[test] -fn a_rejected_comparison_keeps_the_current_versions() { - use wordcraft_doc::{CharProps, InlineObject}; - - let mut s = Session::new(wordcraft_doc::Document::from_text("Editable. Locked.")); - s.path = Some(std::path::PathBuf::from("original.docx")); - s.file_name = Some("original.docx".into()); - s.run("file.versions", &json!({"save": "snapshot"})).unwrap(); - let end = s.doc.end_of(StoryRef::Body); - s.doc.insert_object(&end, InlineObject::PermEnd { id: 1 }, &CharProps::default()).unwrap(); - let start = s.doc.start_of(StoryRef::Body); - s.doc.insert_object(&start, InlineObject::PermStart { id: 1, group: String::new(), editor: s.author.clone() }, &CharProps::default()).unwrap(); - s.run("review.restrict", &json!({"mode": "readOnly"})).unwrap(); - let before = s.doc.clone(); - - assert!(s.run("review.compare", &json!({"text": "Different document.", "showIn": "new"})).is_err()); - assert_eq!(s.doc, before); - assert_eq!(s.versions.len(), 1, "a rejected comparison discarded the current document's snapshot"); - assert_eq!(s.path.as_deref(), Some(std::path::Path::new("original.docx"))); - assert_eq!(s.file_name.as_deref(), Some("original.docx")); -} - -/// A mailing result opens beside a protected source document, which keeps its save target, -/// versions, password and text. -#[test] -fn a_mailing_result_beside_a_protected_document_keeps_its_file_identity() { - use wordcraft_doc::{CharProps, InlineObject}; - - let mut s = Session::new(wordcraft_doc::Document::from_text("Protected source.")); - s.path = Some(std::path::PathBuf::from("source.docx")); - s.file_name = Some("source.docx".into()); - s.run("file.versions", &json!({"save": "snapshot"})).unwrap(); - let end = s.doc.end_of(StoryRef::Body); - s.doc.insert_object(&end, InlineObject::PermEnd { id: 1 }, &CharProps::default()).unwrap(); - let start = s.doc.start_of(StoryRef::Body); - s.doc.insert_object(&start, InlineObject::PermStart { id: 1, group: String::new(), editor: s.author.clone() }, &CharProps::default()).unwrap(); - s.run("review.restrict", &json!({"mode": "readOnly"})).unwrap(); - let before = s.doc.clone(); - let before_id = s.document_id(); - let before_dirty = s.dirty; - s.password = Some(crate::io::Password::new("synthetic file password")); - s.bib_style = "Chicago".into(); - - s.run("mailings.envelopes", &json!({"delivery": "Recipient"})).unwrap(); - assert_ne!(s.document_id(), before_id, "the envelope is a new document"); - s.activate(before_id).unwrap(); - assert_eq!(s.doc, before); - assert_eq!(s.versions.len(), 1); - assert_eq!(s.path.as_deref(), Some(std::path::Path::new("source.docx"))); - assert_eq!(s.file_name.as_deref(), Some("source.docx")); - assert_eq!(s.document_id(), before_id); - assert_eq!(s.dirty, before_dirty); - assert_eq!(s.password.as_ref().map(crate::io::Password::as_str), Some("synthetic file password")); - assert_eq!(s.bib_style, "Chicago"); -} - -/// #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() }; - Arc::make_mut(&mut s.doc.styles).upsert(Style { - id: "HideC".into(), - name: "Hide C".into(), - kind: StyleKind::Character, - chr: hidden.clone(), - ..Default::default() - }); - Arc::make_mut(&mut 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(); - Arc::make_mut(&mut 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(); - Arc::make_mut(&mut 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. -
  3. Classificazione dell’evento
  4. -
  5. Azioni correttive
  6. -
- - - - - - -
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); -} - -/// Shape Format › Shape Fill with transparency and gradients (#1500): each form sets the fill of -/// every selected shape in one undo step; a colour alone keeps the transparency; hostile values -/// are clamped; the old `{"color"}` form still works. -#[test] -fn shape_fill_transparency_and_gradients() { - use wordcraft_doc::fill::{Fill, GradientKind, PathShape}; - use wordcraft_doc::props::Rgb; - let mut s = s(); - run(&mut s, "insert.shape", json!({"kind": "rectangle"})); - run(&mut s, "select.collapse", json!({"end": true})); - run(&mut s, "insert.shape", json!({"kind": "ellipse"})); - run(&mut s, "select.objects", json!({"index": 0})); - run(&mut s, "select.addObject", json!({"index": 1})); - let fills = |s: &Session| -> Vec> { - cmd::objects::all_objects(s) - .into_iter() - .map(|(_, o)| match o { - wordcraft_doc::InlineObject::Shape { fill, .. } => fill, - o => panic!("{o:?}"), - }) - .collect() - }; - let before = fills(&s); - run(&mut s, "shape.fill", json!({"color": "112233", "transparency": 40})); - let solid = Fill::Solid { color: Rgb(0x11, 0x22, 0x33), transparency: 40.0 }; - assert_eq!(fills(&s), [Some(solid.clone()), Some(solid)], "both selected shapes"); - run(&mut s, "shape.fill", json!({"color": "445566"})); - assert_eq!(fills(&s)[1], Some(Fill::Solid { color: Rgb(0x44, 0x55, 0x66), transparency: 40.0 }), "a colour alone keeps the transparency"); - run( - &mut s, - "shape.fill", - json!({"gradient": {"type": "rectangular", "focus": [50, 50, 50, 50], "stops": [{"pos": 100, "color": "FFFFFF", "transparency": 30}, {"pos": 0, "color": "000000"}]}}), - ); - let Some(Fill::Gradient(g)) = &fills(&s)[0] else { panic!("{:?}", fills(&s)) }; - assert_eq!(g.kind, GradientKind::Path { shape: PathShape::Rect, focus: [50.0; 4] }); - assert_eq!(g.stops.iter().map(|st| (st.pos, st.color, st.transparency)).collect::>(), [(0.0, Rgb::BLACK, 0.0), (100.0, Rgb::WHITE, 30.0)]); - assert!(g.rot_with_shape, "turns with the shape, as Word's new gradients do"); - run( - &mut s, - "shape.fill", - json!({"gradient": {"type": "linear", "angle": -90, "stops": [{"pos": -5, "color": "FF0000", "transparency": 500}, {"pos": 1e9, "color": "0000FF"}]}}), - ); - let Some(Fill::Gradient(g)) = &fills(&s)[1] else { panic!() }; - assert_eq!(g.kind, GradientKind::Linear { angle: 270.0, scaled: false }); - assert_eq!((g.stops[0].pos, g.stops[0].transparency, g.stops[1].pos), (0.0, 100.0, 100.0)); - // One undo step per command, for every shape. - run(&mut s, "edit.undo", json!({})); - assert!(matches!(fills(&s)[1], Some(Fill::Gradient(_)))); - for _ in 0..3 { - run(&mut s, "edit.undo", json!({})); - } - assert_eq!(fills(&s), before); - run(&mut s, "shape.fill", json!({"color": null})); - assert_eq!(fills(&s), [None, None]); - for bad in [ - json!({"gradient": {"stops": []}}), - json!({"gradient": {"type": "spiral", "stops": [{"pos": 0, "color": "000000"}, {"pos": 100, "color": "FFFFFF"}]}}), - json!({"gradient": 5}), - json!({"color": "nope"}), - ] { - assert!(s.run("shape.fill", &bad).is_err(), "{bad}"); - } -} - -/// A drag in the Format Shape pane applies its value as it goes and undoes in one step. -#[test] -fn steps_since_a_mark_merge_into_one_undo_step() { - let mut s = s(); - run(&mut s, "insert.shape", json!({"kind": "rectangle"})); - let fill = |s: &Session| match cmd::objects::selected(s) { - Some((_, wordcraft_doc::InlineObject::Shape { fill, .. })) => fill, - o => panic!("{o:?}"), - }; - let before = fill(&s); - let mark = s.undo_mark(); - for t in [10, 20, 30] { - run(&mut s, "shape.fill", json!({"color": "112233", "transparency": t})); - } - s.merge_undo_since(mark); - assert_eq!(s.undo_mark(), mark + 1); - run(&mut s, "edit.undo", json!({})); - assert_eq!(fill(&s), before); - s.merge_undo_since(mark + 5); - assert_eq!(s.undo_mark(), mark, "nothing newer: nothing merged"); -} - -/// 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; - Arc::make_mut(&mut d.styles).get_mut("TOC2").unwrap().para.tabs = Some(vec![right]); - let num = Arc::make_mut(&mut 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"); - - // A selection is as good as its worst paragraph: an empty one or one starting with a space - // refuses a drop cap for all (the button greys out); None works while there is one to take away. - let mut s = self::s(); - run(&mut s, "text.insert", json!({"text": "First"})); - run(&mut s, "text.newParagraph", json!({})); - run(&mut s, "text.newParagraph", json!({})); - run(&mut s, "text.insert", json!({"text": "Third"})); - assert_eq!(s.doc.body.len(), 3); - run(&mut s, "select.all", json!({})); - assert!(s.run("insert.dropCap", &json!({"position": "dropped"})).is_err(), "an empty paragraph in the selection"); - assert!(s.doc.body.iter().filter_map(|b| b.as_para()).all(|p| p.props.drop_cap.is_none()), "nothing changed"); - assert!(s.why_disabled("insert.dropCap").is_some(), "greyed out: nothing can take a drop cap or lose one"); - run(&mut s, "select.range", json!({"anchor": {"block": 2, "off": 0}, "focus": {"block": 2, "off": 0}})); - run(&mut s, "insert.dropCap", json!({"position": "dropped"})); - // From the drop cap up into the empty paragraph (the caret there): None takes it away, the - // other positions are refused, as the enabled check says. - run(&mut s, "select.range", json!({"anchor": {"block": 2, "off": 5}, "focus": {"block": 1, "off": 0}})); - assert_eq!(s.why_disabled("insert.dropCap"), None, "a drop cap to take away"); - assert!(s.run("insert.dropCap", &json!({"position": "margin"})).is_err(), "the empty paragraph can't take one"); - assert!(s.run("insert.dropCap", &json!({"position": "none"})).is_ok(), "None takes it away"); - assert!(s.doc.body.iter().filter_map(|b| b.as_para()).all(|p| p.props.drop_cap.is_none()), "taken away"); - let mut s = self::s(); - run(&mut s, "text.insert", json!({"text": " first"})); - run(&mut s, "text.newParagraph", json!({})); - run(&mut s, "text.insert", json!({"text": "Second"})); - run(&mut s, "select.all", json!({})); - assert!(s.run("insert.dropCap", &json!({})).is_err(), "a paragraph starting with a space in the selection"); -} - -/// #2601: `keep` moves a whole selection to Dropped / In margin without flattening it: each -/// paragraph keeps its own lines, distance and font, one without a drop cap gets the defaults, -/// and it is one undo step. Without `keep` the options not given are reset, as before. -#[test] -fn drop_cap_keep_leaves_each_paragraphs_options() { - let caps = |s: &Session| { - s.doc - .body - .iter() - .filter_map(|b| b.as_para()) - .map(|p| (p.props.drop_cap, p.props.drop_cap_margin, p.props.drop_cap_distance, p.props.drop_cap_font.clone())) - .collect::>() - }; - let mut s = s(); - run(&mut s, "text.insert", json!({"text": "First"})); - for text in ["Second", "Third"] { - run(&mut s, "text.newParagraph", json!({})); - run(&mut s, "text.insert", json!({"text": text})); - } - run(&mut s, "select.range", json!({"anchor": {"block": 0, "off": 0}, "focus": {"block": 0, "off": 0}})); - run(&mut s, "insert.dropCap", json!({"position": "dropped", "lines": 5, "distance": 7.0, "font": "Liberation Mono"})); - run(&mut s, "select.range", json!({"anchor": {"block": 1, "off": 0}, "focus": {"block": 1, "off": 0}})); - run(&mut s, "insert.dropCap", json!({"position": "dropped", "lines": 2, "distance": 3.0})); - let before = caps(&s); - run(&mut s, "select.all", json!({})); - run(&mut s, "insert.dropCap", json!({"position": "margin", "keep": true})); - assert_eq!( - caps(&s), - vec![ - (Some(5), Some(true), Some(7.0), Some("Liberation Mono".to_string())), - (Some(2), Some(true), Some(3.0), None), - (Some(3), Some(true), None, None), - ], - "each keeps its own options; the one without gets the defaults" - ); - run(&mut s, "edit.undo", json!({})); - assert_eq!(caps(&s), before, "one undo step"); - - // Options given explicitly win over the kept ones; a junk `keep` is false. - run(&mut s, "insert.dropCap", json!({"position": "dropped", "keep": true, "lines": 4})); - assert_eq!(caps(&s).iter().map(|c| c.0).collect::>(), vec![Some(4); 3]); - assert_eq!(caps(&s).first().map(|c| (c.2, c.3.clone())), Some((Some(7.0), Some("Liberation Mono".to_string()))), "distance and font still kept"); - // A blank font given with `keep` clears the kept font (the text's own again). - run(&mut s, "insert.dropCap", json!({"position": "dropped", "keep": true, "font": " "})); - assert_eq!(caps(&s).first().map(|c| c.3.clone()), Some(None), "blank font clears the kept one"); - run(&mut s, "insert.dropCap", json!({"position": "dropped", "lines": 5, "distance": 7.0, "font": "Liberation Mono"})); - run(&mut s, "insert.dropCap", json!({"position": "margin", "keep": "yes"})); - assert_eq!(caps(&s), vec![(Some(3), Some(true), None, None); 3], "junk keep: reset what isn't given"); - // None ignores keep. - run(&mut s, "insert.dropCap", json!({"position": "none", "keep": true})); - assert_eq!(caps(&s), vec![(None, None, None, None); 3]); -} - -#[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}"); - } -} - -/// #3490: `table.cellMargins` preserves existing margins for any sides not passed. -#[test] -fn table_cell_margins_preserves_unpassed_sides() { - let mut s = s(); - run(&mut s, "insert.table", json!({"rows": 1, "cols": 1})); - let (tp, _, _) = s.sel.focus.path.cell().unwrap(); - - // 1. Updating 'top' only retains default left (5.4), bottom (0.0), and right (5.4). - run(&mut s, "table.cellMargins", json!({"top": 20.0})); - let margins = s.doc.table(StoryRef::Body, &tp).unwrap().props.cell_margins.unwrap(); - assert_eq!(margins, [20.0, 5.4, 0.0, 5.4]); - - // 2. Updating 'left' subsequently retains previously set top (20.0), bottom (0.0), and right (5.4). - run(&mut s, "table.cellMargins", json!({"left": 10.0})); - let margins = s.doc.table(StoryRef::Body, &tp).unwrap().props.cell_margins.unwrap(); - assert_eq!(margins, [20.0, 10.0, 0.0, 5.4]); - - // 3. Updating 'bottom' retains top (20.0), left (10.0), and right (5.4). - run(&mut s, "table.cellMargins", json!({"bottom": 8.0})); - let margins = s.doc.table(StoryRef::Body, &tp).unwrap().props.cell_margins.unwrap(); - assert_eq!(margins, [20.0, 10.0, 8.0, 5.4]); - - // 4. Updating 'right' retains top (20.0), left (10.0), and bottom (8.0). - run(&mut s, "table.cellMargins", json!({"right": 15.0})); - let margins = s.doc.table(StoryRef::Body, &tp).unwrap().props.cell_margins.unwrap(); - assert_eq!(margins, [20.0, 10.0, 8.0, 15.0]); - - // 5. Updating multiple sides at once retains unpassed sides. - run(&mut s, "table.cellMargins", json!({"top": 0.0, "bottom": 0.0})); - let margins = s.doc.table(StoryRef::Body, &tp).unwrap().props.cell_margins.unwrap(); - assert_eq!(margins, [0.0, 10.0, 0.0, 15.0]); -} - -#[test] -fn headless_ui_requests_stay_bounded() { - let mut s = s(); - let big = "x".repeat(1 << 20); - for _ in 0..200 { - run(&mut s, "edit.pasteSpecial", json!({"text": big})); - run(&mut s, "edit.goto", json!({})); - } - assert!(s.ui_requests.len() <= 64, "len = {}", s.ui_requests.len()); - let size: usize = s.ui_requests.iter().map(|r| r.to_string().len()).sum(); - assert!(size < 4 << 20, "queued {size} bytes"); -} - -/// `file.exportPng` writes only to `.png` paths, so automation can't replace other files with -/// page images (#3696). -#[cfg(not(target_arch = "wasm32"))] -#[test] -fn export_png_refuses_a_path_that_is_not_png() { - let dir = scratch_dir("export-png-extension"); - let mut s = s(); - run(&mut s, "text.insert", json!({"text": "Hello"})); - for name in ["notes.txt", "config", "report.docx", "page.png.txt"] { - let target = dir.join(name); - std::fs::write(&target, b"precious user notes").unwrap(); - let r = s.run("file.exportPng", &json!({"path": target.to_string_lossy()})); - assert!(matches!(r, Err(crate::CmdError::Params(_))), "{name}: {r:?}"); - assert_eq!(std::fs::read(&target).unwrap(), b"precious user notes", "{name} was changed"); - } - // A .png path still works, whatever its case. - for name in ["page.png", "PAGE.PNG"] { - let target = dir.join(name); - s.run("file.exportPng", &json!({"path": target.to_string_lossy()})).unwrap(); - assert!(std::fs::read(&target).unwrap().starts_with(b"\x89PNG"), "{name} is not a PNG"); - } - let _ = std::fs::remove_dir_all(&dir); -} - -/// The body's IME replacement epoch advances when the whole body is swapped (setText, Undo/Redo) -/// and stays put for ordinary edits; an equation editing session cannot ride across a replacement. -#[test] -fn ime_body_epoch_marks_body_replacements_not_edits() { - let mut s = s(); - let id = s.document_id(); - run(&mut s, "text.insert", json!({"text": "one"})); - let first = s.ime_body_epoch(); - run(&mut s, "text.insert", json!({"text": " two"})); - assert_eq!(s.ime_body_epoch(), first, "typing is not a replacement"); - - run(&mut s, "document.setText", json!({"text": "same text"})); - let replaced = s.ime_body_epoch(); - assert!(replaced > first, "setText replaces the body under the same document id"); - assert_eq!(s.document_id(), id, "the document id itself is unchanged"); - - // The same text again still replaces the body: a composition must not survive on content - // equality. - run(&mut s, "document.setText", json!({"text": "same text"})); - assert!(s.ime_body_epoch() > replaced); - - // Undo swaps the body back. - let before_undo = s.ime_body_epoch(); - assert!(s.undo()); - assert!(s.ime_body_epoch() > before_undo, "Undo replaces the body"); -} - -/// Replacing the document body while an equation is being edited ends that equation editing -/// session. -#[test] -fn a_body_replacement_ends_equation_editing() { - let mut s = s(); - run(&mut s, "insert.equation", json!({"linear": "x+y"})); - assert!(s.math.is_some()); - run(&mut s, "document.setText", json!({"text": "x+y"})); - assert!(s.math.is_none(), "the old equation's editing session is gone"); -} - -/// A failed command that cleared the equation session puts it back with the document. -#[test] -fn a_failed_replacement_keeps_equation_editing() { - let mut s = s(); - run(&mut s, "insert.equation", json!({"linear": "x+y"})); - let session = s.math.as_ref().map(|m| m.session); - assert!(session.is_some()); - // Missing `text` is rejected before any mutation. - assert!(s.run("document.setText", &json!({})).is_err()); - assert_eq!(s.math.as_ref().map(|m| m.session), session); -} - -/// Track Changes: deleting a range whose text is already marked deleted is a real no-op. It -/// creates no revision record, no undo-visible change and no IME revision. -#[test] -fn deleting_an_already_deleted_tracked_range_is_a_classified_noop() { - let mut s = s(); - run(&mut s, "text.insert", json!({"text": "abc"})); - run(&mut s, "review.trackChanges", json!({"value": true})); - s.sel = crate::Selection { anchor: Pos::body(0, 0), focus: Pos::body(0, 1) }; - run(&mut s, "text.backspace", json!({})); - let revisions = s.doc.revisions.len(); - let revision = s.ime_state_revision().expect("an IME revision"); - s.sel = crate::Selection { anchor: Pos::body(0, 0), focus: Pos::body(0, 1) }; - run(&mut s, "text.backspace", json!({})); - let outcome = s.ime_edit_outcome(); - assert!(outcome.classified, "{outcome:?}"); - assert!(!outcome.content_changed && !outcome.state_changed, "{outcome:?}"); - assert_eq!(s.doc.revisions.len(), revisions, "no unused Delete revision is appended"); - assert_eq!(s.ime_state_revision(), Some(revision), "the no-op did not move the settled caret"); - s.sel = crate::Selection { anchor: Pos::body(0, 0), focus: Pos::body(0, 1) }; - assert!(s.ime_state_revision() > Some(revision), "re-selecting moves the caret, not the document"); -} - -/// `text.insert` classifies the automatic rewrites its own handler performs: smart quotes, -/// list autoformat and AutoCorrect are `transformed`, not the IME's literal insert. -#[test] -fn text_insert_marks_automatic_rewrites_as_transformed() { - let mut session = s(); - run(&mut session, "text.insert", json!({"text": "\""})); - assert!(session.ime_edit_outcome().transformed, "smart quote: {:?}", session.ime_edit_outcome()); - assert_eq!(text(&session), "\u{201C}"); - - let mut session = s(); - for c in ["W", "o", "r", "d", " ", "(", "c", ")", " "] { - run(&mut session, "text.insert", json!({"text": c})); - } - assert!(session.ime_edit_outcome().transformed, "AutoCorrect: {:?}", session.ime_edit_outcome()); - - let mut session = s(); - run(&mut session, "text.insert", json!({"text": "*"})); - run(&mut session, "text.insert", json!({"text": " "})); - assert!(session.ime_edit_outcome().transformed, "list AutoFormat: {:?}", session.ime_edit_outcome()); - - let mut session = s(); - run(&mut session, "text.insert", json!({"text": "A--b"})); - run(&mut session, "text.insert", json!({"text": " "})); - assert!(session.ime_edit_outcome().transformed, "dash AutoFormat: {:?}", session.ime_edit_outcome()); - assert_eq!(text(&session), "A\u{2014}b "); - - let mut session = s(); - run(&mut session, "tools.autocorrect", json!({"doubleSpacePeriod": true})); - for c in "Hello ".chars() { - run(&mut session, "text.insert", json!({"text": c.to_string()})); - } - let outcome = session.ime_edit_outcome(); - assert!(outcome.transformed && outcome.content_changed && outcome.state_changed, "double-space period: {outcome:?}"); - assert_eq!(text(&session), "Hello. "); - run(&mut session, "edit.undo", json!({})); - assert_eq!(text(&session), "Hello ", "undo restores the spaces in one step"); -} - -#[test] -fn style_set_keeps_imported_styles_latent_styles_and_defaults() { - use wordcraft_doc::props::{Rgb, TextColor}; - use wordcraft_doc::styles::{Style, StyleKind}; - for name in ["basic", "lines", "shaded", "casual", "centered", "minimalist", "title"] { - let mut s = s(); - let mut body = s.doc.styles.get("Normal").unwrap().clone(); - body.id = "BodyText".into(); - body.name = "Body Text".into(); - body.builtin = true; - body.based_on = Some("Normal".into()); - body.chr.size = Some(20.0); - body.chr.color = Some(TextColor::Rgb(Rgb(0xFF, 0, 0))); - let grid = Style { id: "GridTable4".into(), name: "Grid Table 4".into(), kind: StyleKind::Table, builtin: true, ..Default::default() }; - Arc::make_mut(&mut s.doc.styles).styles.push(body.clone()); - Arc::make_mut(&mut s.doc.styles).styles.push(grid.clone()); - Arc::make_mut(&mut s.doc.styles).latent_styles = Some("".into()); - Arc::make_mut(&mut s.doc.styles).default_chr.lang = Some("de-DE".into()); - Arc::make_mut(&mut s.doc.styles).default_chr.size = Some(11.0); - run(&mut s, "design.styleSet", json!({ "name": name })); - assert_eq!(s.doc.styles.get("BodyText"), Some(&body), "{name}: BodyText"); - assert_eq!(s.doc.styles.get("GridTable4"), Some(&grid), "{name}: GridTable4"); - assert_eq!(s.doc.styles.latent_styles.as_deref(), Some(""), "{name}"); - assert_eq!(s.doc.styles.default_chr.lang.as_deref(), Some("de-DE"), "{name}"); - assert_eq!(s.doc.styles.default_chr.size, Some(11.0), "{name}"); - assert_eq!(s.doc.styles.styles.iter().filter(|x| x.id == "Heading1").count(), 1, "{name}"); - let saved = crate::io::save_bytes("x.docx", &s.doc).unwrap(); - let back = crate::io::open_bytes("x.docx", &saved).unwrap(); - assert!(back.styles.get("BodyText").is_some(), "{name}: BodyText after round trip"); - } - let mut s = s(); - run(&mut s, "design.styleSet", json!({ "name": "title" })); - assert_eq!(s.doc.styles.get("Title").unwrap().chr.size, Some(36.0)); -} - -fn style_size(s: &Session, id: &str) -> f32 { - s.doc.styles.resolve_char(Some(id), &Default::default()).size -} - -#[test] -fn scale_font_sizes_adds_points_to_every_style() { - let mut s = Session::new(crate::sample::sample_document()); - let ids: Vec = s.doc.styles.styles.iter().map(|x| x.id.clone()).collect(); - let before: Vec = ids.iter().map(|i| style_size(&s, i)).collect(); - let base = s.doc.styles.resolve_char(None, &Default::default()).size; - let runs = serde_json::to_string(&s.doc.body).unwrap(); - run(&mut s, "design.scaleFontSizes", json!({"delta": 2})); - // Every style (explicit or inherited) grows by exactly 2, once. - for (i, b) in ids.iter().zip(&before) { - assert_eq!(style_size(&s, i), b + 2.0, "{i}"); - } - assert_eq!(s.doc.styles.resolve_char(None, &Default::default()).size, base + 2.0); - assert_eq!(serde_json::to_string(&s.doc.body).unwrap(), runs, "runs untouched"); - run(&mut s, "edit.undo", json!({})); - for (i, b) in ids.iter().zip(&before) { - assert_eq!(style_size(&s, i), *b, "undo {i}"); - } -} - -#[test] -fn scale_font_sizes_percent_and_clamp_and_errors() { - let mut s = Session::new(crate::sample::sample_document()); - let n = style_size(&s, "Normal"); - run(&mut s, "design.scaleFontSizes", json!({"percent": 10})); - assert_eq!(style_size(&s, "Normal"), (n * 1.1 * 2.0).round() / 2.0); - run(&mut s, "design.scaleFontSizes", json!({"delta": -500})); - assert_eq!(style_size(&s, "Normal"), 1.0); - for bad in [ - json!({}), - json!({"delta": 1e9}), - json!({"percent": -100}), - json!({"percent": 1e9}), - json!({"delta": "x"}), - json!({"delta": 0}), - json!({"percent": 0}), - json!({"delta": 1, "percent": 10}), - json!({"delta": 2, "percent": "x"}), - json!({"percent": 10, "delta": null}), - ] { - assert!(s.run("design.scaleFontSizes", &bad).is_err(), "{bad}"); - } -} - -#[test] -fn scale_font_sizes_reaches_char_table_and_cs_sizes_and_unset_default() { - use wordcraft_doc::props::CharProps; - use wordcraft_doc::{Style, StyleKind, styles::TablePart}; - let mut s = s(); - Arc::make_mut(&mut s.doc.styles).default_chr.size = None; - Arc::make_mut(&mut s.doc.styles).default_chr.size_cs = Some(10.0); - let mut ch = Style { id: "Big".into(), name: "Big".into(), kind: StyleKind::Character, ..Default::default() }; - ch.chr.size = Some(20.0); - ch.chr.size_cs = Some(22.0); - let mut tb = Style { id: "Grid".into(), name: "Grid".into(), kind: StyleKind::Table, ..Default::default() }; - let mut parts = wordcraft_doc::styles::TableStyleParts::default(); - parts.header_chr.size = Some(14.0); - parts.regions.entry(TablePart::Band2Horz).or_default().chr.size = Some(8.0); - tb.table = Some(parts); - Arc::make_mut(&mut s.doc.styles).styles.extend([ch, tb]); - run(&mut s, "design.scaleFontSizes", json!({"delta": 2})); - let st = &s.doc.styles; - assert_eq!(st.default_chr.size, Some(13.0), "unset default is 11 pt, made explicit"); - assert_eq!(st.default_chr.size_cs, Some(12.0)); - let big = CharProps { style: Some("Big".into()), ..Default::default() }; - assert_eq!(st.resolve_char(None, &big).size, 22.0); - assert_eq!(st.resolve_char(None, &big).size_cs, 24.0); - let grid = st.styles.iter().find(|x| x.id == "Grid").and_then(|x| x.table.as_ref()).unwrap(); - assert_eq!(grid.header_chr.size, Some(16.0)); - assert_eq!(grid.regions.get(&TablePart::Band2Horz).unwrap().chr.size, Some(10.0)); -} - -/// #3676: a 1-based page parameter becomes a 0-based index without wrapping. -/// On 32-bit and wasm the old cast truncated 2^32 to 0 and `0 - 1` panicked. -#[test] -fn page_index_saturates_instead_of_wrapping_to_page_one() { - assert_eq!(crate::p::page_index(0), 0); - assert_eq!(crate::p::page_index(1), 0); - assert_eq!(crate::p::page_index(2), 1); - assert_ne!(crate::p::page_index(1u64 << 32), 0, "2^32 must not wrap to page 1"); - assert_eq!(crate::p::page_index(u64::MAX), usize::MAX - 1); -} - -/// #3676: absolute page numbers that cannot be an index are rejected, not silently -/// turned into page 1. `edit.goto` clamps to the last page, as it does past the end. -#[test] -fn hostile_absolute_pages_never_land_on_page_one() { - let mut s = 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": 1u64 << 32})); - assert_eq!(page(&mut s), 5, "an out-of-range page lands on the last page"); - run(&mut s, "edit.goto", json!({"page": u64::MAX})); - assert_eq!(page(&mut s), 5, "u64::MAX lands on the last page"); - run(&mut s, "edit.goto", json!({"page": 0})); - assert_eq!(page(&mut s), 1, "page 0 is the first page"); -} - -/// #3676: `caret.set` takes a 0-based page; an oversized page is rejected instead of -/// truncating to page 0 on 32-bit and wasm. -#[test] -fn caret_set_rejects_a_page_number_that_does_not_fit() { - let mut s = s(); - run(&mut s, "text.insert", json!({"text": "Hello"})); - let r = s.run("caret.set", &json!({"page": 1u64 << 32, "x": 100.0, "y": 100.0})); - assert!(matches!(r, Err(crate::CmdError::Params(_))), "page 2^32: {r:?}"); -} - -/// #3676: `file.exportPng` rejects a page number that cannot be an index instead of -/// exporting page 1 (the 32-bit truncation) and writing it to the requested path. -#[cfg(not(target_arch = "wasm32"))] -#[test] -fn export_png_rejects_a_page_number_that_does_not_fit() { - let dir = scratch_dir("export-png-hostile-page"); - let mut s = s(); - run(&mut s, "text.insert", json!({"text": "Hello"})); - let target = dir.join("page.png"); - let r = s.run("file.exportPng", &json!({"path": target.to_string_lossy(), "page": 1u64 << 32})); - assert!(matches!(r, Err(crate::CmdError::Params(_))), "page 2^32: {r:?}"); - assert!(!target.exists(), "no file is written for an invalid page"); - let r = s.run("file.exportPng", &json!({"path": target.to_string_lossy(), "page": u64::MAX})); - assert!(matches!(r, Err(crate::CmdError::Params(_))), "page u64::MAX: {r:?}"); - assert!(!target.exists(), "no file is written for an invalid page"); - let _ = std::fs::remove_dir_all(&dir); -} - -#[test] -fn screen_paper_color_is_a_persistent_preference_not_document_formatting() { - use crate::cmd::view::PaperColor; - let mut s = s(); - run(&mut s, "text.insert", json!({"text": "Screen paper test", "raw": true})); - let before = serde_json::to_value(&s.doc).unwrap(); - let docx_before = wordcraft_docx::write(&s.doc).unwrap(); - s.dirty = false; - let dirty = s.dirty; - s.view.dark_mode = true; - for (value, expected) in - [("green", PaperColor::Green), ("cream", PaperColor::Cream), ("gray", PaperColor::Gray), ("document", PaperColor::Document)] - { - run(&mut s, "view.paperColor", json!({"value": value})); - assert_eq!(s.prefs.paper_color, expected); - assert_eq!(serde_json::to_value(&s.doc).unwrap(), before); - assert_eq!(s.dirty, dirty); - assert_eq!(wordcraft_docx::write(&s.doc).unwrap(), docx_before); - assert!(!s.view.dark_mode, "choosing a tint turns off dark paper"); - let saved = serde_json::to_value(&s.prefs).unwrap(); - let restored: crate::Prefs = serde_json::from_value(saved).unwrap(); - assert_eq!(restored.paper_color, expected); - } - for v in [json!({}), json!({"value": "red"}), json!({"value": 42})] { - assert!(s.run("view.paperColor", &v).is_err()); - assert_eq!(s.prefs.paper_color, PaperColor::Document); - } - let legacy: crate::Prefs = serde_json::from_value(json!({})).unwrap(); - assert_eq!(legacy.paper_color, PaperColor::Document); - let future: crate::Prefs = serde_json::from_value(json!({"paperColor":"future", "countNotes":false})).unwrap(); - assert_eq!(future.paper_color, PaperColor::Document); - assert!(!future.count_notes); -} - -/// Font › Emphasis mark puts a mark over (or under) each selected character, and the layout -/// draws one per character; Format state reports it, and none takes it off. -#[test] -fn emphasis_marks_are_set_and_drawn() { - use wordcraft_doc::props::EmphasisMark; - let mut s = Session::new(wordcraft_doc::Document::from_text("強調する")); - run(&mut s, "select.all", json!({})); - run(&mut s, "format.emphasisMark", json!({"mark": "circle"})); - let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); - assert_eq!(p.props_of_char(0).emphasis, Some(EmphasisMark::Circle)); - assert_eq!(run(&mut s, "format.state", json!({}))["emphasisMark"], "circle"); - // One mark per character: hollow circles, drawn as shapes. - let draws = s.layout().pages.first().map(|pg| wordcraft_layout::display::page_display(&s.doc, pg, &Default::default())).unwrap_or_default(); - let marks = draws - .iter() - .filter(|d| matches!(d, wordcraft_layout::display::Draw::Shape { kind: wordcraft_doc::para::ShapeKind::Ellipse, fill: None, .. })) - .count(); - assert_eq!(marks, 4); - run(&mut s, "format.emphasisMark", json!({"mark": "none"})); - assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().props_of_char(0).emphasis, Some(EmphasisMark::None)); - assert!(s.run("format.emphasisMark", &json!({"mark": "sparkles"})).is_err()); -} - -/// Preferences saved while OpenDocument files had their own settings still read, without them, -/// as does a document saved with what those settings kept for its layout. -#[test] -fn what_the_opendocument_settings_saved_still_reads() { - let prefs: crate::Prefs = serde_json::from_value(json!({"countNotes": false, "odf": {"preset": "libreoffice", "columnGap": 0.0}})).unwrap(); - assert!(!prefs.count_notes); - let layout: wordcraft_doc::OdfLayout = serde_json::from_value(json!({"pictureLinesFitPictures": true, "tabsFromIndent": true})).unwrap(); - assert!(layout.tabs_from_indent); -} - -#[test] -fn an_odt_charts_paragraph_is_written_as_read_after_drawing_it() { - // Opening an ODT draws its charts (from their data); that changes nothing in the document, - // so the paragraph showing one is still written as read. - let mut s = Session::new(wordcraft_doc::Document::new()); - run(&mut s, "insert.chart", json!({"type": "column"})); - let first = wordcraft_formats::odt::export(&s.doc).unwrap(); - let doc = crate::io::open_bytes("t.odt", &first).unwrap(); - let mut z = zip::ZipArchive::new(std::io::Cursor::new(wordcraft_formats::odt::export(&doc).unwrap())).unwrap(); - let mut content = String::new(); - std::io::Read::read_to_string(&mut z.by_name("content.xml").unwrap(), &mut content).unwrap(); - assert!(content.contains(r#"draw:name="Chart1""#) && !content.contains("wcChart"), "{content}"); -} - -/// A picture an OpenDocument file makes wider than the text (a rule across the whole page) stays -/// that wide, and its paragraph is written as read: opening a file changes nothing in it. -#[test] -fn an_odts_picture_wider_than_the_text_keeps_its_size() { - 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:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0" xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0" xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0" xmlns:xlink="http://www.w3.org/1999/xlink""#; - let styles = format!( - r#""# - ); - let content = format!( - r#"Text"# - ); - let png = [b"\x89PNG\r\n\x1a\n".as_slice(), &[7u8; 64]].concat(); - 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", b"application/vnd.oasis.opendocument.text".as_slice()), - ("content.xml", content.as_bytes()), - ("styles.xml", styles.as_bytes()), - ("Pictures/r.png", &png), - ] { - z.start_file(name, o).unwrap(); - std::io::Write::write_all(&mut z, data).unwrap(); - } - let doc = crate::io::open_bytes("t.odt", &z.finish().unwrap().into_inner()).unwrap(); - let width = doc.body[0] - .as_para() - .unwrap() - .objects - .iter() - .find_map(|o| if let wordcraft_doc::para::InlineObject::Image { w, .. } = o { Some(*w) } else { None }); - let want = 18.0 * 72.0 / 2.54; - assert!(width.is_some_and(|w| (w - want).abs() < 0.1), "{width:?}, not fitted to the text"); - let mut z = zip::ZipArchive::new(std::io::Cursor::new(wordcraft_formats::odt::export(&doc).unwrap())).unwrap(); - let mut saved = String::new(); - std::io::Read::read_to_string(&mut z.by_name("content.xml").unwrap(), &mut saved).unwrap(); - assert!(saved.contains(r#"draw:name="Rule""#) && saved.contains(r#"svg:width="18cm""#), "written as read: {saved}"); -} - -/// Numbering or a new style makes the styles be written from the model on save: a document in -/// German stays in German, rather than taking the default language of what opens it next. -#[test] -fn an_odts_language_survives_a_save_after_numbering_or_a_new_style() { - let ns = r#"xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0""#; - let styles = format!( - r#""# - ); - let content = format!( - r#"Hallo"# - ); - 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()), ("styles.xml", styles.as_str())] - { - z.start_file(name, o).unwrap(); - std::io::Write::write_all(&mut z, data.as_bytes()).unwrap(); - } - let odt = z.finish().unwrap().into_inner(); - for (command, params) in [("para.numbering", json!({})), ("styles.create", json!({"name": "Notiz"}))] { - let mut s = Session::new(crate::io::open_bytes("t.odt", &odt).unwrap()); - assert_eq!(cmd::default_lang(&s).as_deref(), Some("de-DE"), "the document's, not English"); - run(&mut s, command, params); - let saved = wordcraft_formats::odt::export(&s.doc).unwrap(); - let back = crate::io::open_bytes("t.odt", &saved).unwrap(); - assert_eq!(back.styles.default_chr.lang.as_deref(), Some("de-DE"), "after {command}"); - } -} - -/// A Word package whose body holds `body` and, as `rIdC`, the chunk part `word/afchunk.{ext}`. -#[cfg(not(target_arch = "wasm32"))] -fn docx_with_chunk(body: &str, ext: &str, content_type: &str, chunk: &[u8]) -> Vec { - use std::io::Write; - let files: [(String, Vec); 4] = [ - ( - "[Content_Types].xml".into(), - format!(r#""#).into_bytes(), - ), - ("_rels/.rels".into(), br#""#.to_vec()), - ( - "word/_rels/document.xml.rels".into(), - format!(r#""#).into_bytes(), - ), - ( - "word/document.xml".into(), - format!(r#"{body}"#).into_bytes(), - ), - ]; - let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); - for (name, bytes) in files.iter().map(|(n, b)| (n.as_str(), b.as_slice())).chain([(format!("word/afchunk.{ext}").as_str(), chunk)]) { - zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap(); - zw.write_all(bytes).unwrap(); - } - zw.finish().unwrap().into_inner() -} - -/// A Single File Web Page under a Word or RTF name opens as one, like the web pages of #2853. -#[test] -fn a_single_file_web_page_saved_as_doc_opens_by_content() { - let mht = "MIME-Version: 1.0\r\nContent-Type: multipart/related; boundary=\"B\"\r\n\r\n--B\r\nContent-Type: text/html; charset=\"utf-8\"\r\n\r\n

Izvje\u{161}taj

\r\n--B--\r\n"; - for name in ["report.doc", "report.docx", "report.rtf"] { - let doc = crate::io::open_bytes(name, mht.as_bytes()).unwrap_or_else(|e| panic!("{name}: {e}")); - assert_eq!(doc.plain_text(StoryRef::Body), "Izvje\u{161}taj", "{name}"); - } -} - -#[cfg(not(target_arch = "wasm32"))] -#[test] -fn alt_chunks_open_in_place_as_word_brings_them_in() { - let png = wordcraft_formats::model::base64_encode(&tiny_png()); - let mht = format!( - "MIME-Version: 1.0\r\nContent-Type: multipart/related; boundary=\"B\"\r\n\r\n--B\r\nContent-Location: file:///C:/doc.htm\r\n\ - Content-Transfer-Encoding: quoted-printable\r\nContent-Type: text/html; charset=\"utf-8\"\r\n\r\n\ -

=C4=8Clanak 1.

\r\n\ - --B\r\nContent-Location: file:///C:/doc_files/a.png\r\nContent-Transfer-Encoding: base64\r\nContent-Type: image/png\r\n\r\n{png}\r\n--B--\r\n" - ); - let p = |t: &str| format!(r#"{t}"#); - let chunk = r#""#; - let bytes = docx_with_chunk(&format!("{}{chunk}{}", p("Before"), p("After")), "mht", "message/rfc822", mht.as_bytes()); - let doc = crate::io::open_bytes("chunk.docx", &bytes).unwrap(); - let text = doc.plain_text(StoryRef::Body); - assert!(text.starts_with("Before\nČlanak 1.\n") && text.ends_with("After"), "{text:?}"); - let para = doc.para(StoryRef::Body, &wordcraft_doc::Path::top(1)).unwrap(); - assert_eq!(para.props.align, Some(wordcraft_doc::Align::Center)); - // Web page text: Times New Roman unless the page says otherwise. - assert_eq!(para.props_of_char(0).font.as_deref(), Some("Times New Roman")); - // The picture came along, its bytes under the key it shows. - let key = doc - .body - .iter() - .find_map(|b| match &**b { - wordcraft_doc::Block::Para(p) => p.objects.iter().find_map(|o| match o { - wordcraft_doc::InlineObject::Image { media, .. } => Some(media.clone()), - _ => None, - }), - _ => None, - }) - .expect("the chunk's picture"); - assert_eq!(doc.media.get(&key).map(|m| m.to_vec()), Some(tiny_png())); - - // HTML (in its content type's charset), RTF and plain-text chunks come in through their - // importers too, and an MHT chunk is known by what it holds. - for (ext, ct, data, want) in [ - ("htm", "text/html; charset=windows-1250", &b"

\xc8lanak chunk

"[..], "Članak chunk"), - ("rtf", "application/rtf", br"{\rtf1\ansi Rich chunk\par}", "Rich chunk"), - ("txt", "text/plain", b"Plain chunk", "Plain chunk"), - ("bin", "", mht.as_bytes(), "Članak 1."), - ] { - let bytes = docx_with_chunk(&format!("{chunk}{}", p("End")), ext, ct, data); - let text = crate::io::open_bytes("chunk.docx", &bytes).unwrap().plain_text(StoryRef::Body); - assert!(text.starts_with(want) && text.ends_with("End"), "{ext}: {text:?}"); - } -} - -#[cfg(not(target_arch = "wasm32"))] -#[test] -fn text_from_file_brings_the_files_pictures() { - let dir = scratch_dir("text-from-file-pictures"); - let mut src = Session::new(wordcraft_doc::Document::new()); - let list = Arc::make_mut(&mut src.doc.numbering).add_list(wordcraft_doc::numbering::ListKind::Bullet); - // A list item, then a paragraph (a single paragraph would take the caret's formatting). - src.doc.split_paragraph(&Pos::body(0, 0)).unwrap(); - src.doc.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().props.numbering = - Some(wordcraft_doc::props::NumRef { num: list, level: 0 }); - let key = src.doc.add_media(tiny_png(), "png"); - let image = wordcraft_doc::InlineObject::Image { - media: key, - w: 20.0, - h: 20.0, - alt: String::new(), - float: Default::default(), - crop: [0.0; 4], - ole: None, - fx: Default::default(), - }; - src.doc.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().insert_object(0, image, &Default::default()).unwrap(); - let path = dir.join("pictures.docx"); - std::fs::write(&path, wordcraft_docx::write(&src.doc).unwrap()).unwrap(); - - let mut s = s(); - // A picture and a list of our own first, so the file's key and list id would point at ours. - let ours = s.doc.add_media(vec![9, 9, 9], "png"); - let our_list = Arc::make_mut(&mut s.doc.numbering).add_list(wordcraft_doc::numbering::ListKind::Numbered); - run(&mut s, "insert.textFromFile", json!({"path": path.to_string_lossy()})); - let _ = std::fs::remove_dir_all(&dir); - let shown: Vec = s - .doc - .body - .iter() - .filter_map(|b| match &**b { - wordcraft_doc::Block::Para(p) => p.objects.iter().find_map(|o| match o { - wordcraft_doc::InlineObject::Image { media, .. } => Some(media.clone()), - _ => None, - }), - _ => None, - }) - .collect(); - assert_eq!(shown.len(), 1); - assert_ne!(shown[0], ours); - assert_eq!(s.doc.media.get(&shown[0]).map(|m| m.to_vec()), Some(tiny_png())); - let listed: Vec = s - .doc - .body - .iter() - .filter_map(|b| match &**b { - wordcraft_doc::Block::Para(p) => p.props.numbering.map(|n| n.num), - _ => None, - }) - .collect(); - assert_eq!(listed.len(), 1); - assert_ne!(listed[0], our_list); - assert!(s.doc.numbering.level(listed[0], 0).is_some_and(|l| l.format == wordcraft_doc::section::NumFormat::Bullet)); -} - -#[test] -fn page_down_and_up_move_by_a_page_in_the_continuous_views() { - // Web Layout, Draft and Outline are one long page: Page Down/Up move as far as they do - // between the pages of Print Layout (#5290). - let after_page_down = |view: &str| { - let mut s = s(); - for i in 0..200 { - run(&mut s, "text.insert", json!({"text": format!("Paragraph {i}")})); - run(&mut s, "text.newParagraph", json!({})); - } - run(&mut s, view, json!({})); - run(&mut s, "caret.docStart", json!({})); - let start = s.sel.focus.clone(); - run(&mut s, "caret.pageDown", json!({})); - let down = s.sel.focus.clone(); - run(&mut s, "caret.pageUp", json!({})); - assert_eq!(s.sel.focus, start, "{view}: Page Up goes back"); - run(&mut s, "caret.docEnd", json!({})); - let end = s.sel.focus.clone(); - run(&mut s, "caret.pageDown", json!({})); - assert_eq!(s.sel.focus, end, "{view}: Page Down stops at the end"); - down - }; - let print = after_page_down("view.printLayout"); - assert!(print.path.0[0] > 10, "Print Layout: Page Down goes to the next page: {print:?}"); - for view in ["view.draft", "view.outline", "view.webLayout"] { - assert_eq!(after_page_down(view), print, "{view}"); - } -} - -#[test] -fn page_down_and_up_never_stay_on_a_line_in_the_continuous_views() { - // A page with less than an inch of text area: the views move by the one-inch minimum. The - // caret ends a long line and the paragraphs after it are narrow (a large right indent): the - // line a page away, not the one the caret is on, is where Page Down goes, however far right - // of it the caret is. - for view in ["view.draft", "view.outline", "view.webLayout"] { - let mut s = s(); - run(&mut s, "layout.margins", json!({"top": 370, "bottom": 370})); - let long = "Lorem ipsum dolor sit amet ".repeat(4); - run(&mut s, "text.insert", json!({"text": long})); - run(&mut s, "text.newParagraph", json!({})); - run(&mut s, "para.indents", json!({"right": 700})); - for i in 0..60 { - if i > 0 { - run(&mut s, "text.newParagraph", json!({})); - } - run(&mut s, "text.insert", json!({"text": "a"})); - } - run(&mut s, view, json!({})); - run(&mut s, "caret.docStart", json!({})); - run(&mut s, "caret.end", json!({})); - assert_eq!(s.sel.focus.off, long.chars().count(), "{view}: the first paragraph is one line"); - let mut at = s.sel.focus.clone(); - for step in 0..3 { - run(&mut s, "caret.pageDown", json!({})); - assert!(s.sel.focus > at, "{view}: Page Down {step} moves on from {at:?}"); - at = s.sel.focus.clone(); - } - for step in 0..3 { - run(&mut s, "caret.pageUp", json!({})); - assert!(s.sel.focus < at, "{view}: Page Up {step} moves back from {at:?}"); - at = s.sel.focus.clone(); - } - } -} - -#[test] -fn page_down_extends_a_selection_that_ends_in_collapsed_outline_text() { - // Selecting a collapsed heading and its content ends the selection in hidden text; Page Down - // still extends it from where it shows (the heading), keeping its anchor. - let mut s = s(); - run(&mut s, "text.insert", json!({"text": "First"})); - for i in 0..5 { - run(&mut s, "text.newParagraph", json!({})); - run(&mut s, "text.insert", json!({"text": format!("Hidden {i}")})); - } - run(&mut s, "text.newParagraph", json!({})); - run(&mut s, "text.insert", json!({"text": "Second"})); - for i in 0..200 { - run(&mut s, "text.newParagraph", json!({})); - run(&mut s, "text.insert", json!({"text": format!("Shown {i}")})); - } - run(&mut s, "view.outline", json!({})); - for block in [0, 6] { - run(&mut s, "caret.set", json!({"pos": {"block": block}})); - run(&mut s, "outline.promoteToHeading1", json!({})); - } - run(&mut s, "outline.collapse", json!({"block": 0})); - run(&mut s, "outline.selectSubtree", json!({"block": 0})); - let anchor = s.sel.anchor.clone(); - assert_eq!(s.sel.focus.path.0, [5], "the selection ends in the last hidden paragraph"); - assert!(s.layout().caret(&s.sel.focus).is_none(), "the outline doesn't show its end"); - run(&mut s, "caret.pageDown", json!({"extend": true})); - assert_eq!(s.sel.anchor, anchor, "the anchor stays"); - assert!(s.sel.focus.path.0[0] > 6, "Page Down extends the selection a page on: {:?}", s.sel.focus); - assert!(s.layout().caret(&s.sel.focus).is_some(), "to a place the outline shows"); -} - -/// A picture an OpenDocument file gives no size is shown at its own, fitted to the text as other -/// formats' are; one with a stated size keeps it, in line too. -#[test] -fn an_odts_picture_is_fitted_only_when_it_states_no_size() { - let ns = r#"xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0" xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0" xmlns:xlink="http://www.w3.org/1999/xlink""#; - let styles = format!( - r#""# - ); - let mut png = std::io::Cursor::new(Vec::new()); - image::RgbImage::new(1000, 500).write_to(&mut png, image::ImageFormat::Png).unwrap(); - let png = png.into_inner(); - let open = |attrs: &str| { - let content = format!( - r#""# - ); - 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", b"application/vnd.oasis.opendocument.text".as_slice()), - ("content.xml", content.as_bytes()), - ("styles.xml", styles.as_bytes()), - ("Pictures/a.png", &png), - ] { - z.start_file(name, o).unwrap(); - std::io::Write::write_all(&mut z, data).unwrap(); - } - let doc = crate::io::open_bytes("t.odt", &z.finish().unwrap().into_inner()).unwrap(); - doc.body[0] - .as_para() - .unwrap() - .objects - .iter() - .find_map(|o| if let wordcraft_doc::para::InlineObject::Image { w, h, .. } = o { Some((*w, *h)) } else { None }) - .unwrap() - }; - let (w, h) = open(""); - assert!((w - 306.14).abs() < 0.5 && (h - 153.07).abs() < 0.5, "fitted to the text: {w} {h}"); - let (w, _) = open(r#" svg:width="18cm" svg:height="9cm""#); - assert!((w - 18.0 * 72.0 / 2.54).abs() < 0.5, "as the file says: {w}"); -} - -/// Issue #5667's document: `Intro`, then an inserted `Appendix` paragraph whose mark carries a -/// section break (Normal, indented), then `Body` in Heading 1. The whole of `Appendix` is -/// tracked as an insertion by `rev`, or as a deletion when `delete`. -fn sectioned_revision(delete: bool) -> Session { - use wordcraft_doc::{Revision, RevisionKind}; - let mut s = Session::new(wordcraft_doc::Document::from_text("Intro\nAppendix\nBody")); - run(&mut s, "caret.set", json!({"pos": {"block": 2, "off": 0}})); - run(&mut s, "para.style", json!({"style": "Heading1"})); - let kind = if delete { RevisionKind::Delete } else { RevisionKind::Insert }; - s.doc.revisions.push(Revision { kind, author: "Ada".into(), date: String::new(), move_name: None }); - s.doc.last_section.margin_left = 72.0; - let mut sect = s.doc.last_section.clone(); - sect.margin_left = 144.0; - let p = s.doc.para_mut(StoryRef::Body, &wordcraft_doc::Path(vec![1])).unwrap(); - let len = p.len(); - p.format(0, len, &|c| if delete { c.del = Some(0) } else { c.ins = Some(0) }).unwrap(); - if delete { - p.mark.del = Some(0) - } else { - p.mark.ins = Some(0) - } - p.props.indent_left = Some(36.0); - p.section = Some(Box::new(sect)); - s -} - -#[test] -fn resolving_away_an_empty_paragraph_with_a_section_break_keeps_the_next_paragraphs_properties() { - // #5667: the resolved paragraph was left empty but kept for its section break, so the next - // paragraph joined it and took its properties (Normal, its indent). It goes whole instead, - // as an empty paragraph without a break does, and its section joins the following one. - for (delete, cmd) in [(false, "review.rejectAll"), (true, "review.acceptAll")] { - let mut s = sectioned_revision(delete); - assert_eq!(s.doc.sections().len(), 2); - run(&mut s, cmd, json!({})); - assert_eq!(body_texts(&s), ["Intro", "Body"], "{cmd}"); - let body = s.doc.para(StoryRef::Body, &wordcraft_doc::Path(vec![1])).unwrap(); - assert_eq!(body.props.style.as_deref(), Some("Heading1"), "{cmd}: Body keeps its style"); - assert_eq!(body.props.indent_left, None, "{cmd}: and not the removed paragraph's indent"); - assert!(s.doc.body.iter().filter_map(|b| b.as_para()).all(|p| p.section.is_none()), "{cmd}: the break went with it"); - assert_eq!(s.doc.sections().len(), 1, "{cmd}"); - assert_eq!(s.doc.last_section.margin_left, 72.0, "{cmd}: the following section's setup stays"); - } -} +// A copy of craft-proof's hand-written Czech fixture (`crates/proof/src/cs/test_dict.rs` in +// craft-libs, which a dependency's tests can't share); its install helper resolves this parent import. +use wordcraft_proof::cs::install_dictionary; +mod czech_test_dict; + +use serde_json::json; +use std::sync::Arc; +use wordcraft_doc::props::BorderStyle; +use wordcraft_doc::{Pos, StoryRef}; + +use crate::{Session, cmd}; + +mod bidi; +mod documents; +mod find_apostrophes; +mod fit_one_page; +mod font_sizes; +mod fonts; +mod ime_noop; +mod para_tools; +mod revision_checks; +mod typing; +mod wordart; + +/// True, after saying so on stderr, when this build lacks the Language data the proofing tests +/// need (craft-proof built without `CRAFT_I18N_DIR`). The probes are craft-proof's public +/// `english_source` (the embedded English dictionary) and its American/British variant table +/// (`colour` → `color`): a craft-i18n checkout without some of the files embeds only the rest, +/// so both are asked. Without them English still spells from craft-proof's built-in word list, +/// but there are no hyphenation patterns and no variant table, so the tests that check those +/// return early, as craft-proof's own do; with the data (CI, releases) they all run. +pub(crate) fn no_language_data(test: &str) -> bool { + let missing = wordcraft_proof::english_source(wordcraft_proof::ProofLang::En) != Some(wordcraft_proof::EnglishSource::Embedded) + || wordcraft_proof::en_variants::preferred("colour", wordcraft_proof::ProofLang::En).as_deref() != Some("color"); + if missing { + eprintln!("{test}: skipped, needs the Language data (set CRAFT_I18N_DIR to a craft-i18n checkout)"); + } + missing +} + +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}"); +} + +/// Gap 9: AES-encrypted Word files — Standard encryption (Word 2007) and agile packages — open +/// with the right password whatever Word extension they carry, and ask for one or refuse a wrong +/// one the same way. +#[test] +fn aes_encrypted_packages_open_under_any_word_extension() { + let mut doc = wordcraft_doc::Document::from_text("Old secret minutes"); + doc.core.title = "Minutes".into(); + let pkg = wordcraft_docx::write(&doc).unwrap(); + let standard = wordcraft_docx::encrypt_standard_for_tests(&pkg, "letmein", 128).unwrap(); + let agile = wordcraft_docx::encrypt_with_spin_count(&pkg, "letmein", 8).unwrap(); + for (kind, bytes) in [("standard", &standard), ("agile", &agile)] { + for name in ["old.doc", "old.docx", "old.dot", "noext"] { + let (d, encrypted) = crate::io::open_bytes_with(name, bytes, Some("letmein")).unwrap_or_else(|e| panic!("{kind} {name}: {e}")); + assert!(encrypted, "{kind} {name}"); + assert!(d.plain_text(StoryRef::Body).contains("Old secret minutes"), "{kind} {name}"); + assert_eq!(d.core.title, "Minutes"); + let e = crate::io::open_bytes_with(name, bytes, None).unwrap_err(); + assert!(crate::io::needs_password(&e), "{kind} {name}: {e}"); + let e = crate::io::open_bytes_with(name, bytes, Some("letmeout")).unwrap_err(); + assert!(crate::io::wrong_password(&e), "{kind} {name}: {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")); +} + +/// The command a shortcut runs. +fn id(k: &str) -> Option<&'static str> { + cmd::registry().by_shortcut(k).map(|c| c.id) +} + +#[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). + 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")); +} + +#[test] +fn word_default_shortcuts_resolve_per_platform() { + 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")); + } +} + +#[test] +fn word_default_shortcuts_resolve_function_keys() { + 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 confined_sessions_neither_save_the_dictionary_nor_choose_folders_outside_their_roots() { + // #3737 (findings 34 and WC-35): the custom dictionary file and the folder settings are + // held to the automation roots like any other path. + let base = scratch_dir("dict-roots"); + for d in ["in", "out", "outside"] { + std::fs::create_dir_all(base.join(d)).unwrap(); + } + let roots = crate::file_access::AutomationRoots::new(Some(&base.join("in")), Some(&base.join("out"))).unwrap(); + let mut s = s(); + let outside = base.join("outside/custom-dictionary.txt"); + s.user_dict_path = Some(outside.clone()); + let confined = |s: &mut Session, cmd: &str, v: serde_json::Value| crate::file_access::confine(roots.as_ref(), || s.run(cmd, &v)); + assert!(confined(&mut s, "review.addToDictionary", json!({"word": "zxqengconfined"})).is_ok()); + assert!(wordcraft_proof::user_dictionary().contains(&"zxqengconfined".to_string()), "kept in memory"); + assert!(!outside.exists(), "nothing written outside the write root"); + for key in ["dictionariesFolder", "userTemplatesFolder"] { + assert!(confined(&mut s, "file.options", json!({key: base.join("outside").to_string_lossy()})).is_err(), "{key}"); + } + assert_eq!((s.prefs.dictionaries_folder.as_deref(), s.prefs.user_templates_folder.as_deref()), (None, None)); + // A folder inside the read root is still taken. + let inside = base.join("in"); + assert!(confined(&mut s, "file.options", json!({"userTemplatesFolder": inside.to_string_lossy()})).is_ok()); + let _ = std::fs::remove_dir_all(&base); + assert_eq!(s.prefs.user_templates_folder.as_deref(), Some(inside.to_string_lossy().as_ref())); +} + +#[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"); +} + +/// A command that changes nothing is not an edit (#23): no undo step, no unsaved changes +/// and no relayout. +#[test] +fn a_command_that_changes_nothing_is_not_an_edit() { + let mut s = s(); + let rev = s.rev(); + // Backspace at the start of the document: there is nothing to delete. + run(&mut s, "text.backspace", json!({})); + assert_eq!(s.undo_depth(), 0, "no undo step"); + assert!(!s.dirty, "no unsaved changes"); + assert_eq!(s.rev(), rev, "no relayout"); + // Typing nothing is the same. + run(&mut s, "text.insert", json!({"text": ""})); + assert_eq!(s.undo_depth(), 0, "no undo step"); + assert!(!s.dirty, "no unsaved changes"); + // A command that changes something is an edit. + run(&mut s, "text.insert", json!({"text": "Hello"})); + assert_eq!(s.undo_depth(), 1, "one undo step"); + assert!(s.dirty, "the document is edited"); + // Setting the alignment a paragraph already has changes nothing. + let depth = s.undo_depth(); + run(&mut s, "para.align", json!({"value": "center"})); + assert_eq!(s.undo_depth(), depth + 1, "one undo step"); + let depth = s.undo_depth(); + run(&mut s, "para.align", json!({"value": "center"})); + assert_eq!(s.undo_depth(), depth, "no undo step"); + // A command that changes nothing leaves the unsaved-changes flag and the layout revision as + // they were, even when it calls `touch` itself: a saved document is not marked edited for + // nothing, and its layout is not laid out again. + let mut clean = Session::new(wordcraft_doc::Document::new()); + run(&mut clean, "para.align", json!({"value": "left"})); + clean.dirty = false; + let rev = clean.rev(); + run(&mut clean, "para.align", json!({"value": "left"})); + assert!(!clean.dirty, "no unsaved changes"); + assert_eq!(clean.rev(), rev, "no relayout"); + assert_eq!(clean.undo_depth(), 1, "only the first align is a step"); +} + +/// Five hundred commands that change nothing used to fill the whole undo stack and push the +/// user's own edits out of it, so they could never be undone again (#23). +#[test] +fn no_op_commands_do_not_evict_the_undo_history() { + let mut s = s(); + run(&mut s, "text.insert", json!({"text": "Hello"})); + run(&mut s, "caret.docStart", json!({})); + for _ in 0..600 { + run(&mut s, "text.backspace", json!({})); + } + assert_eq!(s.undo_depth(), 1, "the typing step is still there"); + run(&mut s, "edit.undo", json!({})); + assert_eq!(text(&s), "", "the typed text is still undoable"); +} + +/// A command that changes nothing does not throw the redo stack away (#23). +#[test] +fn a_no_op_command_keeps_the_redo_stack() { + let mut s = s(); + run(&mut s, "text.insert", json!({"text": "Hello"})); + run(&mut s, "edit.undo", json!({})); + assert!(s.can_redo()); + run(&mut s, "text.backspace", json!({})); + assert!(s.can_redo(), "the redo stack survives a command that changed nothing"); + run(&mut s, "edit.redo", json!({})); + assert_eq!(text(&s), "Hello"); +} + +/// Undo and Redo move between steps even when a step's document is the one showing: they are +/// never taken back as commands that changed nothing, so Undo can't get stuck on such a step. +#[test] +fn undo_moves_past_a_step_that_changed_nothing() { + let mut s = s(); + run(&mut s, "text.insert", json!({"text": "Hello"})); + s.checkpoint("Nothing"); + let depth = s.undo_depth(); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.undo_depth(), depth - 1); + assert!(s.can_redo()); + run(&mut s, "edit.redo", json!({})); + assert_eq!(s.undo_depth(), depth); + run(&mut s, "edit.undo", json!({})); + run(&mut s, "edit.undo", json!({})); + assert_eq!(text(&s), ""); +} + +/// Commands that run as one undo step (`join_next_undo`: a dialog's OK, the frames of a drag): +/// a group whose first command changes nothing has no step to merge into, so the next command +/// in it starts the group's step instead of merging into an older edit (#23 follow-up). +#[test] +fn a_joined_command_starts_its_group_when_the_first_changed_nothing() { + let mut s = s(); + run(&mut s, "text.insert", json!({"text": "Hello"})); + let (depth, margins) = (s.undo_depth(), (s.doc.last_section.margin_left, s.doc.last_section.margin_top)); + // Portrait on a portrait page changes nothing. + run(&mut s, "layout.orientation", json!({"value": "portrait"})); + assert_eq!(s.undo_depth(), depth, "the first command of the group is no edit"); + // The next command of the group starts the step the group needs. + s.join_next_undo(); + run(&mut s, "layout.margins", json!({"left": 100.0})); + assert_eq!(s.doc.last_section.margin_left, 100.0); + assert_eq!(s.undo_depth(), depth + 1, "the group's one step"); + // A command that changes nothing in the middle of the group leaves that step alone, and + // the next one merges into it. + s.join_next_undo(); + run(&mut s, "layout.orientation", json!({"value": "portrait"})); + s.join_next_undo(); + run(&mut s, "layout.margins", json!({"top": 60.0})); + assert_eq!(s.undo_depth(), depth + 1, "still the group's one step"); + // Undo takes the whole group back and no more: the typing is still there. + run(&mut s, "edit.undo", json!({})); + assert_eq!((s.doc.last_section.margin_left, s.doc.last_section.margin_top), margins, "the margins the group started from"); + assert_eq!(text(&s), "Hello"); +} + +/// Bold with nothing selected arms the formatting for the next word: the document is the +/// same, so only the caret's formatting changed — and that is an edit (#23). +#[test] +fn a_command_that_only_arms_the_caret_formatting_is_an_edit() { + let mut s = s(); + run(&mut s, "text.insert", json!({"text": "Hello"})); + run(&mut s, "caret.docStart", json!({})); + let depth = s.undo_depth(); + run(&mut s, "format.bold", json!({})); + assert_eq!(s.undo_depth(), depth + 1, "one undo step"); + // Undo takes the armed formatting back, as Word does (docs/typing-parity.md). + run(&mut s, "edit.undo", json!({})); + run(&mut s, "text.insert", json!({"text": "x"})); + assert_eq!(s.doc.para_at(&s.sel.focus).map(|p| p.props_of_char(0).bold), Some(None)); +} + +/// 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(); + c.date_utc = None; + }); + 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 regex_replace_expands_groups() { + // #4177: with Regular expressions on, Replace with expands capture groups. + let rep = |text: &str, q: &str, with: &str| rep_mode(text, q, with, json!({"regex": true})); + let names = "Smith, John and Doe, Jane"; + // The issue's cases: `\n` and `$n` swap words; `${name}` names a group. + assert_eq!(rep(names, r"(\w+), (\w+)", r"\2 \1"), "John Smith and Jane Doe"); + assert_eq!(rep(names, r"(\w+), (\w+)", "$2 $1"), "John Smith and Jane Doe"); + assert_eq!(rep("2024-05", r"(?\d{4})-", "${y}/"), "2024/05"); + // Braced numbers, `$0` / `${0}` (whole match), `$$` (a dollar sign). + assert_eq!(rep("ab", "(a)(b)", "${2}${1}"), "ba"); + assert_eq!(rep("ab", "a", "[$0|${0}]"), "[a|a]b"); + assert_eq!(rep("5", r"(\d)", "$$$1"), "$5"); + // `$name` takes the longest run of letters, digits and `_` (the regex crate's rule). + assert_eq!(rep("ab", "(?a)", "$x_"), "b"); + assert_eq!(rep("ab", "(?a)", "${x}_"), "a_b"); + assert_eq!(rep("ab", "(a)", "$1a"), "b"); + // Missing, unknown and out-of-range groups expand to nothing. + assert_eq!(rep("ab", "(a)", "<$2>"), "<>b"); + assert_eq!(rep("ab", "(a)", r"<\9>"), "<>b"); + assert_eq!(rep("ab", "(a)", "<${nope}>"), "<>b"); + assert_eq!(rep("ab", "(a)", "<$99999999999999999999999>"), "<>b"); + assert_eq!(rep("ab", "(a)|(z)", "<$2>"), "<>b"); + // A `$` that starts no group, and an unclosed brace, stay as written. + assert_eq!(rep("ab", "a", "$ ${x"), "$ ${xb"); + // `\t` a tab, `\\` a backslash; other escapes stay as written. + assert_eq!(rep("ab", "a", r"\t\\\q"), "\t\\\\qb"); + // Word's `^` codes still work. + assert_eq!(rep("ab", "a", "^&^t^&"), "a\tab"); + // `\$` and `\^` keep the character as typed (CodeRabbit on #4177). + assert_eq!(rep("ab", "(a)", r"\$1\^p"), "$1^pb"); + // `special: false` turns off the `^` codes only; groups still expand (CodeRabbit on #4177). + assert_eq!(rep_mode("ab", "(a)", "$1^t", json!({"regex": true, "special": false})), "a^tb"); + // `\n` is a paragraph mark: it splits the paragraph, as `^p` does, in one undo step. + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "one, two"})); + assert_eq!(run(&mut s, "edit.replaceAll", json!({"text": r"(\w+), (\w+)", "with": r"$1\n$2", "regex": true}))["replaced"], 1); + assert_eq!(text(&s), "one\ntwo"); + assert_eq!(s.doc.para_paths(StoryRef::Body).len(), 2); + run(&mut s, "edit.undo", json!({})); + assert_eq!(text(&s), "one, two"); + run(&mut s, "document.setText", json!({"text": "a-b"})); + run(&mut s, "edit.replaceAll", json!({"text": "-", "with": "^p", "regex": true})); + assert_eq!(s.doc.para_paths(StoryRef::Body).len(), 2); + // Plain and wildcard Replace are unchanged: `$1` is literal there. + assert_eq!(rep_mode("ab", "a", "$1", json!({})), "$1b"); + assert_eq!(rep_mode("ab", "(a)", "$1\\1", json!({"wildcards": true})), "$1ab"); + // Hostile Replace text never panics. + run(&mut s, "document.setText", json!({"text": "ab ab"})); + for with in ["$", "${", "${}", "${0", "$\u{e9}", "\\", "${\u{1F600}}", "$18446744073709551616", "${-1}", "$$$", "\\0\\n\\", "^"] { + let _ = s.run("edit.replaceAll", &json!({"text": "(a)(?b)?", "with": with, "regex": true})); + } +} + +/// The body text after Replace All of `q` with `with` in `text`, matching case, with `extra` options. +fn rep_mode(text: &str, q: &str, with: &str, extra: serde_json::Value) -> String { + let mut s = s(); + run(&mut s, "document.setText", json!({"text": text})); + let mut v = json!({"text": q, "with": with, "matchCase": true}); + if let (Some(o), Some(e)) = (v.as_object_mut(), extra.as_object()) { + o.extend(e.clone()); + } + run(&mut s, "edit.replaceAll", v); + self::text(&s) +} + +#[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"); +} + +/// Whether any run in the body is bold. +fn any_bold(s: &Session) -> bool { + s.doc.body.iter().filter_map(|b| b.as_para()).flat_map(|p| p.runs.iter()).any(|r| r.props.bold == Some(true)) +} + +#[test] +fn paste_from_another_program_does_not_bring_back_our_fragment() { + // Review on #6225: when another program owns the clipboard, matching text must not make + // Paste put WordCraft's own formatted fragment back. + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "Bold"})); + run(&mut s, "select.all", json!({})); + run(&mut s, "format.bold", json!({})); + run(&mut s, "edit.copy", json!({})); + assert_eq!(s.clipboard_text, "Bold"); + + // The same text, copied by another program: paste it as plain text, not our bold fragment. + run(&mut s, "document.setText", json!({"text": "Plain "})); + run(&mut s, "caret.docEnd", json!({})); + run(&mut s, "edit.paste", json!({"text": "Bold", "privateClipboard": false})); + assert_eq!(text(&s), "Plain Bold"); + assert!(!any_bold(&s), "{:?}", s.doc.body); + + // Callers that omit the flag (an agent, the CLI) still get the session's own copy. + run(&mut s, "caret.docEnd", json!({})); + run(&mut s, "edit.paste", json!({"text": "Bold"})); + assert!(any_bold(&s), "{:?}", s.doc.body); +} + +#[test] +fn paste_special_does_not_offer_our_fragment_for_another_programs_copy() { + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "Bold"})); + run(&mut s, "select.all", json!({})); + run(&mut s, "format.bold", json!({})); + run(&mut s, "edit.copy", json!({})); + + let r = run(&mut s, "edit.pasteSpecial", json!({"text": "Bold", "privateClipboard": false})); + let ids: Vec<&str> = r["formats"].as_array().map(|a| a.iter().filter_map(|f| f["id"].as_str()).collect()).unwrap_or_default(); + assert!(!ids.contains(&"formatted"), "{ids:?}"); + assert_eq!(s.ui_requests.last().and_then(|r| r.get("privateClipboard")), Some(&json!(false)), "{:?}", s.ui_requests); + + let e = s.run("edit.pasteSpecial", &json!({"as": "formatted", "text": "Bold", "privateClipboard": false})); + assert!(e.is_err(), "another program's copy cannot be pasted as our fragment"); +} + +/// 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); +} + +/// Bold and italic of a paragraph's character, in a document. +fn emphasis_at(d: &wordcraft_doc::Document, block: usize, off: usize) -> (bool, bool) { + match d.body.get(block).map(|b| &**b) { + Some(wordcraft_doc::Block::Para(p)) => { + let c = p.props_of_char(off); + (c.bold.unwrap_or(false), c.italic.unwrap_or(false)) + } + _ => (false, false), + } +} + +#[test] +fn copying_puts_rtf_on_the_clipboard() { + // gap-clipboard-rtf: a copy carries RTF beside its text and HTML, for programs that read RTF. + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "plain bold\nsome italic"})); + s.doc.format_range(&Pos::body(0, 6), &Pos::body(0, 10), &|c| c.bold = Some(true)).unwrap(); + s.doc.format_range(&Pos::body(1, 5), &Pos::body(1, 11), &|c| c.italic = Some(true)).unwrap(); + run(&mut s, "select.all", json!({})); + let r = run(&mut s, "edit.copy", json!({})); + let rtf = r["rtf"].as_str().unwrap_or_default().to_string(); + assert!(rtf.starts_with("{\\rtf1"), "{r}"); + assert_eq!(s.clipboard_rtf, rtf); + let d = wordcraft_formats::rtf::import(rtf.as_bytes()).unwrap(); + let paras: Vec = + d.body.iter().filter_map(|b| if let wordcraft_doc::Block::Para(p) = &**b { Some(p.text.clone()) } else { None }).collect(); + assert_eq!(paras.get(..2), Some(&["plain bold".to_string(), "some italic".to_string()][..]), "{paras:?}"); + assert_eq!(emphasis_at(&d, 0, 0), (false, false)); + assert_eq!(emphasis_at(&d, 0, 7), (true, false)); + assert_eq!(emphasis_at(&d, 1, 7), (false, true)); + + // Pasting it elsewhere keeps the formatting. + let mut t = Session::new(wordcraft_doc::Document::new()); + run(&mut t, "edit.paste", json!({"rtf": rtf})); + assert!(text(&t).starts_with("plain bold\nsome italic"), "{}", text(&t)); + assert_eq!(emphasis_at(&t.doc, 0, 7), (true, false)); + assert_eq!(emphasis_at(&t.doc, 1, 7), (false, true)); + + // Cut carries it too. + run(&mut s, "select.range", json!({"anchor": {"block": 0, "off": 6}, "focus": {"block": 0, "off": 10}})); + let r = run(&mut s, "edit.cut", json!({})); + assert!(r["rtf"].as_str().is_some_and(|t| t.starts_with("{\\rtf1") && t.contains("bold")), "{r}"); + assert_eq!(text(&s).lines().next(), Some("plain ")); + + // Nothing selected: nothing copied, no RTF. + run(&mut s, "caret.docEnd", json!({})); + let r = run(&mut s, "edit.copy", json!({})); + assert!(r.get("rtf").is_none(), "{r}"); +} + +#[test] +fn pasting_hostile_rtf_never_panics() { + let deep = format!("{{\\rtf1 {}x{}", "{\\b ".repeat(5000), "}".repeat(5000)); + let junk = ["{\\rtf1", "{\\rtf1 {\\b bo", "{\\rtf1\\ansi\\u-99999999999? \\bin999999999 x}", "{\\rtf1 \\'zz\\fonttbl{", "{\\rtf\u{0}\u{FFFF}}"]; + for rtf in junk.iter().map(|j| j.to_string()).chain([deep]) { + for id in ["edit.paste", "edit.pasteMerge", "edit.pasteSpecial"] { + let mut t = Session::new(wordcraft_doc::Document::new()); + // `Session::run` turns a panic into an error: look for it. + for v in [json!({"rtf": rtf, "as": "rtf"}), json!({"rtf": rtf, "text": "fallback"})] { + if let Err(e) = t.run(id, &v) { + assert!(!e.to_string().contains("panicked"), "{id}: {e}"); + } + let _ = t.layout(); + } + } + } +} + +#[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() }; + Arc::make_mut(&mut 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::default(), "Numbering toggles off the paragraph's effective numbered level"); + run(&mut s, "para.numbering", json!({})); + run(&mut s, "para.listLevel", json!({"level": 1})); + 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 convert_text_to_table_keeps_inline_content() { + // Issue #3751: the cells were rebuilt from plain text, losing formatting, pictures, links + // and tracked deletions. + use wordcraft_doc::para::InlineObject; + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "Name\t\tLink\tOld"})); + { + let p = s.doc.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap(); + p.format(0, 4, &|c| c.bold = Some(true)).unwrap(); + p.format(6, 10, &|c| c.link = Some("https://example.com".into())).unwrap(); + p.format(11, 14, &|c| c.del = Some(1)).unwrap(); + let img = InlineObject::Image { + media: "pic".into(), + w: 10.0, + h: 10.0, + alt: String::new(), + float: Default::default(), + crop: [0.0; 4], + fx: Default::default(), + ole: None, + }; + p.insert_object(5, img, &Default::default()).unwrap(); + } + run(&mut s, "select.all", json!({})); + run(&mut s, "table.fromText", json!({})); + let t = s.doc.body.iter().find_map(|b| b.as_table()).expect("a table"); + let cell = |c: usize| t.rows[0].cells[c].blocks.iter().find_map(|b| b.as_para()).expect("a paragraph"); + assert_eq!(cell(0).text, "Name"); + assert_eq!(cell(0).props_of_char(0).bold, Some(true), "cell 1 lost bold"); + assert!(matches!(cell(1).objects.as_slice(), [InlineObject::Image { media, .. }] if media == "pic"), "cell 2 lost its picture"); + assert_eq!(cell(2).text, "Link"); + assert_eq!(cell(2).props_of_char(0).link.as_deref(), Some("https://example.com"), "cell 3 lost its link"); + assert_eq!(cell(3).text, "Old"); + assert_eq!(cell(3).props_of_char(0).del, Some(1), "cell 4 lost its tracked deletion"); +} + +/// A table `cols` wide holding `cells` row by row, typed in with Tab; the caret ends in the +/// last cell. +fn typed_table(cols: usize, cells: &[&str]) -> Session { + let mut s = s(); + run(&mut s, "insert.table", json!({"rows": cells.len() / cols, "cols": cols})); + 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 +} + +/// The body as `T` for a table and the text of each paragraph. +fn body_outline(s: &Session) -> Vec { + s.doc.body.iter().map(|b| b.as_para().map_or_else(|| "T".to_string(), |p| p.text.clone())).collect() +} + +fn cell_at(table: u32, r: u32, c: u32) -> serde_json::Value { + json!({"story": "body", "path": [table, r, c, 0], "off": 0}) +} + +/// Convert to Text sets a row's cells apart with a tab, a comma, another character or +/// paragraph marks; rows are paragraphs. +#[test] +fn convert_to_text_takes_each_separator() { + let cases = [ + (json!({}), vec!["a\tb", "c\td"]), + (json!({"separator": "tab"}), vec!["a\tb", "c\td"]), + (json!({"separator": "comma"}), vec!["a,b", "c,d"]), + (json!({"separator": "other", "char": ";"}), vec!["a;b", "c;d"]), + (json!({"separator": "paragraph"}), vec!["a", "b", "c", "d"]), + ]; + for (params, want) in cases { + let mut s = typed_table(2, &["a", "b", "c", "d"]); + run(&mut s, "table.toText", params.clone()); + let got = body_outline(&s); + assert_eq!(got.get(..want.len()), Some(&want.iter().map(|x| x.to_string()).collect::>()[..]), "{params}: {got:?}"); + assert!(s.doc.body.iter().all(|b| b.as_table().is_none()), "{params}"); + } + // U+FFFC marks an object in a paragraph's text, so it cannot be the separator. + let obj = wordcraft_doc::para::OBJ.to_string(); + for bad in [ + json!({"separator": "other"}), + json!({"separator": "other", "char": "\n"}), + json!({"separator": "other", "char": obj}), + json!({"separator": "pipe"}), + ] { + let mut s = typed_table(2, &["a", "b", "c", "d"]); + let depth = s.undo_depth(); + assert!(s.run("table.toText", &bad).is_err(), "{bad}"); + assert!(s.doc.body.iter().any(|b| b.as_table().is_some()), "{bad}: the table stays"); + assert_eq!(s.undo_depth(), depth); + } +} + +/// With rows selected, only those become text; the rows above and below stay tables, and a +/// vertical merge cut by the selection starts again below it. +#[test] +fn convert_to_text_converts_the_selected_rows() { + let mut s = typed_table(2, &["a", "b", "c", "d", "e", "f", "g", "h"]); + let t = s.doc.body.iter().position(|b| b.as_table().is_some()).unwrap() as u32; + // Column 1 merged down rows 1 to 3. + run(&mut s, "select.range", json!({"anchor": cell_at(t, 1, 1), "focus": cell_at(t, 3, 1)})); + run(&mut s, "table.merge", json!({})); + run(&mut s, "select.range", json!({"anchor": cell_at(t, 1, 0), "focus": cell_at(t, 1, 1)})); + run(&mut s, "table.toText", json!({})); + let at = t as usize; + // The merged cell holds d, f and h, one paragraph each. + assert_eq!(body_outline(&s).get(at..at + 5), Some(&["T", "c\td", "f", "h", "T"].map(String::from)[..]), "{:?}", body_outline(&s)); + let head = s.doc.body[at].as_table().unwrap(); + let tail = s.doc.body[at + 4].as_table().unwrap(); + assert_eq!((head.rows.len(), tail.rows.len()), (1, 2)); + use wordcraft_doc::props::VMerge; + let merges: Vec = tail.rows.iter().map(|r| r.cells.last().unwrap().props.vmerge).collect(); + assert_eq!(merges, [VMerge::Restart, VMerge::Continue], "the merge starts again in the first row left"); + assert_eq!(s.sel.focus.path.0, [t + 1], "the caret goes to the converted text"); + + // A merge that went on into the converted rows ends in the last row above them. + let mut s = typed_table(2, &["a", "b", "c", "d", "e", "f"]); + let t = s.doc.body.iter().position(|b| b.as_table().is_some()).unwrap() as u32; + run(&mut s, "select.range", json!({"anchor": cell_at(t, 0, 0), "focus": cell_at(t, 1, 0)})); + run(&mut s, "table.merge", json!({})); + run(&mut s, "select.range", json!({"anchor": cell_at(t, 1, 1), "focus": cell_at(t, 2, 1)})); + run(&mut s, "table.toText", json!({})); + let head = s.doc.body[t as usize].as_table().unwrap(); + assert_eq!(head.rows.len(), 1); + assert_eq!(head.rows[0].cells[0].props.vmerge, VMerge::None, "no merge goes on below the table"); + + // A floating table keeps its place in the part above; the part below goes in the text flow. + let mut s = typed_table(2, &["a", "b", "c", "d", "e", "f"]); + let t = s.doc.body.iter().position(|b| b.as_table().is_some()).unwrap() as u32; + let float = wordcraft_doc::props::TableFloat { x: 100.0, y: 200.0, overlap: true, ..Default::default() }; + s.doc.table_mut(StoryRef::Body, &wordcraft_doc::Path(vec![t])).unwrap().props.float = Some(float); + run(&mut s, "select.range", json!({"anchor": cell_at(t, 1, 0), "focus": cell_at(t, 1, 1)})); + run(&mut s, "table.toText", json!({})); + let at = t as usize; + assert_eq!(s.doc.body[at].as_table().unwrap().props.float, Some(float)); + assert_eq!(s.doc.body[at + 2].as_table().unwrap().props.float, None, "the part below doesn't sit on the part above"); + + // The caret alone in a cell converts every row, as before. + let mut s = typed_table(2, &["a", "b", "c", "d", "e", "f"]); + run(&mut s, "table.toText", json!({})); + assert!(s.doc.body.iter().all(|b| b.as_table().is_none())); +} + +/// A content control around the table keeps both its ends when the first rows, the last rows +/// or every row become text. +#[test] +fn convert_to_text_keeps_a_content_control_around_the_table() { + use wordcraft_doc::{ContentControl, ControlWrap, InlineObject}; + let has = |p: &wordcraft_doc::Paragraph, start: bool| { + p.objects.iter().any(|o| if start { matches!(o, InlineObject::ControlStart { .. }) } else { matches!(o, InlineObject::ControlEnd) }) + }; + for rows in [Some((0, 0)), Some((2, 2)), None] { + let mut s = typed_table(2, &["a", "b", "c", "d", "e", "f"]); + let t = s.doc.body.iter().position(|b| b.as_table().is_some()).unwrap() as u32; + s.doc.table_mut(StoryRef::Body, &wordcraft_doc::Path(vec![t])).unwrap().controls = + ControlWrap { open: vec![ContentControl { tag: "wrap".into(), ..Default::default() }], close: 1 }; + assert!(!s.doc.has_unbalanced_controls()); + match rows { + Some((r0, r1)) => run(&mut s, "select.range", json!({"anchor": cell_at(t, r0, 0), "focus": cell_at(t, r1, 1)})), + None => run(&mut s, "select.range", json!({"anchor": cell_at(t, 1, 0), "focus": cell_at(t, 1, 0)})), + }; + run(&mut s, "table.toText", json!({})); + assert!(!s.doc.has_unbalanced_controls(), "{rows:?}: {:?}", body_outline(&s)); + let at = t as usize; + if rows != Some((2, 2)) { + assert!(s.doc.body[at].as_para().is_some_and(|p| has(p, true)), "{rows:?}: the control starts on the text"); + } + if rows != Some((0, 0)) { + let last = s.doc.body.iter().skip(at).rfind(|b| b.as_para().is_some_and(|p| !p.text.is_empty()) || b.as_table().is_some()).unwrap(); + assert!(last.as_para().is_some_and(|p| has(p, false)), "{rows:?}: the control ends on the text"); + } + } + + // A control around rows 0 to 3, row 1 converted: the table above closes it, the table below + // opens it again. + let mut s = typed_table(2, &["a", "b", "c", "d", "e", "f", "g", "h"]); + let t = s.doc.body.iter().position(|b| b.as_table().is_some()).unwrap() as u32; + { + let table = s.doc.table_mut(StoryRef::Body, &wordcraft_doc::Path(vec![t])).unwrap(); + table.rows[0].controls.open = vec![ContentControl { id: Some(7), tag: "rows".into(), ..Default::default() }]; + table.rows[3].controls.close = 1; + } + run(&mut s, "select.range", json!({"anchor": cell_at(t, 1, 0), "focus": cell_at(t, 1, 1)})); + run(&mut s, "table.toText", json!({})); + let at = t as usize; + let head = s.doc.body[at].as_table().unwrap(); + let tail = s.doc.body[at + 2].as_table().unwrap(); + assert_eq!((head.rows.len(), head.rows[0].controls.close), (1, 1), "the table above closes the control"); + assert_eq!(tail.rows[0].controls.open.iter().map(|c| c.tag.as_str()).collect::>(), ["rows"], "the table below opens it"); + assert_eq!((head.rows[0].controls.open[0].id, tail.rows[0].controls.open[0].id), (Some(7), None), "one w:id in the file"); + assert_eq!(tail.rows[1].controls.close, 1); + assert!(!s.doc.has_unbalanced_controls()); +} + +/// The text keeps its formatting, and a table nested in a cell stays a table. +#[test] +fn convert_to_text_keeps_formatting_and_nested_tables() { + let mut s = typed_table(2, &["Name", "X"]); + let t = s.doc.body.iter().position(|b| b.as_table().is_some()).unwrap() as u32; + let first = json!({"story": "body", "path": [t, 0, 0, 0], "off": 0}); + let end = json!({"story": "body", "path": [t, 0, 0, 0], "off": 4}); + run(&mut s, "select.range", json!({"anchor": first, "focus": end})); + run(&mut s, "format.bold", json!({})); + // A table nested in the second cell, after its text. + s.sel = crate::Selection::caret(wordcraft_doc::Pos { story: StoryRef::Body, path: wordcraft_doc::Path(vec![t, 0, 1, 0]), off: 1 }); + run(&mut s, "text.newParagraph", json!({})); + run(&mut s, "insert.table", json!({"rows": 1, "cols": 1})); + run(&mut s, "text.insert", json!({"text": "Inner"})); + s.sel = crate::Selection::caret(wordcraft_doc::Pos { story: StoryRef::Body, path: wordcraft_doc::Path(vec![t, 0, 0, 0]), off: 0 }); + run(&mut s, "table.toText", json!({})); + let at = t as usize; + let line = s.doc.body[at].as_para().unwrap(); + assert_eq!(line.text, "Name\tX"); + assert_eq!(line.props_of_char(0).bold, Some(true), "the text lost its bold"); + assert_ne!(line.props_of_char(4).bold, Some(true), "the tab takes no formatting"); + let inner = s.doc.body.iter().skip(at + 1).find_map(|b| b.as_table()).expect("the nested table stays a table"); + assert_eq!(table_cell_text(inner), "Inner"); + + // A selected row whose first cell starts with a nested table: the caret goes to the row's + // text, not back into the table above. + let mut s = typed_table(2, &["A", "B", "", "D"]); + let t = s.doc.body.iter().position(|b| b.as_table().is_some()).unwrap() as u32; + s.sel = crate::Selection::caret(wordcraft_doc::Pos { story: StoryRef::Body, path: wordcraft_doc::Path(vec![t, 1, 0, 0]), off: 0 }); + run(&mut s, "insert.table", json!({"rows": 1, "cols": 1})); + let row = &s.doc.body[t as usize].as_table().unwrap().rows[1]; + assert!(row.cells[0].blocks.first().is_some_and(|b| b.as_table().is_some()), "the cell starts with the nested table"); + run(&mut s, "select.range", json!({"anchor": cell_at(t, 1, 1), "focus": cell_at(t, 1, 1)})); + s.sel.anchor = wordcraft_doc::Pos { story: StoryRef::Body, path: wordcraft_doc::Path(vec![t, 1, 0, 1]), off: 0 }; + run(&mut s, "table.toText", json!({})); + let at = t as usize; + assert_eq!(body_outline(&s).get(at..at + 3), Some(&["T", "T", "\tD"].map(String::from)[..]), "{:?}", body_outline(&s)); + assert_eq!(s.sel.focus.path.0, [t + 2], "the caret is on the row's text"); +} + +fn table_cell_text(t: &wordcraft_doc::Table) -> String { + t.rows.iter().flat_map(|r| &r.cells).flat_map(|c| &c.blocks).filter_map(|b| b.as_para()).map(|p| p.text.clone()).collect() +} + +#[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); +} + +/// Tests that install a test dictionary wait for the dictionaries-folder test, which changes the +/// folder and then reads what each language reports: an install in between reports no file. +static DICTIONARIES: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +fn dictionaries_turn() -> std::sync::MutexGuard<'static, ()> { + DICTIONARIES.lock().unwrap_or_else(std::sync::PoisonError::into_inner) +} + +/// 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() { + let _turn = dictionaries_turn(); + 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() { + let _turn = dictionaries_turn(); + 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 › Language › 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 _turn = dictionaries_turn(); + 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), + // The others the tests install, so reloading from the folder keeps their words. + ("it_IT", ITALIAN_TEST_AFF, ITALIAN_TEST_DIC), + ("es_ES", SPANISH_TEST_AFF, SPANISH_TEST_DIC), + ("es_UY", SPANISH_TEST_AFF, URUGUAYAN_TEST_DIC), + // #3252: set_dictionaries_folder() now reloads Czech too (it used to leave its cache + // alone), so a run of this test overlapping czech_proofing_through_the_review_commands + // would otherwise force a reload that finds no cs_CZ here and empties it mid-test. Same + // fixture content either way keeps such a race harmless, as it already does for Polish. + ("cs_CZ", czech_test_dict::AFF, czech_test_dict::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()); + // Each language reports the folder's file, which is searched first. + let reported = |got: Option<&serde_json::Value>, name: &str| { + let want = dir.join(format!("{name}.dic")); + assert_eq!(got, Some(&json!(want.to_string_lossy())), "{name}: {dicts:?}"); + assert_eq!(wordcraft_proof::hunspell::find(&[name]).map(|(_, dic)| dic), Some(want)); + }; + reported(file("pl").as_ref(), "pl_PL"); + reported(file("pt").as_ref(), "pt_BR"); + reported(file("id").as_ref(), "id_ID"); + // Each Spanish variety reports its own file (Spain, Mexico, Latin America, Uruguay, in order). + let spanish: Vec = dicts.iter().filter(|d| d["language"] == "es").map(|d| d["file"].clone()).collect(); + assert_eq!(spanish.len(), 4); + reported(spanish.first(), "es_ES"); + reported(spanish.last(), "es_UY"); + // 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() { + let _turn = dictionaries_turn(); + 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"); +} + +/// The spelling issues of the document: each word with its first suggestion. +fn spelling_with_first_suggestion(s: &mut Session) -> Vec<(String, String)> { + let issues = run(s, "review.issues", json!({})); + issues + .as_array() + .unwrap() + .iter() + .filter(|i| i["kind"] == "spelling") + .map(|i| (i["text"].as_str().unwrap().to_string(), i["suggestions"][0].as_str().unwrap_or("").to_string())) + .collect() +} + +fn pairs(v: &[(&str, &str)]) -> Vec<(String, String)> { + v.iter().map(|(a, b)| (a.to_string(), b.to_string())).collect() +} + +/// English (UK), (Canada) and (Australia) through Review › Language (#1281, #1045): each accepts +/// its own spellings and flags the other dialect's with its own form suggested first, F7 finds +/// it and Change fixes it. +#[test] +fn english_dialects_through_the_review_commands() { + if no_language_data("english_dialects_through_the_review_commands") { + return; + } + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "The colour of the center."})); + // US English by default: the British spelling is flagged, the American one isn't. + assert_eq!(spelling_with_first_suggestion(&mut s), pairs(&[("colour", "color")])); + run(&mut s, "select.all", json!({})); + run(&mut s, "review.language", json!({"lang": "en_gb", "default": true})); + assert_eq!(s.doc.styles.default_chr.lang.as_deref(), Some("en-GB")); + assert_eq!(run(&mut s, "format.state", json!({}))["lang"], "en-GB"); + assert_eq!(spelling_with_first_suggestion(&mut s), pairs(&[("center", "centre")])); + run(&mut s, "caret.docStart", json!({})); + assert_eq!(run(&mut s, "review.spelling", json!({}))["text"], "center"); + run(&mut s, "review.applySuggestion", json!({"text": "centre"})); + assert_eq!(text(&s), "The colour of the centre."); + assert!(spelling_with_first_suggestion(&mut s).is_empty()); + // Canada: British -our and -re, American -ize. + run(&mut s, "document.setText", json!({"text": "We organise the colour and analyze the centre."})); + run(&mut s, "select.all", json!({})); + run(&mut s, "review.language", json!({"lang": "en-CA", "default": true})); + assert_eq!(spelling_with_first_suggestion(&mut s), pairs(&[("organise", "organize")])); + // Australia spells the British way: -ise is right, -yze isn't. + run(&mut s, "select.all", json!({})); + run(&mut s, "review.language", json!({"lang": "en-AU", "default": true})); + assert_eq!(spelling_with_first_suggestion(&mut s), pairs(&[("analyze", "analyse")])); + // Add to Dictionary overrules the dialect. + run(&mut s, "review.addToDictionary", json!({"word": "analyze"})); + assert!(spelling_with_first_suggestion(&mut s).is_empty()); + wordcraft_proof::remove_word("analyze"); +} + +/// A British document keeps its languages through .docx (`w:lang` in the defaults and on a run) +/// and is proofed the same way when it comes back. +#[test] +fn english_dialects_survive_a_docx_round_trip() { + if no_language_data("english_dialects_survive_a_docx_round_trip") { + return; + } + use std::io::Read; + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "Colour and color. Centre and center."})); + run(&mut s, "select.all", json!({})); + run(&mut s, "review.language", json!({"lang": "en-GB", "default": true})); + run(&mut s, "select.text", json!({"text": "Centre and center."})); + run(&mut s, "review.language", json!({"lang": "en-US"})); + let before = spelling_with_first_suggestion(&mut s); + assert_eq!(before, pairs(&[("color", "colour"), ("Centre", "Center")])); + let bytes = crate::io::save_bytes("british.docx", &s.doc).unwrap(); + let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap(); + let mut part = |name: &str| { + let mut xml = String::new(); + zip.by_name(name).unwrap().read_to_string(&mut xml).unwrap(); + xml + }; + assert!(part("word/styles.xml").contains(r#"w:val="en-GB""#), "the default language is written"); + assert!(part("word/document.xml").contains(r#"w:val="en-US""#), "the run's language is written"); + let doc = crate::io::open_bytes("british.docx", &bytes).unwrap(); + assert_eq!(doc.styles.default_chr.lang.as_deref(), Some("en-GB")); + let mut back = Session::new(doc); + assert_eq!(spelling_with_first_suggestion(&mut back), before); +} + +/// An English interface writes new documents in the region's English (English (UK) on a British +/// system, #1045); another region's language leaves US English. +#[test] +fn new_documents_follow_the_regions_english() { + if no_language_data("new_documents_follow_the_regions_english") { + return; + } + let mut s = s(); + s.system_region = wordcraft_geom::Region::from_tag("en_GB.UTF-8"); + assert_eq!(s.region_english().as_deref(), Some("en-GB")); + let tag = s.region_english(); + s.set_editing_language(tag.as_deref()); + assert_eq!(s.doc.styles.default_chr.lang.as_deref(), Some("en-GB"), "the untouched document follows"); + run(&mut s, "document.setText", json!({"text": "Colour, color."})); + assert_eq!(spelling_with_first_suggestion(&mut s), pairs(&[("color", "colour")])); + // The region chosen in Options wins over the system's. + run(&mut s, "file.options", json!({"region": "en-CA"})); + assert_eq!(s.region_english().as_deref(), Some("en-CA")); + for (tag, english) in [("pl-PL", None), ("en-US", None), ("en-AU", Some("en-AU")), ("auto", Some("en-GB"))] { + run(&mut s, "file.options", json!({"region": tag})); + assert_eq!(s.region_english().as_deref(), english, "{tag}"); + } + s.system_region = wordcraft_geom::Region::from_tag("fr-FR"); + assert_eq!(s.region_english(), None); +} + +/// A Spanish interface writes new documents in the region's Spanish (#3360): Uruguay's on a +/// Uruguayan system, Mexico's on a Mexican one, Spain's elsewhere. +#[test] +fn new_documents_follow_the_regions_spanish() { + let mut s = s(); + s.system_region = wordcraft_geom::Region::from_tag("es_UY.UTF-8"); + assert_eq!(s.region_spanish(), "es-UY"); + // The ISO 639-2 code is Spanish too (CodeRabbit review on #3562). + s.system_region = wordcraft_geom::Region::from_tag("spa_MX.UTF-8"); + assert_eq!(s.region_spanish(), "es-MX"); + s.system_region = wordcraft_geom::Region::from_tag("es_UY.UTF-8"); + s.set_editing_language(Some(&s.region_spanish())); + assert_eq!(s.doc.styles.default_chr.lang.as_deref(), Some("es-UY"), "the untouched document follows"); + for (tag, spanish) in [("es-MX", "es-MX"), ("es-419", "es-419"), ("es-ES", "es-ES"), ("en-US", "es-ES"), ("fr-FR", "es-ES"), ("auto", "es-UY")] { + run(&mut s, "file.options", json!({"region": tag})); + assert_eq!(s.region_spanish(), spanish, "{tag}"); + } +} + +// Hand-written synthetic fixtures (no system dictionary data): Spain's has `vosotros` forms, +// Uruguay's has `vos` ones' verbs only (the forms come from the voseo rules). +const SPANISH_TEST_AFF: &str = + "SET UTF-8\nFLAG UTF-8\nTRY aeosrnidlctumpbgvyqhfzjñáéíóú\nMAP 2\nMAP eé\nMAP ií\nREP 2\nREP v b\nREP b v\nSFX S Y 1\nSFX S 0 s [aeiou]\n"; +const SPANISH_TEST_DIC: &str = "10\nesto\nes\nuna\nprueba/S\ndel\ncorrector\ntambién\nhabéis\nvenido\ndecir\n"; +const URUGUAYAN_TEST_DIC: &str = "10\nesto\nes\nuna\nprueba/S\ndel\ncorrector\ntambién\ntener\nvenir\ndecir\n"; + +fn spanish_test_dictionaries() { + use wordcraft_proof::ProofLang; + let _turn = dictionaries_turn(); + for (lang, dic) in [(ProofLang::Es, SPANISH_TEST_DIC), (ProofLang::EsUy, URUGUAYAN_TEST_DIC)] { + wordcraft_proof::es::install_dictionary(lang, SPANISH_TEST_AFF, dic).unwrap(); + } +} + +/// #3553: Spanish proofing through the same commands as Review, F7, Change and Add to +/// Dictionary: `prueva` and `corector` are flagged with `prueba` and `corrector` offered. +#[test] +fn spanish_proofing_through_the_review_commands() { + spanish_test_dictionaries(); + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "esto es una prueva del corector. Habéis venido?"})); + run(&mut s, "select.all", json!({})); + run(&mut s, "review.language", json!({"lang": "es_es", "default": true})); + assert_eq!(s.doc.styles.default_chr.lang.as_deref(), Some("es-ES")); + let issues = run(&mut s, "review.issues", json!({})); + let arr = issues.as_array().unwrap(); + assert!(arr.iter().all(|i| i["lang"] == "es"), "{issues}"); + let has = |text: &str, kind: &str, sugg: &str| { + arr.iter().any(|i| i["text"] == text && i["kind"] == kind && i["suggestions"].as_array().unwrap().iter().any(|x| x == sugg)) + }; + assert!(has("prueva", "spelling", "prueba"), "{issues}"); + assert!(has("corector", "spelling", "corrector"), "{issues}"); + assert!(has("Habéis venido?", "grammar", "¿Habéis venido?"), "{issues}"); + assert_eq!(arr.len(), 3, "{issues}"); + assert!(arr.iter().any(|i| i["message"] == "Posible error ortográfico"), "{issues}"); + run(&mut s, "caret.docStart", json!({})); + assert_eq!(run(&mut s, "review.spelling", json!({}))["text"], "prueva"); + run(&mut s, "review.applySuggestion", json!({"text": "prueba"})); + assert!(text(&s).starts_with("esto es una prueba del"), "{}", text(&s)); + run(&mut s, "review.addToDictionary", json!({"word": "corector"})); + let left = run(&mut s, "review.issues", json!({})); + assert_eq!(left.as_array().unwrap().len(), 1, "{left}"); +} + +/// #3562 review: Add to Dictionary and Ignore All get the issue's own text, decomposed (NFD) as +/// typed; the word is then accepted decomposed and composed, and its issue range was the typed +/// bytes. +#[test] +fn add_and_ignore_all_accept_both_normalization_forms() { + spanish_test_dictionaries(); + let mut s = s(); + let text = "esto zqanade\u{301}do y zqignora\u{301}do"; + run(&mut s, "document.setText", json!({"text": text})); + run(&mut s, "select.all", json!({})); + run(&mut s, "review.language", json!({"lang": "es-ES"})); + let issues = run(&mut s, "review.issues", json!({})); + let words: Vec<(String, u64)> = issues + .as_array() + .unwrap() + .iter() + .filter(|i| i["kind"] == "spelling") + .map(|i| (i["text"].as_str().unwrap().to_string(), i["start"]["off"].as_u64().unwrap())) + .collect(); + assert_eq!(words, [("zqanade\u{301}do".to_string(), 5), ("zqignora\u{301}do".to_string(), text.find("zqign").unwrap() as u64)], "{issues}"); + run(&mut s, "review.addToDictionary", json!({"word": words[0].0})); + run(&mut s, "review.ignoreAll", json!({"word": words[1].0})); + assert!(run(&mut s, "review.issues", json!({})).as_array().unwrap().is_empty()); + // The composed spellings are the same words. + run(&mut s, "document.setText", json!({"text": "esto zqanadédo y zqignorádo"})); + run(&mut s, "select.all", json!({})); + run(&mut s, "review.language", json!({"lang": "es-ES"})); + assert!(run(&mut s, "review.issues", json!({})).as_array().unwrap().is_empty()); + wordcraft_proof::remove_word("zqanadédo"); +} + +/// Uruguayan Spanish accepts the `vos` forms, imperatives included, that Spain's flags. +#[test] +fn uruguayan_spanish_accepts_vos_forms() { + spanish_test_dictionaries(); + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "Vení, decime qué tenés."})); + run(&mut s, "select.all", json!({})); + run(&mut s, "review.language", json!({"lang": "es-UY"})); + assert_eq!(run(&mut s, "format.state", json!({}))["lang"], "es-UY"); + let spelling = |s: &mut Session| -> Vec { + let issues = run(s, "review.issues", json!({})); + issues.as_array().unwrap().iter().filter(|i| i["kind"] == "spelling").filter_map(|i| i["text"].as_str().map(String::from)).collect() + }; + assert_eq!(spelling(&mut s), ["qué"], "only the word the fixture lacks"); + run(&mut s, "select.all", json!({})); + run(&mut s, "review.language", json!({"lang": "es-ES"})); + assert_eq!(spelling(&mut s), ["Vení", "decime", "qué", "tenés"]); +} + +/// 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. +const ITALIAN_TEST_AFF: &str = "SET UTF-8\nTRY aeiortnsclcaffèéè\nMAP 2\nMAP aà\nMAP eéè\nREP 1\nREP caffe caffè\n"; +const ITALIAN_TEST_DIC: &str = "6\ncasa\ncaffè\nroma\ndisse\ne\nper\n"; + +fn italian_test_dictionary() { + let _turn = dictionaries_turn(); + wordcraft_proof::it::install_dictionary(ITALIAN_TEST_AFF, ITALIAN_TEST_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); +} + +/// #2882: Set As Default survives preference serialization and overrides the interface language. +#[test] +fn default_proofing_language_survives_a_restart() { + let mut first = s(); + first.set_editing_language(Some("en-US")); + run(&mut first, "review.language", json!({"lang": "FR_fr", "default": true})); + let saved = serde_json::to_vec(&first.prefs).unwrap(); + run(&mut first, "review.language", json!({"lang": "en-GB"})); + run(&mut first, "edit.undo", json!({})); + run(&mut first, "file.new", json!({})); + assert_eq!(first.doc.styles.default_chr.lang.as_deref(), Some("fr-FR")); + + let mut second = s(); + second.prefs = serde_json::from_slice(&saved).unwrap(); + second.set_editing_language(Some("pl-PL")); + assert_eq!(second.doc.styles.default_chr.lang.as_deref(), Some("fr-FR")); + assert!(!second.dirty && !second.can_undo()); + run(&mut second, "file.new", json!({})); + assert_eq!(second.doc.styles.default_chr.lang.as_deref(), Some("fr-FR")); + let data = crate::cmd::insert::base64_encode(b"Bonjour"); + run(&mut second, "file.open", json!({"path": "note.txt", "data": data})); + assert_eq!(second.doc.styles.default_chr.lang.as_deref(), Some("fr-FR")); + + let mut english = wordcraft_doc::Document::from_text("English document"); + Arc::make_mut(&mut english.styles).default_chr.lang = Some("en-GB".into()); + let bytes = crate::io::save_bytes("english.docx", &english).unwrap(); + let data = crate::cmd::insert::base64_encode(&bytes); + run(&mut second, "file.open", json!({"path": "english.docx", "data": data})); + second.set_editing_language(Some("ru-RU")); + assert_eq!(second.doc.styles.default_chr.lang.as_deref(), Some("en-GB")); + run(&mut second, "file.new", json!({"template": "letter"})); + assert_eq!(second.doc.styles.default_chr.lang.as_deref(), Some("en-US")); +} + +/// Older or malformed settings retain the interface fallback and unrelated preferences. +#[test] +fn default_proofing_language_ignores_damaged_preferences() { + for value in [json!(null), json!("not a tag"), json!(17), json!([])] { + let mut session = s(); + session.prefs = serde_json::from_value(json!({"defaultLanguage": value, "detectLanguage": false})).unwrap(); + session.set_editing_language(Some("pl-PL")); + assert_eq!(session.doc.styles.default_chr.lang.as_deref(), Some("pl-PL")); + assert!(!session.prefs.detect_language); + } + let mut old = s(); + old.prefs = serde_json::from_str("{}").unwrap(); + old.set_editing_language(Some("ru-RU")); + assert_eq!(old.doc.styles.default_chr.lang.as_deref(), Some("ru-RU")); +} + +/// #2882: a final protection rejection rolls back the saved default as well as the document. +#[test] +fn rejected_default_proofing_language_does_not_survive_a_restart() { + for saved in [None, Some("pl-PL")] { + let mut session = Session::new(wordcraft_doc::Document::from_text("Locked intro.\nEditable text.\nLocked end.")); + session.prefs.default_language = saved.map(str::to_string); + session.set_editing_language(Some("en-US")); + run(&mut session, "select.text", json!({"text": "Editable text."})); + run(&mut session, "review.restrict", json!({"exception": "add"})); + run(&mut session, "review.restrict", json!({"mode": "readOnly"})); + run(&mut session, "select.text", json!({"text": "Editable"})); + // Selection-only language formatting is allowed inside the exception. + run(&mut session, "review.language", json!({"lang": "en-GB"})); + let before = session.doc.clone(); + let prefs = serde_json::to_value(&session.prefs).unwrap(); + let undo = session.undo_labels(); + let error = session.run("review.language", &json!({"lang": "fr-FR", "default": true})).unwrap_err(); + assert!(error.to_string().contains("only the editable regions"), "{error}"); + assert_eq!(session.doc, before); + assert_eq!(session.undo_labels(), undo); + assert_eq!(serde_json::to_value(&session.prefs).unwrap(), prefs); + let mut restarted = s(); + restarted.prefs = serde_json::from_value(prefs).unwrap(); + restarted.set_editing_language(Some("en-US")); + run(&mut restarted, "file.new", json!({})); + assert_eq!(restarted.doc.styles.default_chr.lang.as_deref(), Some(saved.unwrap_or("en-US"))); + } +} + +/// 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})); +} + +/// An item's own colour survives item edits and layout changes, and a style colour drops it. +#[test] +fn smart_art_item_colors_stay_until_a_style_is_picked() { + let mut s = s(); + run(&mut s, "insert.smartArt", json!({"layout": "process"})); + let colors = |s: &Session| cmd::smart_art::selected_smart_art(s).unwrap().items.iter().map(|i| i.color.map(|c| c.hex())).collect::>(); + assert!(!cmd::smart_art::selected_smart_art(&s).unwrap().has_custom_colors()); + run(&mut s, "smartArt.itemColor", json!({"index": 1, "color": "FF8800"})); + assert_eq!(colors(&s), [None, Some("FF8800".to_string()), None, None]); + run(&mut s, "smartArt.items", json!({"items": [{"text": "A"}, {"text": "B", "color": "#ff8800"}]})); + run(&mut s, "smartArt.layout", json!({"layout": "cycle"})); + run(&mut s, "smartArt.addShape", json!({"after": 0})); + assert_eq!(colors(&s), [None, None, Some("FF8800".to_string())]); + assert!(s.run("smartArt.itemColor", &json!({"index": 0, "color": "nope"})).is_err()); + run(&mut s, "smartArt.itemColor", json!({"index": 2})); + assert!(!cmd::smart_art::selected_smart_art(&s).unwrap().has_custom_colors()); + run(&mut s, "smartArt.itemColor", json!({"index": 0, "color": "00FF00"})); + run(&mut s, "smartArt.colors", json!({"variant": "accent2"})); + assert!(!cmd::smart_art::selected_smart_art(&s).unwrap().has_custom_colors()); + run(&mut s, "edit.undo", json!({})); + assert!(cmd::smart_art::selected_smart_art(&s).unwrap().has_custom_colors(), "undo brings the colours back"); +} + +/// A malformed colour must not silently replace a coloured item with its style colour. +#[test] +fn smart_art_item_colors_reject_invalid_values_without_changing_the_graphic() { + let mut s = s(); + run(&mut s, "insert.smartArt", json!({"items": [{"text": "Keep", "color": "123456"}]})); + let before = cmd::smart_art::selected_smart_art(&s).unwrap(); + for command in ["insert.smartArt", "smartArt.items"] { + for color in [json!("nope"), json!(42), json!(true), json!([]), json!({})] { + assert!(s.run(command, &json!({"items": [{"text": "Replace", "color": color}]})).is_err(), "{command}: {color}"); + assert_eq!(cmd::smart_art::selected_smart_art(&s).unwrap(), before); + } + run(&mut s, command, json!({"items": [{"text": "Default", "color": null}, {"text": "Omitted"}]})); + assert!(!cmd::smart_art::selected_smart_art(&s).unwrap().has_custom_colors()); + run(&mut s, "edit.undo", json!({})); + assert_eq!(cmd::smart_art::selected_smart_art(&s).unwrap(), before); + } +} + +/// #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.itemColor", + "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"); +} + +/// #3621: with thousands of collapsed headings, an Enter above them (which shifts every index) +/// finds them again without scanning the document once per heading, and a caret move doesn't +/// redo the matching. +#[test] +fn outline_many_collapsed_headings_stay_fast_after_edits() { + const N: usize = 8000; + let text: Vec = (0..N).flat_map(|i| [format!("Heading {i}"), format!("body {i}")]).collect(); + let mut d = wordcraft_doc::Document::from_text(&text.join("\n")); + for i in 0..N { + let p = d.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(2 * i)).unwrap(); + p.props.style = Some("Heading1".into()); + } + let mut s = Session::new(d); + run(&mut s, "view.outline", json!({})); + run(&mut s, "outline.showLevel1", json!({})); + s.sel = crate::Selection::caret(Pos::body(0, 0)); + run(&mut s, "outline.collapse", json!({})); + let collapsed_texts = + |s: &Session| -> Vec { s.view.outline.collapsed.iter().map(|(i, _)| s.doc.body[*i].as_para().unwrap().text.clone()).collect() }; + let before = collapsed_texts(&s); + assert_eq!(before.len(), N, "every heading is collapsed"); + s.sel = crate::Selection::caret(Pos::body(1, 0)); + for _ in 0..10 { + run(&mut s, "text.newParagraph", json!({})); + } + for _ in 0..20 { + run(&mut s, "caret.right", json!({})); + } + assert_eq!(collapsed_texts(&s), before, "the same headings stay collapsed"); +} + +/// #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 formula's `\*` switches format its result when the formula is inserted and when Update Field +/// works it out again. +#[test] +fn table_formula_results_take_the_format_switches() { + for (formula, inserted, updated) in [ + (r"=SUM(ABOVE) \* CardText", "twelve", "twenty-one"), + (r"=SUM(ABOVE) \* CardText \* Caps", "Twelve", "Twenty-one"), + (r"=SUM(ABOVE) \* OrdText \* Upper", "TWELFTH", "TWENTY-FIRST"), + (r"=SUM(ABOVE) \* MERGEFORMAT", "12", "21"), + ] { + let mut s = formula_table(); + let r = run(&mut s, "table.formula", json!({"formula": formula})); + assert_eq!((r["formula"].as_str(), r["result"].as_str()), (Some(formula), Some(inserted))); + 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(updated), "{formula}"); + } +} + +#[test] +fn table_formula_results_take_dollar_text_and_hex() { + for (formula, inserted, updated) in [ + (r"=SUM(ABOVE) \* DollarText", "twelve and 00/100", "twenty-one and 00/100"), + (r"=SUM(ABOVE) \* DollarText \* Upper", "TWELVE AND 00/100", "TWENTY-ONE AND 00/100"), + (r"=SUM(ABOVE) \* Hex", "C", "15"), + ] { + let mut s = formula_table(); + let r = run(&mut s, "table.formula", json!({"formula": formula})); + assert_eq!((r["formula"].as_str(), r["result"].as_str()), (Some(formula), Some(inserted))); + 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(updated), "{formula}"); + } +} + +/// 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() +} + +#[test] +fn table_sort_rejects_invalid_columns_without_changing_rows_or_history() { + for column in [ + json!(-1), + json!(0.5), + json!(0.0), + json!("0"), + json!(null), + json!(true), + json!([]), + json!({}), + json!(1), + json!(u32::MAX as u64 + 1), + json!(u64::MAX), + ] { + let mut s = column_table("en-US", &["Charlie", "Alpha", "Bravo"]); + s.close_typing(); + run(&mut s, "text.insert", json!({"text": "!"})); + let before = s.doc.clone(); + let selection = s.sel.clone(); + let dirty = s.dirty; + assert!(s.run("table.sort", &json!({"column": column, "header": false})).is_err(), "column {column}"); + assert_eq!(s.doc, before, "column {column}"); + assert_eq!(s.sel, selection, "column {column}"); + assert_eq!(s.dirty, dirty, "column {column}"); + run(&mut s, "edit.undo", json!({})); + assert_eq!(column_texts(&s), ["Charlie", "Alpha", "Bravo"], "column {column}: refusal must not consume Undo"); + run(&mut s, "edit.redo", json!({})); + assert_eq!(s.doc, before, "column {column}: Redo must restore the preceding edit"); + } +} + +#[test] +fn table_sort_keeps_default_and_valid_columns() { + let mut s = s(); + run(&mut s, "insert.table", json!({"rows": 3, "cols": 2})); + for (k, value) in ["Charlie", "1", "Alpha", "3", "Bravo", "2"].into_iter().enumerate() { + if k > 0 { + run(&mut s, "text.tab", json!({})); + } + run(&mut s, "text.insert", json!({"text": value})); + } + let original = s.doc.clone(); + for params in [json!({"header": false}), json!({"column": 0, "header": false})] { + run(&mut s, "table.sort", params); + assert_eq!(column_texts(&s), ["Alpha", "Bravo", "Charlie"]); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc, original); + } + run(&mut s, "table.sort", json!({"column": 1, "header": false})); + assert_eq!(column_texts(&s), ["Charlie", "Bravo", "Alpha"]); + run(&mut s, "table.sort", json!({"column": 1, "header": false, "descending": true})); + assert_eq!(column_texts(&s), ["Alpha", "Bravo", "Charlie"]); + // A column may be missing in some rows. Its present cells still sort normally. + let at = s.doc.body.iter().position(|b| b.as_table().is_some()).unwrap() as u32; + let table = s.doc.table_mut(StoryRef::Body, &wordcraft_doc::Path(vec![at])).unwrap(); + table.rows[0].cells.remove(1); + run(&mut s, "table.sort", json!({"column": 1, "header": false})); + assert_eq!(column_texts(&s), ["Charlie", "Bravo", "Alpha"]); + + let mut s = column_table("en-US", &["Title", "3", "1", "2"]); + let at = s.doc.body.iter().position(|b| b.as_table().is_some()).unwrap() as u32; + s.doc.table_mut(StoryRef::Body, &wordcraft_doc::Path(vec![at])).unwrap().rows[0].props.header = true; + run(&mut s, "table.sort", json!({"column": 0})); + assert_eq!(column_texts(&s), ["Title", "1", "2", "3"]); +} + +/// #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")); + Arc::make_mut(&mut 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"); + + // A replace with no `text` and no earlier search is a mistake, not "0 matches" (#3271). + let mut s = super::tests::s(); + for (id, params) in [("edit.replaceAll", json!({"find": "cat", "replace": "dog"})), ("edit.replace", json!({})), ("edit.replaceAll", json!({}))] { + let e = s.run(id, ¶ms).unwrap_err().to_string(); + assert!(e.contains("`text` (string) is required"), "{id}: {e}"); + } + run(&mut s, "edit.replaceAll", json!({"text": "", "with": "x"})); + + // 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); +} + +#[test] +fn window_commands_need_the_desktop_and_name_a_window() { + use crate::cmd::view::OtherWindow; + let mut s = s(); + // Web, CLI, MCP: one window, so View › Window is disabled. + for id in ["view.arrangeAll", "view.sideBySide", "view.syncScroll"] { + assert!(s.run(id, &json!({})).is_err(), "{id} without a window host"); + } + s.windows.available = true; + run(&mut s, "view.arrangeAll", json!({})); + assert_eq!(s.ui_requests.pop(), Some(json!({"windows": "arrange"}))); + assert!(s.run("view.sideBySide", &json!({})).is_err(), "no other window"); + s.windows.others = vec![OtherWindow { id: 7, title: "Notes".into() }, OtherWindow { id: 9, title: "Draft".into() }]; + let e = s.run("view.sideBySide", &json!({})).unwrap_err().to_string(); + assert!(e.contains("7 (Notes)") && e.contains("9 (Draft)"), "several windows: say which: {e}"); + assert!(s.run("view.sideBySide", &json!({"window": 8})).is_err(), "an unknown window"); + assert!(s.run("view.sideBySide", &json!({"window": "9"})).is_err()); + // Side by Side pairs with one window and scrolls together until turned off. + assert!(s.run("view.syncScroll", &json!({})).is_err(), "only while Side by Side is on"); + let r = run(&mut s, "view.sideBySide", json!({"window": 7})); + assert_eq!((r["value"].as_bool(), s.windows.side_by_side, s.windows.sync_scroll), (Some(true), Some(7), true)); + assert_eq!(s.ui_requests.pop(), Some(json!({"windows": "sideBySide", "with": 7}))); + run(&mut s, "view.syncScroll", json!({})); + assert!(!s.windows.sync_scroll); + assert_eq!(s.ui_requests.pop(), Some(json!({"windows": "syncScroll", "with": 7, "value": false}))); + // Asking again for the partner it already has keeps scrolling off and pairs nothing anew. + let r = run(&mut s, "view.sideBySide", json!({"window": 7, "value": true})); + assert_eq!((r["syncScroll"].as_bool(), s.windows.sync_scroll), (Some(false), false)); + assert!(s.ui_requests.is_empty()); + // Another partner replaces the first; the bare button turns it off. + run(&mut s, "view.sideBySide", json!({"window": 9})); + assert_eq!(std::mem::take(&mut s.ui_requests), [json!({"windows": "sideBySide", "off": 7}), json!({"windows": "sideBySide", "with": 9})]); + run(&mut s, "view.sideBySide", json!({})); + assert_eq!((s.windows.side_by_side, s.windows.sync_scroll), (None, false)); + assert_eq!(s.ui_requests.pop(), Some(json!({"windows": "sideBySide", "off": 9}))); + assert!(!s.dirty, "window commands never touch the document"); +} + +/// 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); +} + +/// Change Picture keeps the width and takes the replacement's aspect ratio: swapping a figure for +/// a revised one of a different shape must not stretch it into the old frame. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn picture_change_keeps_width_and_takes_new_aspect() { + let dir = scratch_dir("picture-change-aspect"); + let mut wide = Vec::new(); + image::RgbImage::from_pixel(40, 20, image::Rgb([200, 30, 30])).write_to(&mut std::io::Cursor::new(&mut wide), image::ImageFormat::Png).unwrap(); + let mut tall = Vec::new(); + image::RgbImage::from_pixel(30, 60, image::Rgb([30, 30, 200])).write_to(&mut std::io::Cursor::new(&mut tall), image::ImageFormat::Png).unwrap(); + let (first, second) = (dir.join("wide.png"), dir.join("tall.png")); + std::fs::write(&first, &wide).unwrap(); + std::fs::write(&second, &tall).unwrap(); + + let mut s = Session::new(wordcraft_doc::Document::new()); + run(&mut s, "insert.picture", json!({"path": first.to_string_lossy()})); + let size = |s: &Session| { + let mut out = (0.0f32, 0.0f32, [1.0f32; 4]); + s.doc.objects_in_reading_order(&mut |o| { + if let wordcraft_doc::para::InlineObject::Image { w, h, crop, .. } = o { + out = (*w, *h, *crop); + } + }); + out + }; + let (w0, h0, _) = size(&s); + assert!((w0 / h0 - 2.0).abs() < 1e-3, "inserted at its own 2:1 shape: {w0} x {h0}"); + run(&mut s, "picture.crop", json!({"left": 0.1})); + let (w_cropped, _, crop) = size(&s); + assert!(crop[0] > 0.0, "cropped before the change"); + run(&mut s, "picture.change", json!({"path": second.to_string_lossy()})); + let (w1, h1, crop) = size(&s); + assert!((w1 - w_cropped).abs() < 1e-3, "the width is kept: {w_cropped} -> {w1}"); + assert!((h1 / w1 - 2.0).abs() < 1e-3, "the height follows the new 1:2 picture: {w1} x {h1}"); + assert_eq!(crop, [0.0; 4], "the old picture's crop is dropped"); + 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"); + + // A name given with the document's extension is the whole file name: an agent or a script + // passes one, and appending the extension again made "Report.docm.docm" (#2983). + s.run("file.rename", &json!({"name": "Report.docm"})).unwrap(); + assert_eq!(s.path.as_ref(), Some(&dir.join("Report.docm"))); + assert!(!dir.join("Report.docm.docm").exists()); + // Another extension, or the extension alone, is a stem and still gets one. + s.run("file.rename", &json!({"name": "Report.2026"})).unwrap(); + assert_eq!(s.path.as_ref(), Some(&dir.join("Report.2026.docm"))); + s.run("file.rename", &json!({"name": ".docm"})).unwrap(); + assert_eq!(s.path.as_ref(), Some(&dir.join(".docm.docm"))); + assert_eq!(std::fs::read(dir.join(".docm.docm")).unwrap(), b"original bytes"); + // A name whose bytes do not line up with the extension's length: comparing by slicing at + // `len - ext.len()` could land inside a character and panic (#2983 review). + s.run("file.rename", &json!({"name": "abécde"})).unwrap(); + assert_eq!(s.path.as_ref(), Some(&dir.join("abécde.docm"))); + s.run("file.rename", &json!({"name": "résumé.docm"})).unwrap(); + assert_eq!(s.path.as_ref(), Some(&dir.join("résumé.docm"))); + // Whatever its case; the file is named as typed, so the extension follows the name. + s.run("file.rename", &json!({"name": "Report.DOCM"})).unwrap(); + assert_eq!(s.path.as_ref(), Some(&dir.join("Report.DOCM"))); + assert!(!dir.join("Report.DOCM.docm").exists()); + 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()); +} + +/// A version snapshot belongs to the document it was taken from: opening another document (or +/// starting a new one) starts a new version history, so a restore can't put the previous +/// document's content into the open one (#1327 follow-up). +#[test] +fn a_version_snapshot_does_not_survive_a_document_switch() { + let mut s = Session::new(wordcraft_doc::Document::from_text("First document.")); + s.run("file.versions", &json!({"save": "snapshot"})).unwrap(); + assert_eq!(s.versions.len(), 1); + + // A new document: the snapshot of the previous one is gone with it. + s.run("file.new", &json!({})).unwrap(); + assert!(s.versions.is_empty(), "the snapshot of the previous document survived the switch"); + assert!(s.run("file.versions", &json!({"restore": 0})).is_err(), "the old snapshot is still restorable"); + assert_ne!(s.doc.plain_text(StoryRef::Body), "First document.", "the old document's content came back"); + + // The same for an opened file. + s.run("file.versions", &json!({"save": "snapshot"})).unwrap(); + s.run("file.open", &json!({"path": "other.txt", "data": "aGk="})).unwrap(); + assert!(s.versions.is_empty(), "the snapshot of the previous document survived the switch"); + assert!(s.run("file.versions", &json!({"restore": 0})).is_err(), "the old snapshot is still restorable"); + assert_eq!(s.doc.plain_text(StoryRef::Body), "hi"); + + // A snapshot of the open document still restores into it. + s.run("file.versions", &json!({"save": "snapshot"})).unwrap(); + s.run("text.insert", &json!({"text": " there"})).unwrap(); + s.run("file.versions", &json!({"restore": 0})).unwrap(); + assert_eq!(s.doc.plain_text(StoryRef::Body), "hi"); +} + +/// A comparison that opens the original replaces the open document, so the version snapshots +/// of the document it replaces go with it: a restore must not put the previous document back +/// over the comparison result (#1327 follow-up). +#[test] +fn a_version_snapshot_does_not_survive_a_comparison() { + let dir = std::env::temp_dir().join(format!("wordcraft-compare-versions-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let original = dir.join("original.docx"); + let mut other = Session::new(wordcraft_doc::Document::from_text("Original document.")); + other.run("file.save", &json!({"path": original.to_string_lossy()})).unwrap(); + + let mut s = Session::new(wordcraft_doc::Document::from_text("Current document.")); + s.run("file.versions", &json!({"save": "snapshot"})).unwrap(); + assert_eq!(s.versions.len(), 1); + s.run("review.compare", &json!({"original": original.to_string_lossy(), "text": "Revised document."})).unwrap(); + assert!(s.versions.is_empty(), "the snapshot of the previous document survived the comparison"); + assert!(s.run("file.versions", &json!({"restore": 0})).is_err(), "the old snapshot is still restorable"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Compare's new and revised results are untitled documents even without an `original` path. +#[test] +fn a_version_snapshot_does_not_survive_an_untitled_comparison() { + for show_in in ["new", "revised"] { + let mut s = Session::new(wordcraft_doc::Document::from_text("Current document.")); + s.run("file.versions", &json!({"save": "snapshot"})).unwrap(); + s.run("review.compare", &json!({"text": "Revised document.", "showIn": show_in})).unwrap(); + assert!(s.versions.is_empty(), "old snapshots survived a {show_in} comparison"); + assert!(s.run("file.versions", &json!({"restore": 0})).is_err()); + assert!(s.path.is_none() && s.file_name.is_none()); + } +} + +/// A comparison with an `original` replaces the open document in place — the handler loads the +/// original into `s.doc` without `set_document` — so `document_id` does not move and two +/// identical comparisons produce identical documents. A comparison is an edit of its own all +/// the same: the `replaces_document` guard keeps its undo step (#4004 review). +#[test] +fn a_repeated_comparison_is_still_an_edit() { + let dir = std::env::temp_dir().join(format!("wordcraft-compare-twice-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let original = dir.join("original.docx"); + let mut other = Session::new(wordcraft_doc::Document::from_text("Original document.")); + other.run("file.save", &json!({"path": original.to_string_lossy()})).unwrap(); + + let mut s = Session::new(wordcraft_doc::Document::from_text("Current document.")); + let compare = json!({"original": original.to_string_lossy(), "text": "Revised document."}); + s.run("review.compare", &compare).unwrap(); + let depth = s.undo_depth(); + assert!(s.dirty); + s.run("review.compare", &compare).unwrap(); + assert_eq!(s.undo_depth(), depth + 1, "the second comparison is an edit of its own"); + assert!(s.dirty, "the comparison result is unsaved"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A rejected comparison leaves the current document and its snapshots together. +#[test] +fn a_rejected_comparison_keeps_the_current_versions() { + use wordcraft_doc::{CharProps, InlineObject}; + + let mut s = Session::new(wordcraft_doc::Document::from_text("Editable. Locked.")); + s.path = Some(std::path::PathBuf::from("original.docx")); + s.file_name = Some("original.docx".into()); + s.run("file.versions", &json!({"save": "snapshot"})).unwrap(); + let end = s.doc.end_of(StoryRef::Body); + s.doc.insert_object(&end, InlineObject::PermEnd { id: 1 }, &CharProps::default()).unwrap(); + let start = s.doc.start_of(StoryRef::Body); + s.doc.insert_object(&start, InlineObject::PermStart { id: 1, group: String::new(), editor: s.author.clone() }, &CharProps::default()).unwrap(); + s.run("review.restrict", &json!({"mode": "readOnly"})).unwrap(); + let before = s.doc.clone(); + + assert!(s.run("review.compare", &json!({"text": "Different document.", "showIn": "new"})).is_err()); + assert_eq!(s.doc, before); + assert_eq!(s.versions.len(), 1, "a rejected comparison discarded the current document's snapshot"); + assert_eq!(s.path.as_deref(), Some(std::path::Path::new("original.docx"))); + assert_eq!(s.file_name.as_deref(), Some("original.docx")); +} + +/// A mailing result opens beside a protected source document, which keeps its save target, +/// versions, password and text. +#[test] +fn a_mailing_result_beside_a_protected_document_keeps_its_file_identity() { + use wordcraft_doc::{CharProps, InlineObject}; + + let mut s = Session::new(wordcraft_doc::Document::from_text("Protected source.")); + s.path = Some(std::path::PathBuf::from("source.docx")); + s.file_name = Some("source.docx".into()); + s.run("file.versions", &json!({"save": "snapshot"})).unwrap(); + let end = s.doc.end_of(StoryRef::Body); + s.doc.insert_object(&end, InlineObject::PermEnd { id: 1 }, &CharProps::default()).unwrap(); + let start = s.doc.start_of(StoryRef::Body); + s.doc.insert_object(&start, InlineObject::PermStart { id: 1, group: String::new(), editor: s.author.clone() }, &CharProps::default()).unwrap(); + s.run("review.restrict", &json!({"mode": "readOnly"})).unwrap(); + let before = s.doc.clone(); + let before_id = s.document_id(); + let before_dirty = s.dirty; + s.password = Some(crate::io::Password::new("synthetic file password")); + s.bib_style = "Chicago".into(); + + s.run("mailings.envelopes", &json!({"delivery": "Recipient"})).unwrap(); + assert_ne!(s.document_id(), before_id, "the envelope is a new document"); + s.activate(before_id).unwrap(); + assert_eq!(s.doc, before); + assert_eq!(s.versions.len(), 1); + assert_eq!(s.path.as_deref(), Some(std::path::Path::new("source.docx"))); + assert_eq!(s.file_name.as_deref(), Some("source.docx")); + assert_eq!(s.document_id(), before_id); + assert_eq!(s.dirty, before_dirty); + assert_eq!(s.password.as_ref().map(crate::io::Password::as_str), Some("synthetic file password")); + assert_eq!(s.bib_style, "Chicago"); +} + +/// #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() }; + Arc::make_mut(&mut s.doc.styles).upsert(Style { + id: "HideC".into(), + name: "Hide C".into(), + kind: StyleKind::Character, + chr: hidden.clone(), + ..Default::default() + }); + Arc::make_mut(&mut 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(); + Arc::make_mut(&mut 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(); + Arc::make_mut(&mut 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. +
  3. Classificazione dell’evento
  4. +
  5. Azioni correttive
  6. +
+ + + + + + +
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); +} + +/// Shape Format › Shape Fill with transparency and gradients (#1500): each form sets the fill of +/// every selected shape in one undo step; a colour alone keeps the transparency; hostile values +/// are clamped; the old `{"color"}` form still works. +#[test] +fn shape_fill_transparency_and_gradients() { + use wordcraft_doc::fill::{Fill, GradientKind, PathShape}; + use wordcraft_doc::props::Rgb; + let mut s = s(); + run(&mut s, "insert.shape", json!({"kind": "rectangle"})); + run(&mut s, "select.collapse", json!({"end": true})); + run(&mut s, "insert.shape", json!({"kind": "ellipse"})); + run(&mut s, "select.objects", json!({"index": 0})); + run(&mut s, "select.addObject", json!({"index": 1})); + let fills = |s: &Session| -> Vec> { + cmd::objects::all_objects(s) + .into_iter() + .map(|(_, o)| match o { + wordcraft_doc::InlineObject::Shape { fill, .. } => fill, + o => panic!("{o:?}"), + }) + .collect() + }; + let before = fills(&s); + run(&mut s, "shape.fill", json!({"color": "112233", "transparency": 40})); + let solid = Fill::Solid { color: Rgb(0x11, 0x22, 0x33), transparency: 40.0 }; + assert_eq!(fills(&s), [Some(solid.clone()), Some(solid)], "both selected shapes"); + run(&mut s, "shape.fill", json!({"color": "445566"})); + assert_eq!(fills(&s)[1], Some(Fill::Solid { color: Rgb(0x44, 0x55, 0x66), transparency: 40.0 }), "a colour alone keeps the transparency"); + run( + &mut s, + "shape.fill", + json!({"gradient": {"type": "rectangular", "focus": [50, 50, 50, 50], "stops": [{"pos": 100, "color": "FFFFFF", "transparency": 30}, {"pos": 0, "color": "000000"}]}}), + ); + let Some(Fill::Gradient(g)) = &fills(&s)[0] else { panic!("{:?}", fills(&s)) }; + assert_eq!(g.kind, GradientKind::Path { shape: PathShape::Rect, focus: [50.0; 4] }); + assert_eq!(g.stops.iter().map(|st| (st.pos, st.color, st.transparency)).collect::>(), [(0.0, Rgb::BLACK, 0.0), (100.0, Rgb::WHITE, 30.0)]); + assert!(g.rot_with_shape, "turns with the shape, as Word's new gradients do"); + run( + &mut s, + "shape.fill", + json!({"gradient": {"type": "linear", "angle": -90, "stops": [{"pos": -5, "color": "FF0000", "transparency": 500}, {"pos": 1e9, "color": "0000FF"}]}}), + ); + let Some(Fill::Gradient(g)) = &fills(&s)[1] else { panic!() }; + assert_eq!(g.kind, GradientKind::Linear { angle: 270.0, scaled: false }); + assert_eq!((g.stops[0].pos, g.stops[0].transparency, g.stops[1].pos), (0.0, 100.0, 100.0)); + // One undo step per command, for every shape. + run(&mut s, "edit.undo", json!({})); + assert!(matches!(fills(&s)[1], Some(Fill::Gradient(_)))); + for _ in 0..3 { + run(&mut s, "edit.undo", json!({})); + } + assert_eq!(fills(&s), before); + run(&mut s, "shape.fill", json!({"color": null})); + assert_eq!(fills(&s), [None, None]); + for bad in [ + json!({"gradient": {"stops": []}}), + json!({"gradient": {"type": "spiral", "stops": [{"pos": 0, "color": "000000"}, {"pos": 100, "color": "FFFFFF"}]}}), + json!({"gradient": 5}), + json!({"color": "nope"}), + ] { + assert!(s.run("shape.fill", &bad).is_err(), "{bad}"); + } +} + +/// A drag in the Format Shape pane applies its value as it goes and undoes in one step. +#[test] +fn steps_since_a_mark_merge_into_one_undo_step() { + let mut s = s(); + run(&mut s, "insert.shape", json!({"kind": "rectangle"})); + let fill = |s: &Session| match cmd::objects::selected(s) { + Some((_, wordcraft_doc::InlineObject::Shape { fill, .. })) => fill, + o => panic!("{o:?}"), + }; + let before = fill(&s); + let mark = s.undo_mark(); + for t in [10, 20, 30] { + run(&mut s, "shape.fill", json!({"color": "112233", "transparency": t})); + } + s.merge_undo_since(mark); + assert_eq!(s.undo_mark(), mark + 1); + run(&mut s, "edit.undo", json!({})); + assert_eq!(fill(&s), before); + s.merge_undo_since(mark + 5); + assert_eq!(s.undo_mark(), mark, "nothing newer: nothing merged"); +} + +/// 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; + Arc::make_mut(&mut d.styles).get_mut("TOC2").unwrap().para.tabs = Some(vec![right]); + let num = Arc::make_mut(&mut 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"); + + // A selection is as good as its worst paragraph: an empty one or one starting with a space + // refuses a drop cap for all (the button greys out); None works while there is one to take away. + let mut s = self::s(); + run(&mut s, "text.insert", json!({"text": "First"})); + run(&mut s, "text.newParagraph", json!({})); + run(&mut s, "text.newParagraph", json!({})); + run(&mut s, "text.insert", json!({"text": "Third"})); + assert_eq!(s.doc.body.len(), 3); + run(&mut s, "select.all", json!({})); + assert!(s.run("insert.dropCap", &json!({"position": "dropped"})).is_err(), "an empty paragraph in the selection"); + assert!(s.doc.body.iter().filter_map(|b| b.as_para()).all(|p| p.props.drop_cap.is_none()), "nothing changed"); + assert!(s.why_disabled("insert.dropCap").is_some(), "greyed out: nothing can take a drop cap or lose one"); + run(&mut s, "select.range", json!({"anchor": {"block": 2, "off": 0}, "focus": {"block": 2, "off": 0}})); + run(&mut s, "insert.dropCap", json!({"position": "dropped"})); + // From the drop cap up into the empty paragraph (the caret there): None takes it away, the + // other positions are refused, as the enabled check says. + run(&mut s, "select.range", json!({"anchor": {"block": 2, "off": 5}, "focus": {"block": 1, "off": 0}})); + assert_eq!(s.why_disabled("insert.dropCap"), None, "a drop cap to take away"); + assert!(s.run("insert.dropCap", &json!({"position": "margin"})).is_err(), "the empty paragraph can't take one"); + assert!(s.run("insert.dropCap", &json!({"position": "none"})).is_ok(), "None takes it away"); + assert!(s.doc.body.iter().filter_map(|b| b.as_para()).all(|p| p.props.drop_cap.is_none()), "taken away"); + let mut s = self::s(); + run(&mut s, "text.insert", json!({"text": " first"})); + run(&mut s, "text.newParagraph", json!({})); + run(&mut s, "text.insert", json!({"text": "Second"})); + run(&mut s, "select.all", json!({})); + assert!(s.run("insert.dropCap", &json!({})).is_err(), "a paragraph starting with a space in the selection"); +} + +/// #2601: `keep` moves a whole selection to Dropped / In margin without flattening it: each +/// paragraph keeps its own lines, distance and font, one without a drop cap gets the defaults, +/// and it is one undo step. Without `keep` the options not given are reset, as before. +#[test] +fn drop_cap_keep_leaves_each_paragraphs_options() { + let caps = |s: &Session| { + s.doc + .body + .iter() + .filter_map(|b| b.as_para()) + .map(|p| (p.props.drop_cap, p.props.drop_cap_margin, p.props.drop_cap_distance, p.props.drop_cap_font.clone())) + .collect::>() + }; + let mut s = s(); + run(&mut s, "text.insert", json!({"text": "First"})); + for text in ["Second", "Third"] { + run(&mut s, "text.newParagraph", json!({})); + run(&mut s, "text.insert", json!({"text": text})); + } + run(&mut s, "select.range", json!({"anchor": {"block": 0, "off": 0}, "focus": {"block": 0, "off": 0}})); + run(&mut s, "insert.dropCap", json!({"position": "dropped", "lines": 5, "distance": 7.0, "font": "Liberation Mono"})); + run(&mut s, "select.range", json!({"anchor": {"block": 1, "off": 0}, "focus": {"block": 1, "off": 0}})); + run(&mut s, "insert.dropCap", json!({"position": "dropped", "lines": 2, "distance": 3.0})); + let before = caps(&s); + run(&mut s, "select.all", json!({})); + run(&mut s, "insert.dropCap", json!({"position": "margin", "keep": true})); + assert_eq!( + caps(&s), + vec![ + (Some(5), Some(true), Some(7.0), Some("Liberation Mono".to_string())), + (Some(2), Some(true), Some(3.0), None), + (Some(3), Some(true), None, None), + ], + "each keeps its own options; the one without gets the defaults" + ); + run(&mut s, "edit.undo", json!({})); + assert_eq!(caps(&s), before, "one undo step"); + + // Options given explicitly win over the kept ones; a junk `keep` is false. + run(&mut s, "insert.dropCap", json!({"position": "dropped", "keep": true, "lines": 4})); + assert_eq!(caps(&s).iter().map(|c| c.0).collect::>(), vec![Some(4); 3]); + assert_eq!(caps(&s).first().map(|c| (c.2, c.3.clone())), Some((Some(7.0), Some("Liberation Mono".to_string()))), "distance and font still kept"); + // A blank font given with `keep` clears the kept font (the text's own again). + run(&mut s, "insert.dropCap", json!({"position": "dropped", "keep": true, "font": " "})); + assert_eq!(caps(&s).first().map(|c| c.3.clone()), Some(None), "blank font clears the kept one"); + run(&mut s, "insert.dropCap", json!({"position": "dropped", "lines": 5, "distance": 7.0, "font": "Liberation Mono"})); + run(&mut s, "insert.dropCap", json!({"position": "margin", "keep": "yes"})); + assert_eq!(caps(&s), vec![(Some(3), Some(true), None, None); 3], "junk keep: reset what isn't given"); + // None ignores keep. + run(&mut s, "insert.dropCap", json!({"position": "none", "keep": true})); + assert_eq!(caps(&s), vec![(None, None, None, None); 3]); +} + +#[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}"); + } +} + +/// #3490: `table.cellMargins` preserves existing margins for any sides not passed. +#[test] +fn table_cell_margins_preserves_unpassed_sides() { + let mut s = s(); + run(&mut s, "insert.table", json!({"rows": 1, "cols": 1})); + let (tp, _, _) = s.sel.focus.path.cell().unwrap(); + + // 1. Updating 'top' only retains default left (5.4), bottom (0.0), and right (5.4). + run(&mut s, "table.cellMargins", json!({"top": 20.0})); + let margins = s.doc.table(StoryRef::Body, &tp).unwrap().props.cell_margins.unwrap(); + assert_eq!(margins, [20.0, 5.4, 0.0, 5.4]); + + // 2. Updating 'left' subsequently retains previously set top (20.0), bottom (0.0), and right (5.4). + run(&mut s, "table.cellMargins", json!({"left": 10.0})); + let margins = s.doc.table(StoryRef::Body, &tp).unwrap().props.cell_margins.unwrap(); + assert_eq!(margins, [20.0, 10.0, 0.0, 5.4]); + + // 3. Updating 'bottom' retains top (20.0), left (10.0), and right (5.4). + run(&mut s, "table.cellMargins", json!({"bottom": 8.0})); + let margins = s.doc.table(StoryRef::Body, &tp).unwrap().props.cell_margins.unwrap(); + assert_eq!(margins, [20.0, 10.0, 8.0, 5.4]); + + // 4. Updating 'right' retains top (20.0), left (10.0), and bottom (8.0). + run(&mut s, "table.cellMargins", json!({"right": 15.0})); + let margins = s.doc.table(StoryRef::Body, &tp).unwrap().props.cell_margins.unwrap(); + assert_eq!(margins, [20.0, 10.0, 8.0, 15.0]); + + // 5. Updating multiple sides at once retains unpassed sides. + run(&mut s, "table.cellMargins", json!({"top": 0.0, "bottom": 0.0})); + let margins = s.doc.table(StoryRef::Body, &tp).unwrap().props.cell_margins.unwrap(); + assert_eq!(margins, [0.0, 10.0, 0.0, 15.0]); +} + +#[test] +fn headless_ui_requests_stay_bounded() { + let mut s = s(); + let big = "x".repeat(1 << 20); + for _ in 0..200 { + run(&mut s, "edit.pasteSpecial", json!({"text": big})); + run(&mut s, "edit.goto", json!({})); + } + assert!(s.ui_requests.len() <= 64, "len = {}", s.ui_requests.len()); + let size: usize = s.ui_requests.iter().map(|r| r.to_string().len()).sum(); + assert!(size < 4 << 20, "queued {size} bytes"); +} + +/// `file.exportPng` writes only to `.png` paths, so automation can't replace other files with +/// page images (#3696). +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn export_png_refuses_a_path_that_is_not_png() { + let dir = scratch_dir("export-png-extension"); + let mut s = s(); + run(&mut s, "text.insert", json!({"text": "Hello"})); + for name in ["notes.txt", "config", "report.docx", "page.png.txt"] { + let target = dir.join(name); + std::fs::write(&target, b"precious user notes").unwrap(); + let r = s.run("file.exportPng", &json!({"path": target.to_string_lossy()})); + assert!(matches!(r, Err(crate::CmdError::Params(_))), "{name}: {r:?}"); + assert_eq!(std::fs::read(&target).unwrap(), b"precious user notes", "{name} was changed"); + } + // A .png path still works, whatever its case. + for name in ["page.png", "PAGE.PNG"] { + let target = dir.join(name); + s.run("file.exportPng", &json!({"path": target.to_string_lossy()})).unwrap(); + assert!(std::fs::read(&target).unwrap().starts_with(b"\x89PNG"), "{name} is not a PNG"); + } + let _ = std::fs::remove_dir_all(&dir); +} + +/// The body's IME replacement epoch advances when the whole body is swapped (setText, Undo/Redo) +/// and stays put for ordinary edits; an equation editing session cannot ride across a replacement. +#[test] +fn ime_body_epoch_marks_body_replacements_not_edits() { + let mut s = s(); + let id = s.document_id(); + run(&mut s, "text.insert", json!({"text": "one"})); + let first = s.ime_body_epoch(); + run(&mut s, "text.insert", json!({"text": " two"})); + assert_eq!(s.ime_body_epoch(), first, "typing is not a replacement"); + + run(&mut s, "document.setText", json!({"text": "same text"})); + let replaced = s.ime_body_epoch(); + assert!(replaced > first, "setText replaces the body under the same document id"); + assert_eq!(s.document_id(), id, "the document id itself is unchanged"); + + // The same text again still replaces the body: a composition must not survive on content + // equality. + run(&mut s, "document.setText", json!({"text": "same text"})); + assert!(s.ime_body_epoch() > replaced); + + // Undo swaps the body back. + let before_undo = s.ime_body_epoch(); + assert!(s.undo()); + assert!(s.ime_body_epoch() > before_undo, "Undo replaces the body"); +} + +/// Replacing the document body while an equation is being edited ends that equation editing +/// session. +#[test] +fn a_body_replacement_ends_equation_editing() { + let mut s = s(); + run(&mut s, "insert.equation", json!({"linear": "x+y"})); + assert!(s.math.is_some()); + run(&mut s, "document.setText", json!({"text": "x+y"})); + assert!(s.math.is_none(), "the old equation's editing session is gone"); +} + +/// A failed command that cleared the equation session puts it back with the document. +#[test] +fn a_failed_replacement_keeps_equation_editing() { + let mut s = s(); + run(&mut s, "insert.equation", json!({"linear": "x+y"})); + let session = s.math.as_ref().map(|m| m.session); + assert!(session.is_some()); + // Missing `text` is rejected before any mutation. + assert!(s.run("document.setText", &json!({})).is_err()); + assert_eq!(s.math.as_ref().map(|m| m.session), session); +} + +/// Track Changes: deleting a range whose text is already marked deleted is a real no-op. It +/// creates no revision record, no undo-visible change and no IME revision. +#[test] +fn deleting_an_already_deleted_tracked_range_is_a_classified_noop() { + let mut s = s(); + run(&mut s, "text.insert", json!({"text": "abc"})); + run(&mut s, "review.trackChanges", json!({"value": true})); + s.sel = crate::Selection { anchor: Pos::body(0, 0), focus: Pos::body(0, 1) }; + run(&mut s, "text.backspace", json!({})); + let revisions = s.doc.revisions.len(); + let revision = s.ime_state_revision().expect("an IME revision"); + s.sel = crate::Selection { anchor: Pos::body(0, 0), focus: Pos::body(0, 1) }; + run(&mut s, "text.backspace", json!({})); + let outcome = s.ime_edit_outcome(); + assert!(outcome.classified, "{outcome:?}"); + assert!(!outcome.content_changed && !outcome.state_changed, "{outcome:?}"); + assert_eq!(s.doc.revisions.len(), revisions, "no unused Delete revision is appended"); + assert_eq!(s.ime_state_revision(), Some(revision), "the no-op did not move the settled caret"); + s.sel = crate::Selection { anchor: Pos::body(0, 0), focus: Pos::body(0, 1) }; + assert!(s.ime_state_revision() > Some(revision), "re-selecting moves the caret, not the document"); +} + +/// `text.insert` classifies the automatic rewrites its own handler performs: smart quotes, +/// list autoformat and AutoCorrect are `transformed`, not the IME's literal insert. +#[test] +fn text_insert_marks_automatic_rewrites_as_transformed() { + let mut session = s(); + run(&mut session, "text.insert", json!({"text": "\""})); + assert!(session.ime_edit_outcome().transformed, "smart quote: {:?}", session.ime_edit_outcome()); + assert_eq!(text(&session), "\u{201C}"); + + let mut session = s(); + for c in ["W", "o", "r", "d", " ", "(", "c", ")", " "] { + run(&mut session, "text.insert", json!({"text": c})); + } + assert!(session.ime_edit_outcome().transformed, "AutoCorrect: {:?}", session.ime_edit_outcome()); + + let mut session = s(); + run(&mut session, "text.insert", json!({"text": "*"})); + run(&mut session, "text.insert", json!({"text": " "})); + assert!(session.ime_edit_outcome().transformed, "list AutoFormat: {:?}", session.ime_edit_outcome()); + + let mut session = s(); + run(&mut session, "text.insert", json!({"text": "A--b"})); + run(&mut session, "text.insert", json!({"text": " "})); + assert!(session.ime_edit_outcome().transformed, "dash AutoFormat: {:?}", session.ime_edit_outcome()); + assert_eq!(text(&session), "A\u{2014}b "); + + let mut session = s(); + run(&mut session, "tools.autocorrect", json!({"doubleSpacePeriod": true})); + for c in "Hello ".chars() { + run(&mut session, "text.insert", json!({"text": c.to_string()})); + } + let outcome = session.ime_edit_outcome(); + assert!(outcome.transformed && outcome.content_changed && outcome.state_changed, "double-space period: {outcome:?}"); + assert_eq!(text(&session), "Hello. "); + run(&mut session, "edit.undo", json!({})); + assert_eq!(text(&session), "Hello ", "undo restores the spaces in one step"); +} + +#[test] +fn style_set_keeps_imported_styles_latent_styles_and_defaults() { + use wordcraft_doc::props::{Rgb, TextColor}; + use wordcraft_doc::styles::{Style, StyleKind}; + for name in ["basic", "lines", "shaded", "casual", "centered", "minimalist", "title"] { + let mut s = s(); + let mut body = s.doc.styles.get("Normal").unwrap().clone(); + body.id = "BodyText".into(); + body.name = "Body Text".into(); + body.builtin = true; + body.based_on = Some("Normal".into()); + body.chr.size = Some(20.0); + body.chr.color = Some(TextColor::Rgb(Rgb(0xFF, 0, 0))); + let grid = Style { id: "GridTable4".into(), name: "Grid Table 4".into(), kind: StyleKind::Table, builtin: true, ..Default::default() }; + Arc::make_mut(&mut s.doc.styles).styles.push(body.clone()); + Arc::make_mut(&mut s.doc.styles).styles.push(grid.clone()); + Arc::make_mut(&mut s.doc.styles).latent_styles = Some("".into()); + Arc::make_mut(&mut s.doc.styles).default_chr.lang = Some("de-DE".into()); + Arc::make_mut(&mut s.doc.styles).default_chr.size = Some(11.0); + run(&mut s, "design.styleSet", json!({ "name": name })); + assert_eq!(s.doc.styles.get("BodyText"), Some(&body), "{name}: BodyText"); + assert_eq!(s.doc.styles.get("GridTable4"), Some(&grid), "{name}: GridTable4"); + assert_eq!(s.doc.styles.latent_styles.as_deref(), Some(""), "{name}"); + assert_eq!(s.doc.styles.default_chr.lang.as_deref(), Some("de-DE"), "{name}"); + assert_eq!(s.doc.styles.default_chr.size, Some(11.0), "{name}"); + assert_eq!(s.doc.styles.styles.iter().filter(|x| x.id == "Heading1").count(), 1, "{name}"); + let saved = crate::io::save_bytes("x.docx", &s.doc).unwrap(); + let back = crate::io::open_bytes("x.docx", &saved).unwrap(); + assert!(back.styles.get("BodyText").is_some(), "{name}: BodyText after round trip"); + } + let mut s = s(); + run(&mut s, "design.styleSet", json!({ "name": "title" })); + assert_eq!(s.doc.styles.get("Title").unwrap().chr.size, Some(36.0)); +} + +fn style_size(s: &Session, id: &str) -> f32 { + s.doc.styles.resolve_char(Some(id), &Default::default()).size +} + +#[test] +fn scale_font_sizes_adds_points_to_every_style() { + let mut s = Session::new(crate::sample::sample_document()); + let ids: Vec = s.doc.styles.styles.iter().map(|x| x.id.clone()).collect(); + let before: Vec = ids.iter().map(|i| style_size(&s, i)).collect(); + let base = s.doc.styles.resolve_char(None, &Default::default()).size; + let runs = serde_json::to_string(&s.doc.body).unwrap(); + run(&mut s, "design.scaleFontSizes", json!({"delta": 2})); + // Every style (explicit or inherited) grows by exactly 2, once. + for (i, b) in ids.iter().zip(&before) { + assert_eq!(style_size(&s, i), b + 2.0, "{i}"); + } + assert_eq!(s.doc.styles.resolve_char(None, &Default::default()).size, base + 2.0); + assert_eq!(serde_json::to_string(&s.doc.body).unwrap(), runs, "runs untouched"); + run(&mut s, "edit.undo", json!({})); + for (i, b) in ids.iter().zip(&before) { + assert_eq!(style_size(&s, i), *b, "undo {i}"); + } +} + +#[test] +fn scale_font_sizes_percent_and_clamp_and_errors() { + let mut s = Session::new(crate::sample::sample_document()); + let n = style_size(&s, "Normal"); + run(&mut s, "design.scaleFontSizes", json!({"percent": 10})); + assert_eq!(style_size(&s, "Normal"), (n * 1.1 * 2.0).round() / 2.0); + run(&mut s, "design.scaleFontSizes", json!({"delta": -500})); + assert_eq!(style_size(&s, "Normal"), 1.0); + for bad in [ + json!({}), + json!({"delta": 1e9}), + json!({"percent": -100}), + json!({"percent": 1e9}), + json!({"delta": "x"}), + json!({"delta": 0}), + json!({"percent": 0}), + json!({"delta": 1, "percent": 10}), + json!({"delta": 2, "percent": "x"}), + json!({"percent": 10, "delta": null}), + ] { + assert!(s.run("design.scaleFontSizes", &bad).is_err(), "{bad}"); + } +} + +#[test] +fn scale_font_sizes_reaches_char_table_and_cs_sizes_and_unset_default() { + use wordcraft_doc::props::CharProps; + use wordcraft_doc::{Style, StyleKind, styles::TablePart}; + let mut s = s(); + Arc::make_mut(&mut s.doc.styles).default_chr.size = None; + Arc::make_mut(&mut s.doc.styles).default_chr.size_cs = Some(10.0); + let mut ch = Style { id: "Big".into(), name: "Big".into(), kind: StyleKind::Character, ..Default::default() }; + ch.chr.size = Some(20.0); + ch.chr.size_cs = Some(22.0); + let mut tb = Style { id: "Grid".into(), name: "Grid".into(), kind: StyleKind::Table, ..Default::default() }; + let mut parts = wordcraft_doc::styles::TableStyleParts::default(); + parts.header_chr.size = Some(14.0); + parts.regions.entry(TablePart::Band2Horz).or_default().chr.size = Some(8.0); + tb.table = Some(parts); + Arc::make_mut(&mut s.doc.styles).styles.extend([ch, tb]); + run(&mut s, "design.scaleFontSizes", json!({"delta": 2})); + let st = &s.doc.styles; + assert_eq!(st.default_chr.size, Some(13.0), "unset default is 11 pt, made explicit"); + assert_eq!(st.default_chr.size_cs, Some(12.0)); + let big = CharProps { style: Some("Big".into()), ..Default::default() }; + assert_eq!(st.resolve_char(None, &big).size, 22.0); + assert_eq!(st.resolve_char(None, &big).size_cs, 24.0); + let grid = st.styles.iter().find(|x| x.id == "Grid").and_then(|x| x.table.as_ref()).unwrap(); + assert_eq!(grid.header_chr.size, Some(16.0)); + assert_eq!(grid.regions.get(&TablePart::Band2Horz).unwrap().chr.size, Some(10.0)); +} + +/// #3676: a 1-based page parameter becomes a 0-based index without wrapping. +/// On 32-bit and wasm the old cast truncated 2^32 to 0 and `0 - 1` panicked. +#[test] +fn page_index_saturates_instead_of_wrapping_to_page_one() { + assert_eq!(crate::p::page_index(0), 0); + assert_eq!(crate::p::page_index(1), 0); + assert_eq!(crate::p::page_index(2), 1); + assert_ne!(crate::p::page_index(1u64 << 32), 0, "2^32 must not wrap to page 1"); + assert_eq!(crate::p::page_index(u64::MAX), usize::MAX - 1); +} + +/// #3676: absolute page numbers that cannot be an index are rejected, not silently +/// turned into page 1. `edit.goto` clamps to the last page, as it does past the end. +#[test] +fn hostile_absolute_pages_never_land_on_page_one() { + let mut s = 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": 1u64 << 32})); + assert_eq!(page(&mut s), 5, "an out-of-range page lands on the last page"); + run(&mut s, "edit.goto", json!({"page": u64::MAX})); + assert_eq!(page(&mut s), 5, "u64::MAX lands on the last page"); + run(&mut s, "edit.goto", json!({"page": 0})); + assert_eq!(page(&mut s), 1, "page 0 is the first page"); +} + +/// #3676: `caret.set` takes a 0-based page; an oversized page is rejected instead of +/// truncating to page 0 on 32-bit and wasm. +#[test] +fn caret_set_rejects_a_page_number_that_does_not_fit() { + let mut s = s(); + run(&mut s, "text.insert", json!({"text": "Hello"})); + let r = s.run("caret.set", &json!({"page": 1u64 << 32, "x": 100.0, "y": 100.0})); + assert!(matches!(r, Err(crate::CmdError::Params(_))), "page 2^32: {r:?}"); +} + +/// #3676: `file.exportPng` rejects a page number that cannot be an index instead of +/// exporting page 1 (the 32-bit truncation) and writing it to the requested path. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn export_png_rejects_a_page_number_that_does_not_fit() { + let dir = scratch_dir("export-png-hostile-page"); + let mut s = s(); + run(&mut s, "text.insert", json!({"text": "Hello"})); + let target = dir.join("page.png"); + let r = s.run("file.exportPng", &json!({"path": target.to_string_lossy(), "page": 1u64 << 32})); + assert!(matches!(r, Err(crate::CmdError::Params(_))), "page 2^32: {r:?}"); + assert!(!target.exists(), "no file is written for an invalid page"); + let r = s.run("file.exportPng", &json!({"path": target.to_string_lossy(), "page": u64::MAX})); + assert!(matches!(r, Err(crate::CmdError::Params(_))), "page u64::MAX: {r:?}"); + assert!(!target.exists(), "no file is written for an invalid page"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn screen_paper_color_is_a_persistent_preference_not_document_formatting() { + use crate::cmd::view::PaperColor; + let mut s = s(); + run(&mut s, "text.insert", json!({"text": "Screen paper test", "raw": true})); + let before = serde_json::to_value(&s.doc).unwrap(); + let docx_before = wordcraft_docx::write(&s.doc).unwrap(); + s.dirty = false; + let dirty = s.dirty; + s.view.dark_mode = true; + for (value, expected) in + [("green", PaperColor::Green), ("cream", PaperColor::Cream), ("gray", PaperColor::Gray), ("document", PaperColor::Document)] + { + run(&mut s, "view.paperColor", json!({"value": value})); + assert_eq!(s.prefs.paper_color, expected); + assert_eq!(serde_json::to_value(&s.doc).unwrap(), before); + assert_eq!(s.dirty, dirty); + assert_eq!(wordcraft_docx::write(&s.doc).unwrap(), docx_before); + assert!(!s.view.dark_mode, "choosing a tint turns off dark paper"); + let saved = serde_json::to_value(&s.prefs).unwrap(); + let restored: crate::Prefs = serde_json::from_value(saved).unwrap(); + assert_eq!(restored.paper_color, expected); + } + for v in [json!({}), json!({"value": "red"}), json!({"value": 42})] { + assert!(s.run("view.paperColor", &v).is_err()); + assert_eq!(s.prefs.paper_color, PaperColor::Document); + } + let legacy: crate::Prefs = serde_json::from_value(json!({})).unwrap(); + assert_eq!(legacy.paper_color, PaperColor::Document); + let future: crate::Prefs = serde_json::from_value(json!({"paperColor":"future", "countNotes":false})).unwrap(); + assert_eq!(future.paper_color, PaperColor::Document); + assert!(!future.count_notes); +} + +/// Font › Emphasis mark puts a mark over (or under) each selected character, and the layout +/// draws one per character; Format state reports it, and none takes it off. +#[test] +fn emphasis_marks_are_set_and_drawn() { + use wordcraft_doc::props::EmphasisMark; + let mut s = Session::new(wordcraft_doc::Document::from_text("強調する")); + run(&mut s, "select.all", json!({})); + run(&mut s, "format.emphasisMark", json!({"mark": "circle"})); + let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); + assert_eq!(p.props_of_char(0).emphasis, Some(EmphasisMark::Circle)); + assert_eq!(run(&mut s, "format.state", json!({}))["emphasisMark"], "circle"); + // One mark per character: hollow circles, drawn as shapes. + let draws = s.layout().pages.first().map(|pg| wordcraft_layout::display::page_display(&s.doc, pg, &Default::default())).unwrap_or_default(); + let marks = draws + .iter() + .filter(|d| matches!(d, wordcraft_layout::display::Draw::Shape { kind: wordcraft_doc::para::ShapeKind::Ellipse, fill: None, .. })) + .count(); + assert_eq!(marks, 4); + run(&mut s, "format.emphasisMark", json!({"mark": "none"})); + assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().props_of_char(0).emphasis, Some(EmphasisMark::None)); + assert!(s.run("format.emphasisMark", &json!({"mark": "sparkles"})).is_err()); +} + +/// Preferences saved while OpenDocument files had their own settings still read, without them, +/// as does a document saved with what those settings kept for its layout. +#[test] +fn what_the_opendocument_settings_saved_still_reads() { + let prefs: crate::Prefs = serde_json::from_value(json!({"countNotes": false, "odf": {"preset": "libreoffice", "columnGap": 0.0}})).unwrap(); + assert!(!prefs.count_notes); + let layout: wordcraft_doc::OdfLayout = serde_json::from_value(json!({"pictureLinesFitPictures": true, "tabsFromIndent": true})).unwrap(); + assert!(layout.tabs_from_indent); +} + +#[test] +fn an_odt_charts_paragraph_is_written_as_read_after_drawing_it() { + // Opening an ODT draws its charts (from their data); that changes nothing in the document, + // so the paragraph showing one is still written as read. + let mut s = Session::new(wordcraft_doc::Document::new()); + run(&mut s, "insert.chart", json!({"type": "column"})); + let first = wordcraft_formats::odt::export(&s.doc).unwrap(); + let doc = crate::io::open_bytes("t.odt", &first).unwrap(); + let mut z = zip::ZipArchive::new(std::io::Cursor::new(wordcraft_formats::odt::export(&doc).unwrap())).unwrap(); + let mut content = String::new(); + std::io::Read::read_to_string(&mut z.by_name("content.xml").unwrap(), &mut content).unwrap(); + assert!(content.contains(r#"draw:name="Chart1""#) && !content.contains("wcChart"), "{content}"); +} + +/// A picture an OpenDocument file makes wider than the text (a rule across the whole page) stays +/// that wide, and its paragraph is written as read: opening a file changes nothing in it. +#[test] +fn an_odts_picture_wider_than_the_text_keeps_its_size() { + 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:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0" xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0" xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0" xmlns:xlink="http://www.w3.org/1999/xlink""#; + let styles = format!( + r#""# + ); + let content = format!( + r#"Text"# + ); + let png = [b"\x89PNG\r\n\x1a\n".as_slice(), &[7u8; 64]].concat(); + 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", b"application/vnd.oasis.opendocument.text".as_slice()), + ("content.xml", content.as_bytes()), + ("styles.xml", styles.as_bytes()), + ("Pictures/r.png", &png), + ] { + z.start_file(name, o).unwrap(); + std::io::Write::write_all(&mut z, data).unwrap(); + } + let doc = crate::io::open_bytes("t.odt", &z.finish().unwrap().into_inner()).unwrap(); + let width = doc.body[0] + .as_para() + .unwrap() + .objects + .iter() + .find_map(|o| if let wordcraft_doc::para::InlineObject::Image { w, .. } = o { Some(*w) } else { None }); + let want = 18.0 * 72.0 / 2.54; + assert!(width.is_some_and(|w| (w - want).abs() < 0.1), "{width:?}, not fitted to the text"); + let mut z = zip::ZipArchive::new(std::io::Cursor::new(wordcraft_formats::odt::export(&doc).unwrap())).unwrap(); + let mut saved = String::new(); + std::io::Read::read_to_string(&mut z.by_name("content.xml").unwrap(), &mut saved).unwrap(); + assert!(saved.contains(r#"draw:name="Rule""#) && saved.contains(r#"svg:width="18cm""#), "written as read: {saved}"); +} + +/// Numbering or a new style makes the styles be written from the model on save: a document in +/// German stays in German, rather than taking the default language of what opens it next. +#[test] +fn an_odts_language_survives_a_save_after_numbering_or_a_new_style() { + let ns = r#"xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0""#; + let styles = format!( + r#""# + ); + let content = format!( + r#"Hallo"# + ); + 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()), ("styles.xml", styles.as_str())] + { + z.start_file(name, o).unwrap(); + std::io::Write::write_all(&mut z, data.as_bytes()).unwrap(); + } + let odt = z.finish().unwrap().into_inner(); + for (command, params) in [("para.numbering", json!({})), ("styles.create", json!({"name": "Notiz"}))] { + let mut s = Session::new(crate::io::open_bytes("t.odt", &odt).unwrap()); + assert_eq!(cmd::default_lang(&s).as_deref(), Some("de-DE"), "the document's, not English"); + run(&mut s, command, params); + let saved = wordcraft_formats::odt::export(&s.doc).unwrap(); + let back = crate::io::open_bytes("t.odt", &saved).unwrap(); + assert_eq!(back.styles.default_chr.lang.as_deref(), Some("de-DE"), "after {command}"); + } +} + +/// A Word package whose body holds `body` and, as `rIdC`, the chunk part `word/afchunk.{ext}`. +#[cfg(not(target_arch = "wasm32"))] +fn docx_with_chunk(body: &str, ext: &str, content_type: &str, chunk: &[u8]) -> Vec { + use std::io::Write; + let files: [(String, Vec); 4] = [ + ( + "[Content_Types].xml".into(), + format!(r#""#).into_bytes(), + ), + ("_rels/.rels".into(), br#""#.to_vec()), + ( + "word/_rels/document.xml.rels".into(), + format!(r#""#).into_bytes(), + ), + ( + "word/document.xml".into(), + format!(r#"{body}"#).into_bytes(), + ), + ]; + let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + for (name, bytes) in files.iter().map(|(n, b)| (n.as_str(), b.as_slice())).chain([(format!("word/afchunk.{ext}").as_str(), chunk)]) { + zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap(); + zw.write_all(bytes).unwrap(); + } + zw.finish().unwrap().into_inner() +} + +/// A Single File Web Page under a Word or RTF name opens as one, like the web pages of #2853. +#[test] +fn a_single_file_web_page_saved_as_doc_opens_by_content() { + let mht = "MIME-Version: 1.0\r\nContent-Type: multipart/related; boundary=\"B\"\r\n\r\n--B\r\nContent-Type: text/html; charset=\"utf-8\"\r\n\r\n

Izvje\u{161}taj

\r\n--B--\r\n"; + for name in ["report.doc", "report.docx", "report.rtf"] { + let doc = crate::io::open_bytes(name, mht.as_bytes()).unwrap_or_else(|e| panic!("{name}: {e}")); + assert_eq!(doc.plain_text(StoryRef::Body), "Izvje\u{161}taj", "{name}"); + } +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn alt_chunks_open_in_place_as_word_brings_them_in() { + let png = wordcraft_formats::model::base64_encode(&tiny_png()); + let mht = format!( + "MIME-Version: 1.0\r\nContent-Type: multipart/related; boundary=\"B\"\r\n\r\n--B\r\nContent-Location: file:///C:/doc.htm\r\n\ + Content-Transfer-Encoding: quoted-printable\r\nContent-Type: text/html; charset=\"utf-8\"\r\n\r\n\ +

=C4=8Clanak 1.

\r\n\ + --B\r\nContent-Location: file:///C:/doc_files/a.png\r\nContent-Transfer-Encoding: base64\r\nContent-Type: image/png\r\n\r\n{png}\r\n--B--\r\n" + ); + let p = |t: &str| format!(r#"{t}"#); + let chunk = r#""#; + let bytes = docx_with_chunk(&format!("{}{chunk}{}", p("Before"), p("After")), "mht", "message/rfc822", mht.as_bytes()); + let doc = crate::io::open_bytes("chunk.docx", &bytes).unwrap(); + let text = doc.plain_text(StoryRef::Body); + assert!(text.starts_with("Before\nČlanak 1.\n") && text.ends_with("After"), "{text:?}"); + let para = doc.para(StoryRef::Body, &wordcraft_doc::Path::top(1)).unwrap(); + assert_eq!(para.props.align, Some(wordcraft_doc::Align::Center)); + // Web page text: Times New Roman unless the page says otherwise. + assert_eq!(para.props_of_char(0).font.as_deref(), Some("Times New Roman")); + // The picture came along, its bytes under the key it shows. + let key = doc + .body + .iter() + .find_map(|b| match &**b { + wordcraft_doc::Block::Para(p) => p.objects.iter().find_map(|o| match o { + wordcraft_doc::InlineObject::Image { media, .. } => Some(media.clone()), + _ => None, + }), + _ => None, + }) + .expect("the chunk's picture"); + assert_eq!(doc.media.get(&key).map(|m| m.to_vec()), Some(tiny_png())); + + // HTML (in its content type's charset), RTF and plain-text chunks come in through their + // importers too, and an MHT chunk is known by what it holds. + for (ext, ct, data, want) in [ + ("htm", "text/html; charset=windows-1250", &b"

\xc8lanak chunk

"[..], "Članak chunk"), + ("rtf", "application/rtf", br"{\rtf1\ansi Rich chunk\par}", "Rich chunk"), + ("txt", "text/plain", b"Plain chunk", "Plain chunk"), + ("bin", "", mht.as_bytes(), "Članak 1."), + ] { + let bytes = docx_with_chunk(&format!("{chunk}{}", p("End")), ext, ct, data); + let text = crate::io::open_bytes("chunk.docx", &bytes).unwrap().plain_text(StoryRef::Body); + assert!(text.starts_with(want) && text.ends_with("End"), "{ext}: {text:?}"); + } +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn text_from_file_brings_the_files_pictures() { + let dir = scratch_dir("text-from-file-pictures"); + let mut src = Session::new(wordcraft_doc::Document::new()); + let list = Arc::make_mut(&mut src.doc.numbering).add_list(wordcraft_doc::numbering::ListKind::Bullet); + // A list item, then a paragraph (a single paragraph would take the caret's formatting). + src.doc.split_paragraph(&Pos::body(0, 0)).unwrap(); + src.doc.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().props.numbering = + Some(wordcraft_doc::props::NumRef { num: list, level: 0 }); + let key = src.doc.add_media(tiny_png(), "png"); + let image = wordcraft_doc::InlineObject::Image { + media: key, + w: 20.0, + h: 20.0, + alt: String::new(), + float: Default::default(), + crop: [0.0; 4], + ole: None, + fx: Default::default(), + }; + src.doc.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().insert_object(0, image, &Default::default()).unwrap(); + let path = dir.join("pictures.docx"); + std::fs::write(&path, wordcraft_docx::write(&src.doc).unwrap()).unwrap(); + + let mut s = s(); + // A picture and a list of our own first, so the file's key and list id would point at ours. + let ours = s.doc.add_media(vec![9, 9, 9], "png"); + let our_list = Arc::make_mut(&mut s.doc.numbering).add_list(wordcraft_doc::numbering::ListKind::Numbered); + run(&mut s, "insert.textFromFile", json!({"path": path.to_string_lossy()})); + let _ = std::fs::remove_dir_all(&dir); + let shown: Vec = s + .doc + .body + .iter() + .filter_map(|b| match &**b { + wordcraft_doc::Block::Para(p) => p.objects.iter().find_map(|o| match o { + wordcraft_doc::InlineObject::Image { media, .. } => Some(media.clone()), + _ => None, + }), + _ => None, + }) + .collect(); + assert_eq!(shown.len(), 1); + assert_ne!(shown[0], ours); + assert_eq!(s.doc.media.get(&shown[0]).map(|m| m.to_vec()), Some(tiny_png())); + let listed: Vec = s + .doc + .body + .iter() + .filter_map(|b| match &**b { + wordcraft_doc::Block::Para(p) => p.props.numbering.map(|n| n.num), + _ => None, + }) + .collect(); + assert_eq!(listed.len(), 1); + assert_ne!(listed[0], our_list); + assert!(s.doc.numbering.level(listed[0], 0).is_some_and(|l| l.format == wordcraft_doc::section::NumFormat::Bullet)); +} + +#[test] +fn page_down_and_up_move_by_a_page_in_the_continuous_views() { + // Web Layout, Draft and Outline are one long page: Page Down/Up move as far as they do + // between the pages of Print Layout (#5290). + let after_page_down = |view: &str| { + let mut s = s(); + for i in 0..200 { + run(&mut s, "text.insert", json!({"text": format!("Paragraph {i}")})); + run(&mut s, "text.newParagraph", json!({})); + } + run(&mut s, view, json!({})); + run(&mut s, "caret.docStart", json!({})); + let start = s.sel.focus.clone(); + run(&mut s, "caret.pageDown", json!({})); + let down = s.sel.focus.clone(); + run(&mut s, "caret.pageUp", json!({})); + assert_eq!(s.sel.focus, start, "{view}: Page Up goes back"); + run(&mut s, "caret.docEnd", json!({})); + let end = s.sel.focus.clone(); + run(&mut s, "caret.pageDown", json!({})); + assert_eq!(s.sel.focus, end, "{view}: Page Down stops at the end"); + down + }; + let print = after_page_down("view.printLayout"); + assert!(print.path.0[0] > 10, "Print Layout: Page Down goes to the next page: {print:?}"); + for view in ["view.draft", "view.outline", "view.webLayout"] { + assert_eq!(after_page_down(view), print, "{view}"); + } +} + +#[test] +fn page_down_and_up_never_stay_on_a_line_in_the_continuous_views() { + // A page with less than an inch of text area: the views move by the one-inch minimum. The + // caret ends a long line and the paragraphs after it are narrow (a large right indent): the + // line a page away, not the one the caret is on, is where Page Down goes, however far right + // of it the caret is. + for view in ["view.draft", "view.outline", "view.webLayout"] { + let mut s = s(); + run(&mut s, "layout.margins", json!({"top": 370, "bottom": 370})); + let long = "Lorem ipsum dolor sit amet ".repeat(4); + run(&mut s, "text.insert", json!({"text": long})); + run(&mut s, "text.newParagraph", json!({})); + run(&mut s, "para.indents", json!({"right": 700})); + for i in 0..60 { + if i > 0 { + run(&mut s, "text.newParagraph", json!({})); + } + run(&mut s, "text.insert", json!({"text": "a"})); + } + run(&mut s, view, json!({})); + run(&mut s, "caret.docStart", json!({})); + run(&mut s, "caret.end", json!({})); + assert_eq!(s.sel.focus.off, long.chars().count(), "{view}: the first paragraph is one line"); + let mut at = s.sel.focus.clone(); + for step in 0..3 { + run(&mut s, "caret.pageDown", json!({})); + assert!(s.sel.focus > at, "{view}: Page Down {step} moves on from {at:?}"); + at = s.sel.focus.clone(); + } + for step in 0..3 { + run(&mut s, "caret.pageUp", json!({})); + assert!(s.sel.focus < at, "{view}: Page Up {step} moves back from {at:?}"); + at = s.sel.focus.clone(); + } + } +} + +#[test] +fn page_down_extends_a_selection_that_ends_in_collapsed_outline_text() { + // Selecting a collapsed heading and its content ends the selection in hidden text; Page Down + // still extends it from where it shows (the heading), keeping its anchor. + let mut s = s(); + run(&mut s, "text.insert", json!({"text": "First"})); + for i in 0..5 { + run(&mut s, "text.newParagraph", json!({})); + run(&mut s, "text.insert", json!({"text": format!("Hidden {i}")})); + } + run(&mut s, "text.newParagraph", json!({})); + run(&mut s, "text.insert", json!({"text": "Second"})); + for i in 0..200 { + run(&mut s, "text.newParagraph", json!({})); + run(&mut s, "text.insert", json!({"text": format!("Shown {i}")})); + } + run(&mut s, "view.outline", json!({})); + for block in [0, 6] { + run(&mut s, "caret.set", json!({"pos": {"block": block}})); + run(&mut s, "outline.promoteToHeading1", json!({})); + } + run(&mut s, "outline.collapse", json!({"block": 0})); + run(&mut s, "outline.selectSubtree", json!({"block": 0})); + let anchor = s.sel.anchor.clone(); + assert_eq!(s.sel.focus.path.0, [5], "the selection ends in the last hidden paragraph"); + assert!(s.layout().caret(&s.sel.focus).is_none(), "the outline doesn't show its end"); + run(&mut s, "caret.pageDown", json!({"extend": true})); + assert_eq!(s.sel.anchor, anchor, "the anchor stays"); + assert!(s.sel.focus.path.0[0] > 6, "Page Down extends the selection a page on: {:?}", s.sel.focus); + assert!(s.layout().caret(&s.sel.focus).is_some(), "to a place the outline shows"); +} + +/// A picture an OpenDocument file gives no size is shown at its own, fitted to the text as other +/// formats' are; one with a stated size keeps it, in line too. +#[test] +fn an_odts_picture_is_fitted_only_when_it_states_no_size() { + let ns = r#"xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0" xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0" xmlns:xlink="http://www.w3.org/1999/xlink""#; + let styles = format!( + r#""# + ); + let mut png = std::io::Cursor::new(Vec::new()); + image::RgbImage::new(1000, 500).write_to(&mut png, image::ImageFormat::Png).unwrap(); + let png = png.into_inner(); + let open = |attrs: &str| { + let content = format!( + r#""# + ); + 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", b"application/vnd.oasis.opendocument.text".as_slice()), + ("content.xml", content.as_bytes()), + ("styles.xml", styles.as_bytes()), + ("Pictures/a.png", &png), + ] { + z.start_file(name, o).unwrap(); + std::io::Write::write_all(&mut z, data).unwrap(); + } + let doc = crate::io::open_bytes("t.odt", &z.finish().unwrap().into_inner()).unwrap(); + doc.body[0] + .as_para() + .unwrap() + .objects + .iter() + .find_map(|o| if let wordcraft_doc::para::InlineObject::Image { w, h, .. } = o { Some((*w, *h)) } else { None }) + .unwrap() + }; + let (w, h) = open(""); + assert!((w - 306.14).abs() < 0.5 && (h - 153.07).abs() < 0.5, "fitted to the text: {w} {h}"); + let (w, _) = open(r#" svg:width="18cm" svg:height="9cm""#); + assert!((w - 18.0 * 72.0 / 2.54).abs() < 0.5, "as the file says: {w}"); +} + +/// Issue #5667's document: `Intro`, then an inserted `Appendix` paragraph whose mark carries a +/// section break (Normal, indented), then `Body` in Heading 1. The whole of `Appendix` is +/// tracked as an insertion by `rev`, or as a deletion when `delete`. +fn sectioned_revision(delete: bool) -> Session { + use wordcraft_doc::{Revision, RevisionKind}; + let mut s = Session::new(wordcraft_doc::Document::from_text("Intro\nAppendix\nBody")); + run(&mut s, "caret.set", json!({"pos": {"block": 2, "off": 0}})); + run(&mut s, "para.style", json!({"style": "Heading1"})); + let kind = if delete { RevisionKind::Delete } else { RevisionKind::Insert }; + s.doc.revisions.push(Revision { kind, author: "Ada".into(), date: String::new(), move_name: None }); + s.doc.last_section.margin_left = 72.0; + let mut sect = s.doc.last_section.clone(); + sect.margin_left = 144.0; + let p = s.doc.para_mut(StoryRef::Body, &wordcraft_doc::Path(vec![1])).unwrap(); + let len = p.len(); + p.format(0, len, &|c| if delete { c.del = Some(0) } else { c.ins = Some(0) }).unwrap(); + if delete { + p.mark.del = Some(0) + } else { + p.mark.ins = Some(0) + } + p.props.indent_left = Some(36.0); + p.section = Some(Box::new(sect)); + s +} + +#[test] +fn resolving_away_an_empty_paragraph_with_a_section_break_keeps_the_next_paragraphs_properties() { + // #5667: the resolved paragraph was left empty but kept for its section break, so the next + // paragraph joined it and took its properties (Normal, its indent). It goes whole instead, + // as an empty paragraph without a break does, and its section joins the following one. + for (delete, cmd) in [(false, "review.rejectAll"), (true, "review.acceptAll")] { + let mut s = sectioned_revision(delete); + assert_eq!(s.doc.sections().len(), 2); + run(&mut s, cmd, json!({})); + assert_eq!(body_texts(&s), ["Intro", "Body"], "{cmd}"); + let body = s.doc.para(StoryRef::Body, &wordcraft_doc::Path(vec![1])).unwrap(); + assert_eq!(body.props.style.as_deref(), Some("Heading1"), "{cmd}: Body keeps its style"); + assert_eq!(body.props.indent_left, None, "{cmd}: and not the removed paragraph's indent"); + assert!(s.doc.body.iter().filter_map(|b| b.as_para()).all(|p| p.section.is_none()), "{cmd}: the break went with it"); + assert_eq!(s.doc.sections().len(), 1, "{cmd}"); + assert_eq!(s.doc.last_section.margin_left, 72.0, "{cmd}: the following section's setup stays"); + } +} diff --git a/crates/engine/src/tests/bidi.rs b/crates/engine/src/tests/bidi.rs index 61ff1977db..3c234ec09a 100644 --- a/crates/engine/src/tests/bidi.rs +++ b/crates/engine/src/tests/bidi.rs @@ -1,325 +1,325 @@ -//! Right-to-left editing commands: paragraph direction, alignment as seen on the page, font -//! commands on Persian text, arrow keys in bidirectional text, DOCX save and open. - -use serde_json::json; -use std::sync::Arc; -use wordcraft_doc::props::Align; -use wordcraft_doc::{Pos, StoryRef}; - -use crate::Session; - -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}")) -} - -/// A session with `text` (paragraphs split on `\n`), every paragraph right to left when `rtl`. -fn session(text: &str, rtl: bool) -> Session { - let mut s = Session::new(wordcraft_doc::Document::new()); - run(&mut s, "document.setText", json!({"text": text})); - if rtl { - run(&mut s, "select.all", json!({})); - run(&mut s, "para.rtl", json!({})); - run(&mut s, "caret.docStart", json!({})); - } - s -} - -fn props(s: &Session, i: usize) -> wordcraft_doc::props::ParaProps { - s.doc.para(StoryRef::Body, &wordcraft_doc::Path::top(i)).unwrap().props.clone() -} - -fn caret_x(s: &mut Session) -> f32 { - let l = s.layout(); - l.caret(&s.sel.focus).unwrap().x -} - -#[test] -fn direction_commands_change_only_the_reading_order() { - let mut s = session("سلام دنیا\nHello", false); - run(&mut s, "para.alignCenter", json!({})); - run(&mut s, "para.rtl", json!({})); - assert_eq!(props(&s, 0).bidi, Some(true)); - assert_eq!(props(&s, 0).align, Some(Align::Center), "direction doesn't touch alignment"); - // Left-to-right again, and `value: false` means the other direction. - run(&mut s, "para.ltr", json!({})); - assert_eq!(props(&s, 0).bidi, Some(false)); - run(&mut s, "para.ltr", json!({"value": false})); - assert_eq!(props(&s, 0).bidi, Some(true)); - // A new right-to-left paragraph starts at the right margin (start-aligned). - let mut s = session("متن", true); - assert!((caret_x(&mut s) - 540.0).abs() < 0.5, "caret at {}", caret_x(&mut s)); -} - -#[test] -fn alignment_buttons_mean_what_they_show_in_rtl_paragraphs() { - let mut s = session("متن فارسی", true); - // Align Left in a right-to-left paragraph moves text to the left: its end edge. - run(&mut s, "para.alignLeft", json!({})); - assert_eq!(props(&s, 0).align, Some(Align::Right)); - let x_end = s.layout().caret(&Pos::body(0, "متن فارسی".len())).unwrap().x; - assert!((x_end - 72.0).abs() < 0.5, "text ends at the left margin: {x_end}"); - // Align Right is the start edge; pressing it again keeps it there. - run(&mut s, "para.alignRight", json!({})); - assert_eq!(props(&s, 0).align, Some(Align::Left)); - run(&mut s, "para.alignRight", json!({})); - assert_eq!(props(&s, 0).align, Some(Align::Left)); - // Logical values for scripts. - run(&mut s, "para.align", json!({"value": "end"})); - assert_eq!(props(&s, 0).align, Some(Align::Right)); - run(&mut s, "para.align", json!({"value": "right"})); - assert_eq!(props(&s, 0).align, Some(Align::Left)); -} - -#[test] -fn para_set_on_page_follows_each_paragraphs_own_direction() { - // The Paragraph dialog's alignment and indents are as seen on the page (#1346): over a - // right-to-left and a left-to-right paragraph, Left is each one's own left side. - let mut s = session("سلام دنیا\nHello", false); - run(&mut s, "caret.docStart", json!({})); - run(&mut s, "para.rtl", json!({})); - run(&mut s, "select.all", json!({})); - let depth = s.undo_depth(); - run(&mut s, "para.set", json!({"props": {"spaceAfter": 6}, "onPage": {"align": "left", "left": 36}})); - assert_eq!(s.undo_depth(), depth + 1, "one undo step"); - let (rtl, ltr) = (props(&s, 0), props(&s, 1)); - assert_eq!((rtl.align, rtl.indent_right, rtl.indent_left, rtl.space_after), (Some(Align::Right), Some(36.0), None, Some(6.0))); - assert_eq!((ltr.align, ltr.indent_left, ltr.indent_right, ltr.space_after), (Some(Align::Left), Some(36.0), None, Some(6.0))); - // A direction in `props` comes first: both are right to left now, so Right is their start. - run(&mut s, "para.set", json!({"props": {"bidi": true}, "onPage": {"right": 18}})); - assert_eq!((props(&s, 1).bidi, props(&s, 1).indent_left, props(&s, 1).indent_right), (Some(true), Some(18.0), None)); - assert!(s.run("para.set", &json!({"props": {}, "onPage": {"align": "middle"}})).is_err()); - // Undo takes each command back whole. - run(&mut s, "edit.undo", json!({})); - run(&mut s, "edit.undo", json!({})); - assert_eq!((props(&s, 0).align, props(&s, 0).indent_right, props(&s, 1).indent_left), (None, None, None)); -} - -#[test] -fn font_commands_reach_persian_text() { - let mut s = session("کتاب خوب", true); - run(&mut s, "select.all", json!({})); - run(&mut s, "format.bold", json!({})); - run(&mut s, "format.size", json!({"size": 18})); - run(&mut s, "format.font", json!({"name": "Vazirmatn"})); - let p = s.doc.para(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap(); - let c = p.props_of_char(0).clone(); - let rc = s.doc.styles.resolve_char(None, &c).complex(); - assert_eq!((rc.font.as_str(), rc.size, rc.bold), ("Vazirmatn", 18.0, true), "the complex-script formatting follows"); - // Saved and reopened, Word sees bold and size for the Persian text too. - let bytes = crate::io::save_bytes("x.docx", &s.doc).unwrap(); - let d = crate::io::open_bytes("x.docx", &bytes).unwrap(); - let c = d.para(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().props_of_char(0).clone(); - let rc = d.styles.resolve_char(None, &c).complex(); - assert_eq!((rc.font.as_str(), rc.size, rc.bold), ("Vazirmatn", 18.0, true)); - assert_eq!(d.para(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().props.bidi, Some(true)); -} - -#[test] -fn font_commands_override_inherited_complex_script_properties_explicitly() { - use wordcraft_doc::props::CharProps; - - let mut s = session("שלום ABC", true); - Arc::make_mut(&mut s.doc.styles).default_chr.size_cs = Some(30.0); - Arc::make_mut(&mut s.doc.styles).default_chr.bold_cs = Some(true); - Arc::make_mut(&mut s.doc.styles).default_chr.italic_cs = Some(true); - run(&mut s, "select.all", json!({})); - run(&mut s, "format.bold", json!({"value": false})); - run(&mut s, "format.italic", json!({"value": false})); - run(&mut s, "format.size", json!({"size": 18})); - for (command, expected_size) in [ - (None, 18.0), - (Some("format.growFont"), 20.0), - (Some("format.shrinkFont"), 18.0), - (Some("format.growFont1"), 19.0), - (Some("format.shrinkFont1"), 18.0), - ] { - if let Some(command) = command { - run(&mut s, command, json!({})); - } - let p = s.doc.para(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap(); - let c = p.props_of_char(0); - assert_eq!((c.size_cs, c.bold_cs, c.italic_cs), (Some(expected_size), Some(false), Some(false))); - let d = crate::io::open_bytes("x.docx", &crate::io::save_bytes("x.docx", &s.doc).unwrap()).unwrap(); - let c = d.para(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().props_of_char(0); - let cs = d.styles.resolve_char(None, c).complex(); - assert_eq!((cs.size, cs.bold, cs.italic), (expected_size, false, false)); - } - // The font dialog's command keeps formatting both scripts when given one value. - run(&mut s, "format.set", json!({"props": {"size": 10, "bold": true, "italic": true}})); - let p = s.doc.para(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap(); - let c: &CharProps = p.props_of_char(0); - assert_eq!((c.size_cs, c.bold_cs, c.italic_cs), (Some(10.0), Some(true), Some(true))); - // Callers can still supply distinct values explicitly, including false. - run(&mut s, "format.set", json!({"props": {"size": 12, "sizeCs": 18, "bold": true, "boldCs": false, "italic": true, "italicCs": false}})); - let p = s.doc.para(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap(); - let c = p.props_of_char(0); - assert_eq!((c.size, c.size_cs, c.bold_cs, c.italic_cs), (Some(12.0), Some(18.0), Some(false), Some(false))); -} - -#[test] -fn arrow_keys_move_visually_in_rtl_text() { - let fa = "سلام دنیا"; - let mut s = session(fa, true); - // From the start (the right edge), Left moves forward through the text. - let x0 = caret_x(&mut s); - run(&mut s, "caret.left", json!({})); - assert!(s.sel.focus.off > 0, "Left moves to the next character in right-to-left text"); - assert!(caret_x(&mut s) < x0, "and the caret moves left on the screen"); - run(&mut s, "caret.right", json!({})); - assert_eq!(s.sel.focus.off, 0); - // Right at the start edge goes nowhere in the first paragraph (no previous one). - run(&mut s, "caret.right", json!({})); - assert_eq!(s.sel.focus.off, 0); - // Left all the way reaches the end, then the next paragraph. - let mut s = session(&format!("{fa}\nبعدی"), true); - // (Stepping until the paragraph changes: the لا ligature is one caret position for two letters.) - let mut steps = 0; - while s.sel.focus.path.last() == 0 && steps < 40 { - run(&mut s, "caret.left", json!({})); - steps += 1; - } - assert!(steps <= fa.chars().count() + 1, "{steps} steps"); - assert_eq!(s.sel.focus.path.last(), 1, "past the left end of the line: the next paragraph"); - assert_eq!(s.sel.focus.off, 0, "at its start (right edge)"); - // Shift+Left selects forward in logical order. - run(&mut s, "caret.docStart", json!({})); - run(&mut s, "caret.left", json!({"extend": true})); - let (a, b) = s.sel.ordered(); - assert_eq!((a.off, b.off > 0), (0, true)); - // Left with a selection collapses to its left edge: the logical end. - run(&mut s, "caret.left", json!({})); - assert_eq!(s.sel.focus.off, b.off); -} - -#[test] -fn arrow_keys_in_mixed_text_visit_each_position() { - // A left-to-right paragraph with a Persian word: Right walks across the screen. - let t = "ab سلام cd"; - let mut s = session(t, false); - let mut xs = vec![caret_x(&mut s)]; - for _ in 0..30 { - let before = s.sel.focus.clone(); - run(&mut s, "caret.right", json!({})); - if s.sel.focus == before { - break; - } - xs.push(caret_x(&mut s)); - } - assert!(xs.windows(2).all(|w| w[1] > w[0]), "each Right moves right: {xs:?}"); - assert_eq!(s.sel.focus.off, t.len(), "ends at the end of the paragraph"); -} - -#[test] -fn word_moves_follow_reading_order() { - let mut s = session("یک دو سه", true); - run(&mut s, "caret.wordLeft", json!({})); - assert!(s.sel.focus.off > 0, "Ctrl+Left goes to the next word in right-to-left text"); - run(&mut s, "caret.wordRight", json!({})); - assert_eq!(s.sel.focus.off, 0); -} - -#[test] -fn typing_persian_keeps_logical_order() { - let mut s = session("", true); - for w in ["سلام", " ", "WordCraft", " ", "۱۴۰۳"] { - run(&mut s, "text.insert", json!({"text": w})); - } - assert_eq!(s.doc.plain_text(StoryRef::Body), "سلام WordCraft ۱۴۰۳"); - // The caret after typing a number at the end sits right after it on the screen. - let l = s.layout(); - let c = l.caret(&s.sel.focus).unwrap(); - assert!(c.x < 540.0 && c.x > 72.0); - // select.text finds Persian text. - run(&mut s, "select.text", json!({"text": "سلام"})); - assert_eq!(s.selected_text(), "سلام"); -} - -#[test] -fn explicit_alignment_sets_without_toggling() { - for rtl in [false, true] { - let mut s = session("متن", rtl); - for v in ["center", "center", "right", "right", "left", "left"] { - run(&mut s, "para.align", json!({"value": v})); - let shown = props(&s, 0).align.unwrap_or_default().visual(rtl); - let want = match v { - "center" => Align::Center, - "right" => Align::Right, - _ => Align::Left, - }; - assert_eq!(shown, want, "{v} twice stays {v} (rtl={rtl})"); - } - } -} - -#[test] -fn typing_kurdish_in_new_document_advances_caret_smoothly() { - let mut s = Session::new(wordcraft_doc::Document::new()); - let mut first_x = None; - let mut last_x = 0.0; - for ch in ["م", "ن", " ", "ن", "ا", "و", "م", " ", "ه", "ێ", "ڤ", "ا", "ر", "ه"] { - run(&mut s, "text.insert", json!({"text": ch})); - let l = s.layout(); - let c = l.caret(&s.sel.focus).unwrap(); - if first_x.is_none() { - first_x = Some(c.x); - } - last_x = c.x; - // The caret must not jump back to the left margin (72.0), but stay near the right margin (540.0). - assert!(c.x > 400.0, "caret should not be at left margin: {}", c.x); - } - assert!(last_x < first_x.unwrap(), "caret should advance leftwards overall"); - assert_eq!(props(&s, 0).bidi, Some(true), "paragraph auto-detected as right-to-left"); -} - -/// The runs of the first paragraph in a saved DOCX: (has `w:rtl`, text). -fn saved_runs(s: &Session) -> Vec<(bool, String)> { - use std::io::Read; - let bytes = crate::io::save_bytes("x.docx", &s.doc).unwrap(); - let mut xml = String::new(); - zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap().by_name("word/document.xml").unwrap().read_to_string(&mut xml).unwrap(); - let para = xml.split("").nth(1).unwrap().split("").next().unwrap().to_string(); - para.split("") - .skip(1) - .map(|r| { - let r = r.split("").next().unwrap(); - let text: String = r.split("").skip(1).filter_map(|t| t.split("").next()).collect(); - (r.contains(""), text) - }) - .collect() -} - -#[test] -fn latin_typed_into_reopened_hebrew_is_saved_left_to_right() { - // #1543: a saved file's Hebrew runs say `w:rtl`. Latin typed next to them after reopening - // takes its direction from its own letters, as on screen, so it's saved in a run of its own - // without `w:rtl` (Word would otherwise lay it out right to left again). - let s = session("לפני אחרי", true); - let bytes = crate::io::save_bytes("x.docx", &s.doc).unwrap(); - let mut s = Session::new(crate::io::open_bytes("x.docx", &bytes).unwrap()); - assert!(s.doc.para(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().runs.iter().all(|r| r.props.rtl == Some(true))); - s.sel = crate::Selection::caret(Pos::new(StoryRef::Body, wordcraft_doc::Path::top(0), "לפני ".len())); - run(&mut s, "text.insert", json!({"text": "Foo Bar 2026 "})); - let runs = saved_runs(&s); - assert!(runs.contains(&(false, "Foo Bar 2026".to_string())), "{runs:?}"); - assert!(runs.iter().all(|(rtl, t)| !rtl || !t.chars().any(|c| c.is_ascii_alphanumeric())), "{runs:?}"); - assert_eq!(runs.iter().map(|(_, t)| t.as_str()).collect::(), "לפני Foo Bar 2026 אחרי"); -} - -#[test] -fn a_latin_link_inserted_into_reopened_hebrew_is_saved_left_to_right() { - // Other insertions start from the caret's formatting too (#1543): a link's Latin text - // inserted between reopened Hebrew words isn't saved as right-to-left. - let s = session("לפני אחרי", true); - let bytes = crate::io::save_bytes("x.docx", &s.doc).unwrap(); - let mut s = Session::new(crate::io::open_bytes("x.docx", &bytes).unwrap()); - s.sel = crate::Selection::caret(Pos::new(StoryRef::Body, wordcraft_doc::Path::top(0), "לפני ".len())); - run(&mut s, "insert.link", json!({"url": "https://example.com", "text": "WordCraft site"})); - let runs = saved_runs(&s); - assert!(runs.contains(&(false, "WordCraft site".to_string())), "{runs:?}"); - // The caret's own formatting, as Reveal Formatting shows it, still says right to left. - s.sel = crate::Selection::caret(Pos::new(StoryRef::Body, wordcraft_doc::Path::top(0), "לפ".len())); - s.pending = None; // moving the caret by hand doesn't drop the link's pending formatting - assert_eq!(s.caret_props().rtl, Some(true)); - assert_eq!(s.typing_props().rtl, None); - assert_eq!(s.typing_props().lang_bidi.as_deref(), Some("he-IL"), "the bidi language stays"); -} +//! Right-to-left editing commands: paragraph direction, alignment as seen on the page, font +//! commands on Persian text, arrow keys in bidirectional text, DOCX save and open. + +use serde_json::json; +use std::sync::Arc; +use wordcraft_doc::props::Align; +use wordcraft_doc::{Pos, StoryRef}; + +use crate::Session; + +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}")) +} + +/// A session with `text` (paragraphs split on `\n`), every paragraph right to left when `rtl`. +fn session(text: &str, rtl: bool) -> Session { + let mut s = Session::new(wordcraft_doc::Document::new()); + run(&mut s, "document.setText", json!({"text": text})); + if rtl { + run(&mut s, "select.all", json!({})); + run(&mut s, "para.rtl", json!({})); + run(&mut s, "caret.docStart", json!({})); + } + s +} + +fn props(s: &Session, i: usize) -> wordcraft_doc::props::ParaProps { + s.doc.para(StoryRef::Body, &wordcraft_doc::Path::top(i)).unwrap().props.clone() +} + +fn caret_x(s: &mut Session) -> f32 { + let l = s.layout(); + l.caret(&s.sel.focus).unwrap().x +} + +#[test] +fn direction_commands_change_only_the_reading_order() { + let mut s = session("سلام دنیا\nHello", false); + run(&mut s, "para.alignCenter", json!({})); + run(&mut s, "para.rtl", json!({})); + assert_eq!(props(&s, 0).bidi, Some(true)); + assert_eq!(props(&s, 0).align, Some(Align::Center), "direction doesn't touch alignment"); + // Left-to-right again, and `value: false` means the other direction. + run(&mut s, "para.ltr", json!({})); + assert_eq!(props(&s, 0).bidi, Some(false)); + run(&mut s, "para.ltr", json!({"value": false})); + assert_eq!(props(&s, 0).bidi, Some(true)); + // A new right-to-left paragraph starts at the right margin (start-aligned). + let mut s = session("متن", true); + assert!((caret_x(&mut s) - 540.0).abs() < 0.5, "caret at {}", caret_x(&mut s)); +} + +#[test] +fn alignment_buttons_mean_what_they_show_in_rtl_paragraphs() { + let mut s = session("متن فارسی", true); + // Align Left in a right-to-left paragraph moves text to the left: its end edge. + run(&mut s, "para.alignLeft", json!({})); + assert_eq!(props(&s, 0).align, Some(Align::Right)); + let x_end = s.layout().caret(&Pos::body(0, "متن فارسی".len())).unwrap().x; + assert!((x_end - 72.0).abs() < 0.5, "text ends at the left margin: {x_end}"); + // Align Right is the start edge; pressing it again keeps it there. + run(&mut s, "para.alignRight", json!({})); + assert_eq!(props(&s, 0).align, Some(Align::Left)); + run(&mut s, "para.alignRight", json!({})); + assert_eq!(props(&s, 0).align, Some(Align::Left)); + // Logical values for scripts. + run(&mut s, "para.align", json!({"value": "end"})); + assert_eq!(props(&s, 0).align, Some(Align::Right)); + run(&mut s, "para.align", json!({"value": "right"})); + assert_eq!(props(&s, 0).align, Some(Align::Left)); +} + +#[test] +fn para_set_on_page_follows_each_paragraphs_own_direction() { + // The Paragraph dialog's alignment and indents are as seen on the page (#1346): over a + // right-to-left and a left-to-right paragraph, Left is each one's own left side. + let mut s = session("سلام دنیا\nHello", false); + run(&mut s, "caret.docStart", json!({})); + run(&mut s, "para.rtl", json!({})); + run(&mut s, "select.all", json!({})); + let depth = s.undo_depth(); + run(&mut s, "para.set", json!({"props": {"spaceAfter": 6}, "onPage": {"align": "left", "left": 36}})); + assert_eq!(s.undo_depth(), depth + 1, "one undo step"); + let (rtl, ltr) = (props(&s, 0), props(&s, 1)); + assert_eq!((rtl.align, rtl.indent_right, rtl.indent_left, rtl.space_after), (Some(Align::Right), Some(36.0), None, Some(6.0))); + assert_eq!((ltr.align, ltr.indent_left, ltr.indent_right, ltr.space_after), (Some(Align::Left), Some(36.0), None, Some(6.0))); + // A direction in `props` comes first: both are right to left now, so Right is their start. + run(&mut s, "para.set", json!({"props": {"bidi": true}, "onPage": {"right": 18}})); + assert_eq!((props(&s, 1).bidi, props(&s, 1).indent_left, props(&s, 1).indent_right), (Some(true), Some(18.0), None)); + assert!(s.run("para.set", &json!({"props": {}, "onPage": {"align": "middle"}})).is_err()); + // Undo takes each command back whole. + run(&mut s, "edit.undo", json!({})); + run(&mut s, "edit.undo", json!({})); + assert_eq!((props(&s, 0).align, props(&s, 0).indent_right, props(&s, 1).indent_left), (None, None, None)); +} + +#[test] +fn font_commands_reach_persian_text() { + let mut s = session("کتاب خوب", true); + run(&mut s, "select.all", json!({})); + run(&mut s, "format.bold", json!({})); + run(&mut s, "format.size", json!({"size": 18})); + run(&mut s, "format.font", json!({"name": "Vazirmatn"})); + let p = s.doc.para(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap(); + let c = p.props_of_char(0).clone(); + let rc = s.doc.styles.resolve_char(None, &c).complex(); + assert_eq!((rc.font.as_str(), rc.size, rc.bold), ("Vazirmatn", 18.0, true), "the complex-script formatting follows"); + // Saved and reopened, Word sees bold and size for the Persian text too. + let bytes = crate::io::save_bytes("x.docx", &s.doc).unwrap(); + let d = crate::io::open_bytes("x.docx", &bytes).unwrap(); + let c = d.para(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().props_of_char(0).clone(); + let rc = d.styles.resolve_char(None, &c).complex(); + assert_eq!((rc.font.as_str(), rc.size, rc.bold), ("Vazirmatn", 18.0, true)); + assert_eq!(d.para(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().props.bidi, Some(true)); +} + +#[test] +fn font_commands_override_inherited_complex_script_properties_explicitly() { + use wordcraft_doc::props::CharProps; + + let mut s = session("שלום ABC", true); + Arc::make_mut(&mut s.doc.styles).default_chr.size_cs = Some(30.0); + Arc::make_mut(&mut s.doc.styles).default_chr.bold_cs = Some(true); + Arc::make_mut(&mut s.doc.styles).default_chr.italic_cs = Some(true); + run(&mut s, "select.all", json!({})); + run(&mut s, "format.bold", json!({"value": false})); + run(&mut s, "format.italic", json!({"value": false})); + run(&mut s, "format.size", json!({"size": 18})); + for (command, expected_size) in [ + (None, 18.0), + (Some("format.growFont"), 20.0), + (Some("format.shrinkFont"), 18.0), + (Some("format.growFont1"), 19.0), + (Some("format.shrinkFont1"), 18.0), + ] { + if let Some(command) = command { + run(&mut s, command, json!({})); + } + let p = s.doc.para(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap(); + let c = p.props_of_char(0); + assert_eq!((c.size_cs, c.bold_cs, c.italic_cs), (Some(expected_size), Some(false), Some(false))); + let d = crate::io::open_bytes("x.docx", &crate::io::save_bytes("x.docx", &s.doc).unwrap()).unwrap(); + let c = d.para(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().props_of_char(0); + let cs = d.styles.resolve_char(None, c).complex(); + assert_eq!((cs.size, cs.bold, cs.italic), (expected_size, false, false)); + } + // The font dialog's command keeps formatting both scripts when given one value. + run(&mut s, "format.set", json!({"props": {"size": 10, "bold": true, "italic": true}})); + let p = s.doc.para(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap(); + let c: &CharProps = p.props_of_char(0); + assert_eq!((c.size_cs, c.bold_cs, c.italic_cs), (Some(10.0), Some(true), Some(true))); + // Callers can still supply distinct values explicitly, including false. + run(&mut s, "format.set", json!({"props": {"size": 12, "sizeCs": 18, "bold": true, "boldCs": false, "italic": true, "italicCs": false}})); + let p = s.doc.para(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap(); + let c = p.props_of_char(0); + assert_eq!((c.size, c.size_cs, c.bold_cs, c.italic_cs), (Some(12.0), Some(18.0), Some(false), Some(false))); +} + +#[test] +fn arrow_keys_move_visually_in_rtl_text() { + let fa = "سلام دنیا"; + let mut s = session(fa, true); + // From the start (the right edge), Left moves forward through the text. + let x0 = caret_x(&mut s); + run(&mut s, "caret.left", json!({})); + assert!(s.sel.focus.off > 0, "Left moves to the next character in right-to-left text"); + assert!(caret_x(&mut s) < x0, "and the caret moves left on the screen"); + run(&mut s, "caret.right", json!({})); + assert_eq!(s.sel.focus.off, 0); + // Right at the start edge goes nowhere in the first paragraph (no previous one). + run(&mut s, "caret.right", json!({})); + assert_eq!(s.sel.focus.off, 0); + // Left all the way reaches the end, then the next paragraph. + let mut s = session(&format!("{fa}\nبعدی"), true); + // (Stepping until the paragraph changes: the لا ligature is one caret position for two letters.) + let mut steps = 0; + while s.sel.focus.path.last() == 0 && steps < 40 { + run(&mut s, "caret.left", json!({})); + steps += 1; + } + assert!(steps <= fa.chars().count() + 1, "{steps} steps"); + assert_eq!(s.sel.focus.path.last(), 1, "past the left end of the line: the next paragraph"); + assert_eq!(s.sel.focus.off, 0, "at its start (right edge)"); + // Shift+Left selects forward in logical order. + run(&mut s, "caret.docStart", json!({})); + run(&mut s, "caret.left", json!({"extend": true})); + let (a, b) = s.sel.ordered(); + assert_eq!((a.off, b.off > 0), (0, true)); + // Left with a selection collapses to its left edge: the logical end. + run(&mut s, "caret.left", json!({})); + assert_eq!(s.sel.focus.off, b.off); +} + +#[test] +fn arrow_keys_in_mixed_text_visit_each_position() { + // A left-to-right paragraph with a Persian word: Right walks across the screen. + let t = "ab سلام cd"; + let mut s = session(t, false); + let mut xs = vec![caret_x(&mut s)]; + for _ in 0..30 { + let before = s.sel.focus.clone(); + run(&mut s, "caret.right", json!({})); + if s.sel.focus == before { + break; + } + xs.push(caret_x(&mut s)); + } + assert!(xs.windows(2).all(|w| w[1] > w[0]), "each Right moves right: {xs:?}"); + assert_eq!(s.sel.focus.off, t.len(), "ends at the end of the paragraph"); +} + +#[test] +fn word_moves_follow_reading_order() { + let mut s = session("یک دو سه", true); + run(&mut s, "caret.wordLeft", json!({})); + assert!(s.sel.focus.off > 0, "Ctrl+Left goes to the next word in right-to-left text"); + run(&mut s, "caret.wordRight", json!({})); + assert_eq!(s.sel.focus.off, 0); +} + +#[test] +fn typing_persian_keeps_logical_order() { + let mut s = session("", true); + for w in ["سلام", " ", "WordCraft", " ", "۱۴۰۳"] { + run(&mut s, "text.insert", json!({"text": w})); + } + assert_eq!(s.doc.plain_text(StoryRef::Body), "سلام WordCraft ۱۴۰۳"); + // The caret after typing a number at the end sits right after it on the screen. + let l = s.layout(); + let c = l.caret(&s.sel.focus).unwrap(); + assert!(c.x < 540.0 && c.x > 72.0); + // select.text finds Persian text. + run(&mut s, "select.text", json!({"text": "سلام"})); + assert_eq!(s.selected_text(), "سلام"); +} + +#[test] +fn explicit_alignment_sets_without_toggling() { + for rtl in [false, true] { + let mut s = session("متن", rtl); + for v in ["center", "center", "right", "right", "left", "left"] { + run(&mut s, "para.align", json!({"value": v})); + let shown = props(&s, 0).align.unwrap_or_default().visual(rtl); + let want = match v { + "center" => Align::Center, + "right" => Align::Right, + _ => Align::Left, + }; + assert_eq!(shown, want, "{v} twice stays {v} (rtl={rtl})"); + } + } +} + +#[test] +fn typing_kurdish_in_new_document_advances_caret_smoothly() { + let mut s = Session::new(wordcraft_doc::Document::new()); + let mut first_x = None; + let mut last_x = 0.0; + for ch in ["م", "ن", " ", "ن", "ا", "و", "م", " ", "ه", "ێ", "ڤ", "ا", "ر", "ه"] { + run(&mut s, "text.insert", json!({"text": ch})); + let l = s.layout(); + let c = l.caret(&s.sel.focus).unwrap(); + if first_x.is_none() { + first_x = Some(c.x); + } + last_x = c.x; + // The caret must not jump back to the left margin (72.0), but stay near the right margin (540.0). + assert!(c.x > 400.0, "caret should not be at left margin: {}", c.x); + } + assert!(last_x < first_x.unwrap(), "caret should advance leftwards overall"); + assert_eq!(props(&s, 0).bidi, Some(true), "paragraph auto-detected as right-to-left"); +} + +/// The runs of the first paragraph in a saved DOCX: (has `w:rtl`, text). +fn saved_runs(s: &Session) -> Vec<(bool, String)> { + use std::io::Read; + let bytes = crate::io::save_bytes("x.docx", &s.doc).unwrap(); + let mut xml = String::new(); + zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap().by_name("word/document.xml").unwrap().read_to_string(&mut xml).unwrap(); + let para = xml.split("").nth(1).unwrap().split("").next().unwrap().to_string(); + para.split("") + .skip(1) + .map(|r| { + let r = r.split("").next().unwrap(); + let text: String = r.split("").skip(1).filter_map(|t| t.split("").next()).collect(); + (r.contains(""), text) + }) + .collect() +} + +#[test] +fn latin_typed_into_reopened_hebrew_is_saved_left_to_right() { + // #1543: a saved file's Hebrew runs say `w:rtl`. Latin typed next to them after reopening + // takes its direction from its own letters, as on screen, so it's saved in a run of its own + // without `w:rtl` (Word would otherwise lay it out right to left again). + let s = session("לפני אחרי", true); + let bytes = crate::io::save_bytes("x.docx", &s.doc).unwrap(); + let mut s = Session::new(crate::io::open_bytes("x.docx", &bytes).unwrap()); + assert!(s.doc.para(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().runs.iter().all(|r| r.props.rtl == Some(true))); + s.sel = crate::Selection::caret(Pos::new(StoryRef::Body, wordcraft_doc::Path::top(0), "לפני ".len())); + run(&mut s, "text.insert", json!({"text": "Foo Bar 2026 "})); + let runs = saved_runs(&s); + assert!(runs.contains(&(false, "Foo Bar 2026".to_string())), "{runs:?}"); + assert!(runs.iter().all(|(rtl, t)| !rtl || !t.chars().any(|c| c.is_ascii_alphanumeric())), "{runs:?}"); + assert_eq!(runs.iter().map(|(_, t)| t.as_str()).collect::(), "לפני Foo Bar 2026 אחרי"); +} + +#[test] +fn a_latin_link_inserted_into_reopened_hebrew_is_saved_left_to_right() { + // Other insertions start from the caret's formatting too (#1543): a link's Latin text + // inserted between reopened Hebrew words isn't saved as right-to-left. + let s = session("לפני אחרי", true); + let bytes = crate::io::save_bytes("x.docx", &s.doc).unwrap(); + let mut s = Session::new(crate::io::open_bytes("x.docx", &bytes).unwrap()); + s.sel = crate::Selection::caret(Pos::new(StoryRef::Body, wordcraft_doc::Path::top(0), "לפני ".len())); + run(&mut s, "insert.link", json!({"url": "https://example.com", "text": "WordCraft site"})); + let runs = saved_runs(&s); + assert!(runs.contains(&(false, "WordCraft site".to_string())), "{runs:?}"); + // The caret's own formatting, as Reveal Formatting shows it, still says right to left. + s.sel = crate::Selection::caret(Pos::new(StoryRef::Body, wordcraft_doc::Path::top(0), "לפ".len())); + s.pending = None; // moving the caret by hand doesn't drop the link's pending formatting + assert_eq!(s.caret_props().rtl, Some(true)); + assert_eq!(s.typing_props().rtl, None); + assert_eq!(s.typing_props().lang_bidi.as_deref(), Some("he-IL"), "the bidi language stays"); +} diff --git a/crates/engine/src/tests/czech_test_dict.rs b/crates/engine/src/tests/czech_test_dict.rs index 6cc59759e6..382e95d25f 100644 --- a/crates/engine/src/tests/czech_test_dict.rs +++ b/crates/engine/src/tests/czech_test_dict.rs @@ -1,59 +1,59 @@ -//! Hand-written synthetic Czech test data. No words, rules or suggestion tables were copied -//! from a system dictionary. This is a small fixture, not a redistributable Czech dictionary. - -pub const AFF: &str = "SET UTF-8 -FLAG UTF-8 -FORBIDDENWORD F -TRY aeiouěščřžýáíéůúntklmp -KEY xn|qř -MAP 5 -MAP eěé -MAP cč -MAP rř -MAP uůú -MAP ií -REP 1 -REP mňe mě -SFX A Y 2 # synthetic declension -SFX A o a o -SFX A o ě o -SFX B N 1 -SFX B 0 e/C . -SFX C N 1 -SFX C e ejší e -SFX D N 1 -SFX D 0 itel/E . -SFX E N 1 -SFX E 0 é . -SFX P Y 1 -SFX P ha ze ha -PFX X Y 1 -PFX X 0 ne . -"; - -pub const DIC: &str = "20 -město/AX -rychl/B -uč/D -Praha/P -škola -počítač -příliš -žluťoučký -kůň -úpěl -ďábelské -ódy -zpráva -zprava -děkujeme -mě -nůž -si -to -zqzakázané/F -"; - -pub fn install() { - super::install_dictionary(AFF, DIC).unwrap(); -} +//! Hand-written synthetic Czech test data. No words, rules or suggestion tables were copied +//! from a system dictionary. This is a small fixture, not a redistributable Czech dictionary. + +pub const AFF: &str = "SET UTF-8 +FLAG UTF-8 +FORBIDDENWORD F +TRY aeiouěščřžýáíéůúntklmp +KEY xn|qř +MAP 5 +MAP eěé +MAP cč +MAP rř +MAP uůú +MAP ií +REP 1 +REP mňe mě +SFX A Y 2 # synthetic declension +SFX A o a o +SFX A o ě o +SFX B N 1 +SFX B 0 e/C . +SFX C N 1 +SFX C e ejší e +SFX D N 1 +SFX D 0 itel/E . +SFX E N 1 +SFX E 0 é . +SFX P Y 1 +SFX P ha ze ha +PFX X Y 1 +PFX X 0 ne . +"; + +pub const DIC: &str = "20 +město/AX +rychl/B +uč/D +Praha/P +škola +počítač +příliš +žluťoučký +kůň +úpěl +ďábelské +ódy +zpráva +zprava +děkujeme +mě +nůž +si +to +zqzakázané/F +"; + +pub fn install() { + super::install_dictionary(AFF, DIC).unwrap(); +} diff --git a/crates/engine/src/tests/documents.rs b/crates/engine/src/tests/documents.rs index cdeeff5135..2f0151b33b 100644 --- a/crates/engine/src/tests/documents.rs +++ b/crates/engine/src/tests/documents.rs @@ -1,410 +1,410 @@ -//! Several open documents in one session: File › New and Open open beside the others, each -//! document keeps its own text, selection, undo and find state, and work addressed to a document -//! by id lands in that document whichever one is active. - -use serde_json::{Value, json}; -use wordcraft_doc::StoryRef; - -use crate::Session; - -fn s() -> Session { - Session::new(wordcraft_doc::Document::new()) -} - -fn run(s: &mut Session, id: &str, v: Value) -> Value { - s.run(id, &v).unwrap_or_else(|e| panic!("{id}: {e}")) -} - -fn text(s: &Session) -> String { - s.doc.plain_text(StoryRef::Body) -} - -fn ids(s: &Session) -> Vec { - s.documents().iter().map(|d| d.id).collect() -} - -fn temp_dir(name: &str) -> std::path::PathBuf { - let dir = std::env::temp_dir().join(format!("wordcraft-engine-docs-{name}-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - dir -} - -#[test] -fn new_opens_another_document_and_each_keeps_its_own_text_and_undo() { - let mut s = s(); - let a = s.document_id(); - run(&mut s, "text.insert", json!({"text": "Alpha"})); - run(&mut s, "file.new", json!({})); - let b = s.document_id(); - assert_ne!(a, b); - assert_eq!(ids(&s), vec![a, b], "opening order"); - let docs = s.documents(); - assert_eq!(docs.iter().map(|d| (d.title.as_str(), d.active)).collect::>(), vec![("Document1", false), ("Document2", true)]); - assert!(docs[0].dirty && !docs[1].dirty); - assert_eq!(text(&s), "", "the new document is active"); - run(&mut s, "text.insert", json!({"text": "Beta"})); - - s.activate(a).unwrap(); - assert_eq!(text(&s), "Alpha"); - assert_eq!(s.document_id(), a, "ids stay with their documents"); - s.activate(a).unwrap(); - s.activate(b).unwrap(); - assert_eq!(text(&s), "Beta"); - run(&mut s, "file.new", json!({})); - assert_eq!(s.documents()[2].title, "Document3"); -} - -/// Undo goes with its document: edit A, edit B, back to A, Undo takes A's edit back and leaves -/// B alone; Redo puts it back. Find state stays with its document too. -#[test] -fn undo_and_find_stay_with_their_document_across_switches() { - let mut s = s(); - let a = s.document_id(); - run(&mut s, "text.insert", json!({"text": "Alpha"})); - s.find.query = "Alpha".into(); - let b = s.add_document(wordcraft_doc::Document::new()); - assert_eq!(s.find.query, "", "a new document has its own find state"); - run(&mut s, "text.insert", json!({"text": "Beta"})); - s.find.query = "Beta".into(); - - s.activate(a).unwrap(); - assert_eq!(s.find.query, "Alpha"); - run(&mut s, "edit.undo", json!({})); - assert_eq!(text(&s), ""); - assert_eq!(s.with_document(b, |s| (text(s), s.undo_depth(), s.find.query.clone())).unwrap(), ("Beta".to_string(), 1, "Beta".to_string())); - run(&mut s, "edit.redo", json!({})); - assert_eq!(text(&s), "Alpha"); - s.activate(b).unwrap(); - run(&mut s, "edit.undo", json!({})); - assert_eq!(text(&s), ""); - assert_eq!(s.with_document(a, |s| text(s)).unwrap(), "Alpha"); -} - -#[cfg(not(target_arch = "wasm32"))] -#[test] -fn open_replaces_an_untouched_blank_document_and_otherwise_opens_beside() { - let dir = temp_dir("open"); - let one = dir.join("one.txt"); - let two = dir.join("two.txt"); - std::fs::write(&one, "One").unwrap(); - std::fs::write(&two, "Two").unwrap(); - - let mut s = s(); - assert!(s.is_untouched_blank()); - let r = run(&mut s, "file.open", json!({"path": one.to_string_lossy()})); - assert_eq!(r["alreadyOpen"], false); - assert_eq!(s.documents().len(), 1, "the untouched blank document was replaced"); - let first = s.document_id(); - assert_eq!(s.documents()[0].title, "one"); - - run(&mut s, "file.open", json!({"path": two.to_string_lossy()})); - assert_eq!(s.documents().len(), 2, "a document with a file stays open"); - let second = s.document_id(); - assert_eq!(text(&s), "Two"); - - // The same file again, by another spelling: it becomes the active document. - let again = dir.join(".").join("one.txt"); - let r = run(&mut s, "file.open", json!({"path": again.to_string_lossy()})); - assert_eq!((r["alreadyOpen"].as_bool(), r["id"].as_u64()), (Some(true), Some(first))); - assert_eq!((s.document_id(), s.documents().len()), (first, 2)); - assert_eq!(s.find_open(&two), Some(second)); - - // A failed open changes nothing. - assert!(s.run("file.open", &json!({"path": dir.join("missing.docx").to_string_lossy()})).is_err()); - assert_eq!((ids(&s), s.document_id()), (vec![first, second], first)); - - // An edited blank document isn't replaced. - let mut s = self::s(); - run(&mut s, "text.insert", json!({"text": "x"})); - run(&mut s, "text.backspace", json!({})); - assert!(!s.is_untouched_blank()); - run(&mut s, "file.open", json!({"path": one.to_string_lossy()})); - assert_eq!(s.documents().len(), 2); - let _ = std::fs::remove_dir_all(&dir); -} - -#[test] -fn close_activates_the_previous_document_and_the_last_leaves_a_blank_one() { - let mut s = s(); - let a = s.document_id(); - let b = s.add_document(wordcraft_doc::Document::from_text("B")); - let c = s.add_document(wordcraft_doc::Document::from_text("C")); - s.activate(b).unwrap(); - s.close_document(b).unwrap(); - assert_eq!((ids(&s), s.document_id()), (vec![a, c], a), "the one opened before it"); - s.close_document(c).unwrap(); - s.close_document(a).unwrap(); - assert_eq!(s.documents().len(), 1); - assert!(![a, b, c].contains(&s.document_id())); - assert!(s.is_untouched_blank()); - assert!(s.close_document(a).is_err(), "closed documents are gone"); - - // Closing the first makes the next one active. - let d = s.document_id(); - let e = s.add_document(wordcraft_doc::Document::new()); - s.activate(d).unwrap(); - s.close_document(d).unwrap(); - assert_eq!(s.document_id(), e); -} - -#[test] -fn close_document_refuses_unsaved_changes_unless_forced() { - let mut s = s(); - let a = s.document_id(); - run(&mut s, "text.insert", json!({"text": "Draft"})); - let b = s.add_document(wordcraft_doc::Document::new()); - assert!(s.run("file.closeDocument", &json!({"id": a})).is_err()); - assert_eq!(ids(&s), vec![a, b], "still open"); - let r = run(&mut s, "file.closeDocument", json!({})); - assert_eq!((r["closed"].as_u64(), r["active"].as_u64()), (Some(b), Some(a))); - let r = run(&mut s, "file.closeDocument", json!({"id": a, "force": true})); - assert_eq!(r["documents"].as_array().map(Vec::len), Some(1)); - assert!(s.is_untouched_blank()); - for bad in [json!({"id": -1}), json!({"id": "x"}), json!({"id": u64::MAX}), json!({"id": 1.5}), json!({"id": []})] { - assert!(s.run("file.closeDocument", &bad).is_err(), "{bad}"); - } - assert_eq!(s.documents().len(), 1); -} - -/// Work addressed to a document by id lands in it, whichever document is active, and the active -/// one is left as it was. -#[test] -fn with_document_works_on_the_given_document_and_keeps_the_active_one() { - let mut s = s(); - let a = s.document_id(); - run(&mut s, "text.insert", json!({"text": "Alpha"})); - let b = s.add_document(wordcraft_doc::Document::new()); - run(&mut s, "text.insert", json!({"text": "Beta"})); - let (b_rev, b_undo) = (s.rev(), s.undo_depth()); - - let r = s.with_document(a, |s| { - s.run("text.insert", &json!({"text": " more"})).unwrap(); - s.run("edit.advancedFind", &json!({})).ok(); - s.document_id() - }); - assert_eq!(r.unwrap(), a); - assert_eq!(s.document_id(), b); - assert_eq!((text(&s), s.rev(), s.undo_depth()), ("Beta".to_string(), b_rev, b_undo), "B untouched"); - assert_eq!(s.with_document(a, |s| text(s)).unwrap(), "Alpha more"); - assert!(s.ui_requests.iter().all(|q| q.get("open").is_none() || q["document"] == a), "requests name their document: {:?}", s.ui_requests); - assert!(s.with_document(u64::MAX, |_| ()).is_err()); - - // Closing the active document inside leaves the one it ran on active. - s.with_document(a, |s| s.close_document(b)).unwrap().unwrap(); - assert_eq!(ids(&s), vec![a]); - assert_eq!(s.document_id(), a); -} - -/// A Read Aloud reading names its document: switching documents stops it, and turning citation -/// skipping on in another document doesn't restart it at the first document's positions there. -#[test] -fn read_aloud_belongs_to_its_document() { - let mut s = s(); - let a = s.document_id(); - run(&mut s, "text.insert", json!({"text": "One sentence. Another one."})); - run(&mut s, "caret.docStart", json!({})); - run(&mut s, "review.readAloud", json!({})); - assert_eq!(s.read_aloud.document, Some(a)); - let b = s.add_document(wordcraft_doc::Document::from_text("Other text.")); - assert_eq!(s.read_aloud.state(), crate::speech::State::Stopped, "the reading of A stopped"); - run(&mut s, "readAloud.skipCitations", json!({"value": false})); - assert_eq!(s.read_aloud.document, Some(a), "nothing restarted in B"); - run(&mut s, "review.readAloud", json!({})); - assert_eq!(s.read_aloud.document, Some(b)); -} - -/// Tests that `view.switchWindows` lists open documents, activates by document ID, and requests switching by window ID. -#[test] -fn switch_windows_lists_and_activates() { - let mut s = s(); - let a = s.document_id(); - let b = s.add_document(wordcraft_doc::Document::new()); - let r = run(&mut s, "view.switchWindows", json!({})); - let list = r["documents"].as_array().unwrap(); - assert_eq!( - list.iter().map(|d| (d["id"].as_u64(), d["title"].as_str(), d["active"].as_bool())).collect::>(), - vec![(Some(a), Some("Document1"), Some(false)), (Some(b), Some("Document2"), Some(true))] - ); - assert!(list[0].get("path").is_some() && list[0].get("dirty").is_some()); - run(&mut s, "view.switchWindows", json!({"id": a})); - assert_eq!(s.document_id(), a); - s.ui_requests.clear(); - run(&mut s, "view.switchWindows", json!({"window": 1})); - assert_eq!(s.ui_requests, vec![json!({"windows": "switchWindow", "window": 1})]); - s.windows.available = true; - s.windows.current = Some(1); - s.windows.others = vec![crate::cmd::view::OtherWindow { id: 2, title: "Second".into() }]; - s.ui_requests.clear(); - run(&mut s, "view.switchWindows", json!({"window": 2})); - assert_eq!(s.ui_requests, vec![json!({"windows": "switchWindow", "window": 2})]); - assert!(s.run("view.switchWindows", &json!({"window": 3})).is_err(), "unknown window key fails when windows are available"); - for bad in [json!({"id": 0}), json!({"id": u64::MAX}), json!({"id": -5}), json!({"id": "1"}), json!({"id": 1e300}), json!({"id": {}})] { - assert!(s.run("view.switchWindows", &bad).is_err(), "{bad}"); - assert_eq!(s.document_id(), a); - } - for bad in [json!({"window": "1"}), json!({"window": -1}), json!({"window": {}})] { - assert!(s.run("view.switchWindows", &bad).is_err(), "{bad}"); - } - let junk = [json!(null), json!([]), json!({"id": null}), json!({"text": 5})]; - for id in ["view.switchWindows", "file.closeDocument", "file.new"] { - for j in &junk { - let _ = s.run(id, j); - s.clamp_selection(); - let _ = s.layout(); - } - } - assert!(!s.documents().is_empty()); -} - -#[test] -fn document_ids_are_unique_across_sessions_and_replacements() { - let mut one = s(); - let two = s(); - assert_ne!(one.document_id(), two.document_id()); - let before = one.document_id(); - one.set_document(wordcraft_doc::Document::new()); - assert_ne!(one.document_id(), before); - assert_eq!(ids(&one), vec![one.document_id()], "the replacement takes its place in the list"); -} - -/// A Start screen's stand-in document gives back its name: the first document a person makes is -/// `Document1`, the next `Document2`, and after closing the last one, New makes `Document2` -/// again only if no other document has taken it. -#[test] -fn a_stand_in_document_gives_back_its_untitled_name() { - let mut s = s(); - let placeholder = s.document_id(); - s.release_untitled_name(); - run(&mut s, "file.new", json!({})); - s.close_document(placeholder).unwrap(); - assert_eq!(s.documents()[0].title, "Document1"); - run(&mut s, "file.new", json!({})); - assert_eq!(s.documents()[1].title, "Document2"); - // A document with work keeps its name. - run(&mut s, "text.insert", json!({"text": "Work"})); - s.release_untitled_name(); - run(&mut s, "file.new", json!({})); - assert_eq!(s.documents()[2].title, "Document3"); -} - -/// Two windows on one document: each keeps its own caret and zoom; a caret inside text another -/// window deleted is clamped, so typing there lands at the end of what is left; Undo in either -/// window undoes the document's last edit, and typing in one window never joins the other's -/// undo step. -#[test] -fn two_views_of_one_document_keep_their_own_caret_and_share_undo() { - let mut s = s(); - run(&mut s, "text.insert", json!({"text": "Hello world"})); - let mut b = s.copy_view(); - let a_caret = s.sel.focus.clone(); - - // Window B: zoom in; its caret is after "world". - let mut a = s.take_view(); - s.put_view(b); - run(&mut s, "view.zoom", json!({"value": 150})); - b = s.take_view(); - s.put_view(a); - assert_eq!(s.view.zoom, 1.0, "window A keeps its zoom"); - - // Window A deletes " world", where B's caret is. - run(&mut s, "select.text", json!({"text": " world"})); - run(&mut s, "text.delete", json!({})); - assert_eq!(text(&s).trim(), "Hello"); - a = s.take_view(); - s.put_view(b); - assert!((s.view.zoom - 1.5).abs() < 1e-6, "window B keeps its zoom"); - assert_eq!(s.sel.focus.off, 5, "B's caret is clamped into what is left"); - run(&mut s, "text.insert", json!({"text": "!"})); - assert_eq!(text(&s).trim(), "Hello!"); - - // Undo in A undoes B's typing; Undo in B then undoes A's delete. - b = s.take_view(); - s.put_view(a); - run(&mut s, "edit.undo", json!({})); - assert_eq!(text(&s).trim(), "Hello"); - a = s.take_view(); - s.put_view(b); - run(&mut s, "edit.undo", json!({})); - assert_eq!(text(&s).trim(), "Hello world"); - - // Typing in A, then B, then A again: three undo steps. - b = s.take_view(); - s.put_view(a); - s.sel = crate::Selection::caret(a_caret); - run(&mut s, "text.insert", json!({"text": "x"})); - a = s.take_view(); - s.put_view(b); - run(&mut s, "text.insert", json!({"text": "y"})); - b = s.take_view(); - s.put_view(a); - run(&mut s, "text.insert", json!({"text": "z"})); - run(&mut s, "edit.undo", json!({})); - assert!(!text(&s).contains('z') && text(&s).contains('x') && text(&s).contains('y'), "{}", text(&s)); - drop(b); -} - -/// An input method's promise that the next insertion joins its replacement belongs to the -/// window that made it: another window on the document typing at the same place gets an undo -/// step of its own. -#[test] -fn input_method_joins_stay_with_their_window() { - let mut s = s(); - run(&mut s, "text.insert", json!({"text": "Hello"})); - s.join_next_ime_text_replacement(); - let b = s.copy_view(); - let a = s.take_view(); - s.put_view(b); - run(&mut s, "text.insert", json!({"text": "y"})); - run(&mut s, "edit.undo", json!({})); - assert_eq!(text(&s).trim(), "Hello", "B's typing is its own undo step"); - drop(a); -} - -/// Check spelling as you type is one setting for the app, not one per open document (#5740): -/// turned off (File › Options › Proofing, or Review's command) with one document active, it is off -/// in the others too, and switching to another document doesn't turn it back on, so what is saved -/// for the next run is what was chosen, whichever document is active then. -#[test] -fn check_spelling_is_one_setting_for_every_open_document() { - let mut s = s(); - let a = s.document_id(); - run(&mut s, "file.new", json!({})); - let b = s.document_id(); - run(&mut s, "file.new", json!({})); - let c = s.document_id(); - assert!(s.view.proofing); - s.activate(a).unwrap(); - run(&mut s, "file.options", json!({"checkSpelling": false})); - for id in [b, c, a] { - s.activate(id).unwrap(); - assert!(!s.view.proofing, "off in document {id}"); - assert!(!s.prefs.check_spelling); - } - run(&mut s, "review.proofing", json!({"value": true})); - for id in [b, c] { - s.activate(id).unwrap(); - assert!(s.view.proofing, "back on in document {id}"); - } - // A document opened now starts with the setting as it is. - run(&mut s, "review.proofing", json!({"value": false})); - run(&mut s, "file.new", json!({})); - assert!(!s.view.proofing); -} - -/// A window's view put back after Check spelling as you type changed in another window takes the -/// setting as it is now, not as it was when the window's view was taken (#5740): every view made -/// active follows the saved preference. -#[test] -fn a_window_view_put_back_follows_the_spelling_setting() { - let mut s = s(); - assert!(s.view.proofing); - let held = s.take_view(); - run(&mut s, "file.options", json!({"checkSpelling": false})); - s.put_view(held); - assert!(!s.view.proofing, "the view taken with spelling on comes back with it off"); - let held = s.take_view(); - run(&mut s, "review.proofing", json!({"value": true})); - s.put_view(held); - assert!(s.view.proofing); -} +//! Several open documents in one session: File › New and Open open beside the others, each +//! document keeps its own text, selection, undo and find state, and work addressed to a document +//! by id lands in that document whichever one is active. + +use serde_json::{Value, json}; +use wordcraft_doc::StoryRef; + +use crate::Session; + +fn s() -> Session { + Session::new(wordcraft_doc::Document::new()) +} + +fn run(s: &mut Session, id: &str, v: Value) -> Value { + s.run(id, &v).unwrap_or_else(|e| panic!("{id}: {e}")) +} + +fn text(s: &Session) -> String { + s.doc.plain_text(StoryRef::Body) +} + +fn ids(s: &Session) -> Vec { + s.documents().iter().map(|d| d.id).collect() +} + +fn temp_dir(name: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("wordcraft-engine-docs-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +#[test] +fn new_opens_another_document_and_each_keeps_its_own_text_and_undo() { + let mut s = s(); + let a = s.document_id(); + run(&mut s, "text.insert", json!({"text": "Alpha"})); + run(&mut s, "file.new", json!({})); + let b = s.document_id(); + assert_ne!(a, b); + assert_eq!(ids(&s), vec![a, b], "opening order"); + let docs = s.documents(); + assert_eq!(docs.iter().map(|d| (d.title.as_str(), d.active)).collect::>(), vec![("Document1", false), ("Document2", true)]); + assert!(docs[0].dirty && !docs[1].dirty); + assert_eq!(text(&s), "", "the new document is active"); + run(&mut s, "text.insert", json!({"text": "Beta"})); + + s.activate(a).unwrap(); + assert_eq!(text(&s), "Alpha"); + assert_eq!(s.document_id(), a, "ids stay with their documents"); + s.activate(a).unwrap(); + s.activate(b).unwrap(); + assert_eq!(text(&s), "Beta"); + run(&mut s, "file.new", json!({})); + assert_eq!(s.documents()[2].title, "Document3"); +} + +/// Undo goes with its document: edit A, edit B, back to A, Undo takes A's edit back and leaves +/// B alone; Redo puts it back. Find state stays with its document too. +#[test] +fn undo_and_find_stay_with_their_document_across_switches() { + let mut s = s(); + let a = s.document_id(); + run(&mut s, "text.insert", json!({"text": "Alpha"})); + s.find.query = "Alpha".into(); + let b = s.add_document(wordcraft_doc::Document::new()); + assert_eq!(s.find.query, "", "a new document has its own find state"); + run(&mut s, "text.insert", json!({"text": "Beta"})); + s.find.query = "Beta".into(); + + s.activate(a).unwrap(); + assert_eq!(s.find.query, "Alpha"); + run(&mut s, "edit.undo", json!({})); + assert_eq!(text(&s), ""); + assert_eq!(s.with_document(b, |s| (text(s), s.undo_depth(), s.find.query.clone())).unwrap(), ("Beta".to_string(), 1, "Beta".to_string())); + run(&mut s, "edit.redo", json!({})); + assert_eq!(text(&s), "Alpha"); + s.activate(b).unwrap(); + run(&mut s, "edit.undo", json!({})); + assert_eq!(text(&s), ""); + assert_eq!(s.with_document(a, |s| text(s)).unwrap(), "Alpha"); +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn open_replaces_an_untouched_blank_document_and_otherwise_opens_beside() { + let dir = temp_dir("open"); + let one = dir.join("one.txt"); + let two = dir.join("two.txt"); + std::fs::write(&one, "One").unwrap(); + std::fs::write(&two, "Two").unwrap(); + + let mut s = s(); + assert!(s.is_untouched_blank()); + let r = run(&mut s, "file.open", json!({"path": one.to_string_lossy()})); + assert_eq!(r["alreadyOpen"], false); + assert_eq!(s.documents().len(), 1, "the untouched blank document was replaced"); + let first = s.document_id(); + assert_eq!(s.documents()[0].title, "one"); + + run(&mut s, "file.open", json!({"path": two.to_string_lossy()})); + assert_eq!(s.documents().len(), 2, "a document with a file stays open"); + let second = s.document_id(); + assert_eq!(text(&s), "Two"); + + // The same file again, by another spelling: it becomes the active document. + let again = dir.join(".").join("one.txt"); + let r = run(&mut s, "file.open", json!({"path": again.to_string_lossy()})); + assert_eq!((r["alreadyOpen"].as_bool(), r["id"].as_u64()), (Some(true), Some(first))); + assert_eq!((s.document_id(), s.documents().len()), (first, 2)); + assert_eq!(s.find_open(&two), Some(second)); + + // A failed open changes nothing. + assert!(s.run("file.open", &json!({"path": dir.join("missing.docx").to_string_lossy()})).is_err()); + assert_eq!((ids(&s), s.document_id()), (vec![first, second], first)); + + // An edited blank document isn't replaced. + let mut s = self::s(); + run(&mut s, "text.insert", json!({"text": "x"})); + run(&mut s, "text.backspace", json!({})); + assert!(!s.is_untouched_blank()); + run(&mut s, "file.open", json!({"path": one.to_string_lossy()})); + assert_eq!(s.documents().len(), 2); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn close_activates_the_previous_document_and_the_last_leaves_a_blank_one() { + let mut s = s(); + let a = s.document_id(); + let b = s.add_document(wordcraft_doc::Document::from_text("B")); + let c = s.add_document(wordcraft_doc::Document::from_text("C")); + s.activate(b).unwrap(); + s.close_document(b).unwrap(); + assert_eq!((ids(&s), s.document_id()), (vec![a, c], a), "the one opened before it"); + s.close_document(c).unwrap(); + s.close_document(a).unwrap(); + assert_eq!(s.documents().len(), 1); + assert!(![a, b, c].contains(&s.document_id())); + assert!(s.is_untouched_blank()); + assert!(s.close_document(a).is_err(), "closed documents are gone"); + + // Closing the first makes the next one active. + let d = s.document_id(); + let e = s.add_document(wordcraft_doc::Document::new()); + s.activate(d).unwrap(); + s.close_document(d).unwrap(); + assert_eq!(s.document_id(), e); +} + +#[test] +fn close_document_refuses_unsaved_changes_unless_forced() { + let mut s = s(); + let a = s.document_id(); + run(&mut s, "text.insert", json!({"text": "Draft"})); + let b = s.add_document(wordcraft_doc::Document::new()); + assert!(s.run("file.closeDocument", &json!({"id": a})).is_err()); + assert_eq!(ids(&s), vec![a, b], "still open"); + let r = run(&mut s, "file.closeDocument", json!({})); + assert_eq!((r["closed"].as_u64(), r["active"].as_u64()), (Some(b), Some(a))); + let r = run(&mut s, "file.closeDocument", json!({"id": a, "force": true})); + assert_eq!(r["documents"].as_array().map(Vec::len), Some(1)); + assert!(s.is_untouched_blank()); + for bad in [json!({"id": -1}), json!({"id": "x"}), json!({"id": u64::MAX}), json!({"id": 1.5}), json!({"id": []})] { + assert!(s.run("file.closeDocument", &bad).is_err(), "{bad}"); + } + assert_eq!(s.documents().len(), 1); +} + +/// Work addressed to a document by id lands in it, whichever document is active, and the active +/// one is left as it was. +#[test] +fn with_document_works_on_the_given_document_and_keeps_the_active_one() { + let mut s = s(); + let a = s.document_id(); + run(&mut s, "text.insert", json!({"text": "Alpha"})); + let b = s.add_document(wordcraft_doc::Document::new()); + run(&mut s, "text.insert", json!({"text": "Beta"})); + let (b_rev, b_undo) = (s.rev(), s.undo_depth()); + + let r = s.with_document(a, |s| { + s.run("text.insert", &json!({"text": " more"})).unwrap(); + s.run("edit.advancedFind", &json!({})).ok(); + s.document_id() + }); + assert_eq!(r.unwrap(), a); + assert_eq!(s.document_id(), b); + assert_eq!((text(&s), s.rev(), s.undo_depth()), ("Beta".to_string(), b_rev, b_undo), "B untouched"); + assert_eq!(s.with_document(a, |s| text(s)).unwrap(), "Alpha more"); + assert!(s.ui_requests.iter().all(|q| q.get("open").is_none() || q["document"] == a), "requests name their document: {:?}", s.ui_requests); + assert!(s.with_document(u64::MAX, |_| ()).is_err()); + + // Closing the active document inside leaves the one it ran on active. + s.with_document(a, |s| s.close_document(b)).unwrap().unwrap(); + assert_eq!(ids(&s), vec![a]); + assert_eq!(s.document_id(), a); +} + +/// A Read Aloud reading names its document: switching documents stops it, and turning citation +/// skipping on in another document doesn't restart it at the first document's positions there. +#[test] +fn read_aloud_belongs_to_its_document() { + let mut s = s(); + let a = s.document_id(); + run(&mut s, "text.insert", json!({"text": "One sentence. Another one."})); + run(&mut s, "caret.docStart", json!({})); + run(&mut s, "review.readAloud", json!({})); + assert_eq!(s.read_aloud.document, Some(a)); + let b = s.add_document(wordcraft_doc::Document::from_text("Other text.")); + assert_eq!(s.read_aloud.state(), crate::speech::State::Stopped, "the reading of A stopped"); + run(&mut s, "readAloud.skipCitations", json!({"value": false})); + assert_eq!(s.read_aloud.document, Some(a), "nothing restarted in B"); + run(&mut s, "review.readAloud", json!({})); + assert_eq!(s.read_aloud.document, Some(b)); +} + +/// Tests that `view.switchWindows` lists open documents, activates by document ID, and requests switching by window ID. +#[test] +fn switch_windows_lists_and_activates() { + let mut s = s(); + let a = s.document_id(); + let b = s.add_document(wordcraft_doc::Document::new()); + let r = run(&mut s, "view.switchWindows", json!({})); + let list = r["documents"].as_array().unwrap(); + assert_eq!( + list.iter().map(|d| (d["id"].as_u64(), d["title"].as_str(), d["active"].as_bool())).collect::>(), + vec![(Some(a), Some("Document1"), Some(false)), (Some(b), Some("Document2"), Some(true))] + ); + assert!(list[0].get("path").is_some() && list[0].get("dirty").is_some()); + run(&mut s, "view.switchWindows", json!({"id": a})); + assert_eq!(s.document_id(), a); + s.ui_requests.clear(); + run(&mut s, "view.switchWindows", json!({"window": 1})); + assert_eq!(s.ui_requests, vec![json!({"windows": "switchWindow", "window": 1})]); + s.windows.available = true; + s.windows.current = Some(1); + s.windows.others = vec![crate::cmd::view::OtherWindow { id: 2, title: "Second".into() }]; + s.ui_requests.clear(); + run(&mut s, "view.switchWindows", json!({"window": 2})); + assert_eq!(s.ui_requests, vec![json!({"windows": "switchWindow", "window": 2})]); + assert!(s.run("view.switchWindows", &json!({"window": 3})).is_err(), "unknown window key fails when windows are available"); + for bad in [json!({"id": 0}), json!({"id": u64::MAX}), json!({"id": -5}), json!({"id": "1"}), json!({"id": 1e300}), json!({"id": {}})] { + assert!(s.run("view.switchWindows", &bad).is_err(), "{bad}"); + assert_eq!(s.document_id(), a); + } + for bad in [json!({"window": "1"}), json!({"window": -1}), json!({"window": {}})] { + assert!(s.run("view.switchWindows", &bad).is_err(), "{bad}"); + } + let junk = [json!(null), json!([]), json!({"id": null}), json!({"text": 5})]; + for id in ["view.switchWindows", "file.closeDocument", "file.new"] { + for j in &junk { + let _ = s.run(id, j); + s.clamp_selection(); + let _ = s.layout(); + } + } + assert!(!s.documents().is_empty()); +} + +#[test] +fn document_ids_are_unique_across_sessions_and_replacements() { + let mut one = s(); + let two = s(); + assert_ne!(one.document_id(), two.document_id()); + let before = one.document_id(); + one.set_document(wordcraft_doc::Document::new()); + assert_ne!(one.document_id(), before); + assert_eq!(ids(&one), vec![one.document_id()], "the replacement takes its place in the list"); +} + +/// A Start screen's stand-in document gives back its name: the first document a person makes is +/// `Document1`, the next `Document2`, and after closing the last one, New makes `Document2` +/// again only if no other document has taken it. +#[test] +fn a_stand_in_document_gives_back_its_untitled_name() { + let mut s = s(); + let placeholder = s.document_id(); + s.release_untitled_name(); + run(&mut s, "file.new", json!({})); + s.close_document(placeholder).unwrap(); + assert_eq!(s.documents()[0].title, "Document1"); + run(&mut s, "file.new", json!({})); + assert_eq!(s.documents()[1].title, "Document2"); + // A document with work keeps its name. + run(&mut s, "text.insert", json!({"text": "Work"})); + s.release_untitled_name(); + run(&mut s, "file.new", json!({})); + assert_eq!(s.documents()[2].title, "Document3"); +} + +/// Two windows on one document: each keeps its own caret and zoom; a caret inside text another +/// window deleted is clamped, so typing there lands at the end of what is left; Undo in either +/// window undoes the document's last edit, and typing in one window never joins the other's +/// undo step. +#[test] +fn two_views_of_one_document_keep_their_own_caret_and_share_undo() { + let mut s = s(); + run(&mut s, "text.insert", json!({"text": "Hello world"})); + let mut b = s.copy_view(); + let a_caret = s.sel.focus.clone(); + + // Window B: zoom in; its caret is after "world". + let mut a = s.take_view(); + s.put_view(b); + run(&mut s, "view.zoom", json!({"value": 150})); + b = s.take_view(); + s.put_view(a); + assert_eq!(s.view.zoom, 1.0, "window A keeps its zoom"); + + // Window A deletes " world", where B's caret is. + run(&mut s, "select.text", json!({"text": " world"})); + run(&mut s, "text.delete", json!({})); + assert_eq!(text(&s).trim(), "Hello"); + a = s.take_view(); + s.put_view(b); + assert!((s.view.zoom - 1.5).abs() < 1e-6, "window B keeps its zoom"); + assert_eq!(s.sel.focus.off, 5, "B's caret is clamped into what is left"); + run(&mut s, "text.insert", json!({"text": "!"})); + assert_eq!(text(&s).trim(), "Hello!"); + + // Undo in A undoes B's typing; Undo in B then undoes A's delete. + b = s.take_view(); + s.put_view(a); + run(&mut s, "edit.undo", json!({})); + assert_eq!(text(&s).trim(), "Hello"); + a = s.take_view(); + s.put_view(b); + run(&mut s, "edit.undo", json!({})); + assert_eq!(text(&s).trim(), "Hello world"); + + // Typing in A, then B, then A again: three undo steps. + b = s.take_view(); + s.put_view(a); + s.sel = crate::Selection::caret(a_caret); + run(&mut s, "text.insert", json!({"text": "x"})); + a = s.take_view(); + s.put_view(b); + run(&mut s, "text.insert", json!({"text": "y"})); + b = s.take_view(); + s.put_view(a); + run(&mut s, "text.insert", json!({"text": "z"})); + run(&mut s, "edit.undo", json!({})); + assert!(!text(&s).contains('z') && text(&s).contains('x') && text(&s).contains('y'), "{}", text(&s)); + drop(b); +} + +/// An input method's promise that the next insertion joins its replacement belongs to the +/// window that made it: another window on the document typing at the same place gets an undo +/// step of its own. +#[test] +fn input_method_joins_stay_with_their_window() { + let mut s = s(); + run(&mut s, "text.insert", json!({"text": "Hello"})); + s.join_next_ime_text_replacement(); + let b = s.copy_view(); + let a = s.take_view(); + s.put_view(b); + run(&mut s, "text.insert", json!({"text": "y"})); + run(&mut s, "edit.undo", json!({})); + assert_eq!(text(&s).trim(), "Hello", "B's typing is its own undo step"); + drop(a); +} + +/// Check spelling as you type is one setting for the app, not one per open document (#5740): +/// turned off (File › Options › Proofing, or Review's command) with one document active, it is off +/// in the others too, and switching to another document doesn't turn it back on, so what is saved +/// for the next run is what was chosen, whichever document is active then. +#[test] +fn check_spelling_is_one_setting_for_every_open_document() { + let mut s = s(); + let a = s.document_id(); + run(&mut s, "file.new", json!({})); + let b = s.document_id(); + run(&mut s, "file.new", json!({})); + let c = s.document_id(); + assert!(s.view.proofing); + s.activate(a).unwrap(); + run(&mut s, "file.options", json!({"checkSpelling": false})); + for id in [b, c, a] { + s.activate(id).unwrap(); + assert!(!s.view.proofing, "off in document {id}"); + assert!(!s.prefs.check_spelling); + } + run(&mut s, "review.proofing", json!({"value": true})); + for id in [b, c] { + s.activate(id).unwrap(); + assert!(s.view.proofing, "back on in document {id}"); + } + // A document opened now starts with the setting as it is. + run(&mut s, "review.proofing", json!({"value": false})); + run(&mut s, "file.new", json!({})); + assert!(!s.view.proofing); +} + +/// A window's view put back after Check spelling as you type changed in another window takes the +/// setting as it is now, not as it was when the window's view was taken (#5740): every view made +/// active follows the saved preference. +#[test] +fn a_window_view_put_back_follows_the_spelling_setting() { + let mut s = s(); + assert!(s.view.proofing); + let held = s.take_view(); + run(&mut s, "file.options", json!({"checkSpelling": false})); + s.put_view(held); + assert!(!s.view.proofing, "the view taken with spelling on comes back with it off"); + let held = s.take_view(); + run(&mut s, "review.proofing", json!({"value": true})); + s.put_view(held); + assert!(s.view.proofing); +} diff --git a/crates/engine/src/tests/find_apostrophes.rs b/crates/engine/src/tests/find_apostrophes.rs index c06bb381fe..7e4dac65e5 100644 --- a/crates/engine/src/tests/find_apostrophes.rs +++ b/crates/engine/src/tests/find_apostrophes.rs @@ -1,48 +1,48 @@ -use super::*; - -/// Keyboard smart quotes stay searchable, and replacement keeps their original spelling. -#[test] -fn keyboard_apostrophe_finds_smart_quoted_typing() { - let mut s = s(); - for ch in "Tau'va".chars() { - run(&mut s, "text.insert", json!({"text": ch.to_string()})); - } - assert_eq!(text(&s), "Tau’va"); - for query in ["Tau'", "Tau'v", "Tau'va", "tau'VA"] { - assert_eq!(run(&mut s, "edit.find", json!({"text": query}))["count"], 1, "{query}"); - } - assert_eq!(run(&mut s, "edit.find", json!({"text": "tau'va", "matchCase": true}))["count"], 0); - assert_eq!(run(&mut s, "edit.replaceAll", json!({"text": "Tau'va", "with": "[^&]", "matchCase": false}))["replaced"], 1); - assert_eq!(text(&s), "[Tau’va]"); - run(&mut s, "edit.undo", json!({})); - assert_eq!(text(&s), "Tau’va"); - run(&mut s, "edit.redo", json!({})); - assert_eq!(text(&s), "[Tau’va]"); -} - -/// Normal search uses raw UTF-8 ranges while explicit syntax and modifier letters stay exact. -#[test] -fn normal_apostrophe_search_keeps_exact_modes_and_original_byte_ranges() { - let mut s = s(); - let original = "🙂 Tau'va Tau’va Tau‘va Tauʼva"; - run(&mut s, "document.setText", json!({"text": original})); - let found = run(&mut s, "edit.find", json!({"text": "Tau'va", "wholeWord": true})); - assert_eq!(found["count"], 3); - let ranges: Vec<_> = - found["matches"].as_array().unwrap().iter().map(|m| (m["start"]["off"].as_u64().unwrap(), m["end"]["off"].as_u64().unwrap())).collect(); - assert_eq!(ranges, [(5, 11), (12, 20), (21, 29)]); - for options in [json!({"special": false}), json!({"wildcards": true}), json!({"regex": true})] { - let mut exact = super::s(); - run(&mut exact, "document.setText", json!({"text": original})); - let mut params = options; - params["text"] = json!("Tau'va"); - assert_eq!(run(&mut exact, "edit.find", params)["count"], 1); - } - for query in ["Tau^u39va", "Tau^u8217va", "Tau’va", "Tau‘va", "Tauʼva"] { - assert_eq!(run(&mut s, "edit.find", json!({"text": query}))["count"], 1, "{query}"); - } - assert_eq!(run(&mut s, "edit.replaceAll", json!({"text": "Tau'va", "with": "<^&>"}))["replaced"], 3); - assert_eq!(text(&s), "🙂 Tauʼva"); - run(&mut s, "edit.undo", json!({})); - assert_eq!(text(&s), original); -} +use super::*; + +/// Keyboard smart quotes stay searchable, and replacement keeps their original spelling. +#[test] +fn keyboard_apostrophe_finds_smart_quoted_typing() { + let mut s = s(); + for ch in "Tau'va".chars() { + run(&mut s, "text.insert", json!({"text": ch.to_string()})); + } + assert_eq!(text(&s), "Tau’va"); + for query in ["Tau'", "Tau'v", "Tau'va", "tau'VA"] { + assert_eq!(run(&mut s, "edit.find", json!({"text": query}))["count"], 1, "{query}"); + } + assert_eq!(run(&mut s, "edit.find", json!({"text": "tau'va", "matchCase": true}))["count"], 0); + assert_eq!(run(&mut s, "edit.replaceAll", json!({"text": "Tau'va", "with": "[^&]", "matchCase": false}))["replaced"], 1); + assert_eq!(text(&s), "[Tau’va]"); + run(&mut s, "edit.undo", json!({})); + assert_eq!(text(&s), "Tau’va"); + run(&mut s, "edit.redo", json!({})); + assert_eq!(text(&s), "[Tau’va]"); +} + +/// Normal search uses raw UTF-8 ranges while explicit syntax and modifier letters stay exact. +#[test] +fn normal_apostrophe_search_keeps_exact_modes_and_original_byte_ranges() { + let mut s = s(); + let original = "🙂 Tau'va Tau’va Tau‘va Tauʼva"; + run(&mut s, "document.setText", json!({"text": original})); + let found = run(&mut s, "edit.find", json!({"text": "Tau'va", "wholeWord": true})); + assert_eq!(found["count"], 3); + let ranges: Vec<_> = + found["matches"].as_array().unwrap().iter().map(|m| (m["start"]["off"].as_u64().unwrap(), m["end"]["off"].as_u64().unwrap())).collect(); + assert_eq!(ranges, [(5, 11), (12, 20), (21, 29)]); + for options in [json!({"special": false}), json!({"wildcards": true}), json!({"regex": true})] { + let mut exact = super::s(); + run(&mut exact, "document.setText", json!({"text": original})); + let mut params = options; + params["text"] = json!("Tau'va"); + assert_eq!(run(&mut exact, "edit.find", params)["count"], 1); + } + for query in ["Tau^u39va", "Tau^u8217va", "Tau’va", "Tau‘va", "Tauʼva"] { + assert_eq!(run(&mut s, "edit.find", json!({"text": query}))["count"], 1, "{query}"); + } + assert_eq!(run(&mut s, "edit.replaceAll", json!({"text": "Tau'va", "with": "<^&>"}))["replaced"], 3); + assert_eq!(text(&s), "🙂 Tauʼva"); + run(&mut s, "edit.undo", json!({})); + assert_eq!(text(&s), original); +} diff --git a/crates/engine/src/tests/fit_one_page.rs b/crates/engine/src/tests/fit_one_page.rs index a7458bf8a2..0e58feb43c 100644 --- a/crates/engine/src/tests/fit_one_page.rs +++ b/crates/engine/src/tests/fit_one_page.rs @@ -1,1150 +1,1150 @@ -use serde_json::json; -use wordcraft_doc::Document; -use wordcraft_doc::para::PAGE_BREAK; -use wordcraft_doc::props::CharProps; - -use crate::Session; - -/// Creates a document with `lines` lines of text. -fn create_multiline_doc(lines: usize) -> Session { - let mut text = String::new(); - for i in 1..=lines { - text.push_str(&format!("Line {i}: The quick brown fox jumps over the lazy dog.\n")); - } - Session::new(Document::from_text(&text)) -} - -#[test] -fn fit_one_page_fails_on_single_page() { - let mut s = create_multiline_doc(5); - let before = s.doc.clone(); - let res = s.run("layout.fitOnePage", &json!({})); - assert!(res.is_err()); - let err = res.unwrap_err().to_string(); - assert!(err.contains("already fits on one page"), "unexpected error: {err}"); - assert_eq!(s.doc, before, "document must not change on failure"); -} - -#[test] -fn fit_one_page_fails_on_hard_page_break() { - let text = format!("Page 1 content\n{PAGE_BREAK}Page 2 content\n"); - let mut s = Session::new(Document::from_text(&text)); - let before = s.doc.clone(); - let res = s.run("layout.fitOnePage", &json!({})); - assert!(res.is_err()); - let err = res.unwrap_err().to_string(); - assert!(err.contains("explicit page or section breaks"), "unexpected error: {err}"); - assert_eq!(s.doc, before, "document must not change on failure"); -} - -#[test] -fn fit_one_page_refuses_page_break_before_before_search() { - let mut s = create_multiline_doc(27); - assert_eq!(s.layout().pages.len(), 2); - if let wordcraft_doc::Block::Para(p) = std::sync::Arc::make_mut(&mut s.doc.body[1]) { - p.props.page_break_before = Some(true); - } - s.touch(); - s.relayout(); - assert_eq!(s.layout().pages.len(), 2); - let before = s.doc.clone(); - - let res = s.run("layout.fitOnePage", &json!({})); - assert!(res.is_err()); - let err = res.unwrap_err().to_string(); - assert!(err.contains("explicit page or section breaks"), "page_break_before must be reported as an explicit break: {err}"); - assert_eq!(s.doc, before, "document must not change on refusal"); -} - -#[test] -fn fit_one_page_fails_on_next_page_section_break() { - let mut s = Session::new(Document::from_text("First section body text")); - s.run("layout.break", &json!({"kind": "nextPage"})).unwrap(); - s.run("text.insert", &json!({"text": "Second section text on next page"})).unwrap(); - assert_eq!(s.layout().pages.len(), 2, "break must cause 2 pages"); - - let before = s.doc.clone(); - let res = s.run("layout.fitOnePage", &json!({})); - assert!(res.is_err()); - let err = res.unwrap_err().to_string(); - assert!(err.contains("explicit page or section breaks"), "unexpected error: {err}"); - assert_eq!(s.doc, before, "document must not change on failure"); -} - -#[test] -fn fit_one_page_condenses_slight_overflow_and_undoes() { - // Find the boundary where the document just spills onto page 2 - let mut lines = 30; - let mut s = create_multiline_doc(lines); - while s.layout().pages.len() < 2 && lines < 80 { - lines += 2; - s = create_multiline_doc(lines); - } - assert_eq!(s.layout().pages.len(), 2, "must start as a 2-page document with {lines} lines"); - - let doc_before = s.doc.clone(); - let res = s.run("layout.fitOnePage", &json!({})); - assert!(res.is_ok(), "fit should succeed on slight overflow: {res:?}"); - assert_eq!(s.layout().pages.len(), 1, "document must now fit on 1 page"); - assert!(s.status.contains("Document fitted to 1 page"), "status was: {}", s.status); - - // Test single atomic undo - let undo_res = s.run("edit.undo", &json!({})); - assert!(undo_res.is_ok(), "undo must succeed"); - assert_eq!(s.layout().pages.len(), 2, "undo must restore 2-page layout"); - assert_eq!(s.doc, doc_before, "undo must restore exact pre-fit document state"); - - // Test redo - let redo_res = s.run("edit.redo", &json!({})); - assert!(redo_res.is_ok(), "redo must succeed"); - assert_eq!(s.layout().pages.len(), 1, "redo must restore 1-page layout"); -} - -#[test] -fn fit_one_page_respects_max_delta() { - let mut s = create_multiline_doc(27); - assert_eq!(s.layout().pages.len(), 2, "must start as 2-page document"); - - let doc_backup = s.doc.clone(); - let ok_res = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.5, "adjustSpacing": false})); - assert!(ok_res.is_ok(), "must succeed when positive font delta 0.5 is permitted: {ok_res:?}"); - assert_eq!(s.layout().pages.len(), 1); - - s.doc = doc_backup; - let res = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.0, "adjustSpacing": false})); - assert!(res.is_err(), "must fail when font delta is capped at 0.0 and spacing is disabled"); -} - -#[test] -fn fit_one_page_respects_step_pt() { - let mut text = String::new(); - for i in 1..=28 { - text.push_str(&format!("Line {i}: The quick brown fox jumps over the lazy dog.\n")); - } - let mut s = Session::new(Document::from_text(&text)); - for b in &mut s.doc.body { - if let wordcraft_doc::Block::Para(p) = std::sync::Arc::make_mut(b) { - p.mark.size = Some(11.4); - for r in &mut p.runs { - r.props.size = Some(11.4); - } - } - } - assert_eq!(s.layout().pages.len(), 2, "must start as 2-page document at 11.4 pt"); - - // Both calls hold maxDeltaPt constant at 0.4 and disable spacing adjustments. - // Default 0.5 pt step generates no candidates <= 0.4 pt, so it must fail. - let doc_backup = s.doc.clone(); - let default_res = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.4, "adjustSpacing": false})); - assert!(default_res.is_err(), "default step 0.5 must skip all candidates when maxDelta is 0.4"); - - // With stepPt = 0.1 under the same maxDeltaPt = 0.4, fine-grained steps (0.1..0.4) succeed (11.4 -> 11.0 pt). - s.doc = doc_backup; - let res = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.4, "stepPt": 0.1, "adjustSpacing": false})); - assert!(res.is_ok(), "stepPt=0.1 must generate fine-grained candidates under maxDelta 0.4: {res:?}"); - assert_eq!(s.layout().pages.len(), 1); -} - -#[test] -fn fit_one_page_allows_spacing_only_at_font_floor() { - let mut lines = 35; - let mut s = create_multiline_doc(lines); - let set_font = |s: &mut Session| { - for b in &mut s.doc.body { - if let wordcraft_doc::Block::Para(p) = std::sync::Arc::make_mut(b) { - for r in &mut p.runs { - r.props = CharProps { size: Some(8.5), ..Default::default() }; - } - } - } - s.touch(); - s.relayout(); - }; - set_font(&mut s); - while s.layout().pages.len() < 2 && lines < 120 { - lines += 1; - s = create_multiline_doc(lines); - set_font(&mut s); - } - assert_eq!(s.layout().pages.len(), 2); - - // Spacing-only candidate must be permitted to run and succeed - let res = s.run("layout.fitOnePage", &json!({})); - assert!(res.is_ok(), "spacing-only candidate must succeed at font floor: {res:?}"); - assert_eq!(s.layout().pages.len(), 1); - assert!(s.status.contains("spacing: "), "status: {}", s.status); - assert!(!s.status.contains("font: -"), "font must remain untouched: {}", s.status); -} - -#[test] -fn fit_one_page_fails_when_body_font_already_at_minimum_readable_size() { - let mut lines = 35; - let mut s = create_multiline_doc(lines); - let set_font = |s: &mut Session| { - for b in &mut s.doc.body { - if let wordcraft_doc::Block::Para(p) = std::sync::Arc::make_mut(b) { - for r in &mut p.runs { - r.props = CharProps { size: Some(8.0), ..Default::default() }; - } - } - } - s.touch(); - s.relayout(); - }; - set_font(&mut s); - while s.layout().pages.len() < 2 && lines < 120 { - lines += 1; - s = create_multiline_doc(lines); - set_font(&mut s); - } - assert_eq!(s.layout().pages.len(), 2); - - // When spacing adjustment is disabled, no candidate can run without reducing font - let res = s.run("layout.fitOnePage", &json!({"adjustSpacing": false})); - assert!(res.is_err(), "must refuse to shrink font when already <= 8.5 pt"); - let err = res.unwrap_err().to_string(); - assert!(err.contains("Unable to shrink document further without reducing text beyond readability limits."), "unexpected error message: {err}"); -} - -#[test] -fn fit_one_page_bounds_fine_step_loop() { - let mut lines = 28; - let mut s = create_multiline_doc(lines); - while s.layout().pages.len() < 2 && lines < 80 { - lines += 1; - s = create_multiline_doc(lines); - } - assert_eq!(s.layout().pages.len(), 2, "fixture must reach candidate generation on 2 pages"); - - // With stepPt = 0.001 and maxDeltaPt = 2.0 (ratio = 2000), fine-step candidate count - // is clamped by MAX_FINE_STEPS (20), executing quickly without doing thousands of iterations. - let res = s.run("layout.fitOnePage", &json!({"stepPt": 0.001, "maxDeltaPt": 2.0, "adjustSpacing": false})); - assert!(res.is_ok(), "fitOnePage with bounded fine steps succeeds or terminates gracefully: {res:?}"); -} - -#[test] -fn fit_one_page_supports_max_delta_above_2pt() { - let mut lines = 28; - let mut s = create_multiline_doc(lines); - while s.layout().pages.len() < 2 && lines < 80 { - lines += 1; - s = create_multiline_doc(lines); - } - // Add 8 lines of spill (within the 12-line spill limit) - for i in 1..=8 { - s.run("text.insert", &json!({"text": format!("Spill paragraph line {i}\n")})).unwrap(); - } - assert_eq!(s.layout().pages.len(), 2); - - // With maxDeltaPt = 3.0, candidate above 2 pt fits the document - let res = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 3.0})); - assert!(res.is_ok(), "fitOnePage with maxDeltaPt=3.0 must succeed: {res:?}"); - assert_eq!(s.layout().pages.len(), 1, "must fit on 1 page"); -} - -#[test] -fn fit_one_page_anti_compound_guard_prevents_repeated_invocations() { - let mut lines = 28; - let mut s = create_multiline_doc(lines); - loop { - for b in &mut s.doc.body { - if let wordcraft_doc::Block::Para(p) = std::sync::Arc::make_mut(b) { - p.props.line_spacing = Some(wordcraft_doc::props::LineSpacing::Multiple(0.94)); - for r in &mut p.runs { - r.props.size = Some(11.0); - } - } - } - s.touch(); - s.relayout(); - if s.layout().pages.len() >= 2 || lines >= 80 { - break; - } - lines += 1; - s = create_multiline_doc(lines); - } - assert_eq!(s.layout().pages.len(), 2); - - // Simulate a document that already used the full 1.0 pt font and 0.94 spacing budgets. - s.doc.fit_state = Some(wordcraft_doc::FitState { - cum_delta: 1.0, - cum_space: 0.94, - original_font_sizes: vec![12.0], - original_line_spacings: vec![wordcraft_doc::props::LineSpacing::Multiple(1.0)], - ..Default::default() - }); - s.touch(); - s.relayout(); - let doc_before = s.doc.clone(); - - let res = s.run("layout.fitOnePage", &json!({})); - assert!(res.is_err(), "exhausted budgets must be rejected"); - let err = res.unwrap_err().to_string(); - assert!(err.contains("Unable to shrink document further without reducing text beyond readability limits."), "unexpected error message: {err}"); - assert_eq!(s.doc, doc_before, "document must not be mutated on rejected invocation"); -} - -#[test] -fn fit_one_page_already_fits_wins_over_exhausted_budget() { - let mut s = create_multiline_doc(5); - assert_eq!(s.layout().pages.len(), 1); - for b in &mut s.doc.body { - if let wordcraft_doc::Block::Para(p) = std::sync::Arc::make_mut(b) { - p.props.line_spacing = Some(wordcraft_doc::props::LineSpacing::Multiple(0.94)); - for r in &mut p.runs { - r.props.size = Some(11.0); - } - } - } - s.doc.fit_state = Some(wordcraft_doc::FitState { - cum_delta: 1.0, - cum_space: 0.94, - original_font_sizes: vec![12.0], - original_line_spacings: vec![wordcraft_doc::props::LineSpacing::Multiple(1.0)], - ..Default::default() - }); - s.touch(); - s.relayout(); - - let res = s.run("layout.fitOnePage", &json!({})); - assert!(res.is_err()); - let err = res.unwrap_err().to_string(); - assert!(err.contains("already fits on one page"), "already-fits must take precedence over exhausted budgets: {err}"); -} - -#[test] -fn fit_one_page_checks_complex_script_font_floor() { - let mut lines = 28; - let mut s = create_multiline_doc(lines); - while s.layout().pages.len() < 2 && lines < 80 { - lines += 1; - s = create_multiline_doc(lines); - } - assert_eq!(s.layout().pages.len(), 2); - - // Insert an Arabic run with size 12.0 pt but size_cs 9.0 pt - let arabic_p = wordcraft_doc::Paragraph::with_text( - "نص تجريبي باللغة العربية", - CharProps { size: Some(12.0), size_cs: Some(9.0), cs: Some(true), ..Default::default() }, - ); - s.doc.body.insert(0, wordcraft_doc::para_block(arabic_p)); - assert_eq!(s.layout().pages.len(), 2); - - // A 1.0 pt reduction would reduce size_cs from 9.0 to 8.0 (< 8.5 pt floor). - // The trial must not commit. - let _ = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 1.0, "adjustSpacing": false})); - let p = s.doc.body[0].as_para().unwrap(); - let cs_size = p.runs[0].props.size_cs.unwrap_or(9.0); - assert!(cs_size >= 8.5, "complex-script size must remain >= 8.5 pt, was {cs_size}"); -} - -#[test] -fn fit_one_page_checks_editable_text_box_font_floor() { - let mut lines = 28; - let mut s = create_multiline_doc(lines); - while s.layout().pages.len() < 2 && lines < 80 { - lines += 1; - s = create_multiline_doc(lines); - } - assert_eq!(s.layout().pages.len(), 2); - - // Add a body-anchored text box with 9.0 pt font - let box_id = s.doc.add_part( - wordcraft_doc::PartKind::TextBox, - vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text( - "Text box body text", - CharProps { size: Some(9.0), ..Default::default() }, - ))], - ); - // Link it into body - let mut shape_p = wordcraft_doc::Paragraph::with_text("Before box ", CharProps::default()); - let shape = wordcraft_doc::InlineObject::Shape { - kind: wordcraft_doc::para::ShapeKind::TextBox, - w: 100.0, - h: 50.0, - fill: None, - stroke: None, - stroke_width: 0.5, - float: Default::default(), - story: Some(box_id), - freeform: None, - effects: Default::default(), - extra: Default::default(), - }; - shape_p.objects.push(shape); - s.doc.body.insert(0, wordcraft_doc::para_block(shape_p)); - assert_eq!(s.layout().pages.len(), 2); - - // With maxDeltaPt = 1.0 and adjustSpacing = false, 9.0 pt would reduce to 8.0 pt. - // The trial must not be committed. - let _ = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 1.0, "adjustSpacing": false})); - let part = s.doc.parts.get(&box_id).unwrap(); - let box_p = part.blocks[0].as_para().unwrap(); - let sz = box_p.runs[0].props.size.unwrap(); - assert!(sz >= 8.5, "editable text box font must remain >= 8.5 pt, was {sz}"); -} - -#[test] -fn fit_one_page_fails_when_overflow_exceeds_limits() { - let mut lines = 28; - let mut s = create_multiline_doc(lines); - while s.layout().pages.len() < 2 { - lines += 1; - s = create_multiline_doc(lines); - } - // Add 16 extra lines to page 2: this overflow is too large to fit into page 1 with maxDeltaPt = 0.5 - for i in 1..=16 { - s.run("text.insert", &json!({"text": format!("Overflow line {i}\n")})).unwrap(); - } - assert_eq!(s.layout().pages.len(), 2, "must be a 2-page document"); - - let res = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.5})); - assert!(res.is_err(), "must refuse when overflow exceeds aesthetic reduction limits"); - let err = res.unwrap_err().to_string(); - assert!(err.contains("exceeds limits for aesthetic page reduction"), "unexpected error message: {err}"); -} - -#[test] -fn fit_one_page_validates_effective_font_in_tables_before_committing() { - let mut s = Session::new(Document::new()); - let mut table = wordcraft_doc::Table::new(1, 1, 400.0); - let cell_p = wordcraft_doc::Paragraph::with_text("Cell text", CharProps { size: Some(9.0), ..Default::default() }); - table.rows[0].cells[0].blocks = vec![wordcraft_doc::para_block(cell_p)]; - s.doc.body = vec![std::sync::Arc::new(wordcraft_doc::Block::Table(table))]; - - let mut text = String::new(); - for i in 1..=45 { - text.push_str(&format!("Line {i} following table: The quick brown fox jumps over the lazy dog.\n")); - } - s.doc.body.push(wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(&text, CharProps::default()))); - assert_eq!(s.layout().pages.len(), 2); - - // If maxDeltaPt is 1.0, 9.0 pt would reduce to 8.0 pt (violating 8.5 pt floor). - // The candidate must not be committed. - let _ = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 1.0, "adjustSpacing": false})); - if let Some(tbl) = s.doc.body[0].as_table() { - let p = tbl.rows[0].cells[0].blocks[0].as_para().unwrap(); - let sz = p.runs[0].props.size.unwrap(); - assert!(sz >= 8.5, "table cell font size must remain >= 8.5 pt, was {sz}"); - } -} - -#[test] -fn fit_one_page_validates_effective_font_in_table_styles_before_committing() { - let mut s = Session::new(Document::new()); - // Create a custom table style with 9.0 pt font - let st = wordcraft_doc::styles::Style { - id: "CustomTableStyle".into(), - name: "CustomTableStyle".into(), - kind: wordcraft_doc::styles::StyleKind::Table, - chr: CharProps { size: Some(9.0), ..Default::default() }, - table: Some(wordcraft_doc::styles::TableStyleParts::default()), - ..Default::default() - }; - std::sync::Arc::make_mut(&mut s.doc.styles).upsert(st); - - let mut table = wordcraft_doc::Table::new(1, 1, 400.0); - table.props.style = Some("CustomTableStyle".to_string()); - let cell_p = wordcraft_doc::Paragraph::with_text("Cell text inheriting table style", CharProps::default()); - table.rows[0].cells[0].blocks = vec![wordcraft_doc::para_block(cell_p)]; - s.doc.body = vec![std::sync::Arc::new(wordcraft_doc::Block::Table(table))]; - - let mut text = String::new(); - for i in 1..=45 { - text.push_str(&format!("Line {i} following table: The quick brown fox jumps over the lazy dog.\n")); - } - s.doc.body.push(wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(&text, CharProps::default()))); - assert_eq!(s.layout().pages.len(), 2); - - // If maxDeltaPt is 1.0, 9.0 pt in the table style would reduce to 8.0 pt (violating 8.5 pt floor). - // The candidate must not be committed. - let _ = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 1.0, "adjustSpacing": false})); - let style = s.doc.styles.table_style("CustomTableStyle").unwrap(); - let sz = style.chr.size.unwrap(); - assert!(sz >= 8.5, "table style font size must remain >= 8.5 pt, was {sz}"); -} - -#[test] -fn fit_one_page_measures_only_final_page_placed_lines() { - let mut s = Session::new(Document::new()); - let words = "The quick brown fox jumps over the lazy dog and runs across the wide open fields in the sunshine. "; - let mut reps = 20; - loop { - let text = words.repeat(reps); - s.doc.body = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(&text, CharProps::default()))]; - s.touch(); - s.relayout(); - if s.layout().pages.len() >= 2 { - break; - } - reps += 1; - } - assert_eq!(s.layout().pages.len(), 2); - - let res = s.run("layout.fitOnePage", &json!({})); - assert!(res.is_ok(), "fitOnePage must succeed when single paragraph slightly spills onto page 2: {res:?}"); - assert_eq!(s.layout().pages.len(), 1); -} - -#[test] -fn fit_one_page_exempts_style_inherited_superscript() { - let mut s = Session::new(Document::new()); - // Create a Character style with Superscript alignment and 7.0 pt font - let st = wordcraft_doc::styles::Style { - id: "SuperStyle".into(), - name: "SuperStyle".into(), - kind: wordcraft_doc::styles::StyleKind::Character, - chr: CharProps { vert_align: Some(wordcraft_doc::props::VertAlign::Superscript), size: Some(7.0), ..Default::default() }, - ..Default::default() - }; - std::sync::Arc::make_mut(&mut s.doc.styles).upsert(st); - - let words = "Regular readable text that fills up lines in the document body. "; - let mut reps = 20; - loop { - let mut p = wordcraft_doc::Paragraph::with_text(&words.repeat(reps), CharProps { size: Some(11.0), ..Default::default() }); - // Add a run with style-inherited superscript - p.runs.push(wordcraft_doc::Run { len: 3, props: CharProps { style: Some("SuperStyle".into()), ..Default::default() } }); - p.text.push_str("123"); - s.doc.body = vec![wordcraft_doc::para_block(p)]; - s.touch(); - s.relayout(); - if s.layout().pages.len() >= 2 { - break; - } - reps += 1; - } - assert_eq!(s.layout().pages.len(), 2); - - // fitOnePage must not be blocked by the 7.0 pt superscript run because it is exempt - let res = s.run("layout.fitOnePage", &json!({})); - assert!(res.is_ok(), "style-inherited superscript must be exempt from font floor: {res:?}"); - assert_eq!(s.layout().pages.len(), 1); -} - -#[test] -fn fit_one_page_refused_when_protect_formatting_enabled() { - let mut s = Session::new(Document::new()); - s.doc.settings.protection = Some("none".into()); - s.doc.settings.protect_formatting = true; - let res = s.run("layout.fitOnePage", &json!({})); - assert!(res.is_err()); - let err = res.unwrap_err().to_string(); - assert!(err.contains("formatting is limited to the document's styles"), "unexpected error: {err}"); -} - -#[test] -fn fit_one_page_zero_max_delta_never_reduces_font() { - let mut s = Session::new(Document::new()); - let words = "Content line with fractional font size for zero delta testing. "; - let mut reps = 20; - loop { - let text = words.repeat(reps); - s.doc.body = - vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(&text, CharProps { size: Some(10.3), ..Default::default() }))]; - s.touch(); - s.relayout(); - if s.layout().pages.len() >= 2 { - break; - } - reps += 1; - } - assert_eq!(s.layout().pages.len(), 2); - - // Call fitOnePage with maxDeltaPt = 0.0 and a tiny stepPt (which should not produce any positive candidate) - let _ = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.0, "stepPt": 0.001})); - let p = s.doc.body[0].as_para().unwrap(); - assert_eq!(p.runs[0].props.size, Some(10.3), "font size must remain completely unchanged when maxDeltaPt is 0"); -} - -#[test] -fn fit_one_page_fails_with_failed_cmd_error() { - let mut s = create_multiline_doc(5); - let res = s.run("layout.fitOnePage", &json!({})); - assert!(matches!(res, Err(crate::CmdError::Failed(_))), "expected CmdError::Failed, got {res:?}"); -} - -#[test] -fn fit_one_page_never_populates_custom_props() { - let mut lines = 30; - let mut s = create_multiline_doc(lines); - while s.layout().pages.len() < 2 && lines < 80 { - lines += 2; - s = create_multiline_doc(lines); - } - assert!(s.doc.custom_props.is_empty()); - let res = s.run("layout.fitOnePage", &json!({})); - assert!(res.is_ok()); - assert!(s.doc.custom_props.is_empty(), "custom_props must not be populated with internal fit state"); -} - -#[test] -fn fit_one_page_allows_subsequent_fit_after_typography_reset() { - use std::sync::Arc; - - let mut lines = 28; - let mut s = create_multiline_doc(lines); - while s.layout().pages.len() < 2 && lines < 80 { - lines += 1; - s = create_multiline_doc(lines); - } - assert_eq!(s.layout().pages.len(), 2); - - // First invocation succeeds - let res1 = s.run("layout.fitOnePage", &json!({})); - assert!(res1.is_ok()); - assert_eq!(s.layout().pages.len(), 1); - - // Reset styles back to Normal with 12 pt font and 1.15 line spacing - if let Some(st) = Arc::make_mut(&mut s.doc.styles).styles.iter_mut().find(|st| st.id == "Normal") { - st.chr.size = Some(12.0); - st.para.line_spacing = Some(wordcraft_doc::props::LineSpacing::Multiple(1.15)); - } - for b in &mut s.doc.body { - if let wordcraft_doc::Block::Para(p) = Arc::make_mut(b) { - p.props.line_spacing = None; - for r in &mut p.runs { - r.props.size = None; - } - } - } - s.touch(); - s.relayout(); - - // Now add lines to make it 2 pages again - while s.layout().pages.len() < 2 { - s.run("text.insert", &json!({"text": "Additional fresh paragraph after style reset.\n"})).unwrap(); - } - assert_eq!(s.layout().pages.len(), 2); - - // A subsequent fitOnePage run must NOT be permanently blocked and should succeed - let res2 = s.run("layout.fitOnePage", &json!({})); - assert!(res2.is_ok(), "fitOnePage must succeed after typography reset: {res2:?}"); - assert_eq!(s.layout().pages.len(), 1); -} - -#[test] -fn fit_one_page_small_paragraph_mark_with_runs_never_blocks_fit() { - let mut lines = 30; - let mut s = create_multiline_doc(lines); - while s.layout().pages.len() < 2 && lines < 80 { - lines += 2; - s = create_multiline_doc(lines); - } - // Set a 1.0 pt paragraph mark on a body paragraph that has normal 11 pt text runs - if let wordcraft_doc::Block::Para(p) = std::sync::Arc::make_mut(&mut s.doc.body[0]) { - p.mark.size = Some(1.0); - } - s.touch(); - s.relayout(); - assert_eq!(s.layout().pages.len(), 2); - - let res = s.run("layout.fitOnePage", &json!({})); - assert!(res.is_ok(), "small mark with normal text runs must not block fit: {res:?}"); - assert_eq!(s.layout().pages.len(), 1); -} - -#[test] -fn fit_one_page_small_comment_font_never_blocks_body_fit() { - let mut lines = 30; - let mut s = create_multiline_doc(lines); - while s.layout().pages.len() < 2 && lines < 80 { - lines += 2; - s = create_multiline_doc(lines); - } - // Add a comment part with 6.0 pt text - let comment_p = wordcraft_doc::Paragraph::with_text("Small comment text", CharProps { size: Some(6.0), ..Default::default() }); - s.doc.add_part(wordcraft_doc::PartKind::Comment, vec![wordcraft_doc::para_block(comment_p)]); - s.touch(); - s.relayout(); - assert_eq!(s.layout().pages.len(), 2); - - let res = s.run("layout.fitOnePage", &json!({})); - assert!(res.is_ok(), "small comment font must not block body fit: {res:?}"); - assert_eq!(s.layout().pages.len(), 1); -} - -#[test] -fn fit_one_page_anti_compound_caps_total_reduction_across_runs() { - let mut s = create_multiline_doc(27); - assert_eq!(s.layout().pages.len(), 2); - - // Initial fit with -0.5 pt font reduction (spacing disabled so font budget is exercised) - let res1 = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.5, "adjustSpacing": false})); - assert!(res1.is_ok()); - assert_eq!(s.layout().pages.len(), 1); - assert_eq!(s.doc.fit_state.as_ref().map(|f| f.cum_delta), Some(0.5)); - - // Add content to spill to 2 pages again - for i in 1..=12 { - s.run("text.insert", &json!({"text": format!("Spill paragraph line {i}\n")})).unwrap(); - } - assert_eq!(s.layout().pages.len(), 2); - - // Candidate requiring > 0.5 pt more (e.g. total > 1.0 pt) must be rejected - let res2 = s.run("layout.fitOnePage", &json!({})); - assert!(res2.is_err(), "second run must not exceed cumulative 1.0 pt reduction budget"); -} - -#[test] -fn fit_one_page_tracks_reduced_text_provenance_without_global_minimum() { - let mut s = create_multiline_doc(27); - // Add a 9.0 pt run to the first paragraph alongside 11.0 pt runs - if let wordcraft_doc::Block::Para(p) = std::sync::Arc::make_mut(&mut s.doc.body[0]) { - p.runs.push(wordcraft_doc::para::Run { len: 5, props: CharProps { size: Some(9.0), ..Default::default() } }); - p.text.push_str("extra"); - } - s.touch(); - s.relayout(); - assert_eq!(s.layout().pages.len(), 2); - - // Initial fit with -0.5 pt: 9.0 pt -> 8.5 pt, 11.0 pt -> 10.5 pt (spacing disabled) - let res1 = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.5, "adjustSpacing": false})); - assert!(res1.is_ok()); - assert_eq!(s.layout().pages.len(), 1); - assert_eq!(s.doc.fit_state.as_ref().map(|f| f.cum_delta), Some(0.5)); - - // Reset only the 8.5 pt run back to 12.0 pt (so current global minimum font becomes 10.5 pt) - if let wordcraft_doc::Block::Para(p) = std::sync::Arc::make_mut(&mut s.doc.body[0]) { - p.runs.last_mut().unwrap().props.size = Some(12.0); - } - s.touch(); - s.relayout(); - - // Add content to spill to 2 pages again - for i in 1..=12 { - s.run("text.insert", &json!({"text": format!("Spill paragraph line {i}\n")})).unwrap(); - } - assert_eq!(s.layout().pages.len(), 2); - - // Because the 10.5 pt runs are still reduced, fit state must NOT be discarded, - // and a candidate requiring > 0.5 pt more font reduction must be rejected by the 1.0 pt cumulative cap - let res2 = s.run("layout.fitOnePage", &json!({})); - assert!(res2.is_err(), "cumulative budget must still hold even when the original minimum font was changed"); -} - -#[test] -fn fit_one_page_allows_spill_with_bottom_placed_footnote() { - let mut lines = 28; - let mut s = create_multiline_doc(lines); - while s.layout().pages.len() < 2 && lines < 80 { - lines += 1; - s = create_multiline_doc(lines); - } - assert_eq!(s.layout().pages.len(), 2); - - // Add a single-line footnote to the document (reference on page 1) - let note_p = wordcraft_doc::Paragraph::with_text("Short footnote text.", CharProps::default()); - let note_id = s.doc.add_part(wordcraft_doc::PartKind::Footnote, vec![wordcraft_doc::para_block(note_p)]); - let noteref = wordcraft_doc::para::InlineObject::NoteRef { kind: wordcraft_doc::para::NoteKind::Footnote, id: note_id, custom: String::new() }; - s.doc.insert_object(&wordcraft_doc::Pos::body(0, 5), noteref, &Default::default()).unwrap(); - s.touch(); - s.relayout(); - assert_eq!(s.layout().pages.len(), 2); - - // Even though the footnote is placed at the bottom of the page (large y), - // fill_ratio must measure only actual content height so fitOnePage is not falsely refused - let res = s.run("layout.fitOnePage", &json!({})); - assert!(res.is_ok(), "bottom-placed footnote must not drive fill ratio above 30%: {res:?}"); - assert_eq!(s.layout().pages.len(), 1); -} - -#[test] -fn fit_one_page_with_final_page_footnote() { - let mut lines = 28; - let mut s = create_multiline_doc(lines); - while s.layout().pages.len() < 2 && lines < 80 { - lines += 1; - s = create_multiline_doc(lines); - } - assert_eq!(s.layout().pages.len(), 2); - - // Insert note reference into a paragraph on the final page (page 2) - let last_para_idx = s.doc.body.len().saturating_sub(1); - let note_p = wordcraft_doc::Paragraph::with_text("Final page footnote text.", CharProps::default()); - let note_id = s.doc.add_part(wordcraft_doc::PartKind::Footnote, vec![wordcraft_doc::para_block(note_p)]); - let noteref = wordcraft_doc::para::InlineObject::NoteRef { kind: wordcraft_doc::para::NoteKind::Footnote, id: note_id, custom: String::new() }; - s.doc.insert_object(&wordcraft_doc::Pos::body(last_para_idx, 0), noteref, &Default::default()).unwrap(); - s.touch(); - s.relayout(); - assert_eq!(s.layout().pages.len(), 2); - - // Verify footnote is assigned to the final page (page index 1) - let page2_has_footnote = s.layout().pages[1] - .items - .iter() - .any(|item| matches!(item, wordcraft_layout::Placed::Lines { story: wordcraft_doc::StoryRef::Part(id), .. } if *id == note_id)); - assert!(page2_has_footnote, "footnote must be assigned to page 2"); - - // With modest spill and small footnote within the 30% fill threshold, fitOnePage succeeds - let res = s.run("layout.fitOnePage", &json!({})); - assert!(res.is_ok(), "final-page footnote within 30% fill limit must allow fitOnePage to succeed: {res:?}"); - assert_eq!(s.layout().pages.len(), 1); -} - -#[test] -fn fit_one_page_skips_deleted_text_when_markup_hidden() { - let mut s = create_multiline_doc(27); - assert_eq!(s.layout().pages.len(), 2); - - // Insert a deleted text run with small font (e.g. 6.0 pt, below the 8.5 pt floor) - if let wordcraft_doc::Block::Para(p) = std::sync::Arc::make_mut(&mut s.doc.body[0]) { - let mut del_props = CharProps { size: Some(6.0), ..Default::default() }; - del_props.del = Some(1); - p.runs.push(wordcraft_doc::para::Run { len: 8, props: del_props }); - p.text.push_str("deleted!"); - } - s.touch(); - s.relayout(); - - // When show_markup is false (the default in print view), deleted run is hidden - // and skipped during font-floor scan, allowing fitOnePage to succeed - s.view.show_markup = false; - let doc_backup = s.doc.clone(); - let res = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.5, "adjustSpacing": false})); - assert!(res.is_ok(), "deleted text below floor must be skipped when markup is hidden: {res:?}"); - assert_eq!(s.layout().pages.len(), 1); - - // When show_markup is true, deleted run is drawn and evaluated in font-floor scan, - // blocking font reduction because effective font is below 8.5 pt floor - s.doc = doc_backup; - s.view.show_markup = true; - let res_markup = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.5, "adjustSpacing": false})); - assert!(res_markup.is_err(), "deleted text below floor must block font reduction when markup is shown"); -} - -#[test] -fn fit_one_page_tracks_reduced_complex_script_font_sizes() { - let mut s = create_multiline_doc(27); - assert_eq!(s.layout().pages.len(), 2); - let styles_before = s.doc.styles.clone(); - - // Set complex script font size on body run while keeping standard font size default (12 pt) - if let wordcraft_doc::Block::Para(p) = std::sync::Arc::make_mut(&mut s.doc.body[0]) { - p.runs[0].props.size_cs = Some(14.0); - } - s.touch(); - s.relayout(); - - // First fit reduces font size by 0.5 pt (size -> 11.5, size_cs -> 13.5) - let res = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.5, "adjustSpacing": false})); - assert!(res.is_ok(), "first fit must succeed: {res:?}"); - assert_eq!(s.doc.fit_state.as_ref().map(|f| f.cum_delta), Some(0.5)); - - // User restores every other size (the styles the fit rescaled), leaving only this run's - // explicit complex-script size reduced at 13.5 pt. - s.doc.styles = styles_before; - s.touch(); - s.relayout(); - assert_eq!(s.layout().pages.len(), 2); - - // Because size_cs is still reduced, cumulative tracking retains the 0.5 pt delta provenance - assert_eq!(s.doc.fit_state.as_ref().map(|f| f.cum_delta), Some(0.5)); - - // Second fit within remaining 0.5 pt budget succeeds up to 1.0 pt cap - let res2 = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.5, "adjustSpacing": false})); - assert!(res2.is_ok(), "second fit within remaining budget must succeed: {res2:?}"); - assert_eq!(s.doc.fit_state.as_ref().map(|f| f.cum_delta), Some(1.0)); - - // Third fit exceeding cumulative 1.0 limit must be refused - let res3 = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.5, "adjustSpacing": false})); - assert!(res3.is_err(), "fit exceeding cumulative limit must be refused"); -} - -#[test] -fn fit_one_page_preserves_budget_across_document_serialization() { - let mut s = create_multiline_doc(27); - assert_eq!(s.layout().pages.len(), 2); - - // Apply fit - let res = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.5, "adjustSpacing": false})); - assert!(res.is_ok(), "fit must succeed: {res:?}"); - assert_eq!(s.doc.fit_state.as_ref().map(|f| f.cum_delta), Some(0.5)); - - // Serialize and deserialize document (e.g. saving and reopening) - let json_bytes = serde_json::to_vec(&s.doc).expect("serialization must succeed"); - let reopened_doc: wordcraft_doc::Document = serde_json::from_slice(&json_bytes).expect("deserialization must succeed"); - - // Verify fit_state is preserved outside custom properties - assert_eq!(reopened_doc.fit_state.as_ref().map(|f| f.cum_delta), Some(0.5)); - assert!(reopened_doc.custom_props.is_empty(), "custom_props must remain empty"); - - // Reopen into a new session with additional spill - let mut s2 = Session::new(reopened_doc); - let extra_p = wordcraft_doc::Paragraph::with_text("Extra overflow paragraph.", CharProps::default()); - s2.doc.body.push(wordcraft_doc::para_block(extra_p)); - s2.touch(); - s2.relayout(); - - // Second fit on reopened document preserves previous 0.5 delta, allowing only remaining 0.5 pt up to 1.0 cap - let res2 = s2.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.5, "adjustSpacing": false})); - assert!(res2.is_ok(), "second fit within remaining budget must succeed"); - assert_eq!(s2.doc.fit_state.as_ref().map(|f| f.cum_delta), Some(1.0)); - - // Third fit exceeding the cumulative 1.0 limit must be refused - let res3 = s2.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.5, "adjustSpacing": false})); - assert!(res3.is_err(), "fit exceeding cumulative limit must be refused"); -} - -#[test] -fn fit_one_page_enforces_durable_limit_when_fit_state_missing() { - // A document with body font already at 8.5 pt readability floor and fit_state == None - let set_font = |s: &mut Session| { - for b in &mut s.doc.body { - if let wordcraft_doc::Block::Para(p) = std::sync::Arc::make_mut(b) { - for r in &mut p.runs { - r.props.size = Some(8.5); - } - } - } - s.touch(); - s.relayout(); - }; - let mut lines = 27; - let mut s = create_multiline_doc(lines); - set_font(&mut s); - while s.layout().pages.len() < 2 && lines < 120 { - lines += 1; - s = create_multiline_doc(lines); - set_font(&mut s); - } - assert_eq!(s.layout().pages.len(), 2); - s.doc.fit_state = None; - s.touch(); - s.relayout(); - let before = s.doc.clone(); - - // With adjustSpacing = false, font reduction is bounded by the durable 8.5 pt floor and refused - let res = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.5, "adjustSpacing": false})); - assert!(res.is_err(), "fit must be refused when typography is at durable readability floor"); - let err = res.unwrap_err().to_string(); - assert!(err.contains("Unable to shrink document further without reducing text beyond readability limits."), "unexpected error: {err}"); - assert_eq!(s.doc, before, "a refused fit must leave the document unchanged"); -} - -#[test] -fn fit_one_page_skips_hidden_text_when_hidden_marks_off() { - let mut s = create_multiline_doc(27); - assert_eq!(s.layout().pages.len(), 2); - - // Insert a hidden text run with small font (6.0 pt, below the 8.5 pt floor) into an existing paragraph - if let wordcraft_doc::Block::Para(p) = std::sync::Arc::make_mut(&mut s.doc.body[0]) { - let mut hidden_props = CharProps { size: Some(6.0), ..Default::default() }; - hidden_props.hidden = Some(true); - p.runs.push(wordcraft_doc::para::Run { len: 7, props: hidden_props }); - p.text.push_str("secret!"); - } - s.touch(); - s.relayout(); - - // When marks are off, hidden text is skipped from layout and floor checks - s.view.marks = false; - s.prefs.marks.hidden = false; - let doc_backup = s.doc.clone(); - - let res = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.5, "adjustSpacing": false})); - assert!(res.is_ok(), "hidden text with small font must be skipped when hidden marks are off: {res:?}"); - assert_eq!(s.layout().pages.len(), 1); - - // Reset document and test with marks turned on - s.doc = doc_backup; - s.view.marks = true; - s.touch(); - s.relayout(); - - let res_with_marks = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.5, "adjustSpacing": false})); - assert!(res_with_marks.is_err(), "hidden text with small font must block fit when marks are visible"); -} - -#[test] -fn fit_one_page_accounts_for_vertical_alignment_padding() { - let mut s = create_multiline_doc(26); - let extra_p = wordcraft_doc::Paragraph::with_text("Spill line on page 2.", CharProps::default()); - s.doc.body.push(wordcraft_doc::para_block(extra_p)); - - // Set page vertical alignment to Bottom - s.doc.last_section.valign = wordcraft_doc::section::PageVAlign::Bottom; - s.touch(); - s.relayout(); - assert_eq!(s.layout().pages.len(), 2); - - // Fit must succeed because the actual spill height (1 line) is well under 30% of page height, - // even though the line was translated to the bottom of page 2 by valign padding. - let res = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.5, "adjustSpacing": false})); - assert!(res.is_ok(), "fit must succeed with vertical alignment padding: {res:?}"); - assert_eq!(s.layout().pages.len(), 1); -} - -#[test] -fn fit_one_page_accounts_for_note_separator_decor_story() { - let mut lines = 28; - let mut s = create_multiline_doc(lines); - while s.layout().pages.len() < 2 && lines < 80 { - lines += 1; - s = create_multiline_doc(lines); - } - assert_eq!(s.layout().pages.len(), 2); - - // Set custom footnote separator story so layout produces DECOR_STORY - s.doc.footnote_separators.separator = Some(vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text("---", CharProps::default()))]); - - // Insert note reference into a paragraph on the final page (page 2) - let last_para_idx = s.doc.body.len().saturating_sub(1); - let note_p = wordcraft_doc::Paragraph::with_text("Final page footnote text.", CharProps::default()); - let note_id = s.doc.add_part(wordcraft_doc::PartKind::Footnote, vec![wordcraft_doc::para_block(note_p)]); - let noteref = wordcraft_doc::para::InlineObject::NoteRef { kind: wordcraft_doc::para::NoteKind::Footnote, id: note_id, custom: String::new() }; - s.doc.insert_object(&wordcraft_doc::Pos::body(last_para_idx, 0), noteref, &Default::default()).unwrap(); - s.touch(); - s.relayout(); - assert_eq!(s.layout().pages.len(), 2); - - // Verify layout produces DECOR_STORY on page 2 - let page2 = &s.layout().pages[1]; - let has_decor = - page2.items.iter().any(|item| matches!(item, wordcraft_layout::Placed::Lines { story, .. } if *story == wordcraft_layout::DECOR_STORY)); - assert!(has_decor, "footnote on page 2 must generate DECOR_STORY separator"); - - // Fit must succeed without DECOR_STORY inflating body_max_y - let res = s.run("layout.fitOnePage", &json!({})); - assert!(res.is_ok(), "fit with final page footnote and DECOR_STORY must succeed: {res:?}"); - assert_eq!(s.layout().pages.len(), 1); -} - -#[test] -fn fit_one_page_tracks_fixed_spacing_independent_of_paragraph_mark_size() { - let mut lines = 28; - let mut s = create_multiline_doc(lines); - let set_spacing = |s: &mut Session| { - for b in &mut s.doc.body { - if let wordcraft_doc::Block::Para(p) = std::sync::Arc::make_mut(b) { - p.props.line_spacing = Some(wordcraft_doc::props::LineSpacing::Exactly(16.0)); - } - } - s.touch(); - s.relayout(); - }; - set_spacing(&mut s); - while s.layout().pages.len() < 2 && lines < 80 { - lines += 1; - s = create_multiline_doc(lines); - set_spacing(&mut s); - } - assert_eq!(s.layout().pages.len(), 2); - - // Fit with spacing reduction - let res = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.0, "adjustSpacing": true})); - assert!(res.is_ok(), "spacing reduction must succeed: {res:?}"); - assert!(s.doc.fit_state.as_ref().is_some_and(|f| f.cum_space < 1.0)); - - // Change paragraph mark font size on paragraphs (e.g. from 12 pt to 24 pt) - for b in &mut s.doc.body { - if let wordcraft_doc::Block::Para(p) = std::sync::Arc::make_mut(b) { - p.mark.size = Some(24.0); - } - } - s.touch(); - s.relayout(); - - // Second fit with spacing only must be refused because spacing floor 0.94 was reached - // and tracked provenance was retained despite paragraph mark size change - let res2 = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.0, "adjustSpacing": true})); - assert!(res2.is_err(), "anti-compound guard must detect reduced fixed spacing and reject repeat compression"); -} - -#[test] -fn fit_one_page_reports_search_exhaustion_when_candidates_cannot_remove_page() { - let mut s = create_multiline_doc(27); - assert_eq!(s.layout().pages.len(), 2); - - // Calling with zero maxDeltaPt and adjustSpacing: false produces no candidate steps - let res = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.0, "adjustSpacing": false})); - assert!(res.is_err()); - let err = res.unwrap_err().to_string(); - assert!( - err.contains("no permitted typography adjustment could remove a page"), - "error message must distinguish search exhaustion from overflow limits: {err}" - ); -} - -#[test] -fn fit_one_page_excludes_line_number_decorations_from_final_page_limits() { - let mut lines = 33; - let enable_line_numbers = |s: &mut Session| { - s.doc.last_section.line_numbers = Some(wordcraft_doc::section::LineNumbering::default()); - s.touch(); - s.relayout(); - }; - let mut s = create_multiline_doc(lines); - enable_line_numbers(&mut s); - while s.layout().pages.len() < 2 && lines < 60 { - lines += 1; - s = create_multiline_doc(lines); - enable_line_numbers(&mut s); - } - assert_eq!(s.layout().pages.len(), 2); - - // Every body line on the final page also places a line-number decoration. Counting those as - // content would double the final-page line count and refuse before any candidate is tried. - let decorated = s.layout().pages[1] - .items - .iter() - .filter(|item| matches!(item, wordcraft_layout::Placed::Lines { story, .. } if *story == wordcraft_layout::DECOR_STORY)) - .count(); - assert!(decorated > 0, "the final page must carry line-number decorations"); - - let res = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 4.0})); - assert!(res.is_ok(), "line-number decorations must not count as final-page content: {res:?}"); - assert_eq!(s.layout().pages.len(), 1); -} - -#[test] -fn fit_one_page_counts_side_by_side_footnote_cells_once() { - let mut s = create_multiline_doc(27); - assert_eq!(s.layout().pages.len(), 2); - - // A footnote table whose row's cells sit side by side: their text occupies one vertical - // interval, so their heights must not be summed as if stacked. - let mut table = wordcraft_doc::Table::new(1, 3, 420.0); - let cell_text = "The quick brown fox jumps over the lazy dog again and again."; - for cell in &mut table.rows[0].cells { - cell.blocks = - vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(cell_text, CharProps { size: Some(21.0), ..Default::default() }))]; - } - let note_blocks: Vec> = vec![std::sync::Arc::new(wordcraft_doc::Block::Table(table))]; - let note_id = s.doc.add_part(wordcraft_doc::PartKind::Footnote, note_blocks); - - let last_para_idx = s.doc.body.len().saturating_sub(1); - let noteref = wordcraft_doc::para::InlineObject::NoteRef { kind: wordcraft_doc::para::NoteKind::Footnote, id: note_id, custom: String::new() }; - s.doc.insert_object(&wordcraft_doc::Pos::body(last_para_idx, 0), noteref, &Default::default()).unwrap(); - s.touch(); - s.relayout(); - assert_eq!(s.layout().pages.len(), 2); - - let note_cells = s.layout().pages[1] - .items - .iter() - .filter(|item| matches!(item, wordcraft_layout::Placed::Cell { story: wordcraft_doc::StoryRef::Part(id), .. } if *id == note_id)) - .count(); - assert!(note_cells >= 2, "the footnote table must place its cells side by side on the final page"); - - let res = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 4.0})); - assert!(res.is_ok(), "side-by-side footnote cells must count once in the fill height: {res:?}"); - assert_eq!(s.layout().pages.len(), 1); -} - -#[test] -fn fit_one_page_refuses_when_formatting_changes_cannot_be_tracked() { - let mut s = create_multiline_doc(27); - assert_eq!(s.layout().pages.len(), 2); - s.doc.settings.track_changes = true; - s.prefs.markup.track_formatting = true; - let before = s.doc.clone(); - - // Style reductions cannot be recorded as formatting revisions, so the command must refuse - // rather than change typography that rejecting all changes cannot undo. - let res = s.run("layout.fitOnePage", &json!({})); - assert!(res.is_err(), "layout.fitOnePage must refuse while formatting is tracked"); - let err = res.unwrap_err().to_string(); - assert!(err.contains("tracks formatting changes"), "unexpected error: {err}"); - assert_eq!(s.doc, before, "a refused fit must leave the document unchanged"); -} +use serde_json::json; +use wordcraft_doc::Document; +use wordcraft_doc::para::PAGE_BREAK; +use wordcraft_doc::props::CharProps; + +use crate::Session; + +/// Creates a document with `lines` lines of text. +fn create_multiline_doc(lines: usize) -> Session { + let mut text = String::new(); + for i in 1..=lines { + text.push_str(&format!("Line {i}: The quick brown fox jumps over the lazy dog.\n")); + } + Session::new(Document::from_text(&text)) +} + +#[test] +fn fit_one_page_fails_on_single_page() { + let mut s = create_multiline_doc(5); + let before = s.doc.clone(); + let res = s.run("layout.fitOnePage", &json!({})); + assert!(res.is_err()); + let err = res.unwrap_err().to_string(); + assert!(err.contains("already fits on one page"), "unexpected error: {err}"); + assert_eq!(s.doc, before, "document must not change on failure"); +} + +#[test] +fn fit_one_page_fails_on_hard_page_break() { + let text = format!("Page 1 content\n{PAGE_BREAK}Page 2 content\n"); + let mut s = Session::new(Document::from_text(&text)); + let before = s.doc.clone(); + let res = s.run("layout.fitOnePage", &json!({})); + assert!(res.is_err()); + let err = res.unwrap_err().to_string(); + assert!(err.contains("explicit page or section breaks"), "unexpected error: {err}"); + assert_eq!(s.doc, before, "document must not change on failure"); +} + +#[test] +fn fit_one_page_refuses_page_break_before_before_search() { + let mut s = create_multiline_doc(27); + assert_eq!(s.layout().pages.len(), 2); + if let wordcraft_doc::Block::Para(p) = std::sync::Arc::make_mut(&mut s.doc.body[1]) { + p.props.page_break_before = Some(true); + } + s.touch(); + s.relayout(); + assert_eq!(s.layout().pages.len(), 2); + let before = s.doc.clone(); + + let res = s.run("layout.fitOnePage", &json!({})); + assert!(res.is_err()); + let err = res.unwrap_err().to_string(); + assert!(err.contains("explicit page or section breaks"), "page_break_before must be reported as an explicit break: {err}"); + assert_eq!(s.doc, before, "document must not change on refusal"); +} + +#[test] +fn fit_one_page_fails_on_next_page_section_break() { + let mut s = Session::new(Document::from_text("First section body text")); + s.run("layout.break", &json!({"kind": "nextPage"})).unwrap(); + s.run("text.insert", &json!({"text": "Second section text on next page"})).unwrap(); + assert_eq!(s.layout().pages.len(), 2, "break must cause 2 pages"); + + let before = s.doc.clone(); + let res = s.run("layout.fitOnePage", &json!({})); + assert!(res.is_err()); + let err = res.unwrap_err().to_string(); + assert!(err.contains("explicit page or section breaks"), "unexpected error: {err}"); + assert_eq!(s.doc, before, "document must not change on failure"); +} + +#[test] +fn fit_one_page_condenses_slight_overflow_and_undoes() { + // Find the boundary where the document just spills onto page 2 + let mut lines = 30; + let mut s = create_multiline_doc(lines); + while s.layout().pages.len() < 2 && lines < 80 { + lines += 2; + s = create_multiline_doc(lines); + } + assert_eq!(s.layout().pages.len(), 2, "must start as a 2-page document with {lines} lines"); + + let doc_before = s.doc.clone(); + let res = s.run("layout.fitOnePage", &json!({})); + assert!(res.is_ok(), "fit should succeed on slight overflow: {res:?}"); + assert_eq!(s.layout().pages.len(), 1, "document must now fit on 1 page"); + assert!(s.status.contains("Document fitted to 1 page"), "status was: {}", s.status); + + // Test single atomic undo + let undo_res = s.run("edit.undo", &json!({})); + assert!(undo_res.is_ok(), "undo must succeed"); + assert_eq!(s.layout().pages.len(), 2, "undo must restore 2-page layout"); + assert_eq!(s.doc, doc_before, "undo must restore exact pre-fit document state"); + + // Test redo + let redo_res = s.run("edit.redo", &json!({})); + assert!(redo_res.is_ok(), "redo must succeed"); + assert_eq!(s.layout().pages.len(), 1, "redo must restore 1-page layout"); +} + +#[test] +fn fit_one_page_respects_max_delta() { + let mut s = create_multiline_doc(27); + assert_eq!(s.layout().pages.len(), 2, "must start as 2-page document"); + + let doc_backup = s.doc.clone(); + let ok_res = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.5, "adjustSpacing": false})); + assert!(ok_res.is_ok(), "must succeed when positive font delta 0.5 is permitted: {ok_res:?}"); + assert_eq!(s.layout().pages.len(), 1); + + s.doc = doc_backup; + let res = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.0, "adjustSpacing": false})); + assert!(res.is_err(), "must fail when font delta is capped at 0.0 and spacing is disabled"); +} + +#[test] +fn fit_one_page_respects_step_pt() { + let mut text = String::new(); + for i in 1..=28 { + text.push_str(&format!("Line {i}: The quick brown fox jumps over the lazy dog.\n")); + } + let mut s = Session::new(Document::from_text(&text)); + for b in &mut s.doc.body { + if let wordcraft_doc::Block::Para(p) = std::sync::Arc::make_mut(b) { + p.mark.size = Some(11.4); + for r in &mut p.runs { + r.props.size = Some(11.4); + } + } + } + assert_eq!(s.layout().pages.len(), 2, "must start as 2-page document at 11.4 pt"); + + // Both calls hold maxDeltaPt constant at 0.4 and disable spacing adjustments. + // Default 0.5 pt step generates no candidates <= 0.4 pt, so it must fail. + let doc_backup = s.doc.clone(); + let default_res = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.4, "adjustSpacing": false})); + assert!(default_res.is_err(), "default step 0.5 must skip all candidates when maxDelta is 0.4"); + + // With stepPt = 0.1 under the same maxDeltaPt = 0.4, fine-grained steps (0.1..0.4) succeed (11.4 -> 11.0 pt). + s.doc = doc_backup; + let res = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.4, "stepPt": 0.1, "adjustSpacing": false})); + assert!(res.is_ok(), "stepPt=0.1 must generate fine-grained candidates under maxDelta 0.4: {res:?}"); + assert_eq!(s.layout().pages.len(), 1); +} + +#[test] +fn fit_one_page_allows_spacing_only_at_font_floor() { + let mut lines = 35; + let mut s = create_multiline_doc(lines); + let set_font = |s: &mut Session| { + for b in &mut s.doc.body { + if let wordcraft_doc::Block::Para(p) = std::sync::Arc::make_mut(b) { + for r in &mut p.runs { + r.props = CharProps { size: Some(8.5), ..Default::default() }; + } + } + } + s.touch(); + s.relayout(); + }; + set_font(&mut s); + while s.layout().pages.len() < 2 && lines < 120 { + lines += 1; + s = create_multiline_doc(lines); + set_font(&mut s); + } + assert_eq!(s.layout().pages.len(), 2); + + // Spacing-only candidate must be permitted to run and succeed + let res = s.run("layout.fitOnePage", &json!({})); + assert!(res.is_ok(), "spacing-only candidate must succeed at font floor: {res:?}"); + assert_eq!(s.layout().pages.len(), 1); + assert!(s.status.contains("spacing: "), "status: {}", s.status); + assert!(!s.status.contains("font: -"), "font must remain untouched: {}", s.status); +} + +#[test] +fn fit_one_page_fails_when_body_font_already_at_minimum_readable_size() { + let mut lines = 35; + let mut s = create_multiline_doc(lines); + let set_font = |s: &mut Session| { + for b in &mut s.doc.body { + if let wordcraft_doc::Block::Para(p) = std::sync::Arc::make_mut(b) { + for r in &mut p.runs { + r.props = CharProps { size: Some(8.0), ..Default::default() }; + } + } + } + s.touch(); + s.relayout(); + }; + set_font(&mut s); + while s.layout().pages.len() < 2 && lines < 120 { + lines += 1; + s = create_multiline_doc(lines); + set_font(&mut s); + } + assert_eq!(s.layout().pages.len(), 2); + + // When spacing adjustment is disabled, no candidate can run without reducing font + let res = s.run("layout.fitOnePage", &json!({"adjustSpacing": false})); + assert!(res.is_err(), "must refuse to shrink font when already <= 8.5 pt"); + let err = res.unwrap_err().to_string(); + assert!(err.contains("Unable to shrink document further without reducing text beyond readability limits."), "unexpected error message: {err}"); +} + +#[test] +fn fit_one_page_bounds_fine_step_loop() { + let mut lines = 28; + let mut s = create_multiline_doc(lines); + while s.layout().pages.len() < 2 && lines < 80 { + lines += 1; + s = create_multiline_doc(lines); + } + assert_eq!(s.layout().pages.len(), 2, "fixture must reach candidate generation on 2 pages"); + + // With stepPt = 0.001 and maxDeltaPt = 2.0 (ratio = 2000), fine-step candidate count + // is clamped by MAX_FINE_STEPS (20), executing quickly without doing thousands of iterations. + let res = s.run("layout.fitOnePage", &json!({"stepPt": 0.001, "maxDeltaPt": 2.0, "adjustSpacing": false})); + assert!(res.is_ok(), "fitOnePage with bounded fine steps succeeds or terminates gracefully: {res:?}"); +} + +#[test] +fn fit_one_page_supports_max_delta_above_2pt() { + let mut lines = 28; + let mut s = create_multiline_doc(lines); + while s.layout().pages.len() < 2 && lines < 80 { + lines += 1; + s = create_multiline_doc(lines); + } + // Add 8 lines of spill (within the 12-line spill limit) + for i in 1..=8 { + s.run("text.insert", &json!({"text": format!("Spill paragraph line {i}\n")})).unwrap(); + } + assert_eq!(s.layout().pages.len(), 2); + + // With maxDeltaPt = 3.0, candidate above 2 pt fits the document + let res = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 3.0})); + assert!(res.is_ok(), "fitOnePage with maxDeltaPt=3.0 must succeed: {res:?}"); + assert_eq!(s.layout().pages.len(), 1, "must fit on 1 page"); +} + +#[test] +fn fit_one_page_anti_compound_guard_prevents_repeated_invocations() { + let mut lines = 28; + let mut s = create_multiline_doc(lines); + loop { + for b in &mut s.doc.body { + if let wordcraft_doc::Block::Para(p) = std::sync::Arc::make_mut(b) { + p.props.line_spacing = Some(wordcraft_doc::props::LineSpacing::Multiple(0.94)); + for r in &mut p.runs { + r.props.size = Some(11.0); + } + } + } + s.touch(); + s.relayout(); + if s.layout().pages.len() >= 2 || lines >= 80 { + break; + } + lines += 1; + s = create_multiline_doc(lines); + } + assert_eq!(s.layout().pages.len(), 2); + + // Simulate a document that already used the full 1.0 pt font and 0.94 spacing budgets. + s.doc.fit_state = Some(wordcraft_doc::FitState { + cum_delta: 1.0, + cum_space: 0.94, + original_font_sizes: vec![12.0], + original_line_spacings: vec![wordcraft_doc::props::LineSpacing::Multiple(1.0)], + ..Default::default() + }); + s.touch(); + s.relayout(); + let doc_before = s.doc.clone(); + + let res = s.run("layout.fitOnePage", &json!({})); + assert!(res.is_err(), "exhausted budgets must be rejected"); + let err = res.unwrap_err().to_string(); + assert!(err.contains("Unable to shrink document further without reducing text beyond readability limits."), "unexpected error message: {err}"); + assert_eq!(s.doc, doc_before, "document must not be mutated on rejected invocation"); +} + +#[test] +fn fit_one_page_already_fits_wins_over_exhausted_budget() { + let mut s = create_multiline_doc(5); + assert_eq!(s.layout().pages.len(), 1); + for b in &mut s.doc.body { + if let wordcraft_doc::Block::Para(p) = std::sync::Arc::make_mut(b) { + p.props.line_spacing = Some(wordcraft_doc::props::LineSpacing::Multiple(0.94)); + for r in &mut p.runs { + r.props.size = Some(11.0); + } + } + } + s.doc.fit_state = Some(wordcraft_doc::FitState { + cum_delta: 1.0, + cum_space: 0.94, + original_font_sizes: vec![12.0], + original_line_spacings: vec![wordcraft_doc::props::LineSpacing::Multiple(1.0)], + ..Default::default() + }); + s.touch(); + s.relayout(); + + let res = s.run("layout.fitOnePage", &json!({})); + assert!(res.is_err()); + let err = res.unwrap_err().to_string(); + assert!(err.contains("already fits on one page"), "already-fits must take precedence over exhausted budgets: {err}"); +} + +#[test] +fn fit_one_page_checks_complex_script_font_floor() { + let mut lines = 28; + let mut s = create_multiline_doc(lines); + while s.layout().pages.len() < 2 && lines < 80 { + lines += 1; + s = create_multiline_doc(lines); + } + assert_eq!(s.layout().pages.len(), 2); + + // Insert an Arabic run with size 12.0 pt but size_cs 9.0 pt + let arabic_p = wordcraft_doc::Paragraph::with_text( + "نص تجريبي باللغة العربية", + CharProps { size: Some(12.0), size_cs: Some(9.0), cs: Some(true), ..Default::default() }, + ); + s.doc.body.insert(0, wordcraft_doc::para_block(arabic_p)); + assert_eq!(s.layout().pages.len(), 2); + + // A 1.0 pt reduction would reduce size_cs from 9.0 to 8.0 (< 8.5 pt floor). + // The trial must not commit. + let _ = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 1.0, "adjustSpacing": false})); + let p = s.doc.body[0].as_para().unwrap(); + let cs_size = p.runs[0].props.size_cs.unwrap_or(9.0); + assert!(cs_size >= 8.5, "complex-script size must remain >= 8.5 pt, was {cs_size}"); +} + +#[test] +fn fit_one_page_checks_editable_text_box_font_floor() { + let mut lines = 28; + let mut s = create_multiline_doc(lines); + while s.layout().pages.len() < 2 && lines < 80 { + lines += 1; + s = create_multiline_doc(lines); + } + assert_eq!(s.layout().pages.len(), 2); + + // Add a body-anchored text box with 9.0 pt font + let box_id = s.doc.add_part( + wordcraft_doc::PartKind::TextBox, + vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text( + "Text box body text", + CharProps { size: Some(9.0), ..Default::default() }, + ))], + ); + // Link it into body + let mut shape_p = wordcraft_doc::Paragraph::with_text("Before box ", CharProps::default()); + let shape = wordcraft_doc::InlineObject::Shape { + kind: wordcraft_doc::para::ShapeKind::TextBox, + w: 100.0, + h: 50.0, + fill: None, + stroke: None, + stroke_width: 0.5, + float: Default::default(), + story: Some(box_id), + freeform: None, + effects: Default::default(), + extra: Default::default(), + }; + shape_p.objects.push(shape); + s.doc.body.insert(0, wordcraft_doc::para_block(shape_p)); + assert_eq!(s.layout().pages.len(), 2); + + // With maxDeltaPt = 1.0 and adjustSpacing = false, 9.0 pt would reduce to 8.0 pt. + // The trial must not be committed. + let _ = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 1.0, "adjustSpacing": false})); + let part = s.doc.parts.get(&box_id).unwrap(); + let box_p = part.blocks[0].as_para().unwrap(); + let sz = box_p.runs[0].props.size.unwrap(); + assert!(sz >= 8.5, "editable text box font must remain >= 8.5 pt, was {sz}"); +} + +#[test] +fn fit_one_page_fails_when_overflow_exceeds_limits() { + let mut lines = 28; + let mut s = create_multiline_doc(lines); + while s.layout().pages.len() < 2 { + lines += 1; + s = create_multiline_doc(lines); + } + // Add 16 extra lines to page 2: this overflow is too large to fit into page 1 with maxDeltaPt = 0.5 + for i in 1..=16 { + s.run("text.insert", &json!({"text": format!("Overflow line {i}\n")})).unwrap(); + } + assert_eq!(s.layout().pages.len(), 2, "must be a 2-page document"); + + let res = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.5})); + assert!(res.is_err(), "must refuse when overflow exceeds aesthetic reduction limits"); + let err = res.unwrap_err().to_string(); + assert!(err.contains("exceeds limits for aesthetic page reduction"), "unexpected error message: {err}"); +} + +#[test] +fn fit_one_page_validates_effective_font_in_tables_before_committing() { + let mut s = Session::new(Document::new()); + let mut table = wordcraft_doc::Table::new(1, 1, 400.0); + let cell_p = wordcraft_doc::Paragraph::with_text("Cell text", CharProps { size: Some(9.0), ..Default::default() }); + table.rows[0].cells[0].blocks = vec![wordcraft_doc::para_block(cell_p)]; + s.doc.body = vec![std::sync::Arc::new(wordcraft_doc::Block::Table(table))]; + + let mut text = String::new(); + for i in 1..=45 { + text.push_str(&format!("Line {i} following table: The quick brown fox jumps over the lazy dog.\n")); + } + s.doc.body.push(wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(&text, CharProps::default()))); + assert_eq!(s.layout().pages.len(), 2); + + // If maxDeltaPt is 1.0, 9.0 pt would reduce to 8.0 pt (violating 8.5 pt floor). + // The candidate must not be committed. + let _ = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 1.0, "adjustSpacing": false})); + if let Some(tbl) = s.doc.body[0].as_table() { + let p = tbl.rows[0].cells[0].blocks[0].as_para().unwrap(); + let sz = p.runs[0].props.size.unwrap(); + assert!(sz >= 8.5, "table cell font size must remain >= 8.5 pt, was {sz}"); + } +} + +#[test] +fn fit_one_page_validates_effective_font_in_table_styles_before_committing() { + let mut s = Session::new(Document::new()); + // Create a custom table style with 9.0 pt font + let st = wordcraft_doc::styles::Style { + id: "CustomTableStyle".into(), + name: "CustomTableStyle".into(), + kind: wordcraft_doc::styles::StyleKind::Table, + chr: CharProps { size: Some(9.0), ..Default::default() }, + table: Some(wordcraft_doc::styles::TableStyleParts::default()), + ..Default::default() + }; + std::sync::Arc::make_mut(&mut s.doc.styles).upsert(st); + + let mut table = wordcraft_doc::Table::new(1, 1, 400.0); + table.props.style = Some("CustomTableStyle".to_string()); + let cell_p = wordcraft_doc::Paragraph::with_text("Cell text inheriting table style", CharProps::default()); + table.rows[0].cells[0].blocks = vec![wordcraft_doc::para_block(cell_p)]; + s.doc.body = vec![std::sync::Arc::new(wordcraft_doc::Block::Table(table))]; + + let mut text = String::new(); + for i in 1..=45 { + text.push_str(&format!("Line {i} following table: The quick brown fox jumps over the lazy dog.\n")); + } + s.doc.body.push(wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(&text, CharProps::default()))); + assert_eq!(s.layout().pages.len(), 2); + + // If maxDeltaPt is 1.0, 9.0 pt in the table style would reduce to 8.0 pt (violating 8.5 pt floor). + // The candidate must not be committed. + let _ = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 1.0, "adjustSpacing": false})); + let style = s.doc.styles.table_style("CustomTableStyle").unwrap(); + let sz = style.chr.size.unwrap(); + assert!(sz >= 8.5, "table style font size must remain >= 8.5 pt, was {sz}"); +} + +#[test] +fn fit_one_page_measures_only_final_page_placed_lines() { + let mut s = Session::new(Document::new()); + let words = "The quick brown fox jumps over the lazy dog and runs across the wide open fields in the sunshine. "; + let mut reps = 20; + loop { + let text = words.repeat(reps); + s.doc.body = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(&text, CharProps::default()))]; + s.touch(); + s.relayout(); + if s.layout().pages.len() >= 2 { + break; + } + reps += 1; + } + assert_eq!(s.layout().pages.len(), 2); + + let res = s.run("layout.fitOnePage", &json!({})); + assert!(res.is_ok(), "fitOnePage must succeed when single paragraph slightly spills onto page 2: {res:?}"); + assert_eq!(s.layout().pages.len(), 1); +} + +#[test] +fn fit_one_page_exempts_style_inherited_superscript() { + let mut s = Session::new(Document::new()); + // Create a Character style with Superscript alignment and 7.0 pt font + let st = wordcraft_doc::styles::Style { + id: "SuperStyle".into(), + name: "SuperStyle".into(), + kind: wordcraft_doc::styles::StyleKind::Character, + chr: CharProps { vert_align: Some(wordcraft_doc::props::VertAlign::Superscript), size: Some(7.0), ..Default::default() }, + ..Default::default() + }; + std::sync::Arc::make_mut(&mut s.doc.styles).upsert(st); + + let words = "Regular readable text that fills up lines in the document body. "; + let mut reps = 20; + loop { + let mut p = wordcraft_doc::Paragraph::with_text(&words.repeat(reps), CharProps { size: Some(11.0), ..Default::default() }); + // Add a run with style-inherited superscript + p.runs.push(wordcraft_doc::Run { len: 3, props: CharProps { style: Some("SuperStyle".into()), ..Default::default() } }); + p.text.push_str("123"); + s.doc.body = vec![wordcraft_doc::para_block(p)]; + s.touch(); + s.relayout(); + if s.layout().pages.len() >= 2 { + break; + } + reps += 1; + } + assert_eq!(s.layout().pages.len(), 2); + + // fitOnePage must not be blocked by the 7.0 pt superscript run because it is exempt + let res = s.run("layout.fitOnePage", &json!({})); + assert!(res.is_ok(), "style-inherited superscript must be exempt from font floor: {res:?}"); + assert_eq!(s.layout().pages.len(), 1); +} + +#[test] +fn fit_one_page_refused_when_protect_formatting_enabled() { + let mut s = Session::new(Document::new()); + s.doc.settings.protection = Some("none".into()); + s.doc.settings.protect_formatting = true; + let res = s.run("layout.fitOnePage", &json!({})); + assert!(res.is_err()); + let err = res.unwrap_err().to_string(); + assert!(err.contains("formatting is limited to the document's styles"), "unexpected error: {err}"); +} + +#[test] +fn fit_one_page_zero_max_delta_never_reduces_font() { + let mut s = Session::new(Document::new()); + let words = "Content line with fractional font size for zero delta testing. "; + let mut reps = 20; + loop { + let text = words.repeat(reps); + s.doc.body = + vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(&text, CharProps { size: Some(10.3), ..Default::default() }))]; + s.touch(); + s.relayout(); + if s.layout().pages.len() >= 2 { + break; + } + reps += 1; + } + assert_eq!(s.layout().pages.len(), 2); + + // Call fitOnePage with maxDeltaPt = 0.0 and a tiny stepPt (which should not produce any positive candidate) + let _ = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.0, "stepPt": 0.001})); + let p = s.doc.body[0].as_para().unwrap(); + assert_eq!(p.runs[0].props.size, Some(10.3), "font size must remain completely unchanged when maxDeltaPt is 0"); +} + +#[test] +fn fit_one_page_fails_with_failed_cmd_error() { + let mut s = create_multiline_doc(5); + let res = s.run("layout.fitOnePage", &json!({})); + assert!(matches!(res, Err(crate::CmdError::Failed(_))), "expected CmdError::Failed, got {res:?}"); +} + +#[test] +fn fit_one_page_never_populates_custom_props() { + let mut lines = 30; + let mut s = create_multiline_doc(lines); + while s.layout().pages.len() < 2 && lines < 80 { + lines += 2; + s = create_multiline_doc(lines); + } + assert!(s.doc.custom_props.is_empty()); + let res = s.run("layout.fitOnePage", &json!({})); + assert!(res.is_ok()); + assert!(s.doc.custom_props.is_empty(), "custom_props must not be populated with internal fit state"); +} + +#[test] +fn fit_one_page_allows_subsequent_fit_after_typography_reset() { + use std::sync::Arc; + + let mut lines = 28; + let mut s = create_multiline_doc(lines); + while s.layout().pages.len() < 2 && lines < 80 { + lines += 1; + s = create_multiline_doc(lines); + } + assert_eq!(s.layout().pages.len(), 2); + + // First invocation succeeds + let res1 = s.run("layout.fitOnePage", &json!({})); + assert!(res1.is_ok()); + assert_eq!(s.layout().pages.len(), 1); + + // Reset styles back to Normal with 12 pt font and 1.15 line spacing + if let Some(st) = Arc::make_mut(&mut s.doc.styles).styles.iter_mut().find(|st| st.id == "Normal") { + st.chr.size = Some(12.0); + st.para.line_spacing = Some(wordcraft_doc::props::LineSpacing::Multiple(1.15)); + } + for b in &mut s.doc.body { + if let wordcraft_doc::Block::Para(p) = Arc::make_mut(b) { + p.props.line_spacing = None; + for r in &mut p.runs { + r.props.size = None; + } + } + } + s.touch(); + s.relayout(); + + // Now add lines to make it 2 pages again + while s.layout().pages.len() < 2 { + s.run("text.insert", &json!({"text": "Additional fresh paragraph after style reset.\n"})).unwrap(); + } + assert_eq!(s.layout().pages.len(), 2); + + // A subsequent fitOnePage run must NOT be permanently blocked and should succeed + let res2 = s.run("layout.fitOnePage", &json!({})); + assert!(res2.is_ok(), "fitOnePage must succeed after typography reset: {res2:?}"); + assert_eq!(s.layout().pages.len(), 1); +} + +#[test] +fn fit_one_page_small_paragraph_mark_with_runs_never_blocks_fit() { + let mut lines = 30; + let mut s = create_multiline_doc(lines); + while s.layout().pages.len() < 2 && lines < 80 { + lines += 2; + s = create_multiline_doc(lines); + } + // Set a 1.0 pt paragraph mark on a body paragraph that has normal 11 pt text runs + if let wordcraft_doc::Block::Para(p) = std::sync::Arc::make_mut(&mut s.doc.body[0]) { + p.mark.size = Some(1.0); + } + s.touch(); + s.relayout(); + assert_eq!(s.layout().pages.len(), 2); + + let res = s.run("layout.fitOnePage", &json!({})); + assert!(res.is_ok(), "small mark with normal text runs must not block fit: {res:?}"); + assert_eq!(s.layout().pages.len(), 1); +} + +#[test] +fn fit_one_page_small_comment_font_never_blocks_body_fit() { + let mut lines = 30; + let mut s = create_multiline_doc(lines); + while s.layout().pages.len() < 2 && lines < 80 { + lines += 2; + s = create_multiline_doc(lines); + } + // Add a comment part with 6.0 pt text + let comment_p = wordcraft_doc::Paragraph::with_text("Small comment text", CharProps { size: Some(6.0), ..Default::default() }); + s.doc.add_part(wordcraft_doc::PartKind::Comment, vec![wordcraft_doc::para_block(comment_p)]); + s.touch(); + s.relayout(); + assert_eq!(s.layout().pages.len(), 2); + + let res = s.run("layout.fitOnePage", &json!({})); + assert!(res.is_ok(), "small comment font must not block body fit: {res:?}"); + assert_eq!(s.layout().pages.len(), 1); +} + +#[test] +fn fit_one_page_anti_compound_caps_total_reduction_across_runs() { + let mut s = create_multiline_doc(27); + assert_eq!(s.layout().pages.len(), 2); + + // Initial fit with -0.5 pt font reduction (spacing disabled so font budget is exercised) + let res1 = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.5, "adjustSpacing": false})); + assert!(res1.is_ok()); + assert_eq!(s.layout().pages.len(), 1); + assert_eq!(s.doc.fit_state.as_ref().map(|f| f.cum_delta), Some(0.5)); + + // Add content to spill to 2 pages again + for i in 1..=12 { + s.run("text.insert", &json!({"text": format!("Spill paragraph line {i}\n")})).unwrap(); + } + assert_eq!(s.layout().pages.len(), 2); + + // Candidate requiring > 0.5 pt more (e.g. total > 1.0 pt) must be rejected + let res2 = s.run("layout.fitOnePage", &json!({})); + assert!(res2.is_err(), "second run must not exceed cumulative 1.0 pt reduction budget"); +} + +#[test] +fn fit_one_page_tracks_reduced_text_provenance_without_global_minimum() { + let mut s = create_multiline_doc(27); + // Add a 9.0 pt run to the first paragraph alongside 11.0 pt runs + if let wordcraft_doc::Block::Para(p) = std::sync::Arc::make_mut(&mut s.doc.body[0]) { + p.runs.push(wordcraft_doc::para::Run { len: 5, props: CharProps { size: Some(9.0), ..Default::default() } }); + p.text.push_str("extra"); + } + s.touch(); + s.relayout(); + assert_eq!(s.layout().pages.len(), 2); + + // Initial fit with -0.5 pt: 9.0 pt -> 8.5 pt, 11.0 pt -> 10.5 pt (spacing disabled) + let res1 = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.5, "adjustSpacing": false})); + assert!(res1.is_ok()); + assert_eq!(s.layout().pages.len(), 1); + assert_eq!(s.doc.fit_state.as_ref().map(|f| f.cum_delta), Some(0.5)); + + // Reset only the 8.5 pt run back to 12.0 pt (so current global minimum font becomes 10.5 pt) + if let wordcraft_doc::Block::Para(p) = std::sync::Arc::make_mut(&mut s.doc.body[0]) { + p.runs.last_mut().unwrap().props.size = Some(12.0); + } + s.touch(); + s.relayout(); + + // Add content to spill to 2 pages again + for i in 1..=12 { + s.run("text.insert", &json!({"text": format!("Spill paragraph line {i}\n")})).unwrap(); + } + assert_eq!(s.layout().pages.len(), 2); + + // Because the 10.5 pt runs are still reduced, fit state must NOT be discarded, + // and a candidate requiring > 0.5 pt more font reduction must be rejected by the 1.0 pt cumulative cap + let res2 = s.run("layout.fitOnePage", &json!({})); + assert!(res2.is_err(), "cumulative budget must still hold even when the original minimum font was changed"); +} + +#[test] +fn fit_one_page_allows_spill_with_bottom_placed_footnote() { + let mut lines = 28; + let mut s = create_multiline_doc(lines); + while s.layout().pages.len() < 2 && lines < 80 { + lines += 1; + s = create_multiline_doc(lines); + } + assert_eq!(s.layout().pages.len(), 2); + + // Add a single-line footnote to the document (reference on page 1) + let note_p = wordcraft_doc::Paragraph::with_text("Short footnote text.", CharProps::default()); + let note_id = s.doc.add_part(wordcraft_doc::PartKind::Footnote, vec![wordcraft_doc::para_block(note_p)]); + let noteref = wordcraft_doc::para::InlineObject::NoteRef { kind: wordcraft_doc::para::NoteKind::Footnote, id: note_id, custom: String::new() }; + s.doc.insert_object(&wordcraft_doc::Pos::body(0, 5), noteref, &Default::default()).unwrap(); + s.touch(); + s.relayout(); + assert_eq!(s.layout().pages.len(), 2); + + // Even though the footnote is placed at the bottom of the page (large y), + // fill_ratio must measure only actual content height so fitOnePage is not falsely refused + let res = s.run("layout.fitOnePage", &json!({})); + assert!(res.is_ok(), "bottom-placed footnote must not drive fill ratio above 30%: {res:?}"); + assert_eq!(s.layout().pages.len(), 1); +} + +#[test] +fn fit_one_page_with_final_page_footnote() { + let mut lines = 28; + let mut s = create_multiline_doc(lines); + while s.layout().pages.len() < 2 && lines < 80 { + lines += 1; + s = create_multiline_doc(lines); + } + assert_eq!(s.layout().pages.len(), 2); + + // Insert note reference into a paragraph on the final page (page 2) + let last_para_idx = s.doc.body.len().saturating_sub(1); + let note_p = wordcraft_doc::Paragraph::with_text("Final page footnote text.", CharProps::default()); + let note_id = s.doc.add_part(wordcraft_doc::PartKind::Footnote, vec![wordcraft_doc::para_block(note_p)]); + let noteref = wordcraft_doc::para::InlineObject::NoteRef { kind: wordcraft_doc::para::NoteKind::Footnote, id: note_id, custom: String::new() }; + s.doc.insert_object(&wordcraft_doc::Pos::body(last_para_idx, 0), noteref, &Default::default()).unwrap(); + s.touch(); + s.relayout(); + assert_eq!(s.layout().pages.len(), 2); + + // Verify footnote is assigned to the final page (page index 1) + let page2_has_footnote = s.layout().pages[1] + .items + .iter() + .any(|item| matches!(item, wordcraft_layout::Placed::Lines { story: wordcraft_doc::StoryRef::Part(id), .. } if *id == note_id)); + assert!(page2_has_footnote, "footnote must be assigned to page 2"); + + // With modest spill and small footnote within the 30% fill threshold, fitOnePage succeeds + let res = s.run("layout.fitOnePage", &json!({})); + assert!(res.is_ok(), "final-page footnote within 30% fill limit must allow fitOnePage to succeed: {res:?}"); + assert_eq!(s.layout().pages.len(), 1); +} + +#[test] +fn fit_one_page_skips_deleted_text_when_markup_hidden() { + let mut s = create_multiline_doc(27); + assert_eq!(s.layout().pages.len(), 2); + + // Insert a deleted text run with small font (e.g. 6.0 pt, below the 8.5 pt floor) + if let wordcraft_doc::Block::Para(p) = std::sync::Arc::make_mut(&mut s.doc.body[0]) { + let mut del_props = CharProps { size: Some(6.0), ..Default::default() }; + del_props.del = Some(1); + p.runs.push(wordcraft_doc::para::Run { len: 8, props: del_props }); + p.text.push_str("deleted!"); + } + s.touch(); + s.relayout(); + + // When show_markup is false (the default in print view), deleted run is hidden + // and skipped during font-floor scan, allowing fitOnePage to succeed + s.view.show_markup = false; + let doc_backup = s.doc.clone(); + let res = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.5, "adjustSpacing": false})); + assert!(res.is_ok(), "deleted text below floor must be skipped when markup is hidden: {res:?}"); + assert_eq!(s.layout().pages.len(), 1); + + // When show_markup is true, deleted run is drawn and evaluated in font-floor scan, + // blocking font reduction because effective font is below 8.5 pt floor + s.doc = doc_backup; + s.view.show_markup = true; + let res_markup = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.5, "adjustSpacing": false})); + assert!(res_markup.is_err(), "deleted text below floor must block font reduction when markup is shown"); +} + +#[test] +fn fit_one_page_tracks_reduced_complex_script_font_sizes() { + let mut s = create_multiline_doc(27); + assert_eq!(s.layout().pages.len(), 2); + let styles_before = s.doc.styles.clone(); + + // Set complex script font size on body run while keeping standard font size default (12 pt) + if let wordcraft_doc::Block::Para(p) = std::sync::Arc::make_mut(&mut s.doc.body[0]) { + p.runs[0].props.size_cs = Some(14.0); + } + s.touch(); + s.relayout(); + + // First fit reduces font size by 0.5 pt (size -> 11.5, size_cs -> 13.5) + let res = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.5, "adjustSpacing": false})); + assert!(res.is_ok(), "first fit must succeed: {res:?}"); + assert_eq!(s.doc.fit_state.as_ref().map(|f| f.cum_delta), Some(0.5)); + + // User restores every other size (the styles the fit rescaled), leaving only this run's + // explicit complex-script size reduced at 13.5 pt. + s.doc.styles = styles_before; + s.touch(); + s.relayout(); + assert_eq!(s.layout().pages.len(), 2); + + // Because size_cs is still reduced, cumulative tracking retains the 0.5 pt delta provenance + assert_eq!(s.doc.fit_state.as_ref().map(|f| f.cum_delta), Some(0.5)); + + // Second fit within remaining 0.5 pt budget succeeds up to 1.0 pt cap + let res2 = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.5, "adjustSpacing": false})); + assert!(res2.is_ok(), "second fit within remaining budget must succeed: {res2:?}"); + assert_eq!(s.doc.fit_state.as_ref().map(|f| f.cum_delta), Some(1.0)); + + // Third fit exceeding cumulative 1.0 limit must be refused + let res3 = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.5, "adjustSpacing": false})); + assert!(res3.is_err(), "fit exceeding cumulative limit must be refused"); +} + +#[test] +fn fit_one_page_preserves_budget_across_document_serialization() { + let mut s = create_multiline_doc(27); + assert_eq!(s.layout().pages.len(), 2); + + // Apply fit + let res = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.5, "adjustSpacing": false})); + assert!(res.is_ok(), "fit must succeed: {res:?}"); + assert_eq!(s.doc.fit_state.as_ref().map(|f| f.cum_delta), Some(0.5)); + + // Serialize and deserialize document (e.g. saving and reopening) + let json_bytes = serde_json::to_vec(&s.doc).expect("serialization must succeed"); + let reopened_doc: wordcraft_doc::Document = serde_json::from_slice(&json_bytes).expect("deserialization must succeed"); + + // Verify fit_state is preserved outside custom properties + assert_eq!(reopened_doc.fit_state.as_ref().map(|f| f.cum_delta), Some(0.5)); + assert!(reopened_doc.custom_props.is_empty(), "custom_props must remain empty"); + + // Reopen into a new session with additional spill + let mut s2 = Session::new(reopened_doc); + let extra_p = wordcraft_doc::Paragraph::with_text("Extra overflow paragraph.", CharProps::default()); + s2.doc.body.push(wordcraft_doc::para_block(extra_p)); + s2.touch(); + s2.relayout(); + + // Second fit on reopened document preserves previous 0.5 delta, allowing only remaining 0.5 pt up to 1.0 cap + let res2 = s2.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.5, "adjustSpacing": false})); + assert!(res2.is_ok(), "second fit within remaining budget must succeed"); + assert_eq!(s2.doc.fit_state.as_ref().map(|f| f.cum_delta), Some(1.0)); + + // Third fit exceeding the cumulative 1.0 limit must be refused + let res3 = s2.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.5, "adjustSpacing": false})); + assert!(res3.is_err(), "fit exceeding cumulative limit must be refused"); +} + +#[test] +fn fit_one_page_enforces_durable_limit_when_fit_state_missing() { + // A document with body font already at 8.5 pt readability floor and fit_state == None + let set_font = |s: &mut Session| { + for b in &mut s.doc.body { + if let wordcraft_doc::Block::Para(p) = std::sync::Arc::make_mut(b) { + for r in &mut p.runs { + r.props.size = Some(8.5); + } + } + } + s.touch(); + s.relayout(); + }; + let mut lines = 27; + let mut s = create_multiline_doc(lines); + set_font(&mut s); + while s.layout().pages.len() < 2 && lines < 120 { + lines += 1; + s = create_multiline_doc(lines); + set_font(&mut s); + } + assert_eq!(s.layout().pages.len(), 2); + s.doc.fit_state = None; + s.touch(); + s.relayout(); + let before = s.doc.clone(); + + // With adjustSpacing = false, font reduction is bounded by the durable 8.5 pt floor and refused + let res = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.5, "adjustSpacing": false})); + assert!(res.is_err(), "fit must be refused when typography is at durable readability floor"); + let err = res.unwrap_err().to_string(); + assert!(err.contains("Unable to shrink document further without reducing text beyond readability limits."), "unexpected error: {err}"); + assert_eq!(s.doc, before, "a refused fit must leave the document unchanged"); +} + +#[test] +fn fit_one_page_skips_hidden_text_when_hidden_marks_off() { + let mut s = create_multiline_doc(27); + assert_eq!(s.layout().pages.len(), 2); + + // Insert a hidden text run with small font (6.0 pt, below the 8.5 pt floor) into an existing paragraph + if let wordcraft_doc::Block::Para(p) = std::sync::Arc::make_mut(&mut s.doc.body[0]) { + let mut hidden_props = CharProps { size: Some(6.0), ..Default::default() }; + hidden_props.hidden = Some(true); + p.runs.push(wordcraft_doc::para::Run { len: 7, props: hidden_props }); + p.text.push_str("secret!"); + } + s.touch(); + s.relayout(); + + // When marks are off, hidden text is skipped from layout and floor checks + s.view.marks = false; + s.prefs.marks.hidden = false; + let doc_backup = s.doc.clone(); + + let res = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.5, "adjustSpacing": false})); + assert!(res.is_ok(), "hidden text with small font must be skipped when hidden marks are off: {res:?}"); + assert_eq!(s.layout().pages.len(), 1); + + // Reset document and test with marks turned on + s.doc = doc_backup; + s.view.marks = true; + s.touch(); + s.relayout(); + + let res_with_marks = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.5, "adjustSpacing": false})); + assert!(res_with_marks.is_err(), "hidden text with small font must block fit when marks are visible"); +} + +#[test] +fn fit_one_page_accounts_for_vertical_alignment_padding() { + let mut s = create_multiline_doc(26); + let extra_p = wordcraft_doc::Paragraph::with_text("Spill line on page 2.", CharProps::default()); + s.doc.body.push(wordcraft_doc::para_block(extra_p)); + + // Set page vertical alignment to Bottom + s.doc.last_section.valign = wordcraft_doc::section::PageVAlign::Bottom; + s.touch(); + s.relayout(); + assert_eq!(s.layout().pages.len(), 2); + + // Fit must succeed because the actual spill height (1 line) is well under 30% of page height, + // even though the line was translated to the bottom of page 2 by valign padding. + let res = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.5, "adjustSpacing": false})); + assert!(res.is_ok(), "fit must succeed with vertical alignment padding: {res:?}"); + assert_eq!(s.layout().pages.len(), 1); +} + +#[test] +fn fit_one_page_accounts_for_note_separator_decor_story() { + let mut lines = 28; + let mut s = create_multiline_doc(lines); + while s.layout().pages.len() < 2 && lines < 80 { + lines += 1; + s = create_multiline_doc(lines); + } + assert_eq!(s.layout().pages.len(), 2); + + // Set custom footnote separator story so layout produces DECOR_STORY + s.doc.footnote_separators.separator = Some(vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text("---", CharProps::default()))]); + + // Insert note reference into a paragraph on the final page (page 2) + let last_para_idx = s.doc.body.len().saturating_sub(1); + let note_p = wordcraft_doc::Paragraph::with_text("Final page footnote text.", CharProps::default()); + let note_id = s.doc.add_part(wordcraft_doc::PartKind::Footnote, vec![wordcraft_doc::para_block(note_p)]); + let noteref = wordcraft_doc::para::InlineObject::NoteRef { kind: wordcraft_doc::para::NoteKind::Footnote, id: note_id, custom: String::new() }; + s.doc.insert_object(&wordcraft_doc::Pos::body(last_para_idx, 0), noteref, &Default::default()).unwrap(); + s.touch(); + s.relayout(); + assert_eq!(s.layout().pages.len(), 2); + + // Verify layout produces DECOR_STORY on page 2 + let page2 = &s.layout().pages[1]; + let has_decor = + page2.items.iter().any(|item| matches!(item, wordcraft_layout::Placed::Lines { story, .. } if *story == wordcraft_layout::DECOR_STORY)); + assert!(has_decor, "footnote on page 2 must generate DECOR_STORY separator"); + + // Fit must succeed without DECOR_STORY inflating body_max_y + let res = s.run("layout.fitOnePage", &json!({})); + assert!(res.is_ok(), "fit with final page footnote and DECOR_STORY must succeed: {res:?}"); + assert_eq!(s.layout().pages.len(), 1); +} + +#[test] +fn fit_one_page_tracks_fixed_spacing_independent_of_paragraph_mark_size() { + let mut lines = 28; + let mut s = create_multiline_doc(lines); + let set_spacing = |s: &mut Session| { + for b in &mut s.doc.body { + if let wordcraft_doc::Block::Para(p) = std::sync::Arc::make_mut(b) { + p.props.line_spacing = Some(wordcraft_doc::props::LineSpacing::Exactly(16.0)); + } + } + s.touch(); + s.relayout(); + }; + set_spacing(&mut s); + while s.layout().pages.len() < 2 && lines < 80 { + lines += 1; + s = create_multiline_doc(lines); + set_spacing(&mut s); + } + assert_eq!(s.layout().pages.len(), 2); + + // Fit with spacing reduction + let res = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.0, "adjustSpacing": true})); + assert!(res.is_ok(), "spacing reduction must succeed: {res:?}"); + assert!(s.doc.fit_state.as_ref().is_some_and(|f| f.cum_space < 1.0)); + + // Change paragraph mark font size on paragraphs (e.g. from 12 pt to 24 pt) + for b in &mut s.doc.body { + if let wordcraft_doc::Block::Para(p) = std::sync::Arc::make_mut(b) { + p.mark.size = Some(24.0); + } + } + s.touch(); + s.relayout(); + + // Second fit with spacing only must be refused because spacing floor 0.94 was reached + // and tracked provenance was retained despite paragraph mark size change + let res2 = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.0, "adjustSpacing": true})); + assert!(res2.is_err(), "anti-compound guard must detect reduced fixed spacing and reject repeat compression"); +} + +#[test] +fn fit_one_page_reports_search_exhaustion_when_candidates_cannot_remove_page() { + let mut s = create_multiline_doc(27); + assert_eq!(s.layout().pages.len(), 2); + + // Calling with zero maxDeltaPt and adjustSpacing: false produces no candidate steps + let res = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.0, "adjustSpacing": false})); + assert!(res.is_err()); + let err = res.unwrap_err().to_string(); + assert!( + err.contains("no permitted typography adjustment could remove a page"), + "error message must distinguish search exhaustion from overflow limits: {err}" + ); +} + +#[test] +fn fit_one_page_excludes_line_number_decorations_from_final_page_limits() { + let mut lines = 33; + let enable_line_numbers = |s: &mut Session| { + s.doc.last_section.line_numbers = Some(wordcraft_doc::section::LineNumbering::default()); + s.touch(); + s.relayout(); + }; + let mut s = create_multiline_doc(lines); + enable_line_numbers(&mut s); + while s.layout().pages.len() < 2 && lines < 60 { + lines += 1; + s = create_multiline_doc(lines); + enable_line_numbers(&mut s); + } + assert_eq!(s.layout().pages.len(), 2); + + // Every body line on the final page also places a line-number decoration. Counting those as + // content would double the final-page line count and refuse before any candidate is tried. + let decorated = s.layout().pages[1] + .items + .iter() + .filter(|item| matches!(item, wordcraft_layout::Placed::Lines { story, .. } if *story == wordcraft_layout::DECOR_STORY)) + .count(); + assert!(decorated > 0, "the final page must carry line-number decorations"); + + let res = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 4.0})); + assert!(res.is_ok(), "line-number decorations must not count as final-page content: {res:?}"); + assert_eq!(s.layout().pages.len(), 1); +} + +#[test] +fn fit_one_page_counts_side_by_side_footnote_cells_once() { + let mut s = create_multiline_doc(27); + assert_eq!(s.layout().pages.len(), 2); + + // A footnote table whose row's cells sit side by side: their text occupies one vertical + // interval, so their heights must not be summed as if stacked. + let mut table = wordcraft_doc::Table::new(1, 3, 420.0); + let cell_text = "The quick brown fox jumps over the lazy dog again and again."; + for cell in &mut table.rows[0].cells { + cell.blocks = + vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(cell_text, CharProps { size: Some(21.0), ..Default::default() }))]; + } + let note_blocks: Vec> = vec![std::sync::Arc::new(wordcraft_doc::Block::Table(table))]; + let note_id = s.doc.add_part(wordcraft_doc::PartKind::Footnote, note_blocks); + + let last_para_idx = s.doc.body.len().saturating_sub(1); + let noteref = wordcraft_doc::para::InlineObject::NoteRef { kind: wordcraft_doc::para::NoteKind::Footnote, id: note_id, custom: String::new() }; + s.doc.insert_object(&wordcraft_doc::Pos::body(last_para_idx, 0), noteref, &Default::default()).unwrap(); + s.touch(); + s.relayout(); + assert_eq!(s.layout().pages.len(), 2); + + let note_cells = s.layout().pages[1] + .items + .iter() + .filter(|item| matches!(item, wordcraft_layout::Placed::Cell { story: wordcraft_doc::StoryRef::Part(id), .. } if *id == note_id)) + .count(); + assert!(note_cells >= 2, "the footnote table must place its cells side by side on the final page"); + + let res = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 4.0})); + assert!(res.is_ok(), "side-by-side footnote cells must count once in the fill height: {res:?}"); + assert_eq!(s.layout().pages.len(), 1); +} + +#[test] +fn fit_one_page_refuses_when_formatting_changes_cannot_be_tracked() { + let mut s = create_multiline_doc(27); + assert_eq!(s.layout().pages.len(), 2); + s.doc.settings.track_changes = true; + s.prefs.markup.track_formatting = true; + let before = s.doc.clone(); + + // Style reductions cannot be recorded as formatting revisions, so the command must refuse + // rather than change typography that rejecting all changes cannot undo. + let res = s.run("layout.fitOnePage", &json!({})); + assert!(res.is_err(), "layout.fitOnePage must refuse while formatting is tracked"); + let err = res.unwrap_err().to_string(); + assert!(err.contains("tracks formatting changes"), "unexpected error: {err}"); + assert_eq!(s.doc, before, "a refused fit must leave the document unchanged"); +} diff --git a/crates/engine/src/tests/font_sizes.rs b/crates/engine/src/tests/font_sizes.rs index ad337534d6..8db1332851 100644 --- a/crates/engine/src/tests/font_sizes.rs +++ b/crates/engine/src/tests/font_sizes.rs @@ -1,230 +1,230 @@ -//! Relative font sizes keep each selected run's size (#4903). - -use serde_json::json; -use wordcraft_doc::{Document, Pos}; - -use crate::Session; - -fn mixed() -> Session { - let mut doc = Document::from_text("Large heading\nSmall detail"); - for (block, size) in [(0, 20.0), (1, 10.0)] { - let end = doc.para_at(&Pos::body(block, 0)).unwrap().len(); - doc.format_range(&Pos::body(block, 0), &Pos::body(block, end), &|c| { - c.size = Some(size); - c.size_cs = Some(size); - }) - .unwrap(); - } - let mut s = Session::new(doc); - s.run("select.all", &json!({})).unwrap(); - s -} - -fn sizes(s: &Session) -> Vec { - (0..2) - .map(|block| { - let p = s.doc.para_at(&Pos::body(block, 0)).unwrap(); - s.doc.styles.resolve_char(p.props.style.as_deref(), p.props_of_char(0)).size - }) - .collect() -} - -#[test] -fn relative_font_sizes_preserve_mixed_runs_and_one_undo() { - for (command, expected) in [ - ("format.growFont", [22.0, 10.5]), - ("format.shrinkFont", [18.0, 9.0]), - ("format.growFont1", [21.0, 11.0]), - ("format.shrinkFont1", [19.0, 9.0]), - ] { - let mut s = mixed(); - let before = s.doc.clone(); - let selection = s.sel.clone(); - let depth = s.undo_depth(); - s.run(command, &json!({})).unwrap(); - assert_eq!(sizes(&s), expected, "{command}"); - assert_eq!(s.sel, selection); - assert_eq!(s.undo_depth(), depth + 1); - let after = s.doc.clone(); - s.run("edit.undo", &json!({})).unwrap(); - assert_eq!(s.doc, before); - s.run("edit.redo", &json!({})).unwrap(); - assert_eq!(s.doc, after); - } -} - -#[test] -fn relative_font_sizes_resolve_styles_and_preserve_other_properties() { - use wordcraft_doc::props::CharProps; - use wordcraft_doc::styles::{Style, StyleKind}; - - let mut s = mixed(); - let styles = std::sync::Arc::make_mut(&mut s.doc.styles); - styles.default_chr.size_cs = Some(30.0); - styles.upsert(Style { - id: "Large".into(), - name: "Large".into(), - based_on: Some("Normal".into()), - chr: CharProps { size: Some(20.0), bold: Some(true), ..Default::default() }, - ..Default::default() - }); - styles.upsert(Style { - id: "Small".into(), - name: "Small".into(), - kind: StyleKind::Character, - chr: CharProps { size: Some(10.0), italic: Some(true), ..Default::default() }, - ..Default::default() - }); - for block in 0..2 { - let p = s.doc.para_mut(wordcraft_doc::StoryRef::Body, &wordcraft_doc::Path::top(block)).unwrap(); - p.props.style = Some(if block == 0 { "Large" } else { "Normal" }.into()); - for c in std::iter::once(&mut p.mark).chain(p.runs.iter_mut().map(|r| &mut r.props)) { - c.size = None; - c.size_cs = None; - if block == 1 { - c.style = Some("Small".into()); - } - } - } - s.run("format.growFont", &json!({})).unwrap(); - assert_eq!(sizes(&s), [22.0, 10.5]); - for (block, size) in [(0, 22.0), (1, 10.5)] { - let p = s.doc.para_at(&Pos::body(block, 0)).unwrap(); - assert_eq!(p.mark.size, Some(size)); - for (_, c) in p.run_ranges() { - assert_eq!((c.size, c.size_cs), (Some(size), Some(size)), "both scripts still get the explicit override"); - let resolved = s.doc.styles.resolve_char(p.props.style.as_deref(), c); - assert!(if block == 0 { resolved.bold } else { resolved.italic }); - } - } -} - -#[test] -fn relative_font_sizes_change_only_the_selected_unicode_pieces() { - let mut s = Session::new(Document::from_text("éA中Bz")); - s.doc.format_range(&Pos::body(0, 2), &Pos::body(0, 3), &|c| c.size = Some(20.0)).unwrap(); - s.doc.format_range(&Pos::body(0, 3), &Pos::body(0, 6), &|c| c.size = Some(10.0)).unwrap(); - let before = s.doc.clone(); - s.run("select.range", &json!({"anchor": Pos::body(0, 2), "focus": Pos::body(0, 6)})).unwrap(); - s.run("format.growFont1", &json!({})).unwrap(); - let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); - let original = before.para_at(&Pos::body(0, 0)).unwrap(); - assert_eq!(p.text, "éA中Bz"); - assert_eq!(p.props_of_char(2).size, Some(21.0)); - assert_eq!(p.props_of_char(3).size, Some(11.0)); - assert_eq!(p.props_of_char(0), original.props_of_char(0)); - assert_eq!(p.props_of_char(6), original.props_of_char(6)); - assert_eq!(p.mark, original.mark, "a partial selection leaves the paragraph mark alone"); -} - -#[test] -fn relative_font_sizes_keep_the_caret_and_word_targets() { - let mut s = Session::new(Document::new()); - s.run("format.size", &json!({"size": 10})).unwrap(); - for size in [11.0, 12.0] { - s.run("format.growFont1", &json!({})).unwrap(); - assert_eq!(s.pending.as_ref().unwrap().size, Some(size)); - assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().mark.size, Some(size)); - } - s.run("text.insert", &json!({"text": "abcd ef"})).unwrap(); - s.doc.format_range(&Pos::body(0, 0), &Pos::body(0, 2), &|c| c.size = Some(20.0)).unwrap(); - s.doc.format_range(&Pos::body(0, 2), &Pos::body(0, 4), &|c| c.size = Some(10.0)).unwrap(); - s.run("caret.set", &json!({"pos": Pos::body(0, 2)})).unwrap(); - let selection = s.sel.clone(); - s.run("format.growFont1", &json!({})).unwrap(); - assert_eq!(s.sel, selection); - let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); - assert_eq!((p.props_of_char(0).size, p.props_of_char(2).size, p.props_of_char(5).size), (Some(21.0), Some(11.0), Some(12.0))); -} - -#[test] -fn relative_font_sizes_keep_column_cell_and_text_box_targets() { - use wordcraft_doc::{Path, StoryRef}; - - let mut s = super::column_doc(); - super::block(&mut s); - for (block, size) in [(0, 20.0), (1, 10.0), (2, 14.0)] { - s.doc.format_range(&Pos::body(block, 0), &Pos::body(block, 6), &|c| c.size = Some(size)).unwrap(); - } - s.run("format.growFont1", &json!({})).unwrap(); - assert!(s.column_segments().is_some()); - for (block, size) in [(0, 20.0), (1, 10.0), (2, 14.0)] { - let p = s.doc.para_at(&Pos::body(block, 0)).unwrap(); - assert_eq!((p.props_of_char(1).size, p.props_of_char(2).size, p.props_of_char(4).size), (Some(size), Some(size + 1.0), Some(size))); - assert_eq!(p.mark.size, Some(size)); - } - - let mut s = Session::new(Document::new()); - s.run("insert.table", &json!({"rows": 1, "cols": 2})).unwrap(); - let paths = [Path(vec![0, 0, 0, 0]), Path(vec![0, 0, 1, 0])]; - for (path, size) in paths.iter().zip([20.0, 10.0]) { - s.doc.para_mut(StoryRef::Body, path).unwrap().mark.size = Some(size); - } - s.run("table.selectTable", &json!({})).unwrap(); - let _ = s.layout(); - s.run("format.growFont1", &json!({})).unwrap(); - for (path, size) in paths.iter().zip([21.0, 11.0]) { - assert_eq!(s.doc.para(StoryRef::Body, path).unwrap().mark.size, Some(size), "empty cells retain distinct sizes"); - } - - let mut s = Session::new(Document::from_text("Body")); - let id = s.run("insert.textBox", &json!({"text": "AB"})).unwrap()["story"].as_u64().unwrap() as u32; - let story = StoryRef::Part(id); - let at = |off| Pos { story, path: Path::top(0), off }; - for (a, b, size) in [(0, 1, 20.0), (1, 2, 10.0)] { - s.doc.format_range(&at(a), &at(b), &|c| c.size = Some(size)).unwrap(); - } - s.run("select.range", &json!({"anchor": Pos::body(0, 0), "focus": Pos::body(0, "\u{fffc}".len())})).unwrap(); - let before = s.doc.para_at(&Pos::body(0, 0)).unwrap().clone(); - s.run("format.growFont1", &json!({})).unwrap(); - assert_eq!(*s.doc.para_at(&Pos::body(0, 0)).unwrap(), before); - let p = s.doc.para(story, &Path::top(0)).unwrap(); - assert_eq!((p.props_of_char(0).size, p.props_of_char(1).size), (Some(21.0), Some(11.0))); -} - -#[test] -fn relative_font_sizes_keep_bounds_protection_and_saved_sizes() { - for (command, values, expected) in [ - ("format.growFont", [72.0, 1638.0], [80.0, 1638.0]), - ("format.shrinkFont", [1.0, 8.0], [1.0, 1.0]), - ("format.growFont1", [1.0, 1638.0], [2.0, 1638.0]), - ("format.shrinkFont1", [1.0, 1638.0], [1.0, 1637.0]), - ] { - let mut s = mixed(); - for (block, size) in values.into_iter().enumerate() { - let end = s.doc.para_at(&Pos::body(block, 0)).unwrap().len(); - s.doc.format_range(&Pos::body(block, 0), &Pos::body(block, end), &|c| c.size = Some(size)).unwrap(); - } - s.run(command, &json!({})).unwrap(); - assert_eq!(sizes(&s), expected, "{command}"); - let bytes = crate::io::save_bytes("sizes.docx", &s.doc).unwrap(); - let reopened = Session::new(crate::io::open_bytes("sizes.docx", &bytes).unwrap()); - assert_eq!(sizes(&reopened), expected, "saved {command}"); - s.run("review.restrict", &json!({"mode": "readOnly"})).unwrap(); - let before = s.doc.clone(); - let depth = s.undo_depth(); - assert!(s.run(command, &json!({})).is_err()); - assert_eq!(s.doc, before); - assert_eq!(s.undo_depth(), depth); - } -} - -#[test] -fn relative_font_sizes_keep_paragraph_mark_link_filtering() { - let mut s = mixed(); - for (block, style) in [(0, "Hyperlink"), (1, "CustomLink")] { - let p = s.doc.para_mut(wordcraft_doc::StoryRef::Body, &wordcraft_doc::Path::top(block)).unwrap(); - p.mark.link = Some("https://example.test/mark".into()); - p.mark.style = Some(style.into()); - p.runs[0].props.link = Some("https://example.test/text".into()); - p.runs[0].props.style = Some("Hyperlink".into()); - } - s.run("format.growFont", &json!({})).unwrap(); - assert_eq!(sizes(&s), [22.0, 10.5]); - for block in 0..2 { - let p = s.doc.para_at(&Pos::body(block, 0)).unwrap(); - assert_eq!(p.mark.link, None); - assert_eq!(p.mark.style.as_deref(), if block == 0 { None } else { Some("CustomLink") }); - assert!(p.run_ranges().all(|(_, c)| c.link.as_deref() == Some("https://example.test/text") && c.style.as_deref() == Some("Hyperlink"))); - } -} +//! Relative font sizes keep each selected run's size (#4903). + +use serde_json::json; +use wordcraft_doc::{Document, Pos}; + +use crate::Session; + +fn mixed() -> Session { + let mut doc = Document::from_text("Large heading\nSmall detail"); + for (block, size) in [(0, 20.0), (1, 10.0)] { + let end = doc.para_at(&Pos::body(block, 0)).unwrap().len(); + doc.format_range(&Pos::body(block, 0), &Pos::body(block, end), &|c| { + c.size = Some(size); + c.size_cs = Some(size); + }) + .unwrap(); + } + let mut s = Session::new(doc); + s.run("select.all", &json!({})).unwrap(); + s +} + +fn sizes(s: &Session) -> Vec { + (0..2) + .map(|block| { + let p = s.doc.para_at(&Pos::body(block, 0)).unwrap(); + s.doc.styles.resolve_char(p.props.style.as_deref(), p.props_of_char(0)).size + }) + .collect() +} + +#[test] +fn relative_font_sizes_preserve_mixed_runs_and_one_undo() { + for (command, expected) in [ + ("format.growFont", [22.0, 10.5]), + ("format.shrinkFont", [18.0, 9.0]), + ("format.growFont1", [21.0, 11.0]), + ("format.shrinkFont1", [19.0, 9.0]), + ] { + let mut s = mixed(); + let before = s.doc.clone(); + let selection = s.sel.clone(); + let depth = s.undo_depth(); + s.run(command, &json!({})).unwrap(); + assert_eq!(sizes(&s), expected, "{command}"); + assert_eq!(s.sel, selection); + assert_eq!(s.undo_depth(), depth + 1); + let after = s.doc.clone(); + s.run("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc, before); + s.run("edit.redo", &json!({})).unwrap(); + assert_eq!(s.doc, after); + } +} + +#[test] +fn relative_font_sizes_resolve_styles_and_preserve_other_properties() { + use wordcraft_doc::props::CharProps; + use wordcraft_doc::styles::{Style, StyleKind}; + + let mut s = mixed(); + let styles = std::sync::Arc::make_mut(&mut s.doc.styles); + styles.default_chr.size_cs = Some(30.0); + styles.upsert(Style { + id: "Large".into(), + name: "Large".into(), + based_on: Some("Normal".into()), + chr: CharProps { size: Some(20.0), bold: Some(true), ..Default::default() }, + ..Default::default() + }); + styles.upsert(Style { + id: "Small".into(), + name: "Small".into(), + kind: StyleKind::Character, + chr: CharProps { size: Some(10.0), italic: Some(true), ..Default::default() }, + ..Default::default() + }); + for block in 0..2 { + let p = s.doc.para_mut(wordcraft_doc::StoryRef::Body, &wordcraft_doc::Path::top(block)).unwrap(); + p.props.style = Some(if block == 0 { "Large" } else { "Normal" }.into()); + for c in std::iter::once(&mut p.mark).chain(p.runs.iter_mut().map(|r| &mut r.props)) { + c.size = None; + c.size_cs = None; + if block == 1 { + c.style = Some("Small".into()); + } + } + } + s.run("format.growFont", &json!({})).unwrap(); + assert_eq!(sizes(&s), [22.0, 10.5]); + for (block, size) in [(0, 22.0), (1, 10.5)] { + let p = s.doc.para_at(&Pos::body(block, 0)).unwrap(); + assert_eq!(p.mark.size, Some(size)); + for (_, c) in p.run_ranges() { + assert_eq!((c.size, c.size_cs), (Some(size), Some(size)), "both scripts still get the explicit override"); + let resolved = s.doc.styles.resolve_char(p.props.style.as_deref(), c); + assert!(if block == 0 { resolved.bold } else { resolved.italic }); + } + } +} + +#[test] +fn relative_font_sizes_change_only_the_selected_unicode_pieces() { + let mut s = Session::new(Document::from_text("éA中Bz")); + s.doc.format_range(&Pos::body(0, 2), &Pos::body(0, 3), &|c| c.size = Some(20.0)).unwrap(); + s.doc.format_range(&Pos::body(0, 3), &Pos::body(0, 6), &|c| c.size = Some(10.0)).unwrap(); + let before = s.doc.clone(); + s.run("select.range", &json!({"anchor": Pos::body(0, 2), "focus": Pos::body(0, 6)})).unwrap(); + s.run("format.growFont1", &json!({})).unwrap(); + let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); + let original = before.para_at(&Pos::body(0, 0)).unwrap(); + assert_eq!(p.text, "éA中Bz"); + assert_eq!(p.props_of_char(2).size, Some(21.0)); + assert_eq!(p.props_of_char(3).size, Some(11.0)); + assert_eq!(p.props_of_char(0), original.props_of_char(0)); + assert_eq!(p.props_of_char(6), original.props_of_char(6)); + assert_eq!(p.mark, original.mark, "a partial selection leaves the paragraph mark alone"); +} + +#[test] +fn relative_font_sizes_keep_the_caret_and_word_targets() { + let mut s = Session::new(Document::new()); + s.run("format.size", &json!({"size": 10})).unwrap(); + for size in [11.0, 12.0] { + s.run("format.growFont1", &json!({})).unwrap(); + assert_eq!(s.pending.as_ref().unwrap().size, Some(size)); + assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().mark.size, Some(size)); + } + s.run("text.insert", &json!({"text": "abcd ef"})).unwrap(); + s.doc.format_range(&Pos::body(0, 0), &Pos::body(0, 2), &|c| c.size = Some(20.0)).unwrap(); + s.doc.format_range(&Pos::body(0, 2), &Pos::body(0, 4), &|c| c.size = Some(10.0)).unwrap(); + s.run("caret.set", &json!({"pos": Pos::body(0, 2)})).unwrap(); + let selection = s.sel.clone(); + s.run("format.growFont1", &json!({})).unwrap(); + assert_eq!(s.sel, selection); + let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); + assert_eq!((p.props_of_char(0).size, p.props_of_char(2).size, p.props_of_char(5).size), (Some(21.0), Some(11.0), Some(12.0))); +} + +#[test] +fn relative_font_sizes_keep_column_cell_and_text_box_targets() { + use wordcraft_doc::{Path, StoryRef}; + + let mut s = super::column_doc(); + super::block(&mut s); + for (block, size) in [(0, 20.0), (1, 10.0), (2, 14.0)] { + s.doc.format_range(&Pos::body(block, 0), &Pos::body(block, 6), &|c| c.size = Some(size)).unwrap(); + } + s.run("format.growFont1", &json!({})).unwrap(); + assert!(s.column_segments().is_some()); + for (block, size) in [(0, 20.0), (1, 10.0), (2, 14.0)] { + let p = s.doc.para_at(&Pos::body(block, 0)).unwrap(); + assert_eq!((p.props_of_char(1).size, p.props_of_char(2).size, p.props_of_char(4).size), (Some(size), Some(size + 1.0), Some(size))); + assert_eq!(p.mark.size, Some(size)); + } + + let mut s = Session::new(Document::new()); + s.run("insert.table", &json!({"rows": 1, "cols": 2})).unwrap(); + let paths = [Path(vec![0, 0, 0, 0]), Path(vec![0, 0, 1, 0])]; + for (path, size) in paths.iter().zip([20.0, 10.0]) { + s.doc.para_mut(StoryRef::Body, path).unwrap().mark.size = Some(size); + } + s.run("table.selectTable", &json!({})).unwrap(); + let _ = s.layout(); + s.run("format.growFont1", &json!({})).unwrap(); + for (path, size) in paths.iter().zip([21.0, 11.0]) { + assert_eq!(s.doc.para(StoryRef::Body, path).unwrap().mark.size, Some(size), "empty cells retain distinct sizes"); + } + + let mut s = Session::new(Document::from_text("Body")); + let id = s.run("insert.textBox", &json!({"text": "AB"})).unwrap()["story"].as_u64().unwrap() as u32; + let story = StoryRef::Part(id); + let at = |off| Pos { story, path: Path::top(0), off }; + for (a, b, size) in [(0, 1, 20.0), (1, 2, 10.0)] { + s.doc.format_range(&at(a), &at(b), &|c| c.size = Some(size)).unwrap(); + } + s.run("select.range", &json!({"anchor": Pos::body(0, 0), "focus": Pos::body(0, "\u{fffc}".len())})).unwrap(); + let before = s.doc.para_at(&Pos::body(0, 0)).unwrap().clone(); + s.run("format.growFont1", &json!({})).unwrap(); + assert_eq!(*s.doc.para_at(&Pos::body(0, 0)).unwrap(), before); + let p = s.doc.para(story, &Path::top(0)).unwrap(); + assert_eq!((p.props_of_char(0).size, p.props_of_char(1).size), (Some(21.0), Some(11.0))); +} + +#[test] +fn relative_font_sizes_keep_bounds_protection_and_saved_sizes() { + for (command, values, expected) in [ + ("format.growFont", [72.0, 1638.0], [80.0, 1638.0]), + ("format.shrinkFont", [1.0, 8.0], [1.0, 1.0]), + ("format.growFont1", [1.0, 1638.0], [2.0, 1638.0]), + ("format.shrinkFont1", [1.0, 1638.0], [1.0, 1637.0]), + ] { + let mut s = mixed(); + for (block, size) in values.into_iter().enumerate() { + let end = s.doc.para_at(&Pos::body(block, 0)).unwrap().len(); + s.doc.format_range(&Pos::body(block, 0), &Pos::body(block, end), &|c| c.size = Some(size)).unwrap(); + } + s.run(command, &json!({})).unwrap(); + assert_eq!(sizes(&s), expected, "{command}"); + let bytes = crate::io::save_bytes("sizes.docx", &s.doc).unwrap(); + let reopened = Session::new(crate::io::open_bytes("sizes.docx", &bytes).unwrap()); + assert_eq!(sizes(&reopened), expected, "saved {command}"); + s.run("review.restrict", &json!({"mode": "readOnly"})).unwrap(); + let before = s.doc.clone(); + let depth = s.undo_depth(); + assert!(s.run(command, &json!({})).is_err()); + assert_eq!(s.doc, before); + assert_eq!(s.undo_depth(), depth); + } +} + +#[test] +fn relative_font_sizes_keep_paragraph_mark_link_filtering() { + let mut s = mixed(); + for (block, style) in [(0, "Hyperlink"), (1, "CustomLink")] { + let p = s.doc.para_mut(wordcraft_doc::StoryRef::Body, &wordcraft_doc::Path::top(block)).unwrap(); + p.mark.link = Some("https://example.test/mark".into()); + p.mark.style = Some(style.into()); + p.runs[0].props.link = Some("https://example.test/text".into()); + p.runs[0].props.style = Some("Hyperlink".into()); + } + s.run("format.growFont", &json!({})).unwrap(); + assert_eq!(sizes(&s), [22.0, 10.5]); + for block in 0..2 { + let p = s.doc.para_at(&Pos::body(block, 0)).unwrap(); + assert_eq!(p.mark.link, None); + assert_eq!(p.mark.style.as_deref(), if block == 0 { None } else { Some("CustomLink") }); + assert!(p.run_ranges().all(|(_, c)| c.link.as_deref() == Some("https://example.test/text") && c.style.as_deref() == Some("Hyperlink"))); + } +} diff --git a/crates/engine/src/tests/fonts.rs b/crates/engine/src/tests/fonts.rs index 960c56329f..33f74d81ee 100644 --- a/crates/engine/src/tests/fonts.rs +++ b/crates/engine/src/tests/fonts.rs @@ -1,511 +1,511 @@ -//! Fonts added at run time (#582): `font.add`, `font.list`, `font.remove`. - -use serde_json::json; -use wordcraft_doc::Pos; - -use crate::Session; -use crate::cmd::insert::base64_encode; - -/// The bundled face `file` of family `original` renamed `family` (as long as `original`), so it -/// is a font the database doesn't have. -fn renamed(file: &str, original: &str, family: &str) -> Vec { - assert_eq!(family.len(), original.len()); - let mut data = std::fs::read(std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets/fonts").join(file)).unwrap(); - let utf16 = |s: &str| s.encode_utf16().flat_map(u16::to_be_bytes).collect::>(); - for (from, to) in [(utf16(original), utf16(family)), (original.as_bytes().to_vec(), family.as_bytes().to_vec())] { - let mut i = 0; - while let Some(at) = data[i..].windows(from.len()).position(|w| w == from) { - data[i + at..i + at + to.len()].copy_from_slice(&to); - i += at + to.len(); - } - } - data -} - -/// The caret's x after `text` set in `font`. -fn text_end_x(s: &mut Session, font: &str, text: &str) -> f32 { - s.run("document.setText", &json!({"text": text})).unwrap(); - s.run("select.all", &json!({})).unwrap(); - s.run("format.font", &json!({"name": font})).unwrap(); - s.layout().caret_on(&Pos::body(0, text.len()), 0).unwrap().x -} - -#[test] -fn an_added_font_is_listed_and_lays_out_text_already_set_in_it() { - // A monospaced face, so it measures unlike whatever proportional font the system falls back - // to (Source Serif 4 itself where no system fonts are installed, as on the FreeBSD runner). - // The name has no Serif/Sans/Mono in it, which would pick a fallback of that kind. - const FAMILY: &str = "Cmdfont Face 4"; - // Narrow letters: set monospaced they take far more room than in any proportional font. - const TEXT: &str = "illicit little lilies fill tiny jill"; - let mut s = Session::new(wordcraft_doc::Document::new()); - let mono = text_end_x(&mut s, "JetBrains Mono", TEXT); - // Not installed yet: the text falls back to another font. - let missing = text_end_x(&mut s, FAMILY, TEXT); - assert!((missing - mono).abs() > 1.0, "the fallback measures differently: {missing} vs {mono}"); - - let data = base64_encode(&renamed("JetBrainsMono-Regular.ttf", "JetBrains Mono", FAMILY)); - let r = s.run("font.add", &json!({"data": data, "name": "CmdfontFace4-Regular.ttf"})).unwrap(); - assert_eq!(r["families"], json!([FAMILY])); - assert_eq!(r["faces"][0]["style"], "Regular"); - // The text already set in it re-lays out with it, without touching the document. - let x = s.layout().caret_on(&Pos::body(0, TEXT.len()), 0).unwrap().x; - assert!((x - mono).abs() < 0.01, "measured with the added font: {x} vs {mono} (missing {missing})"); - - let list = s.run("font.list", &json!({"all": true})).unwrap(); - assert!(list["fonts"].as_array().unwrap().iter().any(|f| f["families"] == json!([FAMILY]))); - assert!(list["families"].as_array().unwrap().iter().any(|f| f == FAMILY)); - // It exports to PDF like any other font. - assert!(crate::io::save_bytes("out.pdf", &s.doc).unwrap().starts_with(b"%PDF")); - - // Adding it again changes nothing. - let again = s.run("font.add", &json!({"data": data, "name": "again.ttf"})); - assert!(again.unwrap_err().to_string().contains("already available")); - - let removed = s.run("font.remove", &json!({"family": FAMILY})).unwrap(); - assert_eq!(removed["removed"].as_array().unwrap().len(), 1); - let x = s.layout().caret_on(&Pos::body(0, TEXT.len()), 0).unwrap().x; - assert!((x - missing).abs() < 0.01, "falls back again once removed"); - assert!(s.run("font.remove", &json!({"family": FAMILY})).is_err()); -} - -#[test] -fn adding_a_font_never_changes_text_that_does_not_use_it() { - const FAMILY: &str = "Metric Serif 4"; - let mut s = Session::new(crate::sample::sample_document()); - let before = s.layout(); - let positions = |s: &mut Session| { - let l = s.layout(); - (0..s.doc.body.len().min(12)).filter_map(|b| l.caret_on(&Pos::body(b, 0), 0).map(|c| (c.page, c.x, c.top))).collect::>() - }; - let a = positions(&mut s); - s.run("font.add", &json!({"data": base64_encode(&renamed("SourceSerif4-Bold.ttf", "Source Serif 4", FAMILY)), "name": "m.ttf"})).unwrap(); - assert_eq!(positions(&mut s), a); - assert_eq!(s.layout().pages.len(), before.pages.len()); -} - -#[test] -fn bad_font_input_is_an_error() { - let mut s = Session::new(wordcraft_doc::Document::new()); - for p in [ - json!({"data": base64_encode(b"definitely not a font")}), - json!({"data": "%%% not base64"}), - json!({"data": base64_encode(b"wOF2\0\x01\0\0\xff\xff\xff\xff")}), - json!({"path": "/nonexistent/font.ttf"}), - json!({"path": env!("CARGO_MANIFEST_DIR")}), - json!({"path": concat!(env!("CARGO_MANIFEST_DIR"), "/Cargo.toml")}), - json!({"url": "https://fonts.googleapis.com/css2?family=Roboto"}), - ] { - assert!(s.run("font.add", &p).is_err(), "{p}"); - } - assert!(s.run("font.remove", &json!({})).is_err()); - assert!(s.run("font.remove", &json!({"family": "Source Sans 3"})).is_err(), "the bundled fonts stay"); - // No file: the UI is asked for a picker. - let r = s.run("font.add", &json!({})).unwrap(); - assert_eq!(r["pending"], true); - assert!(s.ui_requests.iter().any(|r| r["open"] == "addFont")); - - let r = s.run("font.loadSystem", &json!({})).unwrap(); - assert_eq!(r["pending"], true); - assert!(s.ui_requests.iter().any(|r| r["open"] == "loadSystemFonts")); -} - -/// A .docx whose text is set in `family`, embedding `font` through the font table (#1171): as an -/// obfuscated `.odttf` when `key` is a real GUID, as a plain `.ttf` when it is all zeros. -fn docx_embedding(family: &str, font: Option<(&[u8], &str)>, text: &str) -> Vec { - const W: &str = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"; - const R: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; - const PR: &str = "http://schemas.openxmlformats.org/package/2006/relationships"; - let rels = |items: &[(&str, &str)]| { - let list: String = items.iter().map(|(t, g)| format!(r#""#)).collect(); - format!(r#"{list}"#) - }; - let mut files: Vec<(String, Vec)> = vec![ - ( - "[Content_Types].xml".into(), - br#""#.to_vec(), - ), - ("_rels/.rels".into(), rels(&[("officeDocument", "word/document.xml")]).into_bytes()), - ("word/_rels/document.xml.rels".into(), rels(&[("fontTable", "fontTable.xml")]).into_bytes()), - ( - "word/document.xml".into(), - format!(r#"{text}"#).into_bytes(), - ), - ]; - let mut table = format!(r#""#); - if let Some((data, key)) = font { - let digits = key.replace(['{', '}', '-'], ""); - let hex: Vec = (0..16).map(|i| u8::from_str_radix(&digits[i * 2..i * 2 + 2], 16).unwrap()).collect(); - let zero = hex.iter().all(|b| *b == 0); - let mut data = data.to_vec(); - // Obfuscation (ECMA-376 Part 1, 17.8.1): the first 32 bytes XOR the key's bytes, reversed. - for i in 0..32 { - data[i] ^= hex[15 - i % 16]; - } - let ext = if zero { "ttf" } else { "odttf" }; - table.push_str(&format!(r#""#)); - files.push(("word/_rels/fontTable.xml.rels".into(), rels(&[("font", &format!("fonts/font1.{ext}"))]).into_bytes())); - files.push((format!("word/fonts/font1.{ext}"), data)); - } - table.push_str(""); - files.push(("word/fontTable.xml".into(), table.into_bytes())); - let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); - for (name, bytes) in files { - zip.start_file(name, zip::write::SimpleFileOptions::default()).unwrap(); - std::io::Write::write_all(&mut zip, &bytes).unwrap(); - } - zip.finish().unwrap().into_inner() -} - -#[test] -fn fonts_embedded_in_a_docx_lay_out_its_text() { - const TEXT: &str = "illicit little lilies fill tiny jill"; - let end_x = - |bytes: &[u8]| Session::new(crate::io::open_bytes("e.docx", bytes).unwrap()).layout().caret_on(&Pos::body(0, TEXT.len()), 0).unwrap().x; - let mono = end_x(&docx_embedding("JetBrains Mono", None, TEXT)); - // Both forms Word and other producers write: an obfuscated .odttf with its key, a plain .ttf - // with the all-zero key. Each is a family no other test or system has. - for (family, key) in [("JetBrains Odtt", "{9F1C2A4E-7B3D-4C58-A1E6-0D2B8F5C3E77}"), ("JetBrains Zero", "{00000000-0000-0000-0000-000000000000}")] - { - let font = renamed("JetBrainsMono-Regular.ttf", "JetBrains Mono", family); - let missing = end_x(&docx_embedding(family, None, TEXT)); - assert!((missing - mono).abs() > 1.0, "{family}: not embedded, the text falls back: {missing} vs {mono}"); - let x = end_x(&docx_embedding(family, Some((&font, key)), TEXT)); - assert!((x - mono).abs() < 0.01, "{family}: measured with the embedded font: {x} vs {mono} (missing {missing})"); - } -} - -/// A font Word embeds as a subset ("embed only the characters used") goes by a made-up family -/// inside (`___WRD_EMBED_SUB_46`), and the text asks for it by the font table's name -/// (`OBRBWW+Century Schoolbook`): the text is measured with it all the same (#2240). It fell back -/// to a substitute, as if nothing were embedded. -#[test] -fn a_subset_font_word_embeds_lays_out_text_set_in_its_table_name() { - const TEXT: &str = "illicit little lilies fill tiny jill"; - const FAMILY: &str = "QWERTY+JetBrains Sub"; - let end_x = - |bytes: &[u8]| Session::new(crate::io::open_bytes("e.docx", bytes).unwrap()).layout().caret_on(&Pos::body(0, TEXT.len()), 0).unwrap().x; - let mono = end_x(&docx_embedding("JetBrains Mono", None, TEXT)); - let missing = end_x(&docx_embedding(FAMILY, None, TEXT)); - assert!((missing - mono).abs() > 1.0, "not embedded, the text falls back: {missing} vs {mono}"); - let subset = renamed("JetBrainsMono-Regular.ttf", "JetBrains Mono", "___WRD_EMBED_7"); - let x = end_x(&docx_embedding(FAMILY, Some((&subset, "{6A0E3B57-1C2D-4E8F-9A7B-3D5C1E0F2B64}")), TEXT)); - assert!((x - mono).abs() < 0.01, "measured with the embedded subset: {x} vs {mono} (missing {missing})"); -} - -/// `font.missing`'s fonts, as "font>shownAs". -fn missing_list(s: &mut Session) -> Vec { - let m = s.run("font.missing", &json!({})).unwrap(); - m["missing"] - .as_array() - .unwrap() - .iter() - .map(|f| { - let shown: Vec<&str> = f["shownAs"].as_array().unwrap().iter().map(|x| x.as_str().unwrap()).collect(); - format!("{}>{}", f["font"].as_str().unwrap(), shown.join(",")) - }) - .collect() -} - -/// The missing fonts of `doc` as laid out. -fn missing_in(doc: wordcraft_doc::Document) -> Vec { - let mut s = Session::new(doc); - let layout = s.layout(); - crate::cmd::font::missing_fonts(&s.doc, &layout) -} - -/// A paragraph of `text` set in `font`. -fn para_in(text: &str, font: &str) -> wordcraft_doc::Paragraph { - wordcraft_doc::Paragraph::with_text(text, wordcraft_doc::CharProps { font: Some(font.into()), ..Default::default() }) -} - -/// A document of these paragraphs. -fn doc_of(paras: Vec) -> wordcraft_doc::Document { - let mut doc = wordcraft_doc::Document::new(); - doc.body = paras.into_iter().map(wordcraft_doc::para_block).collect(); - doc -} - -#[test] -fn fonts_that_are_not_installed_are_reported_with_the_font_shown_instead() { - // A family no system has, with no Serif/Mono in its name: it's drawn in a sans substitute. - const MISSING: &str = "Wordcraft Nowhere Face"; - let mut s = Session::new(wordcraft_doc::Document::new()); - s.run("document.setText", &json!({"text": "Bundled\nMissing"})).unwrap(); - s.run("select.all", &json!({})).unwrap(); - s.run("format.font", &json!({"name": "JetBrains Mono"})).unwrap(); - assert!(s.run("format.state", &json!({})).unwrap()["fontShownAs"].is_null(), "a bundled font is installed"); - assert_eq!(missing_list(&mut s), Vec::::new()); - - s.run("select.text", &json!({"text": "Missing"})).unwrap(); - s.run("format.font", &json!({"name": MISSING})).unwrap(); - let st = s.run("format.state", &json!({})).unwrap(); - assert_eq!(st["font"], MISSING); - let shown = st["fontShownAs"].as_str().unwrap_or_default().to_string(); - assert!(!shown.is_empty() && shown != MISSING, "the font box learns the substitute: {st}"); - assert_eq!(missing_list(&mut s), [format!("{MISSING}>{shown}")], "only the missing font, once"); - - // A mixed selection has no single font, so nothing to flag in the font box. - s.run("select.all", &json!({})).unwrap(); - assert!(s.run("format.state", &json!({})).unwrap()["fontShownAs"].is_null()); -} - -#[test] -fn a_font_only_a_style_names_is_not_reported_missing() { - let mut s = Session::new(wordcraft_doc::Document::new()); - s.run("document.setText", &json!({"text": "Body"})).unwrap(); - s.run("styles.create", &json!({"name": "Unused Look"})).unwrap(); - s.run("styles.modify", &json!({"style": "Unused Look", "chr": {"font": "Wordcraft Style Only Face"}})).unwrap(); - // Creating a style applies it, as in Word: put the text back in Normal. - s.run("para.style", &json!({"style": "Normal"})).unwrap(); - s.run("select.all", &json!({})).unwrap(); - s.run("format.font", &json!({"name": "JetBrains Mono"})).unwrap(); - assert_eq!(missing_list(&mut s), Vec::::new(), "no text uses the style"); - // Text in the style, with no font of its own, is. - let mut s = Session::new(wordcraft_doc::Document::new()); - s.run("document.setText", &json!({"text": "Body"})).unwrap(); - s.run("styles.create", &json!({"name": "Used Look"})).unwrap(); - s.run("styles.modify", &json!({"style": "Used Look", "chr": {"font": "Wordcraft Style Only Face"}})).unwrap(); - s.run("para.style", &json!({"style": "Used Look"})).unwrap(); - let list = missing_list(&mut s); - assert!(list.iter().any(|f| f.starts_with("Wordcraft Style Only Face>")), "{list:?}"); -} - -#[test] -fn missing_fonts_are_what_layout_drew_in_shown_text() { - let mut doc = doc_of(vec![ - para_in("one", "Wordcraft Nowhere Face"), - para_in("two", "wordcraft nowhere face"), - para_in("three", "JetBrains Mono"), - // Spaces, a picture's placeholder and zero-width characters show no font. - para_in(" \u{FFFC}\t\u{200B}\u{FEFF}\u{FE0F}\u{3164}\u{034F}", "Wordcraft Blank Face"), - ]); - // Hidden text isn't laid out. - doc.body.push(wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text( - "secret", - wordcraft_doc::CharProps { font: Some("Wordcraft Hidden Face".into()), hidden: Some(true), ..Default::default() }, - ))); - // A text box story nothing shows any more (deleted, not yet pruned). - doc.parts.insert( - 77, - wordcraft_doc::Part { - kind: wordcraft_doc::PartKind::TextBox, - blocks: vec![wordcraft_doc::para_block(para_in("gone", "Wordcraft Orphan Face"))], - body: Default::default(), - }, - ); - let names: Vec = missing_in(doc).into_iter().map(|f| f.font).collect(); - assert_eq!(names.len(), 1, "one font whatever its case, and nothing else: {names:?}"); - assert!(names[0].eq_ignore_ascii_case("Wordcraft Nowhere Face")); -} - -#[test] -fn metric_twins_are_listed_but_flagged_compatible() { - // A twin by name, installed or not here: "Liberation Sans" stands in for Arial wherever it's - // the substitute; a made-up family's substitute is never its twin. - let m = missing_in(doc_of(vec![para_in("Text 😀", "Arial"), para_in("Text", "Wordcraft Nowhere Face")])); - let nowhere = m.iter().find(|f| f.font == "Wordcraft Nowhere Face").expect("listed"); - assert!(!nowhere.metric_compatible, "{nowhere:?}"); - match crate::cmd::font::shown_as(&wordcraft_doc::Document::new(), "Arial") { - // Arial is installed: not listed. - None => assert!(!m.iter().any(|f| f.font == "Arial"), "{m:?}"), - Some(sub) => { - let arial = m.iter().find(|f| f.font == "Arial").expect("listed"); - // An emoji drawn in a fallback face doesn't change the verdict on the substitute. - assert_eq!(arial.metric_compatible, wordcraft_fonts::word::metric_compatible("Arial", &sub), "{arial:?}"); - } - } -} - -#[test] -fn a_missing_roman_font_drawn_in_cambria_is_reported() { - // Word draws a missing font the font table classes as roman in Cambria (#1613): installed or - // not, Cambria isn't the font the document asks for. - const ROMAN: &str = "Wordcraft Roman Gone"; - let mut doc = doc_of(vec![para_in("Text", ROMAN)]); - doc.settings.roman_fonts = vec![ROMAN.into()]; - let m = missing_in(doc.clone()); - assert_eq!(m.iter().map(|f| f.font.as_str()).collect::>(), [ROMAN], "{m:?}"); - assert!(!m[0].metric_compatible, "{m:?}"); - // The font box names the face layout draws, not the generic substitute. - let cambria = wordcraft_fonts::word::resolve("Cambria", false, false).face.get().family.clone(); - assert_eq!(m[0].shown_as, std::slice::from_ref(&cambria)); - let mut s = Session::new(doc); - s.run("select.all", &json!({})).unwrap(); - assert_eq!(s.run("format.state", &json!({})).unwrap()["fontShownAs"], cambria.as_str()); -} - -#[test] -fn a_missing_east_asian_font_is_reported_for_cjk_text_only() { - let props = - wordcraft_doc::CharProps { font: Some("JetBrains Mono".into()), font_ea: Some("Wordcraft Nowhere Mincho".into()), ..Default::default() }; - let m = missing_in(doc_of(vec![wordcraft_doc::Paragraph::with_text("abc 日本語", props.clone())])); - assert_eq!(m.len(), 1, "{m:?}"); - assert_eq!(m[0].font, "Wordcraft Nowhere Mincho"); - assert!(!m[0].shown_as.is_empty() && !m[0].metric_compatible, "{m:?}"); - // Without CJK text the East Asian font draws nothing. - assert!(missing_in(doc_of(vec![wordcraft_doc::Paragraph::with_text("abc", props)])).is_empty()); -} - -#[test] -fn symbol_fonts_are_shown_as_unicode_without_a_missing_font() { - let mut s = Session::new(wordcraft_doc::Document::new()); - s.run("document.setText", &json!({"text": "\u{F0FC}"})).unwrap(); - s.run("select.all", &json!({})).unwrap(); - s.run("format.font", &json!({"name": "Wingdings"})).unwrap(); - // Not installed, so it's drawn in another face, but on purpose: nothing to report. - if wordcraft_fonts::word::resolve("Wingdings", false, false).substituted { - assert!(s.run("format.state", &json!({})).unwrap()["fontShownAs"].is_string()); - } - let layout = s.layout(); - assert!(crate::cmd::font::missing_fonts(&s.doc, &layout).is_empty()); -} - -#[test] -fn missing_fonts_from_command_search_say_so_in_the_status_line() { - let mut s = Session::new(doc_of(vec![para_in("Text", "Wordcraft Nowhere Face")])); - s.run("font.missing", &json!({})).unwrap(); - assert!(s.status.contains("Wordcraft Nowhere Face"), "{}", s.status); - // Thousands of missing fonts: a few named, the rest counted. - let mut s = Session::new(doc_of((0..40).map(|i| para_in("x", &format!("Wordcraft Many Face {i}"))).collect())); - s.run("font.missing", &json!({})).unwrap(); - assert!(s.status.ends_with(" and 35 more") && s.status.len() < 300, "{}", s.status); -} - -#[test] -fn a_huge_font_name_is_kept_short() { - let name = format!("Wordcraft {}", "é".repeat(100_000)); - let m = missing_in(doc_of(vec![para_in("Text", &name)])); - assert_eq!(m.len(), 1); - assert!(m[0].font.chars().count() <= 256, "{}", m[0].font.len()); -} - -#[test] -fn symbol_fonts_drawn_as_unicode_are_not_reported() { - // Word's bullets are Symbol characters; WordCraft draws them as their Unicode equivalents. - let m = missing_in(doc_of(vec![para_in("\u{F0B7}", "Symbol"), para_in("\u{F0FC}", "Wingdings")])); - assert!(m.is_empty(), "{m:?}"); -} - -#[test] -fn cjk_text_is_reported_under_its_east_asian_font_not_the_latin_one() { - let props = - wordcraft_doc::CharProps { font: Some("Wordcraft Latin Gone".into()), font_ea: Some("Wordcraft Asian Gone".into()), ..Default::default() }; - let names = |text: &str| -> Vec { - missing_in(doc_of(vec![wordcraft_doc::Paragraph::with_text(text, props.clone())])).into_iter().map(|f| f.font).collect() - }; - assert_eq!(names("日本語"), ["Wordcraft Asian Gone"], "no character asks for the Latin font"); - assert_eq!(names("abc 日本語"), ["Wordcraft Asian Gone", "Wordcraft Latin Gone"]); - // Without an East Asian font the CJK text asks for the Latin one. - let latin_only = wordcraft_doc::CharProps { font: Some("Wordcraft Latin Gone".into()), ..Default::default() }; - let m = missing_in(doc_of(vec![wordcraft_doc::Paragraph::with_text("日本語", latin_only)])); - assert_eq!(m.iter().map(|f| f.font.as_str()).collect::>(), ["Wordcraft Latin Gone"]); -} - -#[test] -fn a_symbol_font_named_for_complex_script_text_is_not_reported() { - let props = wordcraft_doc::CharProps { font: Some("JetBrains Mono".into()), font_cs: Some("Symbol".into()), ..Default::default() }; - assert!(missing_in(doc_of(vec![wordcraft_doc::Paragraph::with_text("سلام", props)])).is_empty()); -} - -#[test] -fn a_hostile_number_of_missing_fonts_stays_bounded() { - let paras = (0..3000).map(|i| para_in("x", &format!("Wordcraft Hostile Face {i}"))).collect(); - let m = missing_in(doc_of(paras)); - assert!(!m.is_empty() && m.len() <= wordcraft_layout::MAX_SUBSTITUTED, "{}", m.len()); -} - -#[test] -fn a_font_key_matches_the_listed_name_however_long_or_padded() { - // Leading spaces and more than 256 characters: layout keeps 256, the list trims them. - let name = format!(" Wordcraft {}", "x".repeat(400)); - let m = missing_in(doc_of(vec![para_in("Text", &name)])); - assert_eq!(m.len(), 1); - assert_eq!(crate::cmd::font::font_key(&name), crate::cmd::font::font_key(&m[0].font)); - // The status line shortens such names. - let mut s = Session::new(doc_of(vec![para_in("Text", &name)])); - s.run("font.missing", &json!({})).unwrap(); - assert!(s.status.chars().count() < 80, "{}", s.status); -} - -#[test] -fn only_characters_that_draw_count() { - use wordcraft_layout::para::draws_something; - for c in ['a', '日', '€', '\u{1F600}'] { - assert!(draws_something(c), "{c:?}"); - } - for c in [' ', '\t', '\u{FFFC}', '\u{00AD}', '\u{200B}', '\u{FE0F}', '\u{3164}', '\u{FFF0}', '\u{1BCA0}', '\u{E0041}'] { - assert!(!draws_something(c), "{c:?}"); - } -} - -#[test] -fn a_blank_font_name_is_not_a_missing_font() { - // A file's `w:ascii=" "`: the text is drawn in a substitute, but there's no font to name. - assert!(missing_in(doc_of(vec![para_in("Text", " "), para_in("More", "\t")])).is_empty()); -} - -#[test] -fn a_padded_name_is_judged_by_the_face_its_text_is_drawn_in() { - // Layout looks "Arial " up spaces and all, so its text is in a substitute even where Arial is - // installed; one of Arial's twins (Helvetica on a Mac) measures like it. - let m = missing_in(doc_of(vec![para_in("Text", "Arial ")])); - let Some(arial) = m.iter().find(|f| f.font == "Arial") else { return }; - let drawn = arial.shown_as.first().expect("drawn in a face"); - assert_eq!(arial.metric_compatible, wordcraft_fonts::word::metric_compatible("Arial", drawn), "{arial:?}"); -} - -#[test] -fn an_east_asian_font_is_shown_as_the_face_its_text_is_drawn_in() { - // MS Mincho is roman in many font tables, but Cambria is the run's font's substitute: an - // East Asian font's text goes to the run's font or a fallback face, as the list says. - const MINCHO: &str = "Wordcraft Mincho Gone"; - let props = wordcraft_doc::CharProps { font: Some("JetBrains Mono".into()), font_ea: Some(MINCHO.into()), ..Default::default() }; - let mut doc = doc_of(vec![wordcraft_doc::Paragraph::with_text("日", props)]); - doc.settings.roman_fonts = vec![MINCHO.into()]; - let shown = crate::cmd::font::east_asian_shown_as(&doc, MINCHO, "JetBrains Mono").expect("not installed"); - let m = missing_in(doc.clone()); - assert_eq!(m.iter().map(|f| f.font.as_str()).collect::>(), [MINCHO], "{m:?}"); - assert_eq!(m[0].shown_as, [shown], "{m:?}"); - assert!(crate::cmd::font::east_asian_shown_as(&doc, "JetBrains Mono", "JetBrains Mono").is_none(), "installed"); -} - -/// A semicolon-separated LibreOffice font fallback list is not reported as missing when any -/// candidate is installed, and substitutes by candidate family when none is. -#[test] -fn libreoffice_font_fallback_lists_are_not_reported_as_missing_when_installed() { - let db = wordcraft_fonts::FontDb::global(); - let doc = doc_of(vec![para_in("Text", "Liberation Sans;Arial")]); - if db.has_family("Liberation Sans") || db.has_family("Arial") { - let m = missing_in(doc.clone()); - assert!(m.is_empty(), "expected not to be reported as missing: {m:?}"); - } - let bundled_fallback = doc_of(vec![para_in("Text", "NonExistentSans123;Source Sans 3")]); - assert!(missing_in(bundled_fallback).is_empty(), "bundled fallback candidate is installed"); - - // Roundtrip through DOCX export and import: - let docx_bytes = crate::io::save_bytes("out.docx", &doc).unwrap(); - let loaded = wordcraft_docx::read(&docx_bytes).unwrap(); - assert_eq!(loaded.body[0].as_para().unwrap().runs[0].props.font.as_deref(), Some("Liberation Sans;Arial")); - if db.has_family("Liberation Sans") || db.has_family("Arial") { - let m = missing_in(loaded); - assert!(m.is_empty(), "expected not to report installed fallback font as missing: {m:?}"); - } - - // When none of the fonts is installed, missing_fonts flags it with an appropriate substitute: - if !db.has_family("NonExistentSans123") && !db.has_family("Times New Roman") { - let m = missing_in(doc_of(vec![para_in("Text", "NonExistentSans123;Times New Roman")])); - assert_eq!(m.len(), 1); - assert_eq!(m[0].font, "NonExistentSans123;Times New Roman"); - let drawn = m[0].shown_as.first().expect("drawn face"); - assert!(drawn.contains("Serif") || drawn == "Times" || drawn == "Tinos", "drawn: {drawn}"); - } - let m = missing_in(doc_of(vec![para_in("Text", "NonExistentSans123;NonExistentSerif123")])); - assert_eq!(m.len(), 1); - assert_eq!(m[0].font, "NonExistentSans123;NonExistentSerif123"); - let drawn = m[0].shown_as.first().expect("drawn face"); - assert!( - drawn.contains("Serif") || drawn == "Times" || drawn == "Times New Roman" || drawn == "Tinos" || drawn == "Caladea" || drawn == "Georgia", - "drawn: {drawn}" - ); -} +//! Fonts added at run time (#582): `font.add`, `font.list`, `font.remove`. + +use serde_json::json; +use wordcraft_doc::Pos; + +use crate::Session; +use crate::cmd::insert::base64_encode; + +/// The bundled face `file` of family `original` renamed `family` (as long as `original`), so it +/// is a font the database doesn't have. +fn renamed(file: &str, original: &str, family: &str) -> Vec { + assert_eq!(family.len(), original.len()); + let mut data = std::fs::read(std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets/fonts").join(file)).unwrap(); + let utf16 = |s: &str| s.encode_utf16().flat_map(u16::to_be_bytes).collect::>(); + for (from, to) in [(utf16(original), utf16(family)), (original.as_bytes().to_vec(), family.as_bytes().to_vec())] { + let mut i = 0; + while let Some(at) = data[i..].windows(from.len()).position(|w| w == from) { + data[i + at..i + at + to.len()].copy_from_slice(&to); + i += at + to.len(); + } + } + data +} + +/// The caret's x after `text` set in `font`. +fn text_end_x(s: &mut Session, font: &str, text: &str) -> f32 { + s.run("document.setText", &json!({"text": text})).unwrap(); + s.run("select.all", &json!({})).unwrap(); + s.run("format.font", &json!({"name": font})).unwrap(); + s.layout().caret_on(&Pos::body(0, text.len()), 0).unwrap().x +} + +#[test] +fn an_added_font_is_listed_and_lays_out_text_already_set_in_it() { + // A monospaced face, so it measures unlike whatever proportional font the system falls back + // to (Source Serif 4 itself where no system fonts are installed, as on the FreeBSD runner). + // The name has no Serif/Sans/Mono in it, which would pick a fallback of that kind. + const FAMILY: &str = "Cmdfont Face 4"; + // Narrow letters: set monospaced they take far more room than in any proportional font. + const TEXT: &str = "illicit little lilies fill tiny jill"; + let mut s = Session::new(wordcraft_doc::Document::new()); + let mono = text_end_x(&mut s, "JetBrains Mono", TEXT); + // Not installed yet: the text falls back to another font. + let missing = text_end_x(&mut s, FAMILY, TEXT); + assert!((missing - mono).abs() > 1.0, "the fallback measures differently: {missing} vs {mono}"); + + let data = base64_encode(&renamed("JetBrainsMono-Regular.ttf", "JetBrains Mono", FAMILY)); + let r = s.run("font.add", &json!({"data": data, "name": "CmdfontFace4-Regular.ttf"})).unwrap(); + assert_eq!(r["families"], json!([FAMILY])); + assert_eq!(r["faces"][0]["style"], "Regular"); + // The text already set in it re-lays out with it, without touching the document. + let x = s.layout().caret_on(&Pos::body(0, TEXT.len()), 0).unwrap().x; + assert!((x - mono).abs() < 0.01, "measured with the added font: {x} vs {mono} (missing {missing})"); + + let list = s.run("font.list", &json!({"all": true})).unwrap(); + assert!(list["fonts"].as_array().unwrap().iter().any(|f| f["families"] == json!([FAMILY]))); + assert!(list["families"].as_array().unwrap().iter().any(|f| f == FAMILY)); + // It exports to PDF like any other font. + assert!(crate::io::save_bytes("out.pdf", &s.doc).unwrap().starts_with(b"%PDF")); + + // Adding it again changes nothing. + let again = s.run("font.add", &json!({"data": data, "name": "again.ttf"})); + assert!(again.unwrap_err().to_string().contains("already available")); + + let removed = s.run("font.remove", &json!({"family": FAMILY})).unwrap(); + assert_eq!(removed["removed"].as_array().unwrap().len(), 1); + let x = s.layout().caret_on(&Pos::body(0, TEXT.len()), 0).unwrap().x; + assert!((x - missing).abs() < 0.01, "falls back again once removed"); + assert!(s.run("font.remove", &json!({"family": FAMILY})).is_err()); +} + +#[test] +fn adding_a_font_never_changes_text_that_does_not_use_it() { + const FAMILY: &str = "Metric Serif 4"; + let mut s = Session::new(crate::sample::sample_document()); + let before = s.layout(); + let positions = |s: &mut Session| { + let l = s.layout(); + (0..s.doc.body.len().min(12)).filter_map(|b| l.caret_on(&Pos::body(b, 0), 0).map(|c| (c.page, c.x, c.top))).collect::>() + }; + let a = positions(&mut s); + s.run("font.add", &json!({"data": base64_encode(&renamed("SourceSerif4-Bold.ttf", "Source Serif 4", FAMILY)), "name": "m.ttf"})).unwrap(); + assert_eq!(positions(&mut s), a); + assert_eq!(s.layout().pages.len(), before.pages.len()); +} + +#[test] +fn bad_font_input_is_an_error() { + let mut s = Session::new(wordcraft_doc::Document::new()); + for p in [ + json!({"data": base64_encode(b"definitely not a font")}), + json!({"data": "%%% not base64"}), + json!({"data": base64_encode(b"wOF2\0\x01\0\0\xff\xff\xff\xff")}), + json!({"path": "/nonexistent/font.ttf"}), + json!({"path": env!("CARGO_MANIFEST_DIR")}), + json!({"path": concat!(env!("CARGO_MANIFEST_DIR"), "/Cargo.toml")}), + json!({"url": "https://fonts.googleapis.com/css2?family=Roboto"}), + ] { + assert!(s.run("font.add", &p).is_err(), "{p}"); + } + assert!(s.run("font.remove", &json!({})).is_err()); + assert!(s.run("font.remove", &json!({"family": "Source Sans 3"})).is_err(), "the bundled fonts stay"); + // No file: the UI is asked for a picker. + let r = s.run("font.add", &json!({})).unwrap(); + assert_eq!(r["pending"], true); + assert!(s.ui_requests.iter().any(|r| r["open"] == "addFont")); + + let r = s.run("font.loadSystem", &json!({})).unwrap(); + assert_eq!(r["pending"], true); + assert!(s.ui_requests.iter().any(|r| r["open"] == "loadSystemFonts")); +} + +/// A .docx whose text is set in `family`, embedding `font` through the font table (#1171): as an +/// obfuscated `.odttf` when `key` is a real GUID, as a plain `.ttf` when it is all zeros. +fn docx_embedding(family: &str, font: Option<(&[u8], &str)>, text: &str) -> Vec { + const W: &str = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"; + const R: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; + const PR: &str = "http://schemas.openxmlformats.org/package/2006/relationships"; + let rels = |items: &[(&str, &str)]| { + let list: String = items.iter().map(|(t, g)| format!(r#""#)).collect(); + format!(r#"{list}"#) + }; + let mut files: Vec<(String, Vec)> = vec![ + ( + "[Content_Types].xml".into(), + br#""#.to_vec(), + ), + ("_rels/.rels".into(), rels(&[("officeDocument", "word/document.xml")]).into_bytes()), + ("word/_rels/document.xml.rels".into(), rels(&[("fontTable", "fontTable.xml")]).into_bytes()), + ( + "word/document.xml".into(), + format!(r#"{text}"#).into_bytes(), + ), + ]; + let mut table = format!(r#""#); + if let Some((data, key)) = font { + let digits = key.replace(['{', '}', '-'], ""); + let hex: Vec = (0..16).map(|i| u8::from_str_radix(&digits[i * 2..i * 2 + 2], 16).unwrap()).collect(); + let zero = hex.iter().all(|b| *b == 0); + let mut data = data.to_vec(); + // Obfuscation (ECMA-376 Part 1, 17.8.1): the first 32 bytes XOR the key's bytes, reversed. + for i in 0..32 { + data[i] ^= hex[15 - i % 16]; + } + let ext = if zero { "ttf" } else { "odttf" }; + table.push_str(&format!(r#""#)); + files.push(("word/_rels/fontTable.xml.rels".into(), rels(&[("font", &format!("fonts/font1.{ext}"))]).into_bytes())); + files.push((format!("word/fonts/font1.{ext}"), data)); + } + table.push_str(""); + files.push(("word/fontTable.xml".into(), table.into_bytes())); + let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + for (name, bytes) in files { + zip.start_file(name, zip::write::SimpleFileOptions::default()).unwrap(); + std::io::Write::write_all(&mut zip, &bytes).unwrap(); + } + zip.finish().unwrap().into_inner() +} + +#[test] +fn fonts_embedded_in_a_docx_lay_out_its_text() { + const TEXT: &str = "illicit little lilies fill tiny jill"; + let end_x = + |bytes: &[u8]| Session::new(crate::io::open_bytes("e.docx", bytes).unwrap()).layout().caret_on(&Pos::body(0, TEXT.len()), 0).unwrap().x; + let mono = end_x(&docx_embedding("JetBrains Mono", None, TEXT)); + // Both forms Word and other producers write: an obfuscated .odttf with its key, a plain .ttf + // with the all-zero key. Each is a family no other test or system has. + for (family, key) in [("JetBrains Odtt", "{9F1C2A4E-7B3D-4C58-A1E6-0D2B8F5C3E77}"), ("JetBrains Zero", "{00000000-0000-0000-0000-000000000000}")] + { + let font = renamed("JetBrainsMono-Regular.ttf", "JetBrains Mono", family); + let missing = end_x(&docx_embedding(family, None, TEXT)); + assert!((missing - mono).abs() > 1.0, "{family}: not embedded, the text falls back: {missing} vs {mono}"); + let x = end_x(&docx_embedding(family, Some((&font, key)), TEXT)); + assert!((x - mono).abs() < 0.01, "{family}: measured with the embedded font: {x} vs {mono} (missing {missing})"); + } +} + +/// A font Word embeds as a subset ("embed only the characters used") goes by a made-up family +/// inside (`___WRD_EMBED_SUB_46`), and the text asks for it by the font table's name +/// (`OBRBWW+Century Schoolbook`): the text is measured with it all the same (#2240). It fell back +/// to a substitute, as if nothing were embedded. +#[test] +fn a_subset_font_word_embeds_lays_out_text_set_in_its_table_name() { + const TEXT: &str = "illicit little lilies fill tiny jill"; + const FAMILY: &str = "QWERTY+JetBrains Sub"; + let end_x = + |bytes: &[u8]| Session::new(crate::io::open_bytes("e.docx", bytes).unwrap()).layout().caret_on(&Pos::body(0, TEXT.len()), 0).unwrap().x; + let mono = end_x(&docx_embedding("JetBrains Mono", None, TEXT)); + let missing = end_x(&docx_embedding(FAMILY, None, TEXT)); + assert!((missing - mono).abs() > 1.0, "not embedded, the text falls back: {missing} vs {mono}"); + let subset = renamed("JetBrainsMono-Regular.ttf", "JetBrains Mono", "___WRD_EMBED_7"); + let x = end_x(&docx_embedding(FAMILY, Some((&subset, "{6A0E3B57-1C2D-4E8F-9A7B-3D5C1E0F2B64}")), TEXT)); + assert!((x - mono).abs() < 0.01, "measured with the embedded subset: {x} vs {mono} (missing {missing})"); +} + +/// `font.missing`'s fonts, as "font>shownAs". +fn missing_list(s: &mut Session) -> Vec { + let m = s.run("font.missing", &json!({})).unwrap(); + m["missing"] + .as_array() + .unwrap() + .iter() + .map(|f| { + let shown: Vec<&str> = f["shownAs"].as_array().unwrap().iter().map(|x| x.as_str().unwrap()).collect(); + format!("{}>{}", f["font"].as_str().unwrap(), shown.join(",")) + }) + .collect() +} + +/// The missing fonts of `doc` as laid out. +fn missing_in(doc: wordcraft_doc::Document) -> Vec { + let mut s = Session::new(doc); + let layout = s.layout(); + crate::cmd::font::missing_fonts(&s.doc, &layout) +} + +/// A paragraph of `text` set in `font`. +fn para_in(text: &str, font: &str) -> wordcraft_doc::Paragraph { + wordcraft_doc::Paragraph::with_text(text, wordcraft_doc::CharProps { font: Some(font.into()), ..Default::default() }) +} + +/// A document of these paragraphs. +fn doc_of(paras: Vec) -> wordcraft_doc::Document { + let mut doc = wordcraft_doc::Document::new(); + doc.body = paras.into_iter().map(wordcraft_doc::para_block).collect(); + doc +} + +#[test] +fn fonts_that_are_not_installed_are_reported_with_the_font_shown_instead() { + // A family no system has, with no Serif/Mono in its name: it's drawn in a sans substitute. + const MISSING: &str = "Wordcraft Nowhere Face"; + let mut s = Session::new(wordcraft_doc::Document::new()); + s.run("document.setText", &json!({"text": "Bundled\nMissing"})).unwrap(); + s.run("select.all", &json!({})).unwrap(); + s.run("format.font", &json!({"name": "JetBrains Mono"})).unwrap(); + assert!(s.run("format.state", &json!({})).unwrap()["fontShownAs"].is_null(), "a bundled font is installed"); + assert_eq!(missing_list(&mut s), Vec::::new()); + + s.run("select.text", &json!({"text": "Missing"})).unwrap(); + s.run("format.font", &json!({"name": MISSING})).unwrap(); + let st = s.run("format.state", &json!({})).unwrap(); + assert_eq!(st["font"], MISSING); + let shown = st["fontShownAs"].as_str().unwrap_or_default().to_string(); + assert!(!shown.is_empty() && shown != MISSING, "the font box learns the substitute: {st}"); + assert_eq!(missing_list(&mut s), [format!("{MISSING}>{shown}")], "only the missing font, once"); + + // A mixed selection has no single font, so nothing to flag in the font box. + s.run("select.all", &json!({})).unwrap(); + assert!(s.run("format.state", &json!({})).unwrap()["fontShownAs"].is_null()); +} + +#[test] +fn a_font_only_a_style_names_is_not_reported_missing() { + let mut s = Session::new(wordcraft_doc::Document::new()); + s.run("document.setText", &json!({"text": "Body"})).unwrap(); + s.run("styles.create", &json!({"name": "Unused Look"})).unwrap(); + s.run("styles.modify", &json!({"style": "Unused Look", "chr": {"font": "Wordcraft Style Only Face"}})).unwrap(); + // Creating a style applies it, as in Word: put the text back in Normal. + s.run("para.style", &json!({"style": "Normal"})).unwrap(); + s.run("select.all", &json!({})).unwrap(); + s.run("format.font", &json!({"name": "JetBrains Mono"})).unwrap(); + assert_eq!(missing_list(&mut s), Vec::::new(), "no text uses the style"); + // Text in the style, with no font of its own, is. + let mut s = Session::new(wordcraft_doc::Document::new()); + s.run("document.setText", &json!({"text": "Body"})).unwrap(); + s.run("styles.create", &json!({"name": "Used Look"})).unwrap(); + s.run("styles.modify", &json!({"style": "Used Look", "chr": {"font": "Wordcraft Style Only Face"}})).unwrap(); + s.run("para.style", &json!({"style": "Used Look"})).unwrap(); + let list = missing_list(&mut s); + assert!(list.iter().any(|f| f.starts_with("Wordcraft Style Only Face>")), "{list:?}"); +} + +#[test] +fn missing_fonts_are_what_layout_drew_in_shown_text() { + let mut doc = doc_of(vec![ + para_in("one", "Wordcraft Nowhere Face"), + para_in("two", "wordcraft nowhere face"), + para_in("three", "JetBrains Mono"), + // Spaces, a picture's placeholder and zero-width characters show no font. + para_in(" \u{FFFC}\t\u{200B}\u{FEFF}\u{FE0F}\u{3164}\u{034F}", "Wordcraft Blank Face"), + ]); + // Hidden text isn't laid out. + doc.body.push(wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text( + "secret", + wordcraft_doc::CharProps { font: Some("Wordcraft Hidden Face".into()), hidden: Some(true), ..Default::default() }, + ))); + // A text box story nothing shows any more (deleted, not yet pruned). + doc.parts.insert( + 77, + wordcraft_doc::Part { + kind: wordcraft_doc::PartKind::TextBox, + blocks: vec![wordcraft_doc::para_block(para_in("gone", "Wordcraft Orphan Face"))], + body: Default::default(), + }, + ); + let names: Vec = missing_in(doc).into_iter().map(|f| f.font).collect(); + assert_eq!(names.len(), 1, "one font whatever its case, and nothing else: {names:?}"); + assert!(names[0].eq_ignore_ascii_case("Wordcraft Nowhere Face")); +} + +#[test] +fn metric_twins_are_listed_but_flagged_compatible() { + // A twin by name, installed or not here: "Liberation Sans" stands in for Arial wherever it's + // the substitute; a made-up family's substitute is never its twin. + let m = missing_in(doc_of(vec![para_in("Text 😀", "Arial"), para_in("Text", "Wordcraft Nowhere Face")])); + let nowhere = m.iter().find(|f| f.font == "Wordcraft Nowhere Face").expect("listed"); + assert!(!nowhere.metric_compatible, "{nowhere:?}"); + match crate::cmd::font::shown_as(&wordcraft_doc::Document::new(), "Arial") { + // Arial is installed: not listed. + None => assert!(!m.iter().any(|f| f.font == "Arial"), "{m:?}"), + Some(sub) => { + let arial = m.iter().find(|f| f.font == "Arial").expect("listed"); + // An emoji drawn in a fallback face doesn't change the verdict on the substitute. + assert_eq!(arial.metric_compatible, wordcraft_fonts::word::metric_compatible("Arial", &sub), "{arial:?}"); + } + } +} + +#[test] +fn a_missing_roman_font_drawn_in_cambria_is_reported() { + // Word draws a missing font the font table classes as roman in Cambria (#1613): installed or + // not, Cambria isn't the font the document asks for. + const ROMAN: &str = "Wordcraft Roman Gone"; + let mut doc = doc_of(vec![para_in("Text", ROMAN)]); + doc.settings.roman_fonts = vec![ROMAN.into()]; + let m = missing_in(doc.clone()); + assert_eq!(m.iter().map(|f| f.font.as_str()).collect::>(), [ROMAN], "{m:?}"); + assert!(!m[0].metric_compatible, "{m:?}"); + // The font box names the face layout draws, not the generic substitute. + let cambria = wordcraft_fonts::word::resolve("Cambria", false, false).face.get().family.clone(); + assert_eq!(m[0].shown_as, std::slice::from_ref(&cambria)); + let mut s = Session::new(doc); + s.run("select.all", &json!({})).unwrap(); + assert_eq!(s.run("format.state", &json!({})).unwrap()["fontShownAs"], cambria.as_str()); +} + +#[test] +fn a_missing_east_asian_font_is_reported_for_cjk_text_only() { + let props = + wordcraft_doc::CharProps { font: Some("JetBrains Mono".into()), font_ea: Some("Wordcraft Nowhere Mincho".into()), ..Default::default() }; + let m = missing_in(doc_of(vec![wordcraft_doc::Paragraph::with_text("abc 日本語", props.clone())])); + assert_eq!(m.len(), 1, "{m:?}"); + assert_eq!(m[0].font, "Wordcraft Nowhere Mincho"); + assert!(!m[0].shown_as.is_empty() && !m[0].metric_compatible, "{m:?}"); + // Without CJK text the East Asian font draws nothing. + assert!(missing_in(doc_of(vec![wordcraft_doc::Paragraph::with_text("abc", props)])).is_empty()); +} + +#[test] +fn symbol_fonts_are_shown_as_unicode_without_a_missing_font() { + let mut s = Session::new(wordcraft_doc::Document::new()); + s.run("document.setText", &json!({"text": "\u{F0FC}"})).unwrap(); + s.run("select.all", &json!({})).unwrap(); + s.run("format.font", &json!({"name": "Wingdings"})).unwrap(); + // Not installed, so it's drawn in another face, but on purpose: nothing to report. + if wordcraft_fonts::word::resolve("Wingdings", false, false).substituted { + assert!(s.run("format.state", &json!({})).unwrap()["fontShownAs"].is_string()); + } + let layout = s.layout(); + assert!(crate::cmd::font::missing_fonts(&s.doc, &layout).is_empty()); +} + +#[test] +fn missing_fonts_from_command_search_say_so_in_the_status_line() { + let mut s = Session::new(doc_of(vec![para_in("Text", "Wordcraft Nowhere Face")])); + s.run("font.missing", &json!({})).unwrap(); + assert!(s.status.contains("Wordcraft Nowhere Face"), "{}", s.status); + // Thousands of missing fonts: a few named, the rest counted. + let mut s = Session::new(doc_of((0..40).map(|i| para_in("x", &format!("Wordcraft Many Face {i}"))).collect())); + s.run("font.missing", &json!({})).unwrap(); + assert!(s.status.ends_with(" and 35 more") && s.status.len() < 300, "{}", s.status); +} + +#[test] +fn a_huge_font_name_is_kept_short() { + let name = format!("Wordcraft {}", "é".repeat(100_000)); + let m = missing_in(doc_of(vec![para_in("Text", &name)])); + assert_eq!(m.len(), 1); + assert!(m[0].font.chars().count() <= 256, "{}", m[0].font.len()); +} + +#[test] +fn symbol_fonts_drawn_as_unicode_are_not_reported() { + // Word's bullets are Symbol characters; WordCraft draws them as their Unicode equivalents. + let m = missing_in(doc_of(vec![para_in("\u{F0B7}", "Symbol"), para_in("\u{F0FC}", "Wingdings")])); + assert!(m.is_empty(), "{m:?}"); +} + +#[test] +fn cjk_text_is_reported_under_its_east_asian_font_not_the_latin_one() { + let props = + wordcraft_doc::CharProps { font: Some("Wordcraft Latin Gone".into()), font_ea: Some("Wordcraft Asian Gone".into()), ..Default::default() }; + let names = |text: &str| -> Vec { + missing_in(doc_of(vec![wordcraft_doc::Paragraph::with_text(text, props.clone())])).into_iter().map(|f| f.font).collect() + }; + assert_eq!(names("日本語"), ["Wordcraft Asian Gone"], "no character asks for the Latin font"); + assert_eq!(names("abc 日本語"), ["Wordcraft Asian Gone", "Wordcraft Latin Gone"]); + // Without an East Asian font the CJK text asks for the Latin one. + let latin_only = wordcraft_doc::CharProps { font: Some("Wordcraft Latin Gone".into()), ..Default::default() }; + let m = missing_in(doc_of(vec![wordcraft_doc::Paragraph::with_text("日本語", latin_only)])); + assert_eq!(m.iter().map(|f| f.font.as_str()).collect::>(), ["Wordcraft Latin Gone"]); +} + +#[test] +fn a_symbol_font_named_for_complex_script_text_is_not_reported() { + let props = wordcraft_doc::CharProps { font: Some("JetBrains Mono".into()), font_cs: Some("Symbol".into()), ..Default::default() }; + assert!(missing_in(doc_of(vec![wordcraft_doc::Paragraph::with_text("سلام", props)])).is_empty()); +} + +#[test] +fn a_hostile_number_of_missing_fonts_stays_bounded() { + let paras = (0..3000).map(|i| para_in("x", &format!("Wordcraft Hostile Face {i}"))).collect(); + let m = missing_in(doc_of(paras)); + assert!(!m.is_empty() && m.len() <= wordcraft_layout::MAX_SUBSTITUTED, "{}", m.len()); +} + +#[test] +fn a_font_key_matches_the_listed_name_however_long_or_padded() { + // Leading spaces and more than 256 characters: layout keeps 256, the list trims them. + let name = format!(" Wordcraft {}", "x".repeat(400)); + let m = missing_in(doc_of(vec![para_in("Text", &name)])); + assert_eq!(m.len(), 1); + assert_eq!(crate::cmd::font::font_key(&name), crate::cmd::font::font_key(&m[0].font)); + // The status line shortens such names. + let mut s = Session::new(doc_of(vec![para_in("Text", &name)])); + s.run("font.missing", &json!({})).unwrap(); + assert!(s.status.chars().count() < 80, "{}", s.status); +} + +#[test] +fn only_characters_that_draw_count() { + use wordcraft_layout::para::draws_something; + for c in ['a', '日', '€', '\u{1F600}'] { + assert!(draws_something(c), "{c:?}"); + } + for c in [' ', '\t', '\u{FFFC}', '\u{00AD}', '\u{200B}', '\u{FE0F}', '\u{3164}', '\u{FFF0}', '\u{1BCA0}', '\u{E0041}'] { + assert!(!draws_something(c), "{c:?}"); + } +} + +#[test] +fn a_blank_font_name_is_not_a_missing_font() { + // A file's `w:ascii=" "`: the text is drawn in a substitute, but there's no font to name. + assert!(missing_in(doc_of(vec![para_in("Text", " "), para_in("More", "\t")])).is_empty()); +} + +#[test] +fn a_padded_name_is_judged_by_the_face_its_text_is_drawn_in() { + // Layout looks "Arial " up spaces and all, so its text is in a substitute even where Arial is + // installed; one of Arial's twins (Helvetica on a Mac) measures like it. + let m = missing_in(doc_of(vec![para_in("Text", "Arial ")])); + let Some(arial) = m.iter().find(|f| f.font == "Arial") else { return }; + let drawn = arial.shown_as.first().expect("drawn in a face"); + assert_eq!(arial.metric_compatible, wordcraft_fonts::word::metric_compatible("Arial", drawn), "{arial:?}"); +} + +#[test] +fn an_east_asian_font_is_shown_as_the_face_its_text_is_drawn_in() { + // MS Mincho is roman in many font tables, but Cambria is the run's font's substitute: an + // East Asian font's text goes to the run's font or a fallback face, as the list says. + const MINCHO: &str = "Wordcraft Mincho Gone"; + let props = wordcraft_doc::CharProps { font: Some("JetBrains Mono".into()), font_ea: Some(MINCHO.into()), ..Default::default() }; + let mut doc = doc_of(vec![wordcraft_doc::Paragraph::with_text("日", props)]); + doc.settings.roman_fonts = vec![MINCHO.into()]; + let shown = crate::cmd::font::east_asian_shown_as(&doc, MINCHO, "JetBrains Mono").expect("not installed"); + let m = missing_in(doc.clone()); + assert_eq!(m.iter().map(|f| f.font.as_str()).collect::>(), [MINCHO], "{m:?}"); + assert_eq!(m[0].shown_as, [shown], "{m:?}"); + assert!(crate::cmd::font::east_asian_shown_as(&doc, "JetBrains Mono", "JetBrains Mono").is_none(), "installed"); +} + +/// A semicolon-separated LibreOffice font fallback list is not reported as missing when any +/// candidate is installed, and substitutes by candidate family when none is. +#[test] +fn libreoffice_font_fallback_lists_are_not_reported_as_missing_when_installed() { + let db = wordcraft_fonts::FontDb::global(); + let doc = doc_of(vec![para_in("Text", "Liberation Sans;Arial")]); + if db.has_family("Liberation Sans") || db.has_family("Arial") { + let m = missing_in(doc.clone()); + assert!(m.is_empty(), "expected not to be reported as missing: {m:?}"); + } + let bundled_fallback = doc_of(vec![para_in("Text", "NonExistentSans123;Source Sans 3")]); + assert!(missing_in(bundled_fallback).is_empty(), "bundled fallback candidate is installed"); + + // Roundtrip through DOCX export and import: + let docx_bytes = crate::io::save_bytes("out.docx", &doc).unwrap(); + let loaded = wordcraft_docx::read(&docx_bytes).unwrap(); + assert_eq!(loaded.body[0].as_para().unwrap().runs[0].props.font.as_deref(), Some("Liberation Sans;Arial")); + if db.has_family("Liberation Sans") || db.has_family("Arial") { + let m = missing_in(loaded); + assert!(m.is_empty(), "expected not to report installed fallback font as missing: {m:?}"); + } + + // When none of the fonts is installed, missing_fonts flags it with an appropriate substitute: + if !db.has_family("NonExistentSans123") && !db.has_family("Times New Roman") { + let m = missing_in(doc_of(vec![para_in("Text", "NonExistentSans123;Times New Roman")])); + assert_eq!(m.len(), 1); + assert_eq!(m[0].font, "NonExistentSans123;Times New Roman"); + let drawn = m[0].shown_as.first().expect("drawn face"); + assert!(drawn.contains("Serif") || drawn == "Times" || drawn == "Tinos", "drawn: {drawn}"); + } + let m = missing_in(doc_of(vec![para_in("Text", "NonExistentSans123;NonExistentSerif123")])); + assert_eq!(m.len(), 1); + assert_eq!(m[0].font, "NonExistentSans123;NonExistentSerif123"); + let drawn = m[0].shown_as.first().expect("drawn face"); + assert!( + drawn.contains("Serif") || drawn == "Times" || drawn == "Times New Roman" || drawn == "Tinos" || drawn == "Caladea" || drawn == "Georgia", + "drawn: {drawn}" + ); +} diff --git a/crates/engine/src/tests/ime_noop.rs b/crates/engine/src/tests/ime_noop.rs index 73956e68dc..d0350e0811 100644 --- a/crates/engine/src/tests/ime_noop.rs +++ b/crates/engine/src/tests/ime_noop.rs @@ -1,122 +1,122 @@ -//! Regressions where main's "a command that changes nothing is not an edit" (#23) meets the -//! migration's input-method application-change and state-revision tracking. -//! -//! Kept in its own module so the merged `tests.rs` is not touched. - -use serde_json::json; - -use crate::Session; - -/// Creates an empty-document session for the IME no-op regressions. -fn s() -> Session { - Session::new(wordcraft_doc::Document::new()) -} - -/// Runs a command in the test session, panicking with the command id on error. -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}")) -} - -/// An *unclassified* no-op (`text.backspace` at the very start of the body) is not an edit and -/// must not look like one to the input method: no effect flags, no application-change signal, no -/// settled-revision move, and no history/dirty/revision side effects. -#[test] -fn a_no_op_command_is_not_an_ime_state_change() { - let mut s = s(); - run(&mut s, "text.insert", json!({"text": "x"})); - run(&mut s, "caret.docStart", json!({})); - assert!(s.pending.is_none(), "caret movement dropped any pending formatting"); - s.take_pending_ime_application_change(); - let revision = s.ime_state_revision().expect("an IME revision"); - let rev = s.rev(); - let depth = s.undo_depth(); - let dirty = s.dirty; - - run(&mut s, "text.backspace", json!({})); - - let outcome = s.ime_edit_outcome(); - assert!(!outcome.classified, "the handler leaves this no-op unclassified: {outcome:?}"); - assert!(!outcome.content_changed && !outcome.state_changed, "{outcome:?}"); - assert!(!s.take_pending_ime_application_change(), "a no-op is not an application change"); - assert_eq!(s.ime_state_revision(), Some(revision), "the settled IME revision did not move"); - assert_eq!((s.undo_depth(), s.dirty, s.rev()), (depth, dirty, rev)); -} - -/// `document.setText` with identical text replaces the body under the same document id, so the -/// IME replacement epoch must advance — but because nothing observable changed it is not an edit -/// and must not signal an application-origin change (main's #23 no-op rule). -#[test] -fn a_same_text_set_text_advances_the_body_epoch_without_an_application_change() { - let mut s = s(); - run(&mut s, "document.setText", json!({"text": "Same body"})); - s.take_pending_ime_application_change(); - let epoch = s.ime_body_epoch(); - let document_id = s.document_id(); - let rev = s.rev(); - let depth = s.undo_depth(); - - run(&mut s, "document.setText", json!({"text": "Same body"})); - - assert!(s.ime_body_epoch() > epoch, "the body is replaced again under the same document id"); - assert_eq!(s.document_id(), document_id, "the document id itself is unchanged"); - assert!(!s.take_pending_ime_application_change(), "an identical setText is not an application change"); - assert_eq!((s.undo_depth(), s.rev()), (depth, rev)); -} - -/// The IME body replacement promise (`DeleteSurrounding`, then the commit that replaces the -/// erased text) joins only a `text.insert` at the selection the deletion left. Tab, an ordinary -/// command, consumes it: it is no IME replacement receipt itself, and the commit after it owns a -/// new Undo step instead of joining the deletion. -#[test] -fn tab_between_an_ime_replacement_and_its_commit_keeps_the_steps_apart() { - let text = |s: &Session| s.doc.plain_text(wordcraft_doc::StoryRef::Body); - - // The promise itself joins the commit at the unchanged selection. - let mut s = self::s(); - run(&mut s, "text.insert", json!({"text": "ab"})); - run(&mut s, "text.backspace", json!({})); - assert_eq!(text(&s), "a", "DeleteSurrounding erased the b"); - s.join_next_ime_text_replacement(); - let depth = s.undo_depth(); - run(&mut s, "text.insert", json!({"text": "c"})); - assert_eq!(text(&s), "ac"); - assert_eq!(s.undo_depth(), depth, "the commit joins the deletion's step"); - run(&mut s, "edit.undo", json!({})); - assert_eq!(text(&s), "ab", "one Undo restores the deletion and its commit"); - - // Tab consumes the promise: the commit no longer joins the deletion. - let mut s = self::s(); - run(&mut s, "text.insert", json!({"text": "ab"})); - run(&mut s, "text.backspace", json!({})); - s.join_next_ime_text_replacement(); - let depth = s.undo_depth(); - run(&mut s, "text.tab", json!({})); - let outcome = s.ime_edit_outcome(); - assert!(!outcome.classified, "Tab is not an IME replacement receipt: {outcome:?}"); - assert_eq!(s.undo_depth(), depth + 1, "Tab owns its Undo step"); - run(&mut s, "text.insert", json!({"text": "c"})); - assert_eq!(text(&s), "a\tc"); - assert_eq!(s.undo_depth(), depth + 2, "the commit after Tab starts its own Undo step"); - run(&mut s, "edit.undo", json!({})); - assert_eq!(text(&s), "a\t", "Undo takes back only the commit"); - run(&mut s, "edit.undo", json!({})); - assert_eq!(text(&s), "a", "then the Tab"); - run(&mut s, "edit.undo", json!({})); - assert_eq!(text(&s), "ab", "and the IME deletion on its own"); - - // Returning directly to the promised selection must not resurrect a promise consumed by - // Tab. Do not use a caret command here: that command would itself consume a leaked promise. - let mut s = self::s(); - run(&mut s, "text.insert", json!({"text": "ab"})); - run(&mut s, "text.backspace", json!({})); - s.join_next_ime_text_replacement(); - let promised_selection = s.sel.clone(); - run(&mut s, "text.tab", json!({})); - let depth = s.undo_depth(); - s.sel = promised_selection; - run(&mut s, "text.insert", json!({"text": "c"})); - assert_eq!(text(&s), "ac\t"); - assert_eq!(s.undo_depth(), depth + 1, "returning to the old selection cannot reuse the consumed promise"); - run(&mut s, "edit.undo", json!({})); - assert_eq!(text(&s), "a\t", "only the new insert is undone"); -} +//! Regressions where main's "a command that changes nothing is not an edit" (#23) meets the +//! migration's input-method application-change and state-revision tracking. +//! +//! Kept in its own module so the merged `tests.rs` is not touched. + +use serde_json::json; + +use crate::Session; + +/// Creates an empty-document session for the IME no-op regressions. +fn s() -> Session { + Session::new(wordcraft_doc::Document::new()) +} + +/// Runs a command in the test session, panicking with the command id on error. +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}")) +} + +/// An *unclassified* no-op (`text.backspace` at the very start of the body) is not an edit and +/// must not look like one to the input method: no effect flags, no application-change signal, no +/// settled-revision move, and no history/dirty/revision side effects. +#[test] +fn a_no_op_command_is_not_an_ime_state_change() { + let mut s = s(); + run(&mut s, "text.insert", json!({"text": "x"})); + run(&mut s, "caret.docStart", json!({})); + assert!(s.pending.is_none(), "caret movement dropped any pending formatting"); + s.take_pending_ime_application_change(); + let revision = s.ime_state_revision().expect("an IME revision"); + let rev = s.rev(); + let depth = s.undo_depth(); + let dirty = s.dirty; + + run(&mut s, "text.backspace", json!({})); + + let outcome = s.ime_edit_outcome(); + assert!(!outcome.classified, "the handler leaves this no-op unclassified: {outcome:?}"); + assert!(!outcome.content_changed && !outcome.state_changed, "{outcome:?}"); + assert!(!s.take_pending_ime_application_change(), "a no-op is not an application change"); + assert_eq!(s.ime_state_revision(), Some(revision), "the settled IME revision did not move"); + assert_eq!((s.undo_depth(), s.dirty, s.rev()), (depth, dirty, rev)); +} + +/// `document.setText` with identical text replaces the body under the same document id, so the +/// IME replacement epoch must advance — but because nothing observable changed it is not an edit +/// and must not signal an application-origin change (main's #23 no-op rule). +#[test] +fn a_same_text_set_text_advances_the_body_epoch_without_an_application_change() { + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "Same body"})); + s.take_pending_ime_application_change(); + let epoch = s.ime_body_epoch(); + let document_id = s.document_id(); + let rev = s.rev(); + let depth = s.undo_depth(); + + run(&mut s, "document.setText", json!({"text": "Same body"})); + + assert!(s.ime_body_epoch() > epoch, "the body is replaced again under the same document id"); + assert_eq!(s.document_id(), document_id, "the document id itself is unchanged"); + assert!(!s.take_pending_ime_application_change(), "an identical setText is not an application change"); + assert_eq!((s.undo_depth(), s.rev()), (depth, rev)); +} + +/// The IME body replacement promise (`DeleteSurrounding`, then the commit that replaces the +/// erased text) joins only a `text.insert` at the selection the deletion left. Tab, an ordinary +/// command, consumes it: it is no IME replacement receipt itself, and the commit after it owns a +/// new Undo step instead of joining the deletion. +#[test] +fn tab_between_an_ime_replacement_and_its_commit_keeps_the_steps_apart() { + let text = |s: &Session| s.doc.plain_text(wordcraft_doc::StoryRef::Body); + + // The promise itself joins the commit at the unchanged selection. + let mut s = self::s(); + run(&mut s, "text.insert", json!({"text": "ab"})); + run(&mut s, "text.backspace", json!({})); + assert_eq!(text(&s), "a", "DeleteSurrounding erased the b"); + s.join_next_ime_text_replacement(); + let depth = s.undo_depth(); + run(&mut s, "text.insert", json!({"text": "c"})); + assert_eq!(text(&s), "ac"); + assert_eq!(s.undo_depth(), depth, "the commit joins the deletion's step"); + run(&mut s, "edit.undo", json!({})); + assert_eq!(text(&s), "ab", "one Undo restores the deletion and its commit"); + + // Tab consumes the promise: the commit no longer joins the deletion. + let mut s = self::s(); + run(&mut s, "text.insert", json!({"text": "ab"})); + run(&mut s, "text.backspace", json!({})); + s.join_next_ime_text_replacement(); + let depth = s.undo_depth(); + run(&mut s, "text.tab", json!({})); + let outcome = s.ime_edit_outcome(); + assert!(!outcome.classified, "Tab is not an IME replacement receipt: {outcome:?}"); + assert_eq!(s.undo_depth(), depth + 1, "Tab owns its Undo step"); + run(&mut s, "text.insert", json!({"text": "c"})); + assert_eq!(text(&s), "a\tc"); + assert_eq!(s.undo_depth(), depth + 2, "the commit after Tab starts its own Undo step"); + run(&mut s, "edit.undo", json!({})); + assert_eq!(text(&s), "a\t", "Undo takes back only the commit"); + run(&mut s, "edit.undo", json!({})); + assert_eq!(text(&s), "a", "then the Tab"); + run(&mut s, "edit.undo", json!({})); + assert_eq!(text(&s), "ab", "and the IME deletion on its own"); + + // Returning directly to the promised selection must not resurrect a promise consumed by + // Tab. Do not use a caret command here: that command would itself consume a leaked promise. + let mut s = self::s(); + run(&mut s, "text.insert", json!({"text": "ab"})); + run(&mut s, "text.backspace", json!({})); + s.join_next_ime_text_replacement(); + let promised_selection = s.sel.clone(); + run(&mut s, "text.tab", json!({})); + let depth = s.undo_depth(); + s.sel = promised_selection; + run(&mut s, "text.insert", json!({"text": "c"})); + assert_eq!(text(&s), "ac\t"); + assert_eq!(s.undo_depth(), depth + 1, "returning to the old selection cannot reuse the consumed promise"); + run(&mut s, "edit.undo", json!({})); + assert_eq!(text(&s), "a\t", "only the new insert is undone"); +} diff --git a/crates/engine/src/tests/para_tools.rs b/crates/engine/src/tests/para_tools.rs index bca60f06ec..5456be07b2 100644 --- a/crates/engine/src/tests/para_tools.rs +++ b/crates/engine/src/tests/para_tools.rs @@ -1,484 +1,484 @@ -//! Advanced paragraph tools (#5043): conversion and cleanup over the selection or the whole -//! document, one undo step, Track Changes respected. - -use serde_json::json; -use wordcraft_doc::{InlineObject, Path, Pos, RevisionKind, SectionProps, StoryRef}; - -use crate::Session; - -fn s() -> Session { - Session::new(wordcraft_doc::Document::new()) -} - -fn run(s: &mut Session, id: &str, v: 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) -} - -/// Select the whole document (Mod+A's command). -fn select_all(s: &mut Session) { - run(s, "select.all", json!({})); -} - -/// One paragraph with `\n` as manual line breaks, typed as text. -fn type_breaks(s: &mut Session, t: &str) { - run(s, "text.insert", json!({"text": t})); -} - -#[test] -fn line_breaks_become_paragraphs() { - let mut s = s(); - type_breaks(&mut s, "one\ntwo\nthree"); - assert_eq!(s.doc.para_paths(StoryRef::Body).len(), 1); - select_all(&mut s); - run(&mut s, "para.convertBreaks", json!({"scope": "all"})); - assert_eq!(text(&s), "one\ntwo\nthree", "the breaks are paragraph marks, not text"); - assert_eq!(s.doc.para_paths(StoryRef::Body).len(), 3); - // One undo step brings the manual line breaks back. - assert!(s.undo()); - let p0 = s.doc.para(StoryRef::Body, &Path::top(0)).unwrap(); - assert_eq!(p0.text, "one\ntwo\nthree", "back to one paragraph with its breaks"); -} - -#[test] -fn blank_paragraphs_go_in_between() { - let mut s = s(); - run(&mut s, "document.setText", json!({"text": "a\nb\nc"})); - select_all(&mut s); - run(&mut s, "para.blankBetween", json!({"scope": "all"})); - assert_eq!(text(&s), "a\n\nb\n\nc"); -} - -#[test] -fn blank_paragraphs_go_after_too() { - let mut s = s(); - run(&mut s, "document.setText", json!({"text": "a\nb\nc"})); - select_all(&mut s); - run(&mut s, "para.blankBetween", json!({"scope": "all", "after": true})); - assert_eq!(text(&s), "a\n\nb\n\nc\n"); -} - -#[test] -fn separate_paragraphs_blanks_after_every_one() { - let mut s = s(); - run(&mut s, "document.setText", json!({"text": "a\nb\nc"})); - select_all(&mut s); - run(&mut s, "para.separateParagraphs", json!({"scope": "all"})); - assert_eq!(text(&s), "a\n\nb\n\nc\n"); -} - -#[test] -fn blank_paragraphs_are_deleted() { - let mut s = s(); - run(&mut s, "document.setText", json!({"text": "a\n\nb\n\n\nc"})); - select_all(&mut s); - run(&mut s, "para.removeBlank", json!({"scope": "all"})); - assert_eq!(text(&s), "a\nb\nc"); -} - -#[test] -fn a_section_break_paragraph_stays() { - let mut s = s(); - run(&mut s, "document.setText", json!({"text": "a\nb"})); - // The second paragraph carries a section break and is emptied: removing it would drop the - // section, so it stays. - let second = Path::top(1); - let p = s.doc.para_mut(StoryRef::Body, &second).unwrap(); - p.text.clear(); - p.runs.clear(); - p.section = Some(Box::new(SectionProps::default())); - p.touch(); - select_all(&mut s); - run(&mut s, "para.removeBlank", json!({"scope": "all"})); - assert_eq!(s.doc.para_paths(StoryRef::Body).len(), 2, "the section-break paragraph stays"); -} - -#[test] -fn leading_spaces_are_removed() { - let mut s = s(); - run(&mut s, "document.setText", json!({"text": " a\nb\n c"})); - select_all(&mut s); - run(&mut s, "para.trimLeadingSpaces", json!({"scope": "all"})); - assert_eq!(text(&s), "a\nb\nc"); -} - -#[test] -fn extra_spaces_collapse_to_one() { - let mut s = s(); - run(&mut s, "document.setText", json!({"text": "a b c d \n e"})); - select_all(&mut s); - run(&mut s, "para.removeExtraSpaces", json!({"scope": "all"})); - assert_eq!(text(&s), "a b c d \n e", "inner runs collapse; the leading spaces of the second paragraph stay"); -} - -#[test] -fn a_space_run_at_the_end_collapses() { - let mut s = s(); - run(&mut s, "document.setText", json!({"text": "a "})); - select_all(&mut s); - run(&mut s, "para.removeExtraSpaces", json!({"scope": "all"})); - assert_eq!(text(&s), "a "); -} - -#[test] -fn extra_spaces_collapse_without_removing_tabs_or_page_breaks() { - use wordcraft_doc::para::PAGE_BREAK; - - let mut s = s(); - run(&mut s, "document.setText", json!({"text": format!("a \t b {PAGE_BREAK} c\t ")})); - select_all(&mut s); - run(&mut s, "para.removeExtraSpaces", json!({"scope": "all"})); - assert_eq!(text(&s), format!("a \t b {PAGE_BREAK} c\t "), "only repeated spaces collapse"); -} - -#[test] -fn indented_spaces_become_an_indent() { - let mut s = s(); - run(&mut s, "document.setText", json!({"text": " a\nb"})); - select_all(&mut s); - run(&mut s, "para.spacesToIndent", json!({"scope": "all"})); - assert_eq!(text(&s), "a\nb"); - let p = s.doc.para(StoryRef::Body, &Path::top(0)).unwrap(); - assert_eq!(p.props.indent_first, Some(216.0), "three spaces are 3 x 72 pt"); - assert_eq!(s.doc.para(StoryRef::Body, &Path::top(1)).unwrap().props.indent_first, None, "b has no spaces"); -} - -#[test] -fn spaces_to_indent_keeps_spaces_the_indent_limit_cannot_represent() { - let mut many = s(); - run(&mut many, "document.setText", json!({"text": format!("{}a", " ".repeat(24))})); - select_all(&mut many); - run(&mut many, "para.spacesToIndent", json!({"scope": "all"})); - assert_eq!(text(&many), " a", "22 spaces fit in the 1584 pt maximum; two stay as text"); - assert_eq!(many.doc.para(StoryRef::Body, &Path::top(0)).unwrap().props.indent_first, Some(1584.0)); - - let mut nearly_full = s(); - run(&mut nearly_full, "document.setText", json!({"text": " a"})); - run(&mut nearly_full, "para.indents", json!({"firstLine": 1548.0})); - select_all(&mut nearly_full); - run(&mut nearly_full, "para.spacesToIndent", json!({"scope": "all"})); - assert_eq!(text(&nearly_full), " a", "less than 72 pt of room cannot replace one space"); - assert_eq!(nearly_full.doc.para(StoryRef::Body, &Path::top(0)).unwrap().props.indent_first, Some(1548.0)); -} - -#[test] -fn indents_become_leading_spaces() { - let mut s = s(); - run(&mut s, "document.setText", json!({"text": "a"})); - run(&mut s, "para.indents", json!({"left": 72.0, "firstLine": 144.0})); - select_all(&mut s); - run(&mut s, "para.indentToSpaces", json!({"scope": "all"})); - assert_eq!(text(&s), " a", "72 + 144 pt become three spaces"); - let p = s.doc.para(StoryRef::Body, &Path::top(0)).unwrap(); - assert_eq!((p.props.indent_left, p.props.indent_first), (Some(0.0), Some(0.0))); -} - -#[test] -fn a_hanging_indent_that_sums_to_zero_is_still_removed() { - let mut s = s(); - run(&mut s, "document.setText", json!({"text": "a"})); - run(&mut s, "para.indents", json!({"left": 72.0, "firstLine": -72.0})); - select_all(&mut s); - run(&mut s, "para.indentToSpaces", json!({"scope": "all"})); - assert_eq!(text(&s), "a", "the cancelling indents need no leading spaces"); - let p = s.doc.para(StoryRef::Body, &Path::top(0)).unwrap(); - assert_eq!((p.props.indent_left, p.props.indent_first), (Some(0.0), Some(0.0))); - - let mut tracked = Session::new(wordcraft_doc::Document::new()); - run(&mut tracked, "document.setText", json!({"text": "a"})); - run(&mut tracked, "para.indents", json!({"left": 72.0, "firstLine": -72.0})); - run(&mut tracked, "review.trackChanges", json!({"value": true})); - select_all(&mut tracked); - run(&mut tracked, "para.indentToSpaces", json!({"scope": "all"})); - assert!(!tracked.doc.revisions.iter().any(|r| r.kind == RevisionKind::Insert), "no inserted text means no insert revision"); - assert!(tracked.doc.revisions.iter().any(|r| r.kind == RevisionKind::Format), "the removed hanging indent is tracked as formatting"); -} - -#[test] -fn cleanup_keeps_fields_footnote_anchors_and_mixed_formatting() { - let mut s = s(); - run(&mut s, "document.setText", json!({"text": "aa bb "})); - s.sel = crate::Selection { anchor: Pos::body(0, 4), focus: Pos::body(0, 6) }; - run(&mut s, "format.bold", json!({"value": true})); - s.sel = crate::Selection::caret(s.doc.end_of(StoryRef::Body)); - run(&mut s, "insert.field", json!({"instr": "AUTHOR", "result": "Ada"})); - let note = s.run("references.footnote", &json!({"text": "keep this note"})).unwrap()["id"].as_u64().unwrap() as u32; - s.sel = crate::Selection::caret(s.doc.end_of(StoryRef::Body)); - run(&mut s, "text.insert", json!({"text": " tail"})); - - select_all(&mut s); - run(&mut s, "para.removeExtraSpaces", json!({"scope": "all"})); - - let p = s.doc.para(StoryRef::Body, &Path::top(0)).unwrap(); - assert_eq!((p.props_of_char(3).bold, p.props_of_char(4).bold), (Some(true), Some(true)), "the bold run moved but kept its formatting"); - assert!(p.object_offsets().iter().any(|off| matches!(p.object_at(*off), Some(InlineObject::Field { .. }))), "the field stays"); - assert!(p.object_offsets().iter().any(|off| matches!(p.object_at(*off), Some(InlineObject::NoteRef { .. }))), "the footnote anchor stays"); - assert!(s.doc.parts.contains_key(¬e), "the anchored footnote story stays"); -} - -#[test] -fn cleanup_inside_a_table_keeps_the_table_and_its_cell() { - let mut s = s(); - run(&mut s, "insert.table", json!({"rows": 1, "cols": 1})); - run(&mut s, "text.insert", json!({"text": "a b"})); - select_all(&mut s); - run(&mut s, "para.removeExtraSpaces", json!({"scope": "all"})); - let table = s.doc.body.iter().find_map(|block| block.as_table()).expect("the table stays"); - let cell = table.rows[0].cells[0].blocks.iter().find_map(|block| block.as_para()).expect("the cell paragraph stays"); - assert_eq!(cell.text, "a b"); -} - -#[test] -fn whole_document_scope_from_a_table_cell_reaches_the_body() { - let mut s = s(); - run(&mut s, "document.setText", json!({"text": "body text"})); - s.sel = crate::Selection::caret(s.doc.end_of(StoryRef::Body)); - run(&mut s, "insert.table", json!({"rows": 1, "cols": 2})); - let table_index = s.doc.body.iter().position(|block| block.as_table().is_some()).expect("the table was inserted") as u32; - s.sel = crate::Selection::caret(Pos { story: StoryRef::Body, path: Path(vec![table_index, 0, 0, 0]), off: 0 }); - run(&mut s, "text.insert", json!({"text": "cell text"})); - assert!(s.sel.focus.path.depth() > 0, "the caret is inside the table"); - run(&mut s, "para.removeExtraSpaces", json!({"scope": "all"})); - assert!(text(&s).contains("body text"), "body text outside the table was cleaned too: {}", text(&s)); - let table = s.doc.body.iter().find_map(|block| block.as_table()).expect("the table stays"); - let cell = table.rows[0].cells[0].blocks.iter().find_map(|block| block.as_para()).expect("the cell stays"); - assert_eq!(cell.text, "cell text"); -} - -#[test] -fn paragraph_tools_refuse_content_locked_controls() { - for (text, command) in [("a b", "para.removeExtraSpaces"), ("a\nb", "para.convertBreaks")] { - let mut s = s(); - type_breaks(&mut s, text); - select_all(&mut s); - run(&mut s, "developer.richText", json!({"tag": "locked"})); - run(&mut s, "developer.properties", json!({"control": "locked", "lockEdit": true})); - select_all(&mut s); - let before = s.doc.clone(); - let error = s.run(command, &json!({"scope": "all"})).unwrap_err(); - assert!(error.to_string().contains("can't be edited"), "{command}: {error}"); - assert_eq!(s.doc, before, "{command} changed locked content"); - } -} - -#[test] -fn line_breaks_are_removed() { - let mut s = s(); - type_breaks(&mut s, "one\ntwo\nthree"); - select_all(&mut s); - run(&mut s, "para.removeLineBreaks", json!({"scope": "all"})); - assert_eq!(text(&s), "onetwothree"); -} - -#[test] -fn the_selection_is_the_scope() { - let mut s = s(); - run(&mut s, "document.setText", json!({"text": "a b\nc d"})); - // Only the first paragraph: select from its start to its end. - s.sel = crate::Selection { anchor: Pos::body(0, 0), focus: Pos::body(0, 4) }; - run(&mut s, "para.removeExtraSpaces", json!({})); - assert_eq!(text(&s), "a b\nc d", "the second paragraph is untouched"); -} - -#[test] -fn structural_tools_do_not_cross_the_selection() { - let mut breaks = s(); - type_breaks(&mut breaks, "one\ntwo"); - run(&mut breaks, "text.newParagraph", json!({})); - run(&mut breaks, "text.insert", json!({"text": "outside"})); - breaks.sel = crate::Selection { anchor: Pos::body(0, 0), focus: Pos::body(0, 7) }; - run(&mut breaks, "para.convertBreaks", json!({})); - let last = breaks.doc.para_paths(StoryRef::Body).last().cloned().unwrap(); - assert_eq!(breaks.doc.para(StoryRef::Body, &last).unwrap().text, "outside"); - - let mut between = s(); - run(&mut between, "document.setText", json!({"text": "a\nb\nc"})); - between.sel = crate::Selection { anchor: Pos::body(0, 0), focus: Pos::body(1, 1) }; - run(&mut between, "para.blankBetween", json!({})); - assert_eq!(text(&between), "a\n\nb\nc", "only a and b get a blank between them"); - - let mut separate = s(); - run(&mut separate, "document.setText", json!({"text": "a\nb\nc"})); - separate.sel = crate::Selection { anchor: Pos::body(0, 0), focus: Pos::body(1, 1) }; - run(&mut separate, "para.separateParagraphs", json!({})); - assert_eq!(text(&separate), "a\n\nb\n\nc", "the blank after b stays before the unselected c"); - - let mut blank = s(); - run(&mut blank, "document.setText", json!({"text": "a\n\nb\n\nc"})); - blank.sel = crate::Selection::caret(Pos::body(1, 0)); - run(&mut blank, "para.removeBlank", json!({})); - assert_eq!(text(&blank), "a\nb\n\nc", "the other blank paragraph is outside the selection"); -} - -#[test] -fn indent_space_conversions_are_reversible_track_changes() { - let mut to_indent = s(); - run(&mut to_indent, "document.setText", json!({"text": " a"})); - run(&mut to_indent, "review.trackChanges", json!({"value": true})); - select_all(&mut to_indent); - run(&mut to_indent, "para.spacesToIndent", json!({"scope": "all"})); - assert_eq!(to_indent.doc.para(StoryRef::Body, &Path::top(0)).unwrap().props.indent_first, Some(144.0)); - run(&mut to_indent, "review.rejectAll", json!({})); - let p = to_indent.doc.para(StoryRef::Body, &Path::top(0)).unwrap(); - assert_eq!((p.text.as_str(), p.props.indent_first), (" a", None)); - - let mut to_spaces = s(); - run(&mut to_spaces, "document.setText", json!({"text": "a"})); - run(&mut to_spaces, "para.indents", json!({"firstLine": 144.0})); - run(&mut to_spaces, "review.trackChanges", json!({"value": true})); - select_all(&mut to_spaces); - run(&mut to_spaces, "para.indentToSpaces", json!({"scope": "all"})); - run(&mut to_spaces, "review.rejectAll", json!({})); - let p = to_spaces.doc.para(StoryRef::Body, &Path::top(0)).unwrap(); - assert_eq!((p.text.as_str(), p.props.indent_first), ("a", Some(144.0))); - - let mut accepted = s(); - run(&mut accepted, "document.setText", json!({"text": "a"})); - run(&mut accepted, "para.indents", json!({"firstLine": 144.0})); - run(&mut accepted, "review.trackChanges", json!({"value": true})); - select_all(&mut accepted); - run(&mut accepted, "para.indentToSpaces", json!({"scope": "all"})); - run(&mut accepted, "review.acceptAll", json!({})); - let p = accepted.doc.para(StoryRef::Body, &Path::top(0)).unwrap(); - assert_eq!((p.text.as_str(), p.props.indent_first), (" a", Some(0.0))); -} - -#[test] -fn indent_to_spaces_does_not_inherit_revision_marks() { - let mut s = s(); - run(&mut s, "document.setText", json!({"text": "a"})); - run(&mut s, "review.trackChanges", json!({"value": true})); - s.sel = crate::Selection { anchor: Pos::body(0, 0), focus: Pos::body(0, 1) }; - run(&mut s, "format.bold", json!({"value": true})); - run(&mut s, "text.delete", json!({})); - run(&mut s, "review.trackChanges", json!({"value": false})); - run(&mut s, "para.indents", json!({"firstLine": 72.0})); - select_all(&mut s); - run(&mut s, "para.indentToSpaces", json!({"scope": "all"})); - let space = s.doc.para(StoryRef::Body, &Path::top(0)).unwrap().props_of_char(0); - assert_eq!((space.ins, space.del, space.fmt_change.as_ref().map(|_| ())), (None, None, None)); -} - -#[test] -fn converting_too_many_line_breaks_is_atomic() { - let original = format!("{}end", "x\n".repeat(10_001)); - let mut s = s(); - type_breaks(&mut s, &original); - select_all(&mut s); - let before = s.doc.clone(); - let error = s.run("para.convertBreaks", &json!({"scope": "all"})).unwrap_err(); - assert!(error.to_string().contains("at most"), "{error}"); - assert_eq!(s.doc, before, "the refused conversion is all-or-nothing"); -} - -#[test] -fn repeating_a_tracked_deletion_creates_no_empty_revision() { - let mut s = s(); - run(&mut s, "document.setText", json!({"text": " a"})); - run(&mut s, "review.trackChanges", json!({"value": true})); - select_all(&mut s); - run(&mut s, "para.trimLeadingSpaces", json!({"scope": "all"})); - let revisions = s.doc.revisions.len(); - run(&mut s, "para.trimLeadingSpaces", json!({"scope": "all"})); - assert_eq!(s.doc.revisions.len(), revisions, "already-deleted spaces create no revision"); -} - -#[test] -fn deletions_are_recorded_under_track_changes() { - let mut s = s(); - run(&mut s, "document.setText", json!({"text": "a b\n c"})); - run(&mut s, "review.trackChanges", json!({"value": true})); - select_all(&mut s); - run(&mut s, "para.trimLeadingSpaces", json!({"scope": "all"})); - // The text stays marked deleted, not gone, as Word records the deletion. - assert_eq!(text(&s), "a b\n c", "tracked deletions keep their text"); - assert!(s.doc.revisions.iter().any(|r| r.kind == RevisionKind::Delete)); - run(&mut s, "review.acceptAll", json!({})); - assert_eq!(text(&s), "a b\nc", "accepting removes the leading space"); -} - -/// Tracked-delete the bytes `a..b` of the first body paragraph (Track Changes must be on). -fn track_delete_in_first(s: &mut Session, a: usize, b: usize) { - let path = s.doc.para_paths(StoryRef::Body)[0].clone(); - let at = |off: usize| Pos { story: StoryRef::Body, path: path.clone(), off }; - crate::cmd::track_delete(s, &at(a), &at(b)).expect("tracked delete"); -} - -fn first_indent(s: &Session) -> Option { - let path = s.doc.para_paths(StoryRef::Body)[0].clone(); - s.doc.para(StoryRef::Body, &path).expect("paragraph").props.indent_first -} - -fn first_deleted(s: &Session, off: usize) -> bool { - let path = s.doc.para_paths(StoryRef::Body)[0].clone(); - s.doc.para(StoryRef::Body, &path).expect("paragraph").props_of_char(off).del.is_some() -} - -#[test] -fn remove_extra_spaces_skips_tracked_deleted_spaces() { - let mut s = s(); - run(&mut s, "document.setText", json!({"text": "a b"})); - run(&mut s, "review.trackChanges", json!({"value": true})); - track_delete_in_first(&mut s, 2, 3); // the second space is already deleted - select_all(&mut s); - run(&mut s, "para.removeExtraSpaces", json!({"scope": "all"})); - run(&mut s, "review.acceptAll", json!({})); - assert_eq!(text(&s), "a b", "the one live space survives"); - - // Three spaces, the middle one already deleted: of the two live ones the last is kept. - let mut s = self::s(); - run(&mut s, "document.setText", json!({"text": "a b"})); - run(&mut s, "review.trackChanges", json!({"value": true})); - track_delete_in_first(&mut s, 2, 3); - select_all(&mut s); - run(&mut s, "para.removeExtraSpaces", json!({"scope": "all"})); - assert!(first_deleted(&s, 1) && first_deleted(&s, 2), "the first and the already deleted space are deleted"); - assert!(!first_deleted(&s, 3), "the last live space stays"); -} - -#[test] -fn spaces_to_indent_twice_does_not_count_deleted_spaces_again() { - let mut s = s(); - run(&mut s, "document.setText", json!({"text": " x"})); - run(&mut s, "review.trackChanges", json!({"value": true})); - select_all(&mut s); - run(&mut s, "para.spacesToIndent", json!({"scope": "all"})); - assert_eq!(first_indent(&s), Some(144.0)); - run(&mut s, "para.spacesToIndent", json!({"scope": "all"})); - assert_eq!(first_indent(&s), Some(144.0), "the deleted spaces are not indented a second time"); -} - -#[test] -fn convert_breaks_leaves_a_tracked_deleted_break_alone() { - let mut s = s(); - type_breaks(&mut s, "a\nb\nc"); - run(&mut s, "review.trackChanges", json!({"value": true})); - track_delete_in_first(&mut s, 1, 2); - select_all(&mut s); - run(&mut s, "para.convertBreaks", json!({"scope": "all"})); - assert_eq!(s.doc.para_paths(StoryRef::Body).len(), 2, "only the live break becomes a paragraph break"); -} - -#[test] -fn junk_scope_is_refused() { - let mut s = s(); - run(&mut s, "document.setText", json!({"text": "a b"})); - select_all(&mut s); - let e = s.run("para.removeExtraSpaces", &json!({"scope": "junk"})).unwrap_err(); - assert!(format!("{e}").contains("scope"), "{e}"); - assert_eq!(text(&s), "a b", "the document is unchanged"); -} - -#[test] -fn too_many_paragraphs_are_refused_before_any_change() { - let text = "x\n".repeat(15_000); - let mut s = Session::new(wordcraft_doc::Document::from_text(&text)); - select_all(&mut s); - let e = s.run("para.blankBetween", &json!({"scope": "all"})).unwrap_err(); - assert!(format!("{e}").contains("at most"), "{e}"); - let kept = s.doc.para_paths(StoryRef::Body).len(); - assert!((15_000..=15_001).contains(&kept), "nothing changed, {kept}"); -} +//! Advanced paragraph tools (#5043): conversion and cleanup over the selection or the whole +//! document, one undo step, Track Changes respected. + +use serde_json::json; +use wordcraft_doc::{InlineObject, Path, Pos, RevisionKind, SectionProps, StoryRef}; + +use crate::Session; + +fn s() -> Session { + Session::new(wordcraft_doc::Document::new()) +} + +fn run(s: &mut Session, id: &str, v: 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) +} + +/// Select the whole document (Mod+A's command). +fn select_all(s: &mut Session) { + run(s, "select.all", json!({})); +} + +/// One paragraph with `\n` as manual line breaks, typed as text. +fn type_breaks(s: &mut Session, t: &str) { + run(s, "text.insert", json!({"text": t})); +} + +#[test] +fn line_breaks_become_paragraphs() { + let mut s = s(); + type_breaks(&mut s, "one\ntwo\nthree"); + assert_eq!(s.doc.para_paths(StoryRef::Body).len(), 1); + select_all(&mut s); + run(&mut s, "para.convertBreaks", json!({"scope": "all"})); + assert_eq!(text(&s), "one\ntwo\nthree", "the breaks are paragraph marks, not text"); + assert_eq!(s.doc.para_paths(StoryRef::Body).len(), 3); + // One undo step brings the manual line breaks back. + assert!(s.undo()); + let p0 = s.doc.para(StoryRef::Body, &Path::top(0)).unwrap(); + assert_eq!(p0.text, "one\ntwo\nthree", "back to one paragraph with its breaks"); +} + +#[test] +fn blank_paragraphs_go_in_between() { + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "a\nb\nc"})); + select_all(&mut s); + run(&mut s, "para.blankBetween", json!({"scope": "all"})); + assert_eq!(text(&s), "a\n\nb\n\nc"); +} + +#[test] +fn blank_paragraphs_go_after_too() { + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "a\nb\nc"})); + select_all(&mut s); + run(&mut s, "para.blankBetween", json!({"scope": "all", "after": true})); + assert_eq!(text(&s), "a\n\nb\n\nc\n"); +} + +#[test] +fn separate_paragraphs_blanks_after_every_one() { + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "a\nb\nc"})); + select_all(&mut s); + run(&mut s, "para.separateParagraphs", json!({"scope": "all"})); + assert_eq!(text(&s), "a\n\nb\n\nc\n"); +} + +#[test] +fn blank_paragraphs_are_deleted() { + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "a\n\nb\n\n\nc"})); + select_all(&mut s); + run(&mut s, "para.removeBlank", json!({"scope": "all"})); + assert_eq!(text(&s), "a\nb\nc"); +} + +#[test] +fn a_section_break_paragraph_stays() { + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "a\nb"})); + // The second paragraph carries a section break and is emptied: removing it would drop the + // section, so it stays. + let second = Path::top(1); + let p = s.doc.para_mut(StoryRef::Body, &second).unwrap(); + p.text.clear(); + p.runs.clear(); + p.section = Some(Box::new(SectionProps::default())); + p.touch(); + select_all(&mut s); + run(&mut s, "para.removeBlank", json!({"scope": "all"})); + assert_eq!(s.doc.para_paths(StoryRef::Body).len(), 2, "the section-break paragraph stays"); +} + +#[test] +fn leading_spaces_are_removed() { + let mut s = s(); + run(&mut s, "document.setText", json!({"text": " a\nb\n c"})); + select_all(&mut s); + run(&mut s, "para.trimLeadingSpaces", json!({"scope": "all"})); + assert_eq!(text(&s), "a\nb\nc"); +} + +#[test] +fn extra_spaces_collapse_to_one() { + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "a b c d \n e"})); + select_all(&mut s); + run(&mut s, "para.removeExtraSpaces", json!({"scope": "all"})); + assert_eq!(text(&s), "a b c d \n e", "inner runs collapse; the leading spaces of the second paragraph stay"); +} + +#[test] +fn a_space_run_at_the_end_collapses() { + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "a "})); + select_all(&mut s); + run(&mut s, "para.removeExtraSpaces", json!({"scope": "all"})); + assert_eq!(text(&s), "a "); +} + +#[test] +fn extra_spaces_collapse_without_removing_tabs_or_page_breaks() { + use wordcraft_doc::para::PAGE_BREAK; + + let mut s = s(); + run(&mut s, "document.setText", json!({"text": format!("a \t b {PAGE_BREAK} c\t ")})); + select_all(&mut s); + run(&mut s, "para.removeExtraSpaces", json!({"scope": "all"})); + assert_eq!(text(&s), format!("a \t b {PAGE_BREAK} c\t "), "only repeated spaces collapse"); +} + +#[test] +fn indented_spaces_become_an_indent() { + let mut s = s(); + run(&mut s, "document.setText", json!({"text": " a\nb"})); + select_all(&mut s); + run(&mut s, "para.spacesToIndent", json!({"scope": "all"})); + assert_eq!(text(&s), "a\nb"); + let p = s.doc.para(StoryRef::Body, &Path::top(0)).unwrap(); + assert_eq!(p.props.indent_first, Some(216.0), "three spaces are 3 x 72 pt"); + assert_eq!(s.doc.para(StoryRef::Body, &Path::top(1)).unwrap().props.indent_first, None, "b has no spaces"); +} + +#[test] +fn spaces_to_indent_keeps_spaces_the_indent_limit_cannot_represent() { + let mut many = s(); + run(&mut many, "document.setText", json!({"text": format!("{}a", " ".repeat(24))})); + select_all(&mut many); + run(&mut many, "para.spacesToIndent", json!({"scope": "all"})); + assert_eq!(text(&many), " a", "22 spaces fit in the 1584 pt maximum; two stay as text"); + assert_eq!(many.doc.para(StoryRef::Body, &Path::top(0)).unwrap().props.indent_first, Some(1584.0)); + + let mut nearly_full = s(); + run(&mut nearly_full, "document.setText", json!({"text": " a"})); + run(&mut nearly_full, "para.indents", json!({"firstLine": 1548.0})); + select_all(&mut nearly_full); + run(&mut nearly_full, "para.spacesToIndent", json!({"scope": "all"})); + assert_eq!(text(&nearly_full), " a", "less than 72 pt of room cannot replace one space"); + assert_eq!(nearly_full.doc.para(StoryRef::Body, &Path::top(0)).unwrap().props.indent_first, Some(1548.0)); +} + +#[test] +fn indents_become_leading_spaces() { + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "a"})); + run(&mut s, "para.indents", json!({"left": 72.0, "firstLine": 144.0})); + select_all(&mut s); + run(&mut s, "para.indentToSpaces", json!({"scope": "all"})); + assert_eq!(text(&s), " a", "72 + 144 pt become three spaces"); + let p = s.doc.para(StoryRef::Body, &Path::top(0)).unwrap(); + assert_eq!((p.props.indent_left, p.props.indent_first), (Some(0.0), Some(0.0))); +} + +#[test] +fn a_hanging_indent_that_sums_to_zero_is_still_removed() { + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "a"})); + run(&mut s, "para.indents", json!({"left": 72.0, "firstLine": -72.0})); + select_all(&mut s); + run(&mut s, "para.indentToSpaces", json!({"scope": "all"})); + assert_eq!(text(&s), "a", "the cancelling indents need no leading spaces"); + let p = s.doc.para(StoryRef::Body, &Path::top(0)).unwrap(); + assert_eq!((p.props.indent_left, p.props.indent_first), (Some(0.0), Some(0.0))); + + let mut tracked = Session::new(wordcraft_doc::Document::new()); + run(&mut tracked, "document.setText", json!({"text": "a"})); + run(&mut tracked, "para.indents", json!({"left": 72.0, "firstLine": -72.0})); + run(&mut tracked, "review.trackChanges", json!({"value": true})); + select_all(&mut tracked); + run(&mut tracked, "para.indentToSpaces", json!({"scope": "all"})); + assert!(!tracked.doc.revisions.iter().any(|r| r.kind == RevisionKind::Insert), "no inserted text means no insert revision"); + assert!(tracked.doc.revisions.iter().any(|r| r.kind == RevisionKind::Format), "the removed hanging indent is tracked as formatting"); +} + +#[test] +fn cleanup_keeps_fields_footnote_anchors_and_mixed_formatting() { + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "aa bb "})); + s.sel = crate::Selection { anchor: Pos::body(0, 4), focus: Pos::body(0, 6) }; + run(&mut s, "format.bold", json!({"value": true})); + s.sel = crate::Selection::caret(s.doc.end_of(StoryRef::Body)); + run(&mut s, "insert.field", json!({"instr": "AUTHOR", "result": "Ada"})); + let note = s.run("references.footnote", &json!({"text": "keep this note"})).unwrap()["id"].as_u64().unwrap() as u32; + s.sel = crate::Selection::caret(s.doc.end_of(StoryRef::Body)); + run(&mut s, "text.insert", json!({"text": " tail"})); + + select_all(&mut s); + run(&mut s, "para.removeExtraSpaces", json!({"scope": "all"})); + + let p = s.doc.para(StoryRef::Body, &Path::top(0)).unwrap(); + assert_eq!((p.props_of_char(3).bold, p.props_of_char(4).bold), (Some(true), Some(true)), "the bold run moved but kept its formatting"); + assert!(p.object_offsets().iter().any(|off| matches!(p.object_at(*off), Some(InlineObject::Field { .. }))), "the field stays"); + assert!(p.object_offsets().iter().any(|off| matches!(p.object_at(*off), Some(InlineObject::NoteRef { .. }))), "the footnote anchor stays"); + assert!(s.doc.parts.contains_key(¬e), "the anchored footnote story stays"); +} + +#[test] +fn cleanup_inside_a_table_keeps_the_table_and_its_cell() { + let mut s = s(); + run(&mut s, "insert.table", json!({"rows": 1, "cols": 1})); + run(&mut s, "text.insert", json!({"text": "a b"})); + select_all(&mut s); + run(&mut s, "para.removeExtraSpaces", json!({"scope": "all"})); + let table = s.doc.body.iter().find_map(|block| block.as_table()).expect("the table stays"); + let cell = table.rows[0].cells[0].blocks.iter().find_map(|block| block.as_para()).expect("the cell paragraph stays"); + assert_eq!(cell.text, "a b"); +} + +#[test] +fn whole_document_scope_from_a_table_cell_reaches_the_body() { + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "body text"})); + s.sel = crate::Selection::caret(s.doc.end_of(StoryRef::Body)); + run(&mut s, "insert.table", json!({"rows": 1, "cols": 2})); + let table_index = s.doc.body.iter().position(|block| block.as_table().is_some()).expect("the table was inserted") as u32; + s.sel = crate::Selection::caret(Pos { story: StoryRef::Body, path: Path(vec![table_index, 0, 0, 0]), off: 0 }); + run(&mut s, "text.insert", json!({"text": "cell text"})); + assert!(s.sel.focus.path.depth() > 0, "the caret is inside the table"); + run(&mut s, "para.removeExtraSpaces", json!({"scope": "all"})); + assert!(text(&s).contains("body text"), "body text outside the table was cleaned too: {}", text(&s)); + let table = s.doc.body.iter().find_map(|block| block.as_table()).expect("the table stays"); + let cell = table.rows[0].cells[0].blocks.iter().find_map(|block| block.as_para()).expect("the cell stays"); + assert_eq!(cell.text, "cell text"); +} + +#[test] +fn paragraph_tools_refuse_content_locked_controls() { + for (text, command) in [("a b", "para.removeExtraSpaces"), ("a\nb", "para.convertBreaks")] { + let mut s = s(); + type_breaks(&mut s, text); + select_all(&mut s); + run(&mut s, "developer.richText", json!({"tag": "locked"})); + run(&mut s, "developer.properties", json!({"control": "locked", "lockEdit": true})); + select_all(&mut s); + let before = s.doc.clone(); + let error = s.run(command, &json!({"scope": "all"})).unwrap_err(); + assert!(error.to_string().contains("can't be edited"), "{command}: {error}"); + assert_eq!(s.doc, before, "{command} changed locked content"); + } +} + +#[test] +fn line_breaks_are_removed() { + let mut s = s(); + type_breaks(&mut s, "one\ntwo\nthree"); + select_all(&mut s); + run(&mut s, "para.removeLineBreaks", json!({"scope": "all"})); + assert_eq!(text(&s), "onetwothree"); +} + +#[test] +fn the_selection_is_the_scope() { + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "a b\nc d"})); + // Only the first paragraph: select from its start to its end. + s.sel = crate::Selection { anchor: Pos::body(0, 0), focus: Pos::body(0, 4) }; + run(&mut s, "para.removeExtraSpaces", json!({})); + assert_eq!(text(&s), "a b\nc d", "the second paragraph is untouched"); +} + +#[test] +fn structural_tools_do_not_cross_the_selection() { + let mut breaks = s(); + type_breaks(&mut breaks, "one\ntwo"); + run(&mut breaks, "text.newParagraph", json!({})); + run(&mut breaks, "text.insert", json!({"text": "outside"})); + breaks.sel = crate::Selection { anchor: Pos::body(0, 0), focus: Pos::body(0, 7) }; + run(&mut breaks, "para.convertBreaks", json!({})); + let last = breaks.doc.para_paths(StoryRef::Body).last().cloned().unwrap(); + assert_eq!(breaks.doc.para(StoryRef::Body, &last).unwrap().text, "outside"); + + let mut between = s(); + run(&mut between, "document.setText", json!({"text": "a\nb\nc"})); + between.sel = crate::Selection { anchor: Pos::body(0, 0), focus: Pos::body(1, 1) }; + run(&mut between, "para.blankBetween", json!({})); + assert_eq!(text(&between), "a\n\nb\nc", "only a and b get a blank between them"); + + let mut separate = s(); + run(&mut separate, "document.setText", json!({"text": "a\nb\nc"})); + separate.sel = crate::Selection { anchor: Pos::body(0, 0), focus: Pos::body(1, 1) }; + run(&mut separate, "para.separateParagraphs", json!({})); + assert_eq!(text(&separate), "a\n\nb\n\nc", "the blank after b stays before the unselected c"); + + let mut blank = s(); + run(&mut blank, "document.setText", json!({"text": "a\n\nb\n\nc"})); + blank.sel = crate::Selection::caret(Pos::body(1, 0)); + run(&mut blank, "para.removeBlank", json!({})); + assert_eq!(text(&blank), "a\nb\n\nc", "the other blank paragraph is outside the selection"); +} + +#[test] +fn indent_space_conversions_are_reversible_track_changes() { + let mut to_indent = s(); + run(&mut to_indent, "document.setText", json!({"text": " a"})); + run(&mut to_indent, "review.trackChanges", json!({"value": true})); + select_all(&mut to_indent); + run(&mut to_indent, "para.spacesToIndent", json!({"scope": "all"})); + assert_eq!(to_indent.doc.para(StoryRef::Body, &Path::top(0)).unwrap().props.indent_first, Some(144.0)); + run(&mut to_indent, "review.rejectAll", json!({})); + let p = to_indent.doc.para(StoryRef::Body, &Path::top(0)).unwrap(); + assert_eq!((p.text.as_str(), p.props.indent_first), (" a", None)); + + let mut to_spaces = s(); + run(&mut to_spaces, "document.setText", json!({"text": "a"})); + run(&mut to_spaces, "para.indents", json!({"firstLine": 144.0})); + run(&mut to_spaces, "review.trackChanges", json!({"value": true})); + select_all(&mut to_spaces); + run(&mut to_spaces, "para.indentToSpaces", json!({"scope": "all"})); + run(&mut to_spaces, "review.rejectAll", json!({})); + let p = to_spaces.doc.para(StoryRef::Body, &Path::top(0)).unwrap(); + assert_eq!((p.text.as_str(), p.props.indent_first), ("a", Some(144.0))); + + let mut accepted = s(); + run(&mut accepted, "document.setText", json!({"text": "a"})); + run(&mut accepted, "para.indents", json!({"firstLine": 144.0})); + run(&mut accepted, "review.trackChanges", json!({"value": true})); + select_all(&mut accepted); + run(&mut accepted, "para.indentToSpaces", json!({"scope": "all"})); + run(&mut accepted, "review.acceptAll", json!({})); + let p = accepted.doc.para(StoryRef::Body, &Path::top(0)).unwrap(); + assert_eq!((p.text.as_str(), p.props.indent_first), (" a", Some(0.0))); +} + +#[test] +fn indent_to_spaces_does_not_inherit_revision_marks() { + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "a"})); + run(&mut s, "review.trackChanges", json!({"value": true})); + s.sel = crate::Selection { anchor: Pos::body(0, 0), focus: Pos::body(0, 1) }; + run(&mut s, "format.bold", json!({"value": true})); + run(&mut s, "text.delete", json!({})); + run(&mut s, "review.trackChanges", json!({"value": false})); + run(&mut s, "para.indents", json!({"firstLine": 72.0})); + select_all(&mut s); + run(&mut s, "para.indentToSpaces", json!({"scope": "all"})); + let space = s.doc.para(StoryRef::Body, &Path::top(0)).unwrap().props_of_char(0); + assert_eq!((space.ins, space.del, space.fmt_change.as_ref().map(|_| ())), (None, None, None)); +} + +#[test] +fn converting_too_many_line_breaks_is_atomic() { + let original = format!("{}end", "x\n".repeat(10_001)); + let mut s = s(); + type_breaks(&mut s, &original); + select_all(&mut s); + let before = s.doc.clone(); + let error = s.run("para.convertBreaks", &json!({"scope": "all"})).unwrap_err(); + assert!(error.to_string().contains("at most"), "{error}"); + assert_eq!(s.doc, before, "the refused conversion is all-or-nothing"); +} + +#[test] +fn repeating_a_tracked_deletion_creates_no_empty_revision() { + let mut s = s(); + run(&mut s, "document.setText", json!({"text": " a"})); + run(&mut s, "review.trackChanges", json!({"value": true})); + select_all(&mut s); + run(&mut s, "para.trimLeadingSpaces", json!({"scope": "all"})); + let revisions = s.doc.revisions.len(); + run(&mut s, "para.trimLeadingSpaces", json!({"scope": "all"})); + assert_eq!(s.doc.revisions.len(), revisions, "already-deleted spaces create no revision"); +} + +#[test] +fn deletions_are_recorded_under_track_changes() { + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "a b\n c"})); + run(&mut s, "review.trackChanges", json!({"value": true})); + select_all(&mut s); + run(&mut s, "para.trimLeadingSpaces", json!({"scope": "all"})); + // The text stays marked deleted, not gone, as Word records the deletion. + assert_eq!(text(&s), "a b\n c", "tracked deletions keep their text"); + assert!(s.doc.revisions.iter().any(|r| r.kind == RevisionKind::Delete)); + run(&mut s, "review.acceptAll", json!({})); + assert_eq!(text(&s), "a b\nc", "accepting removes the leading space"); +} + +/// Tracked-delete the bytes `a..b` of the first body paragraph (Track Changes must be on). +fn track_delete_in_first(s: &mut Session, a: usize, b: usize) { + let path = s.doc.para_paths(StoryRef::Body)[0].clone(); + let at = |off: usize| Pos { story: StoryRef::Body, path: path.clone(), off }; + crate::cmd::track_delete(s, &at(a), &at(b)).expect("tracked delete"); +} + +fn first_indent(s: &Session) -> Option { + let path = s.doc.para_paths(StoryRef::Body)[0].clone(); + s.doc.para(StoryRef::Body, &path).expect("paragraph").props.indent_first +} + +fn first_deleted(s: &Session, off: usize) -> bool { + let path = s.doc.para_paths(StoryRef::Body)[0].clone(); + s.doc.para(StoryRef::Body, &path).expect("paragraph").props_of_char(off).del.is_some() +} + +#[test] +fn remove_extra_spaces_skips_tracked_deleted_spaces() { + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "a b"})); + run(&mut s, "review.trackChanges", json!({"value": true})); + track_delete_in_first(&mut s, 2, 3); // the second space is already deleted + select_all(&mut s); + run(&mut s, "para.removeExtraSpaces", json!({"scope": "all"})); + run(&mut s, "review.acceptAll", json!({})); + assert_eq!(text(&s), "a b", "the one live space survives"); + + // Three spaces, the middle one already deleted: of the two live ones the last is kept. + let mut s = self::s(); + run(&mut s, "document.setText", json!({"text": "a b"})); + run(&mut s, "review.trackChanges", json!({"value": true})); + track_delete_in_first(&mut s, 2, 3); + select_all(&mut s); + run(&mut s, "para.removeExtraSpaces", json!({"scope": "all"})); + assert!(first_deleted(&s, 1) && first_deleted(&s, 2), "the first and the already deleted space are deleted"); + assert!(!first_deleted(&s, 3), "the last live space stays"); +} + +#[test] +fn spaces_to_indent_twice_does_not_count_deleted_spaces_again() { + let mut s = s(); + run(&mut s, "document.setText", json!({"text": " x"})); + run(&mut s, "review.trackChanges", json!({"value": true})); + select_all(&mut s); + run(&mut s, "para.spacesToIndent", json!({"scope": "all"})); + assert_eq!(first_indent(&s), Some(144.0)); + run(&mut s, "para.spacesToIndent", json!({"scope": "all"})); + assert_eq!(first_indent(&s), Some(144.0), "the deleted spaces are not indented a second time"); +} + +#[test] +fn convert_breaks_leaves_a_tracked_deleted_break_alone() { + let mut s = s(); + type_breaks(&mut s, "a\nb\nc"); + run(&mut s, "review.trackChanges", json!({"value": true})); + track_delete_in_first(&mut s, 1, 2); + select_all(&mut s); + run(&mut s, "para.convertBreaks", json!({"scope": "all"})); + assert_eq!(s.doc.para_paths(StoryRef::Body).len(), 2, "only the live break becomes a paragraph break"); +} + +#[test] +fn junk_scope_is_refused() { + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "a b"})); + select_all(&mut s); + let e = s.run("para.removeExtraSpaces", &json!({"scope": "junk"})).unwrap_err(); + assert!(format!("{e}").contains("scope"), "{e}"); + assert_eq!(text(&s), "a b", "the document is unchanged"); +} + +#[test] +fn too_many_paragraphs_are_refused_before_any_change() { + let text = "x\n".repeat(15_000); + let mut s = Session::new(wordcraft_doc::Document::from_text(&text)); + select_all(&mut s); + let e = s.run("para.blankBetween", &json!({"scope": "all"})).unwrap_err(); + assert!(format!("{e}").contains("at most"), "{e}"); + let kept = s.doc.para_paths(StoryRef::Body).len(); + assert!((15_000..=15_001).contains(&kept), "nothing changed, {kept}"); +} diff --git a/crates/engine/src/tests/revision_checks.rs b/crates/engine/src/tests/revision_checks.rs index fe3e616036..c87d98bef4 100644 --- a/crates/engine/src/tests/revision_checks.rs +++ b/crates/engine/src/tests/revision_checks.rs @@ -1,474 +1,474 @@ -//! #1329: table deletion, Change Case and field unlink respect locked content controls and -//! Track Changes (Reject All restores what they changed). - -use serde_json::{Value, json}; -use wordcraft_doc::para::{InlineObject, OBJ}; -use wordcraft_doc::{Pos, StoryRef, Table}; - -use crate::{Selection, Session}; - -fn run(s: &mut Session, id: &str, v: Value) -> Value { - s.run(id, &v).unwrap_or_else(|e| panic!("{id}: {e}")) -} - -/// The first paragraph's text without its tracked deletions. -fn visible(s: &Session) -> String { - let Some(p) = s.doc.para_at(&Pos::body(0, 0)) else { return String::new() }; - p.run_ranges().filter(|(_, c)| c.del.is_none()).filter_map(|(r, _)| p.text.get(r)).collect::().replace(OBJ, "") -} - -fn table(s: &Session) -> Option<&Table> { - s.doc.body.iter().find_map(|b| b.as_table()) -} - -/// Every cell's text, row by row (deleted text included). -fn cells(s: &Session) -> Vec> { - table(s) - .map(|t| { - t.rows - .iter() - .map(|r| r.cells.iter().map(|c| c.blocks.iter().filter_map(|b| b.as_para()).map(|p| p.plain_text()).collect()).collect()) - .collect() - }) - .unwrap_or_default() -} - -/// Whether every character of every cell in `which` is a tracked deletion. -fn cells_deleted(s: &Session, which: &[(usize, usize)]) -> bool { - let Some(t) = table(s) else { return false }; - which.iter().all(|(r, c)| { - let cell = &t.rows[*r].cells[*c]; - cell.blocks.iter().filter_map(|b| b.as_para()).all(|p| p.run_ranges().all(|(_, props)| props.del.is_some())) - }) -} - -/// A 2×2 table holding A1 B1 / A2 B2, the caret in A1 (at its start). -fn table_session() -> Session { - let mut s = Session::new(wordcraft_doc::Document::new()); - run(&mut s, "insert.table", json!({"rows": 2, "cols": 2})); - for (i, t) in ["A1", "B1", "A2", "B2"].iter().enumerate() { - if i > 0 { - run(&mut s, "text.tab", json!({})); - } - run(&mut s, "text.insert", json!({"text": t})); - } - caret_in(&mut s, 0, 0, 0); - s -} - -fn caret_in(s: &mut Session, r: u32, c: u32, off: usize) { - s.sel = Selection::caret(Pos { story: StoryRef::Body, path: wordcraft_doc::Path(vec![0, r, c, 0]), off }); -} - -/// [`table_session`] with "A1" in a content control tagged `ctl`, locked with `lock`. -fn locked_table(lock: &str) -> Session { - let mut s = table_session(); - let at = |off| Pos { story: StoryRef::Body, path: wordcraft_doc::Path(vec![0, 0, 0, 0]), off }; - // A partial selection inside the paragraph (not the whole paragraph) wraps inline. - s.sel = Selection { anchor: at(0), focus: at(1) }; - run(&mut s, "developer.richText", json!({"tag": "ctl"})); - run(&mut s, "developer.properties", json!({"control": "ctl", lock: true})); - caret_in(&mut s, 0, 0, 0); - s -} - -#[test] -fn table_deletes_refuse_a_control_locked_against_deletion() { - for id in ["table.deleteTable", "table.deleteRow", "table.deleteColumn", "table.deleteCells"] { - let mut s = locked_table("lockDelete"); - let before = s.doc.clone(); - assert!(s.run(id, &json!({})).is_err(), "{id} deleted a locked control"); - assert_eq!(s.doc, before, "{id} changed the document"); - assert!(s.status.contains("can't be deleted"), "{id}: {}", s.status); - } -} - -#[test] -fn tracked_table_delete_marks_the_text_deleted_and_reject_all_restores_it() { - let mut s = table_session(); - run(&mut s, "review.trackChanges", json!({"value": true})); - run(&mut s, "table.deleteTable", json!({})); - // Like Word: the structure stays, its text is marked deleted. - assert!(table(&s).is_some(), "the table was removed under Track Changes"); - assert_eq!(cells(&s), [["A1", "B1"], ["A2", "B2"]]); - assert!(cells_deleted(&s, &[(0, 0), (0, 1), (1, 0), (1, 1)])); - run(&mut s, "review.rejectAll", json!({})); - assert_eq!(cells(&s), [["A1", "B1"], ["A2", "B2"]]); - assert!(!cells_deleted(&s, &[(0, 0)]) && s.doc.revisions.is_empty()); -} - -#[test] -fn tracked_row_column_and_cell_deletes_mark_their_text_deleted() { - for (id, gone, kept) in [ - ("table.deleteRow", vec![(0, 0), (0, 1)], vec![(1, 0), (1, 1)]), - ("table.deleteColumn", vec![(0, 0), (1, 0)], vec![(0, 1), (1, 1)]), - ("table.deleteCells", vec![(0, 0)], vec![(0, 1), (1, 0), (1, 1)]), - ] { - let mut s = table_session(); - run(&mut s, "review.trackChanges", json!({"value": true})); - run(&mut s, id, json!({})); - assert_eq!(cells(&s), [["A1", "B1"], ["A2", "B2"]], "{id} changed the structure"); - assert!(cells_deleted(&s, &gone), "{id}: text not marked deleted"); - for k in &kept { - assert!(!cells_deleted(&s, &[*k]), "{id}: {k:?} marked deleted"); - } - run(&mut s, "review.rejectAll", json!({})); - assert_eq!(cells(&s), [["A1", "B1"], ["A2", "B2"]]); - assert!(s.doc.revisions.is_empty(), "{id}"); - } -} - -#[test] -fn tracked_table_delete_over_tracked_insertions_rejects_back_to_the_original() { - let mut s = table_session(); - run(&mut s, "review.trackChanges", json!({"value": true})); - // One insertion by this author, one by another. - caret_in(&mut s, 0, 0, 2); - run(&mut s, "text.insert", json!({"text": "x"})); - let me = s.author.clone(); - s.author = "Other".into(); - caret_in(&mut s, 1, 1, 2); - run(&mut s, "text.insert", json!({"text": "y"})); - s.author = me; - caret_in(&mut s, 0, 0, 0); - run(&mut s, "table.deleteTable", json!({})); - assert!(table(&s).is_some()); - run(&mut s, "review.rejectAll", json!({})); - assert_eq!(cells(&s), [["A1", "B1"], ["A2", "B2"]]); - assert!(s.doc.revisions.is_empty()); -} - -/// "hello world" with "hello" in a rich text control tagged `ctl`. -fn hello_in_control(lock: Option<&str>) -> Session { - let mut s = Session::new(wordcraft_doc::Document::new()); - run(&mut s, "text.insert", json!({"text": "hello world"})); - s.sel = Selection { anchor: Pos::body(0, 0), focus: Pos::body(0, 5) }; - run(&mut s, "developer.richText", json!({"tag": "ctl"})); - if let Some(l) = lock { - run(&mut s, "developer.properties", json!({"control": "ctl", l: true})); - } - s -} - -#[test] -fn change_case_refuses_content_locked_against_editing() { - let mut s = hello_in_control(Some("lockEdit")); - let w = OBJ.len_utf8(); - // Just the control's content, and the whole paragraph. - for (a, b) in [(w, w + 5), (0, 11 + 2 * w)] { - s.sel = Selection { anchor: Pos::body(0, a), focus: Pos::body(0, b) }; - let before = s.doc.clone(); - assert!(s.run("format.changeCase", &json!({"mode": "upper"})).is_err(), "{a}..{b}"); - assert_eq!(s.doc, before); - } - // Locked against deletion only: the content may change. - let mut s = hello_in_control(Some("lockDelete")); - s.sel = Selection { anchor: Pos::body(0, 0), focus: Pos::body(0, 11 + 2 * w) }; - run(&mut s, "format.changeCase", json!({"mode": "upper"})); - assert_eq!(s.doc.plain_text(StoryRef::Body), "HELLO WORLD"); -} - -#[test] -fn tracked_change_case_rejects_back_to_the_original_case() { - let mut s = Session::new(wordcraft_doc::Document::new()); - run(&mut s, "text.insert", json!({"text": "hello World"})); - run(&mut s, "review.trackChanges", json!({"value": true})); - run(&mut s, "select.all", json!({})); - run(&mut s, "format.changeCase", json!({"mode": "upper"})); - assert_eq!(visible(&s), "HELLO WORLD"); - // The selection still covers the converted text. - let (a, b) = s.sel.ordered(); - assert_eq!((a.off, b.off), (0, s.doc.para_at(&a).map(|p| p.len()).unwrap_or(0))); - run(&mut s, "review.rejectAll", json!({})); - assert_eq!(s.doc.plain_text(StoryRef::Body), "hello World"); - assert!(s.doc.revisions.is_empty()); - // Accept All keeps the new case. - run(&mut s, "select.all", json!({})); - run(&mut s, "format.changeCase", json!({"mode": "upper"})); - run(&mut s, "review.acceptAll", json!({})); - assert_eq!(s.doc.plain_text(StoryRef::Body), "HELLO WORLD"); -} - -#[test] -fn tracked_change_case_of_own_insertion_stays_an_insertion() { - let mut s = Session::new(wordcraft_doc::Document::new()); - run(&mut s, "text.insert", json!({"text": "keep "})); - run(&mut s, "review.trackChanges", json!({"value": true})); - run(&mut s, "text.insert", json!({"text": "new"})); - s.sel = Selection { anchor: Pos::body(0, 5), focus: Pos::body(0, 8) }; - run(&mut s, "format.changeCase", json!({"mode": "upper"})); - assert_eq!(s.doc.plain_text(StoryRef::Body), "keep NEW", "no deleted copy of text that never existed"); - run(&mut s, "review.rejectAll", json!({})); - assert_eq!(s.doc.plain_text(StoryRef::Body), "keep "); -} - -/// "Made by {AUTHOR} on " with "by {AUTHOR} on" in a rich text control tagged `ctl`. -fn field_in_control(lock: &str) -> (Session, Pos) { - let mut s = Session::new(wordcraft_doc::Document::new()); - s.doc.core.creator = "Ada".into(); - run(&mut s, "text.insert", json!({"text": "Made by "})); - run(&mut s, "insert.field", json!({"instr": "AUTHOR", "result": "Ada"})); - run(&mut s, "text.insert", json!({"text": " on "})); - let w = OBJ.len_utf8(); - s.sel = Selection { anchor: Pos::body(0, 5), focus: Pos::body(0, 8 + w + 3) }; - run(&mut s, "developer.richText", json!({"tag": "ctl"})); - run(&mut s, "developer.properties", json!({"control": "ctl", lock: true})); - let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); - let off = p.object_offsets().into_iter().find(|o| matches!(p.object_at(*o), Some(InlineObject::Field { .. }))).unwrap(); - (s, Pos::body(0, off)) -} - -#[test] -fn unlink_refuses_a_field_in_content_locked_against_editing() { - let (mut s, at) = field_in_control("lockEdit"); - s.sel = Selection::caret(at); - let before = s.doc.clone(); - assert!(s.run("fields.unlink", &json!({})).is_err()); - assert_eq!(s.doc, before); - // Locked against deletion only: unlinking is an edit of the content, allowed. - let (mut s, at) = field_in_control("lockDelete"); - s.sel = Selection::caret(at); - run(&mut s, "fields.unlink", json!({})); - assert!(s.doc.plain_text(StoryRef::Body).contains("by Ada on")); -} - -#[test] -fn tracked_unlink_keeps_the_result_as_plain_text() { - let mut s = Session::new(wordcraft_doc::Document::new()); - s.doc.core.creator = "Ada".into(); - run(&mut s, "text.insert", json!({"text": "Made by "})); - run(&mut s, "insert.field", json!({"instr": "AUTHOR", "result": "Ada"})); - run(&mut s, "review.trackChanges", json!({"value": true})); - s.sel = Selection::caret(Pos::body(0, 8)); - run(&mut s, "fields.unlink", json!({})); - // Like Word, not tracked: the result is ordinary text, shown and kept by Reject All. - assert_eq!(visible(&s), "Made by Ada"); - assert!(s.doc.para_at(&Pos::body(0, 0)).unwrap().objects.is_empty()); - run(&mut s, "review.rejectAll", json!({})); - assert_eq!(s.doc.plain_text(StoryRef::Body), "Made by Ada"); -} - -#[test] -fn tracked_delete_table_removes_a_table_with_no_live_text() { - // Delete, Accept All (an empty grid is left), Delete again: the table goes. - let mut s = table_session(); - run(&mut s, "review.trackChanges", json!({"value": true})); - run(&mut s, "table.deleteTable", json!({})); - run(&mut s, "review.acceptAll", json!({})); - assert!(table(&s).is_some()); - caret_in(&mut s, 0, 0, 0); - run(&mut s, "table.deleteTable", json!({})); - assert!(table(&s).is_none(), "an empty table can't be deleted under Track Changes"); - assert!(s.doc.revisions.is_empty(), "no unused revision"); - // A fresh empty table too. - let mut s = Session::new(wordcraft_doc::Document::new()); - run(&mut s, "review.trackChanges", json!({"value": true})); - run(&mut s, "insert.table", json!({"rows": 2, "cols": 2})); - let revs = s.doc.revisions.len(); - run(&mut s, "table.deleteTable", json!({})); - assert!(table(&s).is_none()); - assert_eq!(s.doc.revisions.len(), revs); -} - -/// A 2×2 table whose first row is one cell "Title" across both columns; A | B below; caret in A. -fn merged_title_table(lock: Option<&str>) -> Session { - let mut s = Session::new(wordcraft_doc::Document::new()); - run(&mut s, "insert.table", json!({"rows": 2, "cols": 2})); - let at = |r, c| Pos { story: StoryRef::Body, path: wordcraft_doc::Path(vec![0, r, c, 0]), off: 0 }; - s.sel = Selection { anchor: at(0, 0), focus: at(0, 1) }; - run(&mut s, "table.merge", json!({})); - assert_eq!(table(&s).unwrap().rows[0].cells.len(), 1); - caret_in(&mut s, 0, 0, 0); - run(&mut s, "text.insert", json!({"text": "Title"})); - caret_in(&mut s, 1, 0, 0); - run(&mut s, "text.insert", json!({"text": "A"})); - caret_in(&mut s, 1, 1, 0); - run(&mut s, "text.insert", json!({"text": "B"})); - if let Some(l) = lock { - let p = |off| Pos { story: StoryRef::Body, path: wordcraft_doc::Path(vec![0, 0, 0, 0]), off }; - s.sel = Selection { anchor: p(0), focus: p(2) }; - run(&mut s, "developer.richText", json!({"tag": "ctl"})); - run(&mut s, "developer.properties", json!({"control": "ctl", l: true})); - } - caret_in(&mut s, 1, 0, 0); - s -} - -#[test] -fn delete_columns_leaves_a_merged_cell_that_keeps_a_column_alone() { - // Tracked: only A is marked deleted, not the merged title. - let mut s = merged_title_table(None); - run(&mut s, "review.trackChanges", json!({"value": true})); - run(&mut s, "table.deleteColumn", json!({})); - assert!(cells_deleted(&s, &[(1, 0)])); - assert!(!cells_deleted(&s, &[(0, 0)]), "the merged title was struck"); - assert!(!cells_deleted(&s, &[(1, 1)])); - // Untracked: a lockDelete control in the title doesn't stop the column going. - let mut s = merged_title_table(Some("lockDelete")); - run(&mut s, "table.deleteColumn", json!({})); - assert_eq!(table(&s).map(|t| t.grid.len()), Some(1)); - assert_eq!(s.doc.control_ranges(StoryRef::Body).len(), 1); -} - -#[test] -fn tracked_row_and_column_deletes_over_tracked_changes_reject_back_to_the_original() { - for id in ["table.deleteRow", "table.deleteColumn"] { - let mut s = table_session(); - run(&mut s, "review.trackChanges", json!({"value": true})); - caret_in(&mut s, 0, 0, 2); - run(&mut s, "text.insert", json!({"text": "x"})); - let me = s.author.clone(); - s.author = "Other".into(); - caret_in(&mut s, 0, 1, 2); - run(&mut s, "text.insert", json!({"text": "y"})); - caret_in(&mut s, 1, 0, 2); - run(&mut s, "text.insert", json!({"text": "z"})); - // And a deletion already there. - s.sel = Selection { - anchor: Pos { story: StoryRef::Body, path: wordcraft_doc::Path(vec![0, 0, 0, 0]), off: 0 }, - focus: Pos { story: StoryRef::Body, path: wordcraft_doc::Path(vec![0, 0, 0, 0]), off: 1 }, - }; - run(&mut s, "text.delete", json!({})); - s.author = me; - caret_in(&mut s, 0, 0, 0); - run(&mut s, id, json!({})); - run(&mut s, "review.rejectAll", json!({})); - assert_eq!(cells(&s), [["A1", "B1"], ["A2", "B2"]], "{id}"); - assert!(s.doc.revisions.is_empty(), "{id}"); - } -} - -fn tracked_hello() -> Session { - let mut s = Session::new(wordcraft_doc::Document::new()); - run(&mut s, "text.insert", json!({"text": "hello world"})); - run(&mut s, "review.trackChanges", json!({"value": true})); - run(&mut s, "select.all", json!({})); - run(&mut s, "format.changeCase", json!({"mode": "upper"})); - assert_eq!(visible(&s), "HELLO WORLD"); - s -} - -#[test] -fn tracked_change_case_twice_reads_only_the_live_text() { - let mut s = tracked_hello(); - run(&mut s, "format.changeCase", json!({"mode": "title"})); - assert_eq!(visible(&s), "Hello World"); - // Shift+F3 goes on from UPPERCASE as it does untracked. - let mut plain = Session::new(wordcraft_doc::Document::new()); - run(&mut plain, "text.insert", json!({"text": "HELLO WORLD"})); - run(&mut plain, "select.all", json!({})); - run(&mut plain, "format.changeCase", json!({})); - let mut s = tracked_hello(); - run(&mut s, "format.changeCase", json!({})); - assert_eq!(visible(&s), plain.doc.plain_text(StoryRef::Body)); - run(&mut s, "review.rejectAll", json!({})); - assert_eq!(s.doc.plain_text(StoryRef::Body), "hello world"); -} - -#[test] -fn tracked_change_case_handles_sharp_s_and_mixed_formatting() { - let mut s = Session::new(wordcraft_doc::Document::new()); - run(&mut s, "text.insert", json!({"text": "straße ab"})); - s.sel = Selection { anchor: Pos::body(0, 0), focus: Pos::body(0, 3) }; - run(&mut s, "format.bold", json!({})); - run(&mut s, "review.trackChanges", json!({"value": true})); - run(&mut s, "select.all", json!({})); - run(&mut s, "format.changeCase", json!({"mode": "upper"})); - assert_eq!(visible(&s), "STRASSE AB"); - // Each inserted stretch keeps its run's formatting. - let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); - let live: Vec<(String, bool)> = - p.run_ranges().filter(|(_, c)| c.del.is_none()).map(|(r, c)| (p.text[r].to_string(), c.bold == Some(true))).collect(); - let bold: String = live.iter().filter(|x| x.1).map(|x| x.0.as_str()).collect(); - assert_eq!(bold, "STR"); - run(&mut s, "review.rejectAll", json!({})); - assert_eq!(s.doc.plain_text(StoryRef::Body), "straße ab"); -} - -#[test] -fn tracked_delete_of_struck_table_text_keeps_it_for_reject_all() { - for id in ["table.deleteTable", "table.deleteRow", "table.deleteColumn", "table.deleteCells"] { - let mut s = table_session(); - run(&mut s, "review.trackChanges", json!({"value": true})); - run(&mut s, id, json!({})); - caret_in(&mut s, 0, 0, 0); - // Pressed again: the text is already struck, so nothing is removed outright. - let before = s.doc.clone(); - assert!(s.run(id, &json!({})).is_err(), "{id}"); - assert_eq!(s.doc, before, "{id}"); - assert!(s.status.contains("already marked deleted"), "{id}: {}", s.status); - run(&mut s, "review.rejectAll", json!({})); - assert_eq!(cells(&s), [["A1", "B1"], ["A2", "B2"]], "{id}"); - } -} - -#[derive(Clone, Copy, Debug)] -enum Slot { - FootnoteSeparator, - EndnoteSeparator, - FootnoteContinuationSeparator, - EndnoteContinuationNotice, -} - -const SLOTS: [Slot; 4] = [Slot::FootnoteSeparator, Slot::EndnoteSeparator, Slot::FootnoteContinuationSeparator, Slot::EndnoteContinuationNotice]; - -fn slot_mut(doc: &mut wordcraft_doc::Document, slot: Slot) -> &mut Option { - match slot { - Slot::FootnoteSeparator => &mut doc.footnote_separators.separator, - Slot::EndnoteSeparator => &mut doc.endnote_separators.separator, - Slot::FootnoteContinuationSeparator => &mut doc.footnote_separators.continuation_separator, - Slot::EndnoteContinuationNotice => &mut doc.endnote_separators.continuation_notice, - } -} - -/// A session whose note separator `slot` holds the text "SEP" as a tracked insertion (revision 0). -fn tracked_separator_session(slot: Slot) -> Session { - let mut doc = wordcraft_doc::Document::new(); - doc.revisions.push(wordcraft_doc::Revision { - kind: wordcraft_doc::RevisionKind::Insert, - author: "Alice".into(), - date: String::new(), - move_name: None, - }); - let props = wordcraft_doc::CharProps { ins: Some(0), ..Default::default() }; - let p = wordcraft_doc::Paragraph::with_text("SEP", props); - *slot_mut(&mut doc, slot) = Some(vec![wordcraft_doc::para_block(p)]); - Session::new(doc) -} - -fn separator_para(s: &mut Session, slot: Slot) -> wordcraft_doc::Paragraph { - slot_mut(&mut s.doc, slot).as_ref().and_then(|b| b.iter().find_map(|b| b.as_para())).expect("separator").clone() -} - -#[test] -fn accept_all_resolves_note_separator_revisions() { - for slot in SLOTS { - let mut s = tracked_separator_session(slot); - run(&mut s, "review.acceptAll", json!({})); - let p = separator_para(&mut s, slot); - assert_eq!(p.plain_text(), "SEP", "{slot:?}"); - assert!(p.run_ranges().all(|(_, c)| c.ins.is_none() && c.del.is_none()), "{slot:?}"); - } -} - -#[test] -fn reject_all_resolves_note_separator_revisions() { - for slot in SLOTS { - let mut s = tracked_separator_session(slot); - run(&mut s, "review.rejectAll", json!({})); - assert_eq!(separator_para(&mut s, slot).plain_text(), "", "{slot:?}"); - } -} - -#[test] -fn new_tracked_change_does_not_alias_separator_revision() { - for slot in SLOTS { - let mut s = tracked_separator_session(slot); - run(&mut s, "review.acceptAll", json!({})); - s.author = "Bob".into(); - run(&mut s, "review.trackChanges", json!({"value": true})); - run(&mut s, "text.insert", json!({"text": "NEW "})); - let p = separator_para(&mut s, slot); - assert!(p.run_ranges().all(|(_, c)| c.ins.is_none() && c.del.is_none()), "{slot:?}"); - } -} +//! #1329: table deletion, Change Case and field unlink respect locked content controls and +//! Track Changes (Reject All restores what they changed). + +use serde_json::{Value, json}; +use wordcraft_doc::para::{InlineObject, OBJ}; +use wordcraft_doc::{Pos, StoryRef, Table}; + +use crate::{Selection, Session}; + +fn run(s: &mut Session, id: &str, v: Value) -> Value { + s.run(id, &v).unwrap_or_else(|e| panic!("{id}: {e}")) +} + +/// The first paragraph's text without its tracked deletions. +fn visible(s: &Session) -> String { + let Some(p) = s.doc.para_at(&Pos::body(0, 0)) else { return String::new() }; + p.run_ranges().filter(|(_, c)| c.del.is_none()).filter_map(|(r, _)| p.text.get(r)).collect::().replace(OBJ, "") +} + +fn table(s: &Session) -> Option<&Table> { + s.doc.body.iter().find_map(|b| b.as_table()) +} + +/// Every cell's text, row by row (deleted text included). +fn cells(s: &Session) -> Vec> { + table(s) + .map(|t| { + t.rows + .iter() + .map(|r| r.cells.iter().map(|c| c.blocks.iter().filter_map(|b| b.as_para()).map(|p| p.plain_text()).collect()).collect()) + .collect() + }) + .unwrap_or_default() +} + +/// Whether every character of every cell in `which` is a tracked deletion. +fn cells_deleted(s: &Session, which: &[(usize, usize)]) -> bool { + let Some(t) = table(s) else { return false }; + which.iter().all(|(r, c)| { + let cell = &t.rows[*r].cells[*c]; + cell.blocks.iter().filter_map(|b| b.as_para()).all(|p| p.run_ranges().all(|(_, props)| props.del.is_some())) + }) +} + +/// A 2×2 table holding A1 B1 / A2 B2, the caret in A1 (at its start). +fn table_session() -> Session { + let mut s = Session::new(wordcraft_doc::Document::new()); + run(&mut s, "insert.table", json!({"rows": 2, "cols": 2})); + for (i, t) in ["A1", "B1", "A2", "B2"].iter().enumerate() { + if i > 0 { + run(&mut s, "text.tab", json!({})); + } + run(&mut s, "text.insert", json!({"text": t})); + } + caret_in(&mut s, 0, 0, 0); + s +} + +fn caret_in(s: &mut Session, r: u32, c: u32, off: usize) { + s.sel = Selection::caret(Pos { story: StoryRef::Body, path: wordcraft_doc::Path(vec![0, r, c, 0]), off }); +} + +/// [`table_session`] with "A1" in a content control tagged `ctl`, locked with `lock`. +fn locked_table(lock: &str) -> Session { + let mut s = table_session(); + let at = |off| Pos { story: StoryRef::Body, path: wordcraft_doc::Path(vec![0, 0, 0, 0]), off }; + // A partial selection inside the paragraph (not the whole paragraph) wraps inline. + s.sel = Selection { anchor: at(0), focus: at(1) }; + run(&mut s, "developer.richText", json!({"tag": "ctl"})); + run(&mut s, "developer.properties", json!({"control": "ctl", lock: true})); + caret_in(&mut s, 0, 0, 0); + s +} + +#[test] +fn table_deletes_refuse_a_control_locked_against_deletion() { + for id in ["table.deleteTable", "table.deleteRow", "table.deleteColumn", "table.deleteCells"] { + let mut s = locked_table("lockDelete"); + let before = s.doc.clone(); + assert!(s.run(id, &json!({})).is_err(), "{id} deleted a locked control"); + assert_eq!(s.doc, before, "{id} changed the document"); + assert!(s.status.contains("can't be deleted"), "{id}: {}", s.status); + } +} + +#[test] +fn tracked_table_delete_marks_the_text_deleted_and_reject_all_restores_it() { + let mut s = table_session(); + run(&mut s, "review.trackChanges", json!({"value": true})); + run(&mut s, "table.deleteTable", json!({})); + // Like Word: the structure stays, its text is marked deleted. + assert!(table(&s).is_some(), "the table was removed under Track Changes"); + assert_eq!(cells(&s), [["A1", "B1"], ["A2", "B2"]]); + assert!(cells_deleted(&s, &[(0, 0), (0, 1), (1, 0), (1, 1)])); + run(&mut s, "review.rejectAll", json!({})); + assert_eq!(cells(&s), [["A1", "B1"], ["A2", "B2"]]); + assert!(!cells_deleted(&s, &[(0, 0)]) && s.doc.revisions.is_empty()); +} + +#[test] +fn tracked_row_column_and_cell_deletes_mark_their_text_deleted() { + for (id, gone, kept) in [ + ("table.deleteRow", vec![(0, 0), (0, 1)], vec![(1, 0), (1, 1)]), + ("table.deleteColumn", vec![(0, 0), (1, 0)], vec![(0, 1), (1, 1)]), + ("table.deleteCells", vec![(0, 0)], vec![(0, 1), (1, 0), (1, 1)]), + ] { + let mut s = table_session(); + run(&mut s, "review.trackChanges", json!({"value": true})); + run(&mut s, id, json!({})); + assert_eq!(cells(&s), [["A1", "B1"], ["A2", "B2"]], "{id} changed the structure"); + assert!(cells_deleted(&s, &gone), "{id}: text not marked deleted"); + for k in &kept { + assert!(!cells_deleted(&s, &[*k]), "{id}: {k:?} marked deleted"); + } + run(&mut s, "review.rejectAll", json!({})); + assert_eq!(cells(&s), [["A1", "B1"], ["A2", "B2"]]); + assert!(s.doc.revisions.is_empty(), "{id}"); + } +} + +#[test] +fn tracked_table_delete_over_tracked_insertions_rejects_back_to_the_original() { + let mut s = table_session(); + run(&mut s, "review.trackChanges", json!({"value": true})); + // One insertion by this author, one by another. + caret_in(&mut s, 0, 0, 2); + run(&mut s, "text.insert", json!({"text": "x"})); + let me = s.author.clone(); + s.author = "Other".into(); + caret_in(&mut s, 1, 1, 2); + run(&mut s, "text.insert", json!({"text": "y"})); + s.author = me; + caret_in(&mut s, 0, 0, 0); + run(&mut s, "table.deleteTable", json!({})); + assert!(table(&s).is_some()); + run(&mut s, "review.rejectAll", json!({})); + assert_eq!(cells(&s), [["A1", "B1"], ["A2", "B2"]]); + assert!(s.doc.revisions.is_empty()); +} + +/// "hello world" with "hello" in a rich text control tagged `ctl`. +fn hello_in_control(lock: Option<&str>) -> Session { + let mut s = Session::new(wordcraft_doc::Document::new()); + run(&mut s, "text.insert", json!({"text": "hello world"})); + s.sel = Selection { anchor: Pos::body(0, 0), focus: Pos::body(0, 5) }; + run(&mut s, "developer.richText", json!({"tag": "ctl"})); + if let Some(l) = lock { + run(&mut s, "developer.properties", json!({"control": "ctl", l: true})); + } + s +} + +#[test] +fn change_case_refuses_content_locked_against_editing() { + let mut s = hello_in_control(Some("lockEdit")); + let w = OBJ.len_utf8(); + // Just the control's content, and the whole paragraph. + for (a, b) in [(w, w + 5), (0, 11 + 2 * w)] { + s.sel = Selection { anchor: Pos::body(0, a), focus: Pos::body(0, b) }; + let before = s.doc.clone(); + assert!(s.run("format.changeCase", &json!({"mode": "upper"})).is_err(), "{a}..{b}"); + assert_eq!(s.doc, before); + } + // Locked against deletion only: the content may change. + let mut s = hello_in_control(Some("lockDelete")); + s.sel = Selection { anchor: Pos::body(0, 0), focus: Pos::body(0, 11 + 2 * w) }; + run(&mut s, "format.changeCase", json!({"mode": "upper"})); + assert_eq!(s.doc.plain_text(StoryRef::Body), "HELLO WORLD"); +} + +#[test] +fn tracked_change_case_rejects_back_to_the_original_case() { + let mut s = Session::new(wordcraft_doc::Document::new()); + run(&mut s, "text.insert", json!({"text": "hello World"})); + run(&mut s, "review.trackChanges", json!({"value": true})); + run(&mut s, "select.all", json!({})); + run(&mut s, "format.changeCase", json!({"mode": "upper"})); + assert_eq!(visible(&s), "HELLO WORLD"); + // The selection still covers the converted text. + let (a, b) = s.sel.ordered(); + assert_eq!((a.off, b.off), (0, s.doc.para_at(&a).map(|p| p.len()).unwrap_or(0))); + run(&mut s, "review.rejectAll", json!({})); + assert_eq!(s.doc.plain_text(StoryRef::Body), "hello World"); + assert!(s.doc.revisions.is_empty()); + // Accept All keeps the new case. + run(&mut s, "select.all", json!({})); + run(&mut s, "format.changeCase", json!({"mode": "upper"})); + run(&mut s, "review.acceptAll", json!({})); + assert_eq!(s.doc.plain_text(StoryRef::Body), "HELLO WORLD"); +} + +#[test] +fn tracked_change_case_of_own_insertion_stays_an_insertion() { + let mut s = Session::new(wordcraft_doc::Document::new()); + run(&mut s, "text.insert", json!({"text": "keep "})); + run(&mut s, "review.trackChanges", json!({"value": true})); + run(&mut s, "text.insert", json!({"text": "new"})); + s.sel = Selection { anchor: Pos::body(0, 5), focus: Pos::body(0, 8) }; + run(&mut s, "format.changeCase", json!({"mode": "upper"})); + assert_eq!(s.doc.plain_text(StoryRef::Body), "keep NEW", "no deleted copy of text that never existed"); + run(&mut s, "review.rejectAll", json!({})); + assert_eq!(s.doc.plain_text(StoryRef::Body), "keep "); +} + +/// "Made by {AUTHOR} on " with "by {AUTHOR} on" in a rich text control tagged `ctl`. +fn field_in_control(lock: &str) -> (Session, Pos) { + let mut s = Session::new(wordcraft_doc::Document::new()); + s.doc.core.creator = "Ada".into(); + run(&mut s, "text.insert", json!({"text": "Made by "})); + run(&mut s, "insert.field", json!({"instr": "AUTHOR", "result": "Ada"})); + run(&mut s, "text.insert", json!({"text": " on "})); + let w = OBJ.len_utf8(); + s.sel = Selection { anchor: Pos::body(0, 5), focus: Pos::body(0, 8 + w + 3) }; + run(&mut s, "developer.richText", json!({"tag": "ctl"})); + run(&mut s, "developer.properties", json!({"control": "ctl", lock: true})); + let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); + let off = p.object_offsets().into_iter().find(|o| matches!(p.object_at(*o), Some(InlineObject::Field { .. }))).unwrap(); + (s, Pos::body(0, off)) +} + +#[test] +fn unlink_refuses_a_field_in_content_locked_against_editing() { + let (mut s, at) = field_in_control("lockEdit"); + s.sel = Selection::caret(at); + let before = s.doc.clone(); + assert!(s.run("fields.unlink", &json!({})).is_err()); + assert_eq!(s.doc, before); + // Locked against deletion only: unlinking is an edit of the content, allowed. + let (mut s, at) = field_in_control("lockDelete"); + s.sel = Selection::caret(at); + run(&mut s, "fields.unlink", json!({})); + assert!(s.doc.plain_text(StoryRef::Body).contains("by Ada on")); +} + +#[test] +fn tracked_unlink_keeps_the_result_as_plain_text() { + let mut s = Session::new(wordcraft_doc::Document::new()); + s.doc.core.creator = "Ada".into(); + run(&mut s, "text.insert", json!({"text": "Made by "})); + run(&mut s, "insert.field", json!({"instr": "AUTHOR", "result": "Ada"})); + run(&mut s, "review.trackChanges", json!({"value": true})); + s.sel = Selection::caret(Pos::body(0, 8)); + run(&mut s, "fields.unlink", json!({})); + // Like Word, not tracked: the result is ordinary text, shown and kept by Reject All. + assert_eq!(visible(&s), "Made by Ada"); + assert!(s.doc.para_at(&Pos::body(0, 0)).unwrap().objects.is_empty()); + run(&mut s, "review.rejectAll", json!({})); + assert_eq!(s.doc.plain_text(StoryRef::Body), "Made by Ada"); +} + +#[test] +fn tracked_delete_table_removes_a_table_with_no_live_text() { + // Delete, Accept All (an empty grid is left), Delete again: the table goes. + let mut s = table_session(); + run(&mut s, "review.trackChanges", json!({"value": true})); + run(&mut s, "table.deleteTable", json!({})); + run(&mut s, "review.acceptAll", json!({})); + assert!(table(&s).is_some()); + caret_in(&mut s, 0, 0, 0); + run(&mut s, "table.deleteTable", json!({})); + assert!(table(&s).is_none(), "an empty table can't be deleted under Track Changes"); + assert!(s.doc.revisions.is_empty(), "no unused revision"); + // A fresh empty table too. + let mut s = Session::new(wordcraft_doc::Document::new()); + run(&mut s, "review.trackChanges", json!({"value": true})); + run(&mut s, "insert.table", json!({"rows": 2, "cols": 2})); + let revs = s.doc.revisions.len(); + run(&mut s, "table.deleteTable", json!({})); + assert!(table(&s).is_none()); + assert_eq!(s.doc.revisions.len(), revs); +} + +/// A 2×2 table whose first row is one cell "Title" across both columns; A | B below; caret in A. +fn merged_title_table(lock: Option<&str>) -> Session { + let mut s = Session::new(wordcraft_doc::Document::new()); + run(&mut s, "insert.table", json!({"rows": 2, "cols": 2})); + let at = |r, c| Pos { story: StoryRef::Body, path: wordcraft_doc::Path(vec![0, r, c, 0]), off: 0 }; + s.sel = Selection { anchor: at(0, 0), focus: at(0, 1) }; + run(&mut s, "table.merge", json!({})); + assert_eq!(table(&s).unwrap().rows[0].cells.len(), 1); + caret_in(&mut s, 0, 0, 0); + run(&mut s, "text.insert", json!({"text": "Title"})); + caret_in(&mut s, 1, 0, 0); + run(&mut s, "text.insert", json!({"text": "A"})); + caret_in(&mut s, 1, 1, 0); + run(&mut s, "text.insert", json!({"text": "B"})); + if let Some(l) = lock { + let p = |off| Pos { story: StoryRef::Body, path: wordcraft_doc::Path(vec![0, 0, 0, 0]), off }; + s.sel = Selection { anchor: p(0), focus: p(2) }; + run(&mut s, "developer.richText", json!({"tag": "ctl"})); + run(&mut s, "developer.properties", json!({"control": "ctl", l: true})); + } + caret_in(&mut s, 1, 0, 0); + s +} + +#[test] +fn delete_columns_leaves_a_merged_cell_that_keeps_a_column_alone() { + // Tracked: only A is marked deleted, not the merged title. + let mut s = merged_title_table(None); + run(&mut s, "review.trackChanges", json!({"value": true})); + run(&mut s, "table.deleteColumn", json!({})); + assert!(cells_deleted(&s, &[(1, 0)])); + assert!(!cells_deleted(&s, &[(0, 0)]), "the merged title was struck"); + assert!(!cells_deleted(&s, &[(1, 1)])); + // Untracked: a lockDelete control in the title doesn't stop the column going. + let mut s = merged_title_table(Some("lockDelete")); + run(&mut s, "table.deleteColumn", json!({})); + assert_eq!(table(&s).map(|t| t.grid.len()), Some(1)); + assert_eq!(s.doc.control_ranges(StoryRef::Body).len(), 1); +} + +#[test] +fn tracked_row_and_column_deletes_over_tracked_changes_reject_back_to_the_original() { + for id in ["table.deleteRow", "table.deleteColumn"] { + let mut s = table_session(); + run(&mut s, "review.trackChanges", json!({"value": true})); + caret_in(&mut s, 0, 0, 2); + run(&mut s, "text.insert", json!({"text": "x"})); + let me = s.author.clone(); + s.author = "Other".into(); + caret_in(&mut s, 0, 1, 2); + run(&mut s, "text.insert", json!({"text": "y"})); + caret_in(&mut s, 1, 0, 2); + run(&mut s, "text.insert", json!({"text": "z"})); + // And a deletion already there. + s.sel = Selection { + anchor: Pos { story: StoryRef::Body, path: wordcraft_doc::Path(vec![0, 0, 0, 0]), off: 0 }, + focus: Pos { story: StoryRef::Body, path: wordcraft_doc::Path(vec![0, 0, 0, 0]), off: 1 }, + }; + run(&mut s, "text.delete", json!({})); + s.author = me; + caret_in(&mut s, 0, 0, 0); + run(&mut s, id, json!({})); + run(&mut s, "review.rejectAll", json!({})); + assert_eq!(cells(&s), [["A1", "B1"], ["A2", "B2"]], "{id}"); + assert!(s.doc.revisions.is_empty(), "{id}"); + } +} + +fn tracked_hello() -> Session { + let mut s = Session::new(wordcraft_doc::Document::new()); + run(&mut s, "text.insert", json!({"text": "hello world"})); + run(&mut s, "review.trackChanges", json!({"value": true})); + run(&mut s, "select.all", json!({})); + run(&mut s, "format.changeCase", json!({"mode": "upper"})); + assert_eq!(visible(&s), "HELLO WORLD"); + s +} + +#[test] +fn tracked_change_case_twice_reads_only_the_live_text() { + let mut s = tracked_hello(); + run(&mut s, "format.changeCase", json!({"mode": "title"})); + assert_eq!(visible(&s), "Hello World"); + // Shift+F3 goes on from UPPERCASE as it does untracked. + let mut plain = Session::new(wordcraft_doc::Document::new()); + run(&mut plain, "text.insert", json!({"text": "HELLO WORLD"})); + run(&mut plain, "select.all", json!({})); + run(&mut plain, "format.changeCase", json!({})); + let mut s = tracked_hello(); + run(&mut s, "format.changeCase", json!({})); + assert_eq!(visible(&s), plain.doc.plain_text(StoryRef::Body)); + run(&mut s, "review.rejectAll", json!({})); + assert_eq!(s.doc.plain_text(StoryRef::Body), "hello world"); +} + +#[test] +fn tracked_change_case_handles_sharp_s_and_mixed_formatting() { + let mut s = Session::new(wordcraft_doc::Document::new()); + run(&mut s, "text.insert", json!({"text": "straße ab"})); + s.sel = Selection { anchor: Pos::body(0, 0), focus: Pos::body(0, 3) }; + run(&mut s, "format.bold", json!({})); + run(&mut s, "review.trackChanges", json!({"value": true})); + run(&mut s, "select.all", json!({})); + run(&mut s, "format.changeCase", json!({"mode": "upper"})); + assert_eq!(visible(&s), "STRASSE AB"); + // Each inserted stretch keeps its run's formatting. + let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); + let live: Vec<(String, bool)> = + p.run_ranges().filter(|(_, c)| c.del.is_none()).map(|(r, c)| (p.text[r].to_string(), c.bold == Some(true))).collect(); + let bold: String = live.iter().filter(|x| x.1).map(|x| x.0.as_str()).collect(); + assert_eq!(bold, "STR"); + run(&mut s, "review.rejectAll", json!({})); + assert_eq!(s.doc.plain_text(StoryRef::Body), "straße ab"); +} + +#[test] +fn tracked_delete_of_struck_table_text_keeps_it_for_reject_all() { + for id in ["table.deleteTable", "table.deleteRow", "table.deleteColumn", "table.deleteCells"] { + let mut s = table_session(); + run(&mut s, "review.trackChanges", json!({"value": true})); + run(&mut s, id, json!({})); + caret_in(&mut s, 0, 0, 0); + // Pressed again: the text is already struck, so nothing is removed outright. + let before = s.doc.clone(); + assert!(s.run(id, &json!({})).is_err(), "{id}"); + assert_eq!(s.doc, before, "{id}"); + assert!(s.status.contains("already marked deleted"), "{id}: {}", s.status); + run(&mut s, "review.rejectAll", json!({})); + assert_eq!(cells(&s), [["A1", "B1"], ["A2", "B2"]], "{id}"); + } +} + +#[derive(Clone, Copy, Debug)] +enum Slot { + FootnoteSeparator, + EndnoteSeparator, + FootnoteContinuationSeparator, + EndnoteContinuationNotice, +} + +const SLOTS: [Slot; 4] = [Slot::FootnoteSeparator, Slot::EndnoteSeparator, Slot::FootnoteContinuationSeparator, Slot::EndnoteContinuationNotice]; + +fn slot_mut(doc: &mut wordcraft_doc::Document, slot: Slot) -> &mut Option { + match slot { + Slot::FootnoteSeparator => &mut doc.footnote_separators.separator, + Slot::EndnoteSeparator => &mut doc.endnote_separators.separator, + Slot::FootnoteContinuationSeparator => &mut doc.footnote_separators.continuation_separator, + Slot::EndnoteContinuationNotice => &mut doc.endnote_separators.continuation_notice, + } +} + +/// A session whose note separator `slot` holds the text "SEP" as a tracked insertion (revision 0). +fn tracked_separator_session(slot: Slot) -> Session { + let mut doc = wordcraft_doc::Document::new(); + doc.revisions.push(wordcraft_doc::Revision { + kind: wordcraft_doc::RevisionKind::Insert, + author: "Alice".into(), + date: String::new(), + move_name: None, + }); + let props = wordcraft_doc::CharProps { ins: Some(0), ..Default::default() }; + let p = wordcraft_doc::Paragraph::with_text("SEP", props); + *slot_mut(&mut doc, slot) = Some(vec![wordcraft_doc::para_block(p)]); + Session::new(doc) +} + +fn separator_para(s: &mut Session, slot: Slot) -> wordcraft_doc::Paragraph { + slot_mut(&mut s.doc, slot).as_ref().and_then(|b| b.iter().find_map(|b| b.as_para())).expect("separator").clone() +} + +#[test] +fn accept_all_resolves_note_separator_revisions() { + for slot in SLOTS { + let mut s = tracked_separator_session(slot); + run(&mut s, "review.acceptAll", json!({})); + let p = separator_para(&mut s, slot); + assert_eq!(p.plain_text(), "SEP", "{slot:?}"); + assert!(p.run_ranges().all(|(_, c)| c.ins.is_none() && c.del.is_none()), "{slot:?}"); + } +} + +#[test] +fn reject_all_resolves_note_separator_revisions() { + for slot in SLOTS { + let mut s = tracked_separator_session(slot); + run(&mut s, "review.rejectAll", json!({})); + assert_eq!(separator_para(&mut s, slot).plain_text(), "", "{slot:?}"); + } +} + +#[test] +fn new_tracked_change_does_not_alias_separator_revision() { + for slot in SLOTS { + let mut s = tracked_separator_session(slot); + run(&mut s, "review.acceptAll", json!({})); + s.author = "Bob".into(); + run(&mut s, "review.trackChanges", json!({"value": true})); + run(&mut s, "text.insert", json!({"text": "NEW "})); + let p = separator_para(&mut s, slot); + assert!(p.run_ranges().all(|(_, c)| c.ins.is_none() && c.del.is_none()), "{slot:?}"); + } +} diff --git a/crates/engine/src/tests/typing.rs b/crates/engine/src/tests/typing.rs index 350f8872d6..6dd20ca12e 100644 --- a/crates/engine/src/tests/typing.rs +++ b/crates/engine/src/tests/typing.rs @@ -1,4038 +1,4038 @@ -//! Typing behaviour checked side by side against Word: keystrokes go through the same commands -//! as the keyboard (one `text.insert` per character, Enter, Tab, Backspace), and the expected -//! results are what Word produces for the same keys. - -use serde_json::json; -use std::sync::Arc; -use wordcraft_doc::props::{BorderStyle, VertAlign}; -use wordcraft_doc::{Pos, StoryRef}; - -use crate::Session; - -/// Leaf #152: deleting the empty heading above body text must not promote that text to a heading. -/// Deleting the empty heading mark keeps the next paragraph as body text. -#[test] -fn deleting_a_preceding_empty_heading_keeps_the_body_paragraph_style() { - let mut s = Session::new(wordcraft_doc::Document::from_text("\nBody paragraph.")); - run(&mut s, "para.style", json!({"style": "Heading1"})); - run(&mut s, "text.delete", json!({})); - let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); - assert_eq!(p.text, "Body paragraph."); - assert_eq!(p.props.style, None); - assert_eq!(wordcraft_layout::outline::levels(&s.doc), [wordcraft_layout::outline::BODY]); -} - -/// Create an empty session for command-driven typing fixtures. -fn s() -> Session { - Session::new(wordcraft_doc::Document::new()) -} - -/// Execute a registry command and report its id on a test failure. -fn run(s: &mut Session, id: &str, v: serde_json::Value) { - s.run(id, &v).unwrap_or_else(|e| panic!("{id}: {e}")); -} - -/// Type like a person: `\n` is Enter, `\t` is Tab, `\u{8}` is Backspace, everything else one -/// character at a time. -fn typ(s: &mut Session, keys: &str) { - for c in keys.chars() { - match c { - '\n' => run(s, "text.newParagraph", json!({})), - '\t' => run(s, "text.tab", json!({})), - '\u{8}' => run(s, "text.backspace", json!({})), - c => run(s, "text.insert", json!({"text": c.to_string()})), - } - } -} - -/// Body paragraphs as (text, list level if in a list, in "List Paragraph"). -fn paras(s: &Session) -> Vec<(String, Option, bool)> { - s.doc - .body - .iter() - .filter_map(|b| b.as_para()) - .map(|p| (p.text.clone(), p.props.numbering.filter(|n| n.num != 0).map(|n| n.level), p.props.style.as_deref() == Some("ListParagraph"))) - .collect() -} - -/// Expected paragraph text and list membership for typing assertions. -fn para(text: &str, level: Option) -> (String, Option, bool) { - (text.to_string(), level, level.is_some()) -} - -/// Create a session and enter its fixture through the actual typing commands. -fn typed(keys: &str) -> Session { - let mut s = s(); - typ(&mut s, keys); - s -} - -#[test] -fn sample_text_formulas_expand_on_enter() { - for name in ["rand", "lorem"] { - for (args, paragraphs, sentences) in [ - ("", 5, 3), - ("2", 2, 3), - (" 2 ", 2, 3), - ("2,4", 2, 4), - ("2, 4", 2, 4), - (" 2 , 4 ", 2, 4), - ("1,1", 1, 1), - ("200,1", 200, 1), - ("1,200", 1, 200), - ] { - let mut s = typed(&format!("={name}({args})")); - let depth = s.undo_depth(); - typ(&mut s, "\n"); - let generated: Vec<_> = paras(&s).into_iter().map(|p| p.0).collect(); - assert_eq!(generated.len(), paragraphs + 1, "{name}({args})"); - for text in generated.iter().take(paragraphs) { - assert_eq!(text.matches('.').count(), sentences); - assert!(text.starts_with(if name == "lorem" { "Lorem ipsum" } else { "A blank page" }), "{name}({args}): {text:?}"); - } - assert_eq!(generated.last().unwrap(), ""); - assert_eq!(s.sel.focus, Pos::body(paragraphs, 0)); - assert_eq!(s.undo_depth(), depth + 1); - let expanded = s.doc.clone(); - run(&mut s, "edit.undo", json!({})); - assert_eq!(texts(&s), [format!("={name}({args})")]); - run(&mut s, "edit.redo", json!({})); - assert_eq!(s.doc, expanded); - } - } -} - -#[test] -fn sample_text_invalid_parameters_use_defaults() { - for name in ["rand", "lorem"] { - for args in ["0", "-1", "201", "abc", "1.5", "99999999999999999999999999999", "2,0", "0,4", "2,201", "2,no", "2,", ",4", "1,2,3"] { - let s = typed(&format!("={name}({args})\n")); - let texts = texts(&s); - assert_eq!(texts.len(), 6, "{name}({args})"); - assert!(texts.iter().take(5).all(|text| text.matches('.').count() == 3)); - } - } -} - -#[test] -fn sample_text_requires_an_exact_paragraph_and_caret_at_end() { - for formula in [" =rand()", "=rand() ", "prefix =rand()", "=lorem() suffix", "=other()", "=rand(", "=rand(())"] { - assert_eq!(texts(&typed(&format!("{formula}\n"))), [formula, ""]); - } - let mut s = typed("=rand()"); - run(&mut s, "caret.home", json!({})); - typ(&mut s, "\n"); - assert_eq!(texts(&s), ["", "=rand()"]); - let mut s = typed("=rand()suffix"); - s.sel = crate::Selection { anchor: Pos::body(0, 7), focus: Pos::body(0, 13) }; - typ(&mut s, "\n"); - assert_eq!(texts(&s), ["=rand()", ""]); -} - -#[test] -fn sample_text_preserves_typing_and_paragraph_formatting() { - let mut s = s(); - run(&mut s, "format.bold", json!({})); - run(&mut s, "para.alignCenter", json!({})); - typ(&mut s, "=rand(2,1)\n"); - for p in s.doc.body.iter().filter_map(|b| b.as_para()).take(2) { - assert_eq!(p.props.align, Some(wordcraft_doc::props::Align::Center)); - assert_eq!(p.props_at(0).bold, Some(true)); - } -} - -#[test] -fn sample_text_replacement_tracks_changes() { - let mut s = typed("=lorem(2,1)"); - run(&mut s, "review.trackChanges", json!({})); - typ(&mut s, "\n"); - assert!(s.doc.body.iter().filter_map(|b| b.as_para()).flat_map(|p| &p.runs).any(|r| r.props.ins.is_some())); - run(&mut s, "review.rejectAll", json!({})); - assert_eq!(texts(&s), ["=lorem(2,1)"]); - run(&mut s, "edit.undo", json!({})); - run(&mut s, "review.acceptAll", json!({})); - assert_eq!(texts(&s).len(), 3); - assert!(!s.doc.plain_text(StoryRef::Body).contains("=lorem")); -} - -#[test] -fn enter_twice_after_a_list_item_ends_the_list() { - let s = typed("* item\n\ntext"); - assert_eq!(paras(&s), vec![para("Item", Some(0)), para("text", None)]); -} - -#[test] -fn more_enters_after_a_list_make_blank_paragraphs() { - // The "stuck Enter" bug: after the list ended, further Enters did nothing. - let s = typed("* item\n\n\ntext"); - assert_eq!(paras(&s), vec![para("Item", Some(0)), para("", None), para("text", None)]); - let s = typed("1. item\n\n\n\nafter"); - assert_eq!(paras(&s), vec![para("Item", Some(0)), para("", None), para("", None), para("after", None)]); -} - -#[test] -fn the_paragraph_after_a_list_is_normal_text() { - let mut s = typed("* Apple\nBanana\n\nAfter list"); - let p = s.doc.para_at(&s.sel.focus).unwrap(); - assert_eq!(p.props.style, None, "back to Normal, not an indented List Paragraph"); - assert_eq!(p.props.numbering, None); - // And the next paragraph too. - typ(&mut s, "\nMore"); - assert_eq!(paras(&s).last(), Some(¶("More", None))); -} - -#[test] -fn enter_on_an_empty_nested_item_moves_it_up_a_level_first() { - let s = typed("* a\n\tb\n\ntext"); - assert_eq!(paras(&s), vec![para("A", Some(0)), para("B", Some(1)), para("text", Some(0))]); - let s = typed("* a\n\tb\n\n\ntext"); - assert_eq!(paras(&s), vec![para("A", Some(0)), para("B", Some(1)), para("text", None)]); - let s = typed("* a\n\tb\n\n\n\ntext"); - assert_eq!(paras(&s), vec![para("A", Some(0)), para("B", Some(1)), para("", None), para("text", None)]); - let s = typed("* a\n\tb\n\tc\n\n\n\ntext"); - assert_eq!(paras(&s), vec![para("A", Some(0)), para("B", Some(1)), para("C", Some(2)), para("text", None)]); - let s = typed("1. a\n\tb\n\n\ntext"); - assert_eq!(paras(&s), vec![para("A", Some(0)), para("B", Some(1)), para("text", None)]); -} - -/// #2143: in a Word file whose list comes from the paragraph styles (List Number, List Number 2 -/// with `w:numPr` in styles.xml, none on the paragraphs), Enter on an empty nested item moves it -/// up a level and then ends the list, as with direct numbering. -#[test] -fn enter_on_an_empty_item_numbered_by_its_style_moves_it_up_a_level() { - use std::io::Write; - let w = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main""#; - let style = |id: &str, name: &str, ilvl: u8| { - format!( - r#""# - ) - }; - let styles = format!( - r#"{}{}"#, - style("ListNumber", "List Number", 0), - style("ListNumber2", "List Number 2", 1) - ); - let numbering = format!( - r#""# - ); - let p = |st: &str, text: &str| format!(r#"{text}"#); - let body: String = - [p("ListNumber", "Lorem"), p("ListNumber", "Ipsum"), p("ListNumber2", "Latin"), p("ListNumber2", "Nonsense"), p("ListNumber2", "")].concat(); - let document = format!(r#"{body}"#); - let rel = |id: &str, kind: &str, target: &str| { - format!(r#""#) - }; - let rels_ns = r#"xmlns="http://schemas.openxmlformats.org/package/2006/relationships""#; - let main = format!("{}", rel("rId1", "officeDocument", "word/document.xml")); - let doc_rels = - format!("{}{}", rel("rId1", "styles", "styles.xml"), rel("rId2", "numbering", "numbering.xml")); - let ct = r#""#; - let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); - let o = zip::write::SimpleFileOptions::default(); - for (name, data) in [ - ("[Content_Types].xml", ct), - ("_rels/.rels", main.as_str()), - ("word/_rels/document.xml.rels", doc_rels.as_str()), - ("word/document.xml", document.as_str()), - ("word/styles.xml", styles.as_str()), - ("word/numbering.xml", numbering.as_str()), - ] { - z.start_file(name, o).unwrap(); - z.write_all(data.as_bytes()).unwrap(); - } - let doc = crate::io::open_bytes("t.docx", &z.finish().unwrap().into_inner()).unwrap(); - let mut s = Session::new(doc); - // The list level each paragraph shows, wherever its numbering comes from. - let levels = |s: &Session| -> Vec> { - s.doc - .body - .iter() - .filter_map(|b| b.as_para()) - .map(|p| s.doc.styles.resolve_para(&p.props).numbering.filter(|n| n.num != 0).map(|n| n.level)) - .collect() - }; - assert_eq!(levels(&s), [Some(0), Some(0), Some(1), Some(1), Some(1)]); - s.sel = crate::Selection::caret(Pos::body(4, 0)); - typ(&mut s, "\n"); - assert_eq!(levels(&s), [Some(0), Some(0), Some(1), Some(1), Some(0)]); - assert_eq!(s.sel.focus, Pos::body(4, 0)); - typ(&mut s, "\n"); - assert_eq!(levels(&s), [Some(0), Some(0), Some(1), Some(1), None]); - run(&mut s, "edit.undo", json!({})); - run(&mut s, "edit.undo", json!({})); - assert_eq!(levels(&s), [Some(0), Some(0), Some(1), Some(1), Some(1)]); -} - -#[test] -fn shift_tab_and_tab_change_list_levels() { - let mut s = typed("1. One\nTwo\n\tSub a\nSub b\n"); - run(&mut s, "text.backTab", json!({})); - typ(&mut s, "Three\n\nDone"); - assert_eq!( - paras(&s), - vec![para("One", Some(0)), para("Two", Some(0)), para("Sub a", Some(1)), para("Sub b", Some(1)), para("Three", Some(0)), para("Done", None)] - ); -} - -#[test] -fn backspace_at_the_start_of_an_item_removes_the_number_then_the_indent() { - let mut s = typed("* One\nTwo"); - run(&mut s, "caret.home", json!({})); - let text_x = |s: &mut Session| s.layout().caret(&Pos::body(1, 0)).map(|c| c.x); - let before = text_x(&mut s); - typ(&mut s, "\u{8}"); - // Number gone, still indented with the list: the text stays where it was. - assert_eq!(paras(&s)[1], ("Two".to_string(), None, true)); - assert_eq!(text_x(&mut s), before); - typ(&mut s, "\u{8}|"); - // Second Backspace: Normal paragraph, not joined to the one above. - assert_eq!(paras(&s), vec![para("One", Some(0)), para("|Two", None)]); -} - -#[test] -fn backspace_on_an_empty_item_keeps_the_paragraph() { - let s = typed("* One\n\u{8}Z"); - assert_eq!(paras(&s), vec![para("One", Some(0)), ("Z".to_string(), None, true)]); -} - -#[test] -fn enter_in_the_middle_or_at_the_start_of_an_item() { - let mut s = typed("* Hello world"); - for _ in 0..5 { - run(&mut s, "caret.left", json!({})); - } - typ(&mut s, "\nX"); - assert_eq!(paras(&s), vec![para("Hello ", Some(0)), para("Xworld", Some(0))]); - let mut s = typed("* first\nsecond"); - run(&mut s, "caret.home", json!({})); - typ(&mut s, "\nY"); - assert_eq!(paras(&s), vec![para("First", Some(0)), para("", Some(0)), para("Ysecond", Some(0))]); -} - -fn label_at(s: &Session, i: usize) -> String { - let n = s.doc.para_at(&Pos::body(i, 0)).and_then(|p| p.props.numbering).unwrap(); - s.doc.numbering.level(n.num, n.level).map(|l| l.text.clone()).unwrap_or_default() -} - -/// #3352 follow-up (CodeRabbit): a heading numbered only through its style's linked Multilevel -/// List (#1793) — `props.numbering` itself unset — isn't papered over by a fresh bullet when "- " -/// is typed at its start; the trigger stays as plain text and the heading's own number is kept. -#[test] -fn list_autoformat_does_not_override_a_style_linked_heading_number() { - let mut s = s(); - typ(&mut s, "One"); - run(&mut s, "para.heading1", json!({})); - run(&mut s, "list.multilevelPreset", json!({"preset": "headings"})); - typ(&mut s, "\nTwo"); - run(&mut s, "para.heading2", json!({})); - assert_eq!(numbers(&mut s), [num(Some("1"), "Heading1"), num(Some("1.1"), "Heading2")]); - assert_eq!(s.doc.para_at(&Pos::body(1, 0)).unwrap().props.numbering, None, "numbered through the style, not directly"); - run(&mut s, "caret.home", json!({})); - typ(&mut s, "- "); - assert_eq!(numbers(&mut s), [num(Some("1"), "Heading1"), num(Some("1.1"), "Heading2")], "the heading number survives"); - assert_eq!(paras(&s)[1].0, "- Two", "the trigger stays as typed text, not stripped into a bullet"); -} - -/// A heading numbered only through its style's linked Multilevel List keeps "---" and "+--+--+" -/// as text on Enter, as a paragraph numbered directly does: no border, no table, and at the top -/// of the document the heading keeps its number. -#[test] -fn border_and_table_lines_stay_text_in_a_style_numbered_heading() { - for (above, keys) in [("", "---"), ("Above\n", "---"), ("", "+--+--+"), ("Above\n", "+--+--+")] { - let mut s = s(); - typ(&mut s, &format!("{above}{keys}")); - run(&mut s, "para.heading1", json!({})); - run(&mut s, "list.multilevelPreset", json!({"preset": "headings"})); - let at = Pos::body(s.doc.body.len() - 1, 0); - assert_eq!(s.doc.para_at(&at).unwrap().props.numbering, None, "numbered through the style, not directly"); - typ(&mut s, "\nText"); - assert_eq!(s.doc.plain_text(StoryRef::Body), format!("{above}{keys}\nText"), "{above:?}{keys}"); - assert!(s.doc.body.iter().all(|b| b.as_para().is_some_and(|p| p.props.borders.is_none())), "{above:?}{keys}"); - let mut want = vec![num(Some("1"), "Heading1"), num(None, "Normal")]; - if !above.is_empty() { - want.insert(0, num(None, "Normal")); - } - assert_eq!(numbers(&mut s), want, "{above:?}{keys}"); - } -} - -#[test] -fn list_autoformat_triggers() { - let s = typed("- Item\nNext"); - assert_eq!(label_at(&s, 0), "-"); - assert_eq!(paras(&s), vec![para("Item", Some(0)), para("Next", Some(0))]); - let s = typed("1) a\nb"); - assert_eq!(label_at(&s, 0), "%1)"); - let s = typed("a. alpha\nbeta"); - assert_eq!(paras(&s), vec![para("Alpha", Some(0)), para("beta", Some(0))]); - assert_eq!(label_at(&s, 0), "%1."); - let n = s.doc.para_at(&Pos::body(0, 0)).and_then(|p| p.props.numbering).unwrap(); - assert_eq!(s.doc.numbering.level(n.num, 0).map(|l| l.format), Some(wordcraft_doc::section::NumFormat::LowerLetter)); -} - -/// #3352: typing the trigger at the start of an already-written paragraph (not only a fresh, -/// empty one) still turns it into a list item, keeping the rest of the paragraph's text — as in -/// Word, confirmed in Word for Windows 2026-10-21. -#[test] -fn list_autoformat_triggers_in_front_of_existing_text() { - let mut s = s(); - typ(&mut s, "On peut faire mieux"); - run(&mut s, "caret.home", json!({})); - typ(&mut s, "- "); - assert_eq!(paras(&s), vec![para("On peut faire mieux", Some(0))]); - assert_eq!(label_at(&s, 0), "-"); - // Undo right after takes only the AutoFormat back, as for a fresh paragraph. - run(&mut s, "edit.undo", json!({})); - assert_eq!(paras(&s), vec![("- On peut faire mieux".to_string(), None, false)]); -} - -#[test] -fn typing_1_dot_starts_numbering_again() { - let s = typed("1. a\nb\n\nmid\n1. c\nd"); - let num = |i: usize| s.doc.para_at(&Pos::body(i, 0)).and_then(|p| p.props.numbering).map(|n| n.num); - assert_eq!(num(0), num(1)); - assert_eq!(num(3), num(4)); - assert_ne!(num(0), num(3), "a new list restarts at 1"); -} - -#[test] -fn undo_right_after_autoformat_undoes_only_the_autoformat() { - let mut s = typed("* "); - assert!(paras(&s)[0].1.is_some()); - run(&mut s, "edit.undo", json!({})); - typ(&mut s, "x"); - assert_eq!(paras(&s), vec![("* x".to_string(), None, false)]); - // Same for AutoCorrect. - let mut s = typed("teh "); - run(&mut s, "edit.undo", json!({})); - assert_eq!(s.doc.plain_text(StoryRef::Body), "teh "); -} - -#[test] -fn autocorrect_as_you_type() { - let s = typed("teh cat said hello. world (c) \"quoted\" don't -- yes wait... i think so. e.g. this. "); - assert_eq!(s.doc.plain_text(StoryRef::Body), "The cat said hello. World © “quoted” don’t – yes wait… I think so. E.g. this. "); - // Abbreviations and initials don't end sentences. - let s = typed("see e.g. the map. Ask J. smith or Dr. jones "); - assert_eq!(s.doc.plain_text(StoryRef::Body), "See e.g. the map. Ask J. smith or Dr. jones "); - // Fractions; "iPhone" and file names stay as typed. - let s = typed("1/2 cup. iPhone. see readme.txt "); - assert_eq!(s.doc.plain_text(StoryRef::Body), "½ cup. iPhone. See readme.txt "); -} - -#[test] -fn smart_quotes_nested_and_opening_delimiters() { - let s = typed("\"'nested'\" '\"inner\"' (\"'parens'\")"); - assert_eq!(s.doc.plain_text(StoryRef::Body), "“‘nested’” ‘“inner”’ (“‘parens’”)"); - // Empty quotes preserve open then close - let s = typed("\"\" ''"); - assert_eq!(s.doc.plain_text(StoryRef::Body), "“” ‘’"); - // After opening guillemets and low quotes - let s = typed("«'french'» „'german'“"); - assert_eq!(s.doc.plain_text(StoryRef::Body), "«‘french’» „‘german’“"); -} - -/// #2223: the double-hyphen arrow is a replacement, not the existing arrow bullet. -#[test] -fn double_hyphen_arrow_autocorrect_preserves_typing_scope_and_undo() { - let mut s = typed("go --> "); - assert_eq!(s.doc.plain_text(StoryRef::Body), "Go → "); - assert_eq!(paras(&s)[0].1, None); - run(&mut s, "edit.undo", json!({})); - assert_eq!(s.doc.plain_text(StoryRef::Body), "Go --> "); - let s = typed("go --> here"); - assert_eq!(s.doc.plain_text(StoryRef::Body), "Go → here"); - let s = typed("--> "); - assert_eq!(s.doc.plain_text(StoryRef::Body), "→ "); - assert_eq!(paras(&s)[0].1, None); -} - -#[test] -fn double_hyphen_arrow_respects_autocorrect_preferences() { - for prefs in [json!({"enabled": false}), json!({"replaceText": false}), json!({"delete": "-->"})] { - let mut s = s(); - run(&mut s, "tools.autocorrect", prefs); - typ(&mut s, "go --> here"); - assert!(s.doc.plain_text(StoryRef::Body).ends_with(" --> here")); - } - let mut s = s(); - run(&mut s, "review.language", json!({"lang": "pl-PL", "default": true})); - typ(&mut s, "go --> here"); - assert_eq!(s.doc.plain_text(StoryRef::Body), "Go → here"); - let mut s = self::s(); - run(&mut s, "tools.autocorrect", json!({"add": {"from": "-->", "to": "custom"}})); - typ(&mut s, "go --> here"); - assert_eq!(s.doc.plain_text(StoryRef::Body), "Go custom here"); -} - -/// #835: the English replacement list applies only to English text. Polish "i" ("and") stays -/// lowercase; symbols still change; English and untagged text still get "I". -#[test] -fn autocorrect_replacement_list_follows_the_texts_language() { - // Polish as the document's default language. - let mut s = s(); - run(&mut s, "review.language", json!({"lang": "pl-PL", "default": true})); - typ(&mut s, "Ala i kot. kawa i herbata teh (c) "); - assert_eq!(s.doc.plain_text(StoryRef::Body), "Ala i kot. Kawa i herbata teh © "); - // Polish on the run only: the word's own language counts. - let mut s = self::s(); - typ(&mut s, "Ala"); - run(&mut s, "select.all", json!({})); - run(&mut s, "review.language", json!({"lang": "pl-PL"})); - run(&mut s, "caret.docEnd", json!({})); - typ(&mut s, " i kot "); - assert_eq!(s.doc.plain_text(StoryRef::Body), "Ala i kot "); - // English (any region) and untagged text (the default proofing language is English). - let mut s = self::s(); - run(&mut s, "review.language", json!({"lang": "en-GB", "default": true})); - typ(&mut s, "so i think teh end "); - assert_eq!(s.doc.plain_text(StoryRef::Body), "So I think the end "); - let s = typed("so i think teh end "); - assert_eq!(s.doc.plain_text(StoryRef::Body), "So I think the end "); - // A Polish interface: a new document is Polish by its default language, and text with no - // language at all (a .docx without `w:lang`) follows the editing language too. - let mut s = self::s(); - s.set_editing_language(Some("pl-PL")); - typ(&mut s, "Ala i kot "); - assert_eq!(s.doc.plain_text(StoryRef::Body), "Ala i kot ", "document default from the editing language"); - let mut s = self::s(); - s.set_editing_language(Some("pl-PL")); - Arc::make_mut(&mut s.doc.styles).default_chr.lang = None; - typ(&mut s, "Ala i kot "); - assert_eq!(s.doc.plain_text(StoryRef::Body), "Ala i kot ", "untagged text in the editing language"); - // `eng` is English too, as for proofing. - let mut s = self::s(); - run(&mut s, "review.language", json!({"lang": "eng", "default": true})); - typ(&mut s, "so i think "); - assert_eq!(s.doc.plain_text(StoryRef::Body), "So I think "); -} - -#[test] -fn autocorrect_options_change_what_typing_does() { - let mut s = s(); - // A user entry, a deleted built-in entry, an added exception; names of days. - run(&mut s, "tools.autocorrect", json!({"add": {"from": "wc", "to": "WordCraft"}})); - run(&mut s, "tools.autocorrect", json!({"delete": "teh"})); - run(&mut s, "tools.autocorrect", json!({"addException": "approx."})); - typ(&mut s, "wc ships on monday. teh end is approx. three weeks "); - assert_eq!(s.doc.plain_text(StoryRef::Body), "WordCraft ships on Monday. Teh end is approx. three weeks "); - // AutoFormat options off: straight quotes, no dashes, no list; sentences not capitalised. - let mut s = self::s(); - run(&mut s, "tools.autocorrect", json!({"smartQuotes": false, "dashes": false, "bullets": false, "capSentences": false, "capDays": false})); - typ(&mut s, "* \"hi\" a -- b sunday "); - assert_eq!(s.doc.plain_text(StoryRef::Body), "* \"hi\" a -- b sunday "); - assert_eq!(paras(&s)[0].1, None); -} - -#[test] -fn enter_finishes_a_word_for_autocorrect() { - let s = typed("hello\nteh end\n"); - assert_eq!(s.doc.plain_text(StoryRef::Body), "Hello\nThe end\n"); -} - -/// A word after a no-break space or an ideographic space is corrected as after a plain space: -/// replacements, sentence capitals, ordinals and links. -#[test] -fn autocorrect_finds_the_word_after_a_wide_space() { - let s = typed("a\u{a0}teh x\u{3000}teh "); - assert_eq!(s.doc.plain_text(StoryRef::Body), "a\u{a0}the x\u{3000}the "); - // A narrow no-break space and a figure space too. - let s = typed("a\u{202f}teh x\u{2007}teh "); - assert_eq!(s.doc.plain_text(StoryRef::Body), "a\u{202f}the x\u{2007}the "); - let s = typed("end.\u{a0}hello end.\u{3000}world "); - assert_eq!(s.doc.plain_text(StoryRef::Body), "End.\u{a0}Hello end.\u{3000}World "); - let s = typed("the\u{a0}1st and\u{3000}22nd "); - let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); - let sup = |t: &str| p.props_of_char(p.text.find(t).unwrap() + t.len() - 2).vert_align == Some(VertAlign::Superscript); - assert!(sup("1st") && sup("22nd")); - let s = typed("see\u{a0}https://example.org/log, and\u{3000}https://example.net/day, then "); - let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); - let link = |t: &str| p.props_of_char(p.text.find(t).unwrap()).link.clone(); - assert_eq!(link("https://example.org").as_deref(), Some("https://example.org/log")); - assert_eq!(link("https://example.net").as_deref(), Some("https://example.net/day")); -} - -#[test] -fn ordinals_become_superscript() { - let s = typed("the 1st and 22nd and 13th but not 1th "); - let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); - let sup = |t: &str| { - let i = p.text.find(t).unwrap() + t.len() - 2; - p.props_of_char(i).vert_align == Some(VertAlign::Superscript) - }; - assert!(sup("1st") && sup("22nd") && sup("13th")); - assert!(!sup("1th")); -} - -#[test] -fn bold_and_italic_marks_become_formatting() { - let mut s = s(); - run(&mut s, "tools.autocorrect", json!({"boldItalic": true, "capSentences": false})); - typ(&mut s, "say *very* nice, _quite_ good *two words*. ok\n*end*\nnext"); - assert_eq!(s.doc.plain_text(StoryRef::Body), "say very nice, quite good two words. ok\nend\nnext"); - let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); - let at = |t: &str| p.props_of_char(p.text.find(t).unwrap()); - assert_eq!((at("very").bold, at("very").italic), (Some(true), None)); - assert_eq!((at("quite").italic, at("quite").bold), (Some(true), None)); - // Complex-script text (Arabic, Hebrew) takes the formatting too. - assert_eq!((at("very").bold_cs, at("quite").italic_cs), (Some(true), Some(true))); - assert_eq!(at("two").bold, Some(true)); - assert_eq!(at("words").bold, Some(true)); - for plain in ["say", " nice", ", ", " good", ". ok"] { - assert_eq!((at(plain).bold, at(plain).italic), (None, None), "{plain:?}"); - } - // Enter finishes the marked word too, and the next paragraph is not bold. - let p = s.doc.para_at(&Pos::body(1, 0)).unwrap(); - assert_eq!((p.props_of_char(0).bold, p.mark.bold), (Some(true), None)); - let p = s.doc.para_at(&Pos::body(2, 0)).unwrap(); - assert_eq!((p.props_of_char(0).bold, p.mark.bold), (None, None)); - // With Track Changes on, the paragraph marks carry the same revisions as for unmarked text. - let marks = |keys: &str| { - let mut s = self::s(); - run(&mut s, "tools.autocorrect", json!({"boldItalic": true})); - run(&mut s, "review.trackChanges", json!({"value": true})); - typ(&mut s, keys); - s.doc.body.iter().filter_map(|b| b.as_para()).map(|p| (p.mark.ins.is_some(), p.mark.del.is_some())).collect::>() - }; - assert_eq!(marks("x *end*\n"), marks("x end\n")); - // After a no-break space too. - let mut s = self::s(); - run(&mut s, "tools.autocorrect", json!({"boldItalic": true})); - typ(&mut s, "a\u{a0}*word* "); - assert_eq!(s.doc.plain_text(StoryRef::Body), "a\u{a0}word "); - // A red word between plain marks: the next paragraph takes the closing mark's formatting. - let mut s = self::s(); - run(&mut s, "tools.autocorrect", json!({"boldItalic": true})); - typ(&mut s, "*"); - run(&mut s, "format.color", json!({"color": "FF0000"})); - typ(&mut s, "red"); - run(&mut s, "format.color", json!({"color": "auto"})); - typ(&mut s, "*\nnext"); - let red = s.doc.para_at(&Pos::body(0, 0)).unwrap().props_of_char(0).color; - let p = s.doc.para_at(&Pos::body(1, 0)).unwrap(); - assert!(red.is_some()); - assert_ne!(p.mark.color, red); - assert_ne!(p.props_of_char(0).color, red); - // A heading's next style starts afresh after Enter, as without the marks. - let mut s = self::s(); - run(&mut s, "tools.autocorrect", json!({"boldItalic": true})); - run(&mut s, "para.heading1", json!({})); - run(&mut s, "format.bold", json!({})); - typ(&mut s, "*title*\n"); - let p = s.doc.para_at(&Pos::body(1, 0)).unwrap(); - assert_eq!((p.props.style.as_deref(), p.mark.bold), (Some("Normal"), None)); - // Marks typed before Track Changes was turned on stay: removing them would not be tracked. - let mut s = self::s(); - run(&mut s, "tools.autocorrect", json!({"boldItalic": true})); - typ(&mut s, "x *end*"); - run(&mut s, "review.trackChanges", json!({"value": true})); - typ(&mut s, " "); - assert_eq!(s.doc.plain_text(StoryRef::Body), "X *end* "); - // Marks the same author typed with Track Changes on are converted. - let mut s = self::s(); - run(&mut s, "tools.autocorrect", json!({"boldItalic": true})); - run(&mut s, "review.trackChanges", json!({"value": true})); - typ(&mut s, "x *end* "); - assert_eq!(s.doc.plain_text(StoryRef::Body), "X end "); - // Marks another author inserted stay. - let mut s = self::s(); - run(&mut s, "tools.autocorrect", json!({"boldItalic": true})); - run(&mut s, "review.trackChanges", json!({"value": true})); - run(&mut s, "file.setAuthor", json!({"name": "Ana"})); - typ(&mut s, "x *end*"); - run(&mut s, "file.setAuthor", json!({"name": "Ben"})); - typ(&mut s, " "); - assert_eq!(s.doc.plain_text(StoryRef::Body), "X *end* "); - // Undo right after brings the marks back. - let mut s = self::s(); - run(&mut s, "tools.autocorrect", json!({"boldItalic": true})); - typ(&mut s, "a *b* "); - assert_eq!(s.doc.plain_text(StoryRef::Body), "A b "); - run(&mut s, "edit.undo", json!({})); - assert_eq!(s.doc.plain_text(StoryRef::Body), "A *b* "); - // After Enter, Undo brings the marks back and the Enter stays. - let mut s = self::s(); - run(&mut s, "tools.autocorrect", json!({"boldItalic": true})); - typ(&mut s, "a *b*\n"); - assert_eq!(s.doc.plain_text(StoryRef::Body), "A b\n"); - run(&mut s, "edit.undo", json!({})); - assert_eq!(s.doc.plain_text(StoryRef::Body), "A *b*\n"); -} - -#[test] -fn autocorrect_goes_on_after_bold_and_italic_marks() { - // After a space or punctuation, the word without its marks is replaced and capitalized. - let mut s = s(); - run(&mut s, "tools.autocorrect", json!({"boldItalic": true, "add": {"from": "wc", "to": "WordCraft"}})); - typ(&mut s, "*teh* end. _wc_."); - assert_eq!(s.doc.plain_text(StoryRef::Body), "The end. WordCraft."); - let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); - let at = |t: &str| p.props_of_char(p.text.find(t).unwrap()).clone(); - assert_eq!((at("The").bold, at("WordCraft").italic, at(" end").bold), (Some(true), Some(true), None)); - // One Undo brings the marks back as typed. - run(&mut s, "edit.undo", json!({})); - assert_eq!(s.doc.plain_text(StoryRef::Body), "The end. _wc_."); - // A marked web address becomes a link, and the text typed after it is neither bold nor italic. - let mut s = self::s(); - run(&mut s, "tools.autocorrect", json!({"boldItalic": true})); - typ(&mut s, "see *www.example.org* and _www.example.com_ next "); - let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); - let at = |t: &str| p.props_of_char(p.text.find(t).unwrap()).clone(); - assert_eq!((at("www.example.org").bold, at("www.example.org").link.is_some()), (Some(true), true)); - assert_eq!((at("www.example.com").italic, at("www.example.com").link.is_some()), (Some(true), true)); - for plain in [" and", " next"] { - assert_eq!((at(plain).bold, at(plain).italic, at(plain).link.is_some()), (None, None, false), "{plain:?}"); - } - // After Enter too; a longer replacement moves the Enter with it, and Undo keeps the Enter. - let mut s = self::s(); - run(&mut s, "tools.autocorrect", json!({"boldItalic": true, "add": {"from": "wc", "to": "WordCraft"}})); - typ(&mut s, "*teh*\nok. *wc*\nnext"); - assert_eq!(s.doc.plain_text(StoryRef::Body), "The\nOk. WordCraft\nnext"); - let p = s.doc.para_at(&Pos::body(1, 0)).unwrap(); - assert_eq!(p.props_of_char(p.text.find("WordCraft").unwrap()).bold, Some(true)); - let mut s = self::s(); - run(&mut s, "tools.autocorrect", json!({"boldItalic": true})); - typ(&mut s, "*teh*\n"); - run(&mut s, "edit.undo", json!({})); - assert_eq!(s.doc.plain_text(StoryRef::Body), "*teh*\n"); - // In a span of several words, the first word is replaced and capitalized as it is typed. - let mut s = self::s(); - run(&mut s, "tools.autocorrect", json!({"boldItalic": true})); - typ(&mut s, "*teh end* and _teh rest_ "); - assert_eq!(s.doc.plain_text(StoryRef::Body), "The end and the rest "); - let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); - let at = |t: &str| p.props_of_char(p.text.find(t).unwrap()).clone(); - assert_eq!((at("The end").bold, at("the rest").italic, at(" and").bold), (Some(true), Some(true), None)); -} - -#[test] -fn bold_and_italic_marks_stay_as_typed_unless_they_enclose_words() { - let mut s = s(); - run(&mut s, "tools.autocorrect", json!({"boldItalic": true, "capSentences": false})); - let keys = "2*3* file_name_ * x* *x * ** __ *?!* _-_ "; - typ(&mut s, keys); - assert_eq!(s.doc.plain_text(StoryRef::Body), keys); - // Marks stay while formatting is limited to the document's styles. - let mut s = self::s(); - run(&mut s, "tools.autocorrect", json!({"boldItalic": true})); - run(&mut s, "review.restrict", json!({"mode": "none", "formatting": true})); - typ(&mut s, "*word* "); - assert_eq!(s.doc.plain_text(StoryRef::Body), "*word* "); - // Marks around an object (here a check box content control) stay. - let mut s = self::s(); - run(&mut s, "tools.autocorrect", json!({"boldItalic": true})); - typ(&mut s, "x *"); - run(&mut s, "developer.checkBox", json!({})); - run(&mut s, "caret.docEnd", json!({})); - typ(&mut s, "* "); - let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); - assert_eq!(p.text.matches('*').count(), 2, "{:?}", p.text); - // Off by default. - let s = typed("*bold* _italic_ "); - assert_eq!(s.doc.plain_text(StoryRef::Body), "*bold* _italic_ "); -} - -#[test] -fn web_addresses_become_links() { - let mut s = typed("see https://example.org/log, then "); - let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); - let at = p.text.find("https").unwrap(); - assert_eq!(p.props_of_char(at).link.as_deref(), Some("https://example.org/log")); - assert_eq!(p.props_of_char(p.text.find(',').unwrap()).link, None, "trailing comma stays outside"); - typ(&mut s, "more"); - let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); - assert_eq!(p.props_of_char(p.text.find("more").unwrap()).link, None); -} - -#[test] -fn dashes() { - let s = typed("a -- b and c - d and e--f and café--g "); - assert_eq!(s.doc.plain_text(StoryRef::Body), "A – b and c – d and e—f and café—g "); -} - -/// #2036: a dash AutoFormat makes right after a symbol is text, not the symbol's font. -#[test] -fn a_dash_after_a_symbol_is_not_in_the_symbol_font() { - let mut s = s(); - typ(&mut s, "e"); - run(&mut s, "insert.symbol", json!({"char": "\u{F0FC}", "font": "Wingdings"})); - typ(&mut s, "--f "); - let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); - let dash = p.text.find('\u{2014}').expect("em dash"); - assert_eq!(p.props_of_char(dash).sym_font, None); -} - -/// #2036: the en dashes of "x -- y" and "x - y" after a symbol (and its symbol-font space) are -/// text, not the symbol's font. -#[test] -fn en_dashes_after_a_symbol_are_not_in_the_symbol_font() { - for keys in ["-- f ", "- f "] { - let mut s = s(); - typ(&mut s, "e"); - run(&mut s, "insert.symbol", json!({"char": "\u{F0FC} ", "font": "Wingdings"})); - typ(&mut s, keys); - let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); - let dash = p.text.find('\u{2013}').unwrap_or_else(|| panic!("en dash for {keys:?}: {:?}", p.text)); - assert_eq!(p.props_of_char(dash).sym_font, None, "{keys:?}"); - } -} - -/// #2036: an AutoCorrect replacement of a word that starts with a symbol-font character is text. -#[test] -fn an_autocorrect_replacement_of_a_symbol_word_is_not_in_the_symbol_font() { - let mut s = s(); - run(&mut s, "insert.symbol", json!({"char": "(", "font": "Symbol"})); - typ(&mut s, "c) "); - let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); - let c = p.text.find('\u{A9}').unwrap_or_else(|| panic!("(c) → ©: {:?}", p.text)); - assert_eq!(p.props_of_char(c).sym_font, None); -} - -/// #2036: a letter AutoCorrect capitalises after a symbol-font character is text, not the symbol's font. -#[test] -fn a_capitalised_word_after_a_symbol_is_not_in_the_symbol_font() { - let mut s = s(); - run(&mut s, "insert.symbol", json!({"char": "m", "font": "Wingdings"})); - typ(&mut s, "onday "); - let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); - let m = p.text.find('M').unwrap_or_else(|| panic!("monday → Monday: {:?}", p.text)); - assert_eq!(p.props_of_char(m).sym_font, None); -} - -/// Whether every character of the body paragraph `i` is marked deleted. -fn all_deleted(s: &Session, i: usize, text: &str) -> bool { - s.doc.para_at(&Pos::body(i, 0)).is_some_and(|p| p.text == text && p.run_ranges().all(|(_, c)| c.del.is_some())) -} - -#[test] -fn a_tracked_border_line_is_a_deletion_and_a_formatting_change() { - let bottom = |s: &Session, i| s.doc.para_at(&Pos::body(i, 0)).and_then(|p| p.props.borders).and_then(|b| b.bottom); - let tracked = |s: &Session, i| s.doc.para_at(&Pos::body(i, 0)).is_some_and(|p| p.props.fmt_change.is_some()); - // Characters that were there before Track Changes was turned on are marked deleted. - let mut s = typed("Above\n---"); - run(&mut s, "review.trackChanges", json!({"value": true})); - typ(&mut s, "\n"); - assert!(bottom(&s, 0).is_some() && tracked(&s, 0)); - assert!(all_deleted(&s, 1, "---"), "{:?}", paras(&s)); - // Enter again after the deleted characters is a plain Enter. - typ(&mut s, "\n"); - assert_eq!(texts(&s), ["above", "---", ""]); - assert_eq!(s.sel.focus, Pos::body(2, 0)); - run(&mut s, "edit.undo", json!({})); - run(&mut s, "review.rejectAll", json!({})); - assert_eq!(texts(&s), ["above", "---"]); - assert!(bottom(&s, 0).is_none()); - run(&mut s, "edit.undo", json!({})); - run(&mut s, "review.acceptAll", json!({})); - assert_eq!(texts(&s), ["above", ""]); - assert!(bottom(&s, 0).is_some()); - // At the top of the document the border goes on the characters' paragraph, and typing - // continues in a new one. - let mut s = typed("---"); - run(&mut s, "review.trackChanges", json!({"value": true})); - typ(&mut s, "\nText"); - assert!(bottom(&s, 0).is_some() && tracked(&s, 0)); - assert!(all_deleted(&s, 0, "---"), "{:?}", paras(&s)); - assert_eq!(texts(&s)[1], "text"); - run(&mut s, "review.rejectAll", json!({})); - assert_eq!(texts(&s), ["---"]); - assert!(bottom(&s, 0).is_none()); - // Typed with Track Changes on, the characters go and the border is still a change. - let mut s = typed(""); - run(&mut s, "review.trackChanges", json!({"value": true})); - typ(&mut s, "---\n"); - assert_eq!(texts(&s), ["", ""]); - assert!(bottom(&s, 0).is_some() && tracked(&s, 0)); - run(&mut s, "review.rejectAll", json!({})); - assert!(bottom(&s, 0).is_none()); -} - -#[test] -fn a_tracked_border_line_at_the_top_supersedes_another_authors_formatting() { - use wordcraft_doc::props::Align; - // Ana centers the characters' paragraph; Ben's Enter gives it the border. The formatting - // change is Ben's and holds Ana's centered paragraph without a border. - let mut s = typed("---"); - run(&mut s, "review.trackChanges", json!({"value": true})); - s.author = "Ana".into(); - run(&mut s, "para.align", json!({"value": "center"})); - s.author = "Ben".into(); - typ(&mut s, "\n"); - let p = s.doc.para_at(&Pos::body(0, 0)).cloned().unwrap_or_default(); - assert!(p.props.borders.and_then(|b| b.bottom).is_some()); - let ch = p.props.fmt_change.as_deref(); - let author = ch.and_then(|c| s.doc.revisions.get(c.rev as usize)).map(|r| r.author.as_str()); - assert_eq!(author, Some("Ben"), "{:?}", s.doc.revisions); - assert_eq!(ch.map(|c| (c.old.align, c.old.borders.and_then(|b| b.bottom).is_some())), Some((Some(Align::Center), false))); - run(&mut s, "review.rejectAll", json!({})); - let p = s.doc.para_at(&Pos::body(0, 0)).cloned().unwrap_or_default(); - assert_eq!((p.props.align, p.props.borders.and_then(|b| b.bottom).is_some()), (Some(Align::Center), false)); -} - -#[test] -fn a_tracked_table_line_keeps_its_characters_as_a_deletion() { - let mut s = typed("+--+------+"); - run(&mut s, "review.trackChanges", json!({"value": true})); - typ(&mut s, "\nA"); - let Some(wordcraft_doc::Block::Table(t)) = s.doc.body.first().map(|b| &**b) else { panic!("no table: {:?}", paras(&s)) }; - assert_eq!(t.rows[0].cells[0].blocks[0].as_para().map(|p| p.text.as_str()), Some("A"), "the caret is in the first cell"); - assert!(all_deleted(&s, 1, "+--+------+"), "{:?}", paras(&s)); - // Enter after the deleted characters makes no second table. - run(&mut s, "caret.set", json!({"pos": {"block": 1, "off": 11}})); - typ(&mut s, "\n"); - assert_eq!(s.doc.body.iter().filter(|b| b.as_para().is_none()).count(), 1, "{:?}", paras(&s)); - run(&mut s, "edit.undo", json!({})); - run(&mut s, "review.rejectAll", json!({})); - assert!(s.doc.body.get(1).and_then(|b| b.as_para()).is_some_and(|p| p.text == "+--+------+")); - // The table was inserted untracked, so Reject All keeps it before the characters. - assert_eq!(s.doc.body.len(), 2, "{:?}", paras(&s)); - assert!(matches!(s.doc.body.first().map(|b| &**b), Some(wordcraft_doc::Block::Table(_)))); -} - -#[test] -fn a_border_line_at_the_top_with_formatting_markup_off_is_not_a_formatting_change() { - // Track Changes on with formatting left out of the markup: the characters are still marked - // deleted, the border is not a formatting change, and Reject All keeps it. - let mut s = typed("---"); - run(&mut s, "review.trackChanges", json!({"value": true})); - s.prefs.markup.track_formatting = false; - typ(&mut s, "\n"); - let p = s.doc.para_at(&Pos::body(0, 0)).cloned().unwrap_or_default(); - assert!(p.props.borders.and_then(|b| b.bottom).is_some()); - assert!(p.props.fmt_change.is_none(), "{:?}", p.props.fmt_change); - assert!(all_deleted(&s, 0, "---"), "{:?}", paras(&s)); - run(&mut s, "review.rejectAll", json!({})); - let p = s.doc.para_at(&Pos::body(0, 0)).cloned().unwrap_or_default(); - assert_eq!(p.text, "---"); - assert!(p.props.borders.and_then(|b| b.bottom).is_some()); -} - -#[test] -fn a_border_line_at_the_top_keeps_the_authors_own_earlier_formatting_change() { - // Ana centers the characters' paragraph, then her Enter gives it the border: the paragraph - // keeps Ana's one formatting change, which holds the paragraph from before both. - let mut s = typed("---"); - run(&mut s, "review.trackChanges", json!({"value": true})); - s.author = "Ana".into(); - run(&mut s, "para.align", json!({"value": "center"})); - let rev = s.doc.para_at(&Pos::body(0, 0)).and_then(|p| p.props.fmt_change.as_deref()).map(|c| c.rev); - typ(&mut s, "\n"); - let p = s.doc.para_at(&Pos::body(0, 0)).cloned().unwrap_or_default(); - assert!(p.props.borders.and_then(|b| b.bottom).is_some()); - let ch = p.props.fmt_change.as_deref(); - assert_eq!(ch.map(|c| c.rev), rev); - assert_eq!(ch.map(|c| (c.old.align, c.old.borders.and_then(|b| b.bottom).is_some())), Some((None, false))); - run(&mut s, "review.rejectAll", json!({})); - let p = s.doc.para_at(&Pos::body(0, 0)).cloned().unwrap_or_default(); - assert_eq!((p.text.as_str(), p.props.align, p.props.borders.and_then(|b| b.bottom).is_some()), ("---", None, false)); -} - -#[test] -fn a_border_line_the_content_control_locks_refuse_to_delete_stays_as_backspace_leaves_it() { - // "---" inside an editable rich text control that sits in one locked against editing: - // Backspace can't delete its characters, so Enter is refused too, untracked, and the line - // stays as it is. - let mut s = Session::new(wordcraft_doc::Document::from_text("A\nB\n---\nC\nD")); - s.sel = crate::Selection { anchor: Pos::body(0, 0), focus: Pos::body(4, 1) }; - run(&mut s, "developer.richText", json!({"tag": "outer"})); - s.sel = crate::Selection { anchor: Pos::body(1, 0), focus: Pos::body(3, 1) }; - run(&mut s, "developer.richText", json!({"tag": "inner"})); - run(&mut s, "developer.properties", json!({"control": "outer", "lockEdit": true})); - let before = s.doc.clone(); - for key in ["text.backspace", "text.newParagraph"] { - s.sel = crate::Selection::caret(Pos::body(2, 3)); - assert!(s.run(key, &json!({})).is_err(), "{key}"); - assert_eq!(s.doc, before, "{key}"); - } - assert!(s.doc.para_at(&Pos::body(2, 0)).is_some_and(|p| p.text == "---" && p.props.borders.is_none())); -} - -#[test] -fn border_line_autoformat() { - let s = typed("Above\n---\nBelow"); - assert_eq!(s.doc.plain_text(StoryRef::Body), "Above\nBelow"); - let b = s.doc.para_at(&Pos::body(0, 0)).and_then(|p| p.props.borders).and_then(|b| b.bottom).unwrap(); - assert_eq!(b.style, BorderStyle::Single); - assert!(s.doc.para_at(&Pos::body(1, 0)).unwrap().props.borders.is_none()); - for (keys, style) in [("===", BorderStyle::Double), ("***", BorderStyle::Dotted), ("___", BorderStyle::Thick)] { - let s = typed(&format!("x\n{keys}\n")); - let b = s.doc.para_at(&Pos::body(0, 0)).and_then(|p| p.props.borders).and_then(|b| b.bottom); - assert_eq!(b.map(|b| b.style), Some(style), "{keys}"); - } - // At the top of the document the line goes under that paragraph and typing continues below. - let s = typed("---\nText"); - assert_eq!(s.doc.plain_text(StoryRef::Body), "\nText"); - assert!(s.doc.para_at(&Pos::body(0, 0)).and_then(|p| p.props.borders).is_some()); - assert!(s.doc.para_at(&Pos::body(1, 0)).unwrap().props.borders.is_none()); -} - -#[test] -fn undo_right_after_a_border_line_brings_the_characters_back() { - let mut s = typed("Above\n---\n"); - assert_eq!(s.doc.plain_text(StoryRef::Body), "Above\n"); - run(&mut s, "edit.undo", json!({})); - // The AutoFormat is undone on its own: the characters are back, the Enter stays. - assert_eq!(s.doc.plain_text(StoryRef::Body), "Above\n---\n"); - assert!(s.doc.body.iter().filter_map(|b| b.as_para()).all(|p| p.props.borders.is_none())); - assert_eq!(s.sel.focus, Pos::body(2, 0)); - run(&mut s, "edit.undo", json!({})); - assert_eq!(s.doc.plain_text(StoryRef::Body), "Above\n---"); -} - -/// The first paragraph's (left, first-line) indents as set on the paragraph itself. -fn indents(s: &Session) -> (Option, Option) { - let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); - (p.props.indent_left, p.props.indent_first) -} - -#[test] -fn tab_and_backspace_at_a_paragraph_start_set_its_indents() { - let mut s = s(); - run(&mut s, "tools.autocorrect", json!({"tabIndent": true})); - typ(&mut s, "Some text"); - run(&mut s, "caret.docStart", json!({})); - // The first Tab indents the first line, the next one the whole paragraph. - typ(&mut s, "\t"); - assert_eq!(indents(&s), (None, Some(36.0))); - typ(&mut s, "\t"); - assert_eq!(indents(&s), (Some(36.0), Some(36.0))); - assert_eq!(s.doc.plain_text(StoryRef::Body), "Some text"); - // Backspace takes the first-line indent back, then the left indent, and stops there. - typ(&mut s, "\u{8}"); - assert_eq!(indents(&s), (Some(36.0), Some(0.0))); - typ(&mut s, "\u{8}\u{8}"); - assert_eq!(indents(&s), (Some(0.0), Some(0.0))); - assert_eq!(s.doc.plain_text(StoryRef::Body), "Some text"); - // Each Tab is one undo step. - typ(&mut s, "\t"); - run(&mut s, "edit.undo", json!({})); - assert_eq!(indents(&s), (Some(0.0), Some(0.0))); - // A left indent in characters goes back one tab stop too, instead of the paragraphs joining: - // six characters of 11 or 12 points are 66 or 72 points, two tab stops. - let mut s = self::s(); - run(&mut s, "tools.autocorrect", json!({"tabIndent": true})); - typ(&mut s, "One\nTwo"); - let second = Pos::body(1, 0); - s.doc.para_mut(StoryRef::Body, &second.path).unwrap().props.indent_left_chars = Some(6.0); - s.sel = crate::Selection::caret(second.clone()); - typ(&mut s, "\u{8}"); - assert_eq!(s.doc.plain_text(StoryRef::Body), "One\nTwo"); - let p = s.doc.para_at(&second).unwrap(); - assert_eq!((p.props.indent_left, p.props.indent_left_chars), (Some(36.0), Some(0.0))); - // With formatting limited to the document's styles the option is off: Tab types a tab - // character at the start of the first line and of a later one, and Backspace at the start - // keeps the paragraph's left indent. - let mut s = self::s(); - run(&mut s, "tools.autocorrect", json!({"tabIndent": true})); - typ(&mut s, "One"); - run(&mut s, "text.lineBreak", json!({})); - typ(&mut s, "Two"); - run(&mut s, "review.restrict", json!({"mode": "none", "formatting": true})); - run(&mut s, "caret.home", json!({})); - typ(&mut s, "\t"); - run(&mut s, "caret.docStart", json!({})); - typ(&mut s, "\t"); - assert_eq!(s.doc.plain_text(StoryRef::Body), "\tOne\n\tTwo"); - assert_eq!(indents(&s), (None, None)); - s.doc.para_mut(StoryRef::Body, &Pos::body(0, 0).path).unwrap().props.indent_left = Some(36.0); - typ(&mut s, "\u{8}\u{8}"); - assert_eq!(s.doc.plain_text(StoryRef::Body), "One\n\tTwo"); - assert_eq!(indents(&s), (Some(36.0), None)); - // An empty paragraph takes a tab character. - let mut s = self::s(); - run(&mut s, "tools.autocorrect", json!({"tabIndent": true})); - typ(&mut s, "\tText"); - assert_eq!(s.doc.plain_text(StoryRef::Body), "\tText"); - assert_eq!(indents(&s), (None, None)); - // At the start of a list item numbered by its style, Tab types a tab character, as with the - // option off. - let mut s = style_inherited_list(wordcraft_doc::numbering::ListKind::Numbered, false); - run(&mut s, "tools.autocorrect", json!({"tabIndent": true})); - run(&mut s, "caret.docStart", json!({})); - typ(&mut s, "\t"); - assert_eq!(s.doc.plain_text(StoryRef::Body), "\tAlpha\nBeta"); - assert_eq!(indents(&s), (None, None)); - // With the option off (the default), Tab types a tab character. - let mut s = self::s(); - typ(&mut s, "Some text"); - run(&mut s, "caret.docStart", json!({})); - typ(&mut s, "\t"); - assert_eq!(s.doc.plain_text(StoryRef::Body), "\tSome text"); - assert_eq!(indents(&s), (None, None)); -} - -#[test] -fn tab_before_a_later_line_of_a_paragraph_indents_the_whole_paragraph() { - let mut s = s(); - run(&mut s, "tools.autocorrect", json!({"tabIndent": true})); - typ(&mut s, &"Words that run on past the end of one line. ".repeat(6)); - run(&mut s, "caret.docStart", json!({})); - run(&mut s, "caret.down", json!({})); - run(&mut s, "caret.home", json!({})); - assert!(s.sel.focus.off > 0, "the caret is on the second line"); - typ(&mut s, "\t"); - assert_eq!(indents(&s), (Some(36.0), None)); - assert!(!s.doc.plain_text(StoryRef::Body).contains('\t')); - // After a line break too; inside a line Tab still types a tab character. - let mut s = self::s(); - run(&mut s, "tools.autocorrect", json!({"tabIndent": true})); - typ(&mut s, "One"); - run(&mut s, "text.lineBreak", json!({})); - typ(&mut s, "Two"); - run(&mut s, "caret.home", json!({})); - typ(&mut s, "\t"); - assert_eq!(indents(&s), (Some(36.0), None)); - run(&mut s, "caret.right", json!({})); - typ(&mut s, "\t"); - assert_eq!(s.doc.plain_text(StoryRef::Body), "One\nT\two"); - // Before a later line of a list item numbered by its style, Tab takes it down a level, as - // for a list item numbered directly. - let mut s = style_inherited_list(wordcraft_doc::numbering::ListKind::Numbered, false); - run(&mut s, "tools.autocorrect", json!({"tabIndent": true})); - run(&mut s, "caret.docStart", json!({})); - run(&mut s, "caret.end", json!({})); - run(&mut s, "text.lineBreak", json!({})); - typ(&mut s, "More"); - run(&mut s, "caret.home", json!({})); - typ(&mut s, "\t"); - assert!(!s.doc.plain_text(StoryRef::Body).contains('\t')); - let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); - assert_eq!(p.props.numbering.map(|n| n.level), Some(1)); - assert_eq!(indents(&s), (None, None)); - // A list level past the last one, from a style or set directly, goes to the last level. - let mut s = style_inherited_list(wordcraft_doc::numbering::ListKind::Numbered, false); - let style = Arc::make_mut(&mut s.doc.styles).styles.iter_mut().find(|x| x.id == "ListBase").unwrap(); - style.para.numbering = style.para.numbering.map(|n| wordcraft_doc::props::NumRef { level: u8::MAX, ..n }); - run(&mut s, "tools.autocorrect", json!({"tabIndent": true})); - run(&mut s, "caret.docStart", json!({})); - run(&mut s, "caret.end", json!({})); - run(&mut s, "text.lineBreak", json!({})); - typ(&mut s, "More"); - run(&mut s, "caret.home", json!({})); - typ(&mut s, "\t"); - assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().props.numbering.map(|n| n.level), Some(8)); - let num = s.doc.para_at(&Pos::body(0, 0)).unwrap().props.numbering.unwrap().num; - s.doc.para_mut(StoryRef::Body, &Pos::body(1, 0).path).unwrap().props.numbering = Some(wordcraft_doc::props::NumRef { num, level: u8::MAX }); - s.sel = crate::Selection::caret(Pos::body(1, 0)); - typ(&mut s, "\t"); - assert_eq!(s.doc.para_at(&Pos::body(1, 0)).unwrap().props.numbering.map(|n| n.level), Some(8)); - s.doc.para_mut(StoryRef::Body, &Pos::body(0, 0).path).unwrap().props.numbering = Some(wordcraft_doc::props::NumRef { num, level: u8::MAX }); - run(&mut s, "caret.docStart", json!({})); - run(&mut s, "caret.down", json!({})); - run(&mut s, "caret.home", json!({})); - assert_eq!((s.sel.focus.path.clone(), s.sel.focus.off), (Pos::body(0, 0).path, 6), "the caret is before \"More\""); - typ(&mut s, "\t"); - assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().props.numbering.map(|n| n.level), Some(8)); - s.doc.para_mut(StoryRef::Body, &Pos::body(0, 0).path).unwrap().props.numbering = Some(wordcraft_doc::props::NumRef { num, level: u8::MAX }); - run(&mut s, "outline.demote", json!({})); - assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().props.numbering.map(|n| n.level), Some(8)); -} - -#[test] -fn with_tab_indents_on_tab_in_a_table_cell_moves_to_the_next_cell() { - let mut s = typed("+--+--+\nA"); - run(&mut s, "tools.autocorrect", json!({"tabIndent": true})); - run(&mut s, "caret.home", json!({})); - typ(&mut s, "\t"); - assert_eq!(s.sel.focus.path.cell().map(|(_, r, c)| (r, c)), Some((0, 1))); - let Some(wordcraft_doc::Block::Table(t)) = s.doc.body.first().map(|b| &**b) else { panic!("no table: {:?}", paras(&s)) }; - let a = t.rows[0].cells[0].blocks[0].as_para().unwrap(); - assert_eq!((a.text.as_str(), a.props.indent_left, a.props.indent_first), ("A", None, None)); -} - -#[test] -fn with_tab_indents_off_backspace_before_an_indented_paragraph_joins_it() { - // The paragraph's own left indent, in points or in characters, doesn't stop the join. - for chars in [false, true] { - let mut s = typed("One\nTwo"); - let second = Pos::body(1, 0); - let p = s.doc.para_mut(StoryRef::Body, &second.path).unwrap(); - if chars { - p.props.indent_left_chars = Some(6.0); - } else { - p.props.indent_left = Some(36.0); - } - s.sel = crate::Selection::caret(second); - typ(&mut s, "\u{8}"); - assert_eq!(s.doc.plain_text(StoryRef::Body), "OneTwo", "indent in characters: {chars}"); - } -} - -#[test] -fn plus_and_hyphen_line_becomes_a_table() { - let mut s = typed("+--+------+\nA"); - let Some(wordcraft_doc::Block::Table(t)) = s.doc.body.first().map(|b| &**b) else { panic!("no table: {:?}", paras(&s)) }; - assert_eq!(t.rows.len(), 1); - assert_eq!(t.grid.len(), 2); - assert!((t.grid[1] / t.grid[0] - 3.0).abs() < 0.01, "widths follow the hyphens: {:?}", t.grid); - assert_eq!(t.rows[0].cells[0].blocks[0].as_para().map(|p| p.text.as_str()), Some("A"), "the caret is in the first cell"); - assert!(s.doc.body.get(1).and_then(|b| b.as_para()).is_some_and(|p| p.is_empty()), "a paragraph follows the table"); - // Undo right after brings the typed characters back, the Enter stays. - run(&mut s, "edit.undo", json!({})); - run(&mut s, "edit.undo", json!({})); - assert_eq!(s.doc.plain_text(StoryRef::Body), "+--+------+\n"); - assert!(s.doc.body.iter().all(|b| b.as_para().is_some())); - // Not a table: text around it, or more columns than a table can hold. - let s = typed("x +--+--+\n"); - assert_eq!(s.doc.plain_text(StoryRef::Body), "X +--+--+\n"); - let s = typed(&format!("+{}\n", "-+".repeat(64))); - assert!(s.doc.body.iter().all(|b| b.as_para().is_some())); -} - -/// Body paragraphs as (text, paragraph style). -fn styled(s: &Session) -> Vec<(String, Option)> { - s.doc.body.iter().filter_map(|b| b.as_para()).map(|p| (p.text.clone(), p.props.style.clone())).collect() -} - -fn with_heading_styles(keys: &str) -> Session { - let mut s = s(); - run(&mut s, "tools.autocorrect", json!({"headingStyles": true})); - typ(&mut s, keys); - s -} - -#[test] -fn two_enters_after_a_short_line_make_it_a_heading() { - let h = |t: &str, st: Option<&str>| (t.to_string(), st.map(str::to_string)); - // No tab: Heading 1; one or two tabs: Heading 2 or 3, without the tabs. Typing goes on in - // the paragraph after the heading. - for (keys, style) in [("Introduction\n\nText", "Heading1"), ("\tOur methods\n\nText", "Heading2"), ("\t\tStep 2\n\nText", "Heading3")] { - let s = with_heading_styles(keys); - let text = keys.trim_start_matches('\t').split('\n').next().unwrap_or_default(); - assert_eq!(styled(&s), [h(text, Some(style)), h("Text", None)], "{keys:?}"); - } - // Five words is the most; one Enter only ends the line. - let s = with_heading_styles("One two three four five\n\n"); - assert_eq!(s.doc.body.first().and_then(|b| b.as_para()).and_then(|p| p.props.style.as_deref()), Some("Heading1")); - assert_eq!(styled(&with_heading_styles("Introduction\nText")), [h("Introduction", None), h("Text", None)]); -} - -#[test] -fn a_line_that_is_not_a_short_line_of_body_text_stays_as_it_is() { - // Not a heading: six words, a closing punctuation mark, three tabs, a list item, a style. - for keys in ["One two three four five six\n\n", "Dear Sir,\n\n", "Is it?\n\n", "\t\t\tDeep\n\n", "* Item\n\n"] { - let s = with_heading_styles(keys); - assert!( - s.doc.body.iter().filter_map(|b| b.as_para()).all(|p| p.props.style.as_deref().is_none_or(|st| !st.starts_with("Heading"))), - "{keys:?}: {:?}", - styled(&s) - ); - } - let mut q = self::s(); - run(&mut q, "tools.autocorrect", json!({"headingStyles": true})); - run(&mut q, "para.style", json!({"style": "Quote"})); - typ(&mut q, "Quoted\n\n"); - assert_eq!(q.doc.body.first().and_then(|b| b.as_para()).and_then(|p| p.props.style.as_deref()), Some("Quote")); - // A list item numbered by the Normal style is not body text either. - let mut q = self::s(); - run(&mut q, "tools.autocorrect", json!({"headingStyles": true})); - let num = Arc::make_mut(&mut q.doc.numbering).try_add_list(wordcraft_doc::ListKind::Bullet).unwrap(); - if let Some(normal) = Arc::make_mut(&mut q.doc.styles).get_mut("Normal") { - normal.para.numbering = Some(wordcraft_doc::props::NumRef { num, level: 0 }); - } - typ(&mut q, "Item\n\n"); - assert!(styled(&q).iter().all(|(_, st)| st.as_deref().is_none_or(|st| !st.starts_with("Heading"))), "{:?}", styled(&q)); - // A line with an outline level stays; the body-text level (9 and above) is no outline level. - for (level, style) in [(0, None), (8, None), (9, Some("Heading1")), (u8::MAX, Some("Heading1"))] { - let mut q = with_heading_styles("Title\n"); - q.doc.para_mut(StoryRef::Body, &Pos::body(0, 0).path).unwrap().props.outline_level = Some(level); - typ(&mut q, "\n"); - assert_eq!(styled(&q).first().and_then(|(_, st)| st.as_deref()), style, "outline level {level}"); - } -} - -#[test] -fn built_in_heading_styles_are_off_by_default() { - let s = typed("Introduction\n\n"); - let h = |t: &str| (t.to_string(), None); - assert_eq!(styled(&s), [h("Introduction"), h(""), h("")]); -} - -#[test] -fn undo_right_after_a_heading_autoformat_keeps_the_enter() { - let mut s = with_heading_styles("\tMethods\n\n"); - assert_eq!(styled(&s), [("Methods".to_string(), Some("Heading2".to_string())), (String::new(), None)]); - assert_eq!(s.sel.focus, Pos::body(1, 0)); - run(&mut s, "edit.undo", json!({})); - // The AutoFormat is undone on its own: the tab and Normal text are back, the Enter stays. - assert_eq!(s.doc.plain_text(StoryRef::Body), "\tMethods\n\n"); - assert!(styled(&s).iter().all(|(_, st)| st.is_none())); - assert_eq!(s.sel.focus, Pos::body(2, 0)); -} - -#[test] -fn a_tracked_heading_autoformat_can_be_rejected() { - // Enter in the empty paragraph turns a zero-tab line into Heading 1. - let mut s = with_heading_styles("Title\n"); - run(&mut s, "review.trackChanges", json!({"value": true})); - s.author = "Ana".into(); - typ(&mut s, "\n"); - let p = s.doc.para_at(&Pos::body(0, 0)).cloned().unwrap_or_default(); - assert_eq!((p.text.as_str(), p.props.style.as_deref()), ("Title", Some("Heading1"))); - let ch = p.props.fmt_change.as_deref(); - assert_eq!(ch.and_then(|c| s.doc.revisions.get(c.rev as usize)).map(|r| r.author.as_str()), Some("Ana")); - run(&mut s, "review.rejectAll", json!({})); - assert_eq!(styled(&s).first(), Some(&("Title".to_string(), None))); - - // With Track Changes on, the tab is marked deleted and the style change is a formatting - // revision, so Reject All brings the line back as it was. - let mut s = with_heading_styles("\tMethods\n"); - run(&mut s, "review.trackChanges", json!({"value": true})); - typ(&mut s, "\n"); - let p = s.doc.para_at(&Pos::body(0, 0)).cloned().unwrap_or_default(); - assert_eq!((p.text.as_str(), p.props.style.as_deref()), ("\tMethods", Some("Heading2"))); - assert!(p.run_ranges().any(|(r, c)| r.start == 0 && c.del.is_some()), "the tab is marked deleted"); - assert!(p.props.fmt_change.is_some(), "the style change is tracked"); - run(&mut s, "review.rejectAll", json!({})); - assert_eq!(styled(&s).first(), Some(&("\tMethods".to_string(), None))); - // A tab this author inserted is removed outright, and the style change is still tracked. - let mut s = with_heading_styles("Title\n"); - run(&mut s, "review.trackChanges", json!({"value": true})); - run(&mut s, "caret.set", json!({"pos": {"block": 0, "off": 0}})); - typ(&mut s, "\t"); - run(&mut s, "caret.set", json!({"pos": {"block": 1, "off": 0}})); - typ(&mut s, "\n"); - let p = s.doc.para_at(&Pos::body(0, 0)).cloned().unwrap_or_default(); - assert_eq!((p.text.as_str(), p.props.style.as_deref()), ("Title", Some("Heading2"))); - assert!(p.props.fmt_change.is_some(), "the style change is tracked"); - run(&mut s, "review.rejectAll", json!({})); - assert_eq!(styled(&s).first(), Some(&("Title".to_string(), None))); - // A line holding a tracked deletion is left alone. - let mut s = with_heading_styles("Old title\n"); - run(&mut s, "review.trackChanges", json!({"value": true})); - run(&mut s, "caret.set", json!({"pos": {"block": 0, "off": 3}})); - typ(&mut s, "\u{8}\u{8}\u{8}"); - run(&mut s, "caret.set", json!({"pos": {"block": 1, "off": 0}})); - typ(&mut s, "\n"); - assert!(styled(&s).iter().all(|(_, st)| st.is_none()), "{:?}", styled(&s)); -} - -#[test] -fn a_tracked_heading_autoformat_supersedes_another_authors_formatting() { - use wordcraft_doc::props::Align; - // Ana centers a zero-tab line; Ben's Enter makes it Heading 1. - let mut s = with_heading_styles("Title\n"); - run(&mut s, "review.trackChanges", json!({"value": true})); - s.author = "Ana".into(); - run(&mut s, "caret.set", json!({"pos": {"block": 0, "off": 0}})); - run(&mut s, "para.align", json!({"value": "center"})); - s.author = "Ben".into(); - run(&mut s, "caret.set", json!({"pos": {"block": 1, "off": 0}})); - typ(&mut s, "\n"); - let p = s.doc.para_at(&Pos::body(0, 0)).cloned().unwrap_or_default(); - assert_eq!((p.text.as_str(), p.props.style.as_deref()), ("Title", Some("Heading1"))); - let ch = p.props.fmt_change.as_deref(); - assert_eq!(ch.and_then(|c| s.doc.revisions.get(c.rev as usize)).map(|r| r.author.as_str()), Some("Ben")); - assert_eq!(ch.map(|c| (c.old.style.as_deref(), c.old.align)), Some((None, Some(Align::Center)))); - run(&mut s, "review.rejectAll", json!({})); - let p = s.doc.para_at(&Pos::body(0, 0)).cloned().unwrap_or_default(); - assert_eq!((p.text.as_str(), p.props.style.as_deref(), p.props.align), ("Title", None, Some(Align::Center))); - - // Ana centers the line; Ben types a tab before it and makes it Heading 2. Ben's tab is - // removed, and the heading's formatting change is Ben's, holding Ana's centered Normal line. - let mut s = with_heading_styles("Title\n"); - run(&mut s, "review.trackChanges", json!({"value": true})); - s.author = "Ana".into(); - run(&mut s, "caret.set", json!({"pos": {"block": 0, "off": 0}})); - run(&mut s, "para.align", json!({"value": "center"})); - s.author = "Ben".into(); - typ(&mut s, "\t"); - run(&mut s, "caret.set", json!({"pos": {"block": 1, "off": 0}})); - typ(&mut s, "\n"); - let p = s.doc.para_at(&Pos::body(0, 0)).cloned().unwrap_or_default(); - assert_eq!((p.text.as_str(), p.props.style.as_deref()), ("Title", Some("Heading2"))); - let ch = p.props.fmt_change.as_deref(); - let author = ch.and_then(|c| s.doc.revisions.get(c.rev as usize)).map(|r| r.author.as_str()); - assert_eq!(author, Some("Ben"), "{:?}", s.doc.revisions); - assert_eq!(ch.map(|c| (c.old.style.as_deref(), c.old.align)), Some((None, Some(Align::Center)))); - // Rejecting it brings back Ana's centered Normal line. - run(&mut s, "review.rejectAll", json!({})); - let p = s.doc.para_at(&Pos::body(0, 0)).cloned().unwrap_or_default(); - assert_eq!((p.text.as_str(), p.props.style.as_deref(), p.props.align), ("Title", None, Some(Align::Center))); - // The author's own earlier change keeps the formatting before it. - let mut s = with_heading_styles("Title\n"); - run(&mut s, "review.trackChanges", json!({"value": true})); - s.author = "Ana".into(); - run(&mut s, "caret.set", json!({"pos": {"block": 0, "off": 0}})); - run(&mut s, "para.align", json!({"value": "center"})); - typ(&mut s, "\t"); - run(&mut s, "caret.set", json!({"pos": {"block": 1, "off": 0}})); - typ(&mut s, "\n"); - let p = s.doc.para_at(&Pos::body(0, 0)).cloned().unwrap_or_default(); - let ch = p.props.fmt_change.as_deref(); - assert_eq!(ch.and_then(|c| s.doc.revisions.get(c.rev as usize)).map(|r| r.author.as_str()), Some("Ana")); - assert_eq!(ch.map(|c| c.old.style.as_deref()), Some(None)); - assert!(ch.is_some_and(|c| c.old.align != Some(Align::Center)), "{ch:?}"); -} - -#[test] -fn a_locked_heading_style_is_not_applied_as_you_type() { - let mut s = s(); - run(&mut s, "tools.autocorrect", json!({"headingStyles": true})); - run(&mut s, "review.restrict", json!({"mode": "none", "formatting": true, "lockedStyles": ["Heading1"]})); - typ(&mut s, "Locked\n\n\tOpen\n\n"); - let st = styled(&s); - assert_eq!(st.first(), Some(&("Locked".to_string(), None)), "{st:?}"); - assert!(st.contains(&("Open".to_string(), Some("Heading2".to_string()))), "{st:?}"); -} - -#[test] -fn enter_in_an_editable_region_after_a_protected_short_line_breaks_the_paragraph() { - // Read only, with an editable region from the end of "Title" to the start of "End.": Enter - // twice in the region breaks the paragraph and leaves the protected line Normal text, and a - // border line typed there goes under the paragraph above it, inside the region. - let mut s = Session::new(wordcraft_doc::Document::from_text("Title\nX\nEnd.")); - run(&mut s, "tools.autocorrect", json!({"headingStyles": true})); - s.sel = crate::Selection { anchor: Pos::body(0, 5), focus: Pos::body(2, 0) }; - run(&mut s, "review.restrict", json!({"exception": "add"})); - run(&mut s, "review.restrict", json!({"mode": "readOnly"})); - run(&mut s, "select.text", json!({"text": "X"})); - typ(&mut s, "\n\n"); - assert_eq!(s.doc.body.len(), 5, "{:?}", styled(&s)); - assert!(styled(&s).iter().all(|(_, st)| st.is_none()), "{:?}", styled(&s)); - typ(&mut s, "---\n"); - assert!(s.doc.para_at(&Pos::body(2, 0)).and_then(|p| p.props.borders).and_then(|b| b.bottom).is_some()); - assert_eq!(s.doc.plain_text(StoryRef::Body).replace(wordcraft_doc::para::OBJ, ""), "Title\n\n\n\nEnd."); -} - -#[test] -fn arrow_bullets() { - for (keys, bullet) in [("> ", "\u{27A2}"), ("-> ", "\u{2794}"), ("=> ", "\u{21E8}")] { - let s = typed(&format!("{keys}item\nnext")); - assert_eq!(paras(&s), vec![para("Item", Some(0)), para("next", Some(0))], "{keys}"); - assert_eq!(label_at(&s, 0), bullet, "{keys}"); - } - // An arrow list doesn't join a plain bullet list. - let s = typed("* a\n\n> b"); - assert_eq!(label_at(&s, 0), "\u{2022}"); - assert_eq!(label_at(&s, 1), "\u{27A2}"); -} - -#[test] -fn smileys_are_replaced_inside_words_too() { - let s = typed("so ok:) sad:( meh:| :) fine:-) "); - assert_eq!(s.doc.plain_text(StoryRef::Body), "So ok\u{263A} sad\u{2639} meh\u{1F610} \u{263A} fine\u{263A} "); -} - -#[test] -fn a_large_mixed_document_types_like_word() { - // The corpus typed into Word and WordCraft side by side; the paragraph structure must match. - let mut s = s(); - let prose = "The keeper's log runs to four hundred pages, and almost every one begins with the weather. "; - typ(&mut s, &format!("Notes\n{}\n", prose.repeat(20))); - typ(&mut s, "The supplies were:\n* Lamp oil, 1/2 barrel\nWicks of the 1st quality\n\tSpare chimneys\nSpare springs\n"); - run(&mut s, "text.backTab", json!({})); - typ(&mut s, "Salt beef\n\nThe schedule:\n1. Signal the boat\nLower the stores\n\tCheck the net\n"); - run(&mut s, "text.backTab", json!({})); - typ(&mut s, "Sign the manifest\n\n---\nIn March the storm broke -- and he slept.\n"); - typ(&mut s, "a. Inspect the glass\nReplace the seals\n\n"); - typ(&mut s, &format!("Afterword. teh keeper retired. {}\n* one more\nsecond\n\n\nFinal.", prose.repeat(10))); - let got: Vec<(String, Option)> = paras(&s).into_iter().map(|(t, l, _)| (t.chars().take(24).collect(), l)).collect(); - let want: Vec<(&str, Option)> = vec![ - ("Notes", None), - ("The keeper’s log runs to", None), - ("The supplies were:", None), - ("Lamp oil, ½ barrel", Some(0)), - ("Wicks of the 1st quality", Some(0)), - ("Spare chimneys", Some(1)), - ("Spare springs", Some(1)), - ("Salt beef", Some(0)), - ("The schedule:", None), - ("Signal the boat", Some(0)), - ("Lower the stores", Some(0)), - ("Check the net", Some(1)), - ("Sign the manifest", Some(0)), - ("In March the storm broke", None), - ("Inspect the glass", Some(0)), - ("Replace the seals", Some(0)), - ("Afterword. The keeper re", None), - ("One more", Some(0)), - ("Second", Some(0)), - ("", None), - ("Final.", None), - ]; - let want: Vec<(String, Option)> = want.into_iter().map(|(t, l)| (t.to_string(), l)).collect(); - assert_eq!(got, want); - // Every non-list paragraph is Normal (no list indent left behind). - for (t, l, lp) in paras(&s) { - assert!(l.is_some() || !lp, "{t:?} still a List Paragraph"); - } -} - -#[test] -fn enter_right_after_starting_a_list_cancels_it() { - let s = typed("* \nPlain"); - assert_eq!(paras(&s), vec![para("Plain", None)]); -} - -#[test] -fn shift_enter_stays_inside_the_list_item() { - let mut s = typed("* line one"); - run(&mut s, "text.lineBreak", json!({})); - typ(&mut s, "cont\ntwo"); - assert_eq!(paras(&s), vec![para("Line one\ncont", Some(0)), para("two", Some(0))]); -} - -/// Body paragraph texts, lower-cased (AutoCorrect may capitalise a sentence start). -fn texts(s: &Session) -> Vec { - paras(s).into_iter().map(|p| p.0.to_lowercase()).collect() -} - -#[test] -fn tracked_enter_and_backspace_track_the_paragraph_mark() { - // Issue #229: with Track Changes on, Enter inserts a tracked paragraph mark that Reject All - // takes out again; Backspace over your own Enter removes it outright. - let mut s = typed("one"); - run(&mut s, "review.trackChanges", json!({"value": true})); - typ(&mut s, "\ntwo"); - assert_eq!(texts(&s), ["one", "two"]); - typ(&mut s, "\u{8}\u{8}\u{8}\u{8}"); - assert_eq!(texts(&s), ["one"]); - typ(&mut s, "\n"); - assert_eq!(texts(&s), ["one", ""]); - run(&mut s, "review.rejectAll", json!({})); - assert_eq!(texts(&s), ["one"]); - // A break that was already there isn't removed: Backspace marks it deleted and moves before it. - let mut s = typed("one\ntwo"); - run(&mut s, "review.trackChanges", json!({"value": true})); - run(&mut s, "caret.set", json!({"pos": {"block": 1, "off": 0}})); - typ(&mut s, "\u{8}"); - assert_eq!(texts(&s), ["one", "two"]); - assert_eq!(s.sel.focus, Pos::body(0, 3)); - assert!(s.doc.para_at(&Pos::body(0, 0)).is_some_and(|p| p.mark.del.is_some())); -} - -/// Linked text in each body paragraph, and whether its paragraph mark carries a link. -fn links(s: &Session) -> Vec<(String, bool)> { - s.doc - .body - .iter() - .filter_map(|b| b.as_para()) - .map(|p| { - let linked = p.run_ranges().filter(|(_, c)| c.link.is_some()).filter_map(|(r, _)| p.text.get(r)).collect(); - (linked, p.mark.link.is_some()) - }) - .collect() -} - -#[test] -fn a_link_covers_only_its_own_text() { - // Issue #413: linking a whole paragraph also linked its mark, so Enter carried the link into - // the next paragraph and everything typed there (or hovered below it) became the link. - let mut s = typed("hi\nhello"); - run(&mut s, "select.text", json!({"text": "hello"})); - run(&mut s, "insert.link", json!({"url": "https://example.org/"})); - run(&mut s, "caret.set", json!({"pos": {"block": 1, "off": 5}})); - typ(&mut s, "\nnext"); - run(&mut s, "caret.set", json!({"pos": {"block": 1, "off": 0}})); - typ(&mut s, "\n"); - run(&mut s, "caret.set", json!({"pos": {"block": 1, "off": 0}})); - typ(&mut s, "above"); - assert_eq!(texts(&s), ["hi", "above", "hello", "next"]); - let unlinked = (String::new(), false); - assert_eq!(links(&s), [unlinked.clone(), unlinked.clone(), ("Hello".into(), false), unlinked]); -} - -#[test] -fn typing_at_either_end_of_a_link_is_not_linked() { - // As in Word: a link typed in with Ctrl+K, then text typed after it, at its end later on, - // at its start, or after Enter, stays outside the link; typing inside it extends it. - let mut s = typed("see "); - run(&mut s, "insert.link", json!({"url": "https://example.org/", "text": "site"})); - typ(&mut s, " now"); - run(&mut s, "caret.set", json!({"pos": {"block": 0, "off": 8}})); - typ(&mut s, "!"); - run(&mut s, "caret.set", json!({"pos": {"block": 0, "off": 4}})); - typ(&mut s, "a "); - run(&mut s, "caret.set", json!({"pos": {"block": 0, "off": 8}})); - typ(&mut s, "x"); - run(&mut s, "caret.set", json!({"pos": {"block": 0, "off": 11}})); - typ(&mut s, "\nmore"); - assert_eq!(texts(&s), ["see a sixte", "more! now"]); - assert_eq!(links(&s), [("sixte".into(), false), (String::new(), false)]); -} - -#[test] -fn a_link_on_a_selection_shows_the_text_to_display() { - // Ctrl+K on selected text with a new Text to display: the selection is retyped with it and - // linked, keeping its formatting; typing after it stays outside the link (as in Word). - let mut s = typed("go see kiln now"); - run(&mut s, "select.text", json!({"text": "kiln"})); - run(&mut s, "format.bold", json!({})); - run(&mut s, "insert.link", json!({"url": "https://example.org/", "text": "Oven"})); - typ(&mut s, "!"); - assert_eq!(texts(&s), ["go see oven! now"]); - assert_eq!(links(&s), [("Oven".into(), false)]); - let p = s.doc.body.iter().find_map(|b| b.as_para()).unwrap(); - assert!(p.run_ranges().any(|(r, c)| &p.text[r] == "Oven" && c.bold == Some(true)), "the new text keeps the selection's formatting"); - // The same text, or text that can't stand in one line, leaves the selection as it is. - run(&mut s, "select.text", json!({"text": "see"})); - run(&mut s, "insert.link", json!({"url": "https://example.org/", "text": "see"})); - run(&mut s, "select.text", json!({"text": "now"})); - run(&mut s, "insert.link", json!({"url": "https://example.org/", "text": "two\nlines"})); - assert_eq!(texts(&s), ["go see oven! now"]); -} - -#[test] -fn a_new_selection_drops_the_formatting_left_after_a_link() { - // A link leaves plain formatting for what is typed after it. A selection made next doesn't - // take it: retyping that selection keeps its own formatting. - let bold_run = |s: &Session, text: &str| { - let p = s.doc.body.iter().find_map(|b| b.as_para()).unwrap(); - p.run_ranges().any(|(r, c)| &p.text[r] == text && c.bold == Some(true)) - }; - let mut s = typed("kiln"); - run(&mut s, "select.text", json!({"text": "kiln"})); - run(&mut s, "format.bold", json!({})); - run(&mut s, "insert.link", json!({"url": "https://example.org/", "text": "Oven"})); - run(&mut s, "select.all", json!({})); - run(&mut s, "insert.link", json!({"url": "https://example.org/", "text": "Stove"})); - assert!(bold_run(&s, "Stove"), "select.all"); - run(&mut s, "select.word", json!({})); - run(&mut s, "insert.link", json!({"url": "https://example.org/", "text": "Range"})); - assert!(bold_run(&s, "Range"), "select.word"); - let anchor = serde_json::to_value(s.doc.start_of(s.sel.focus.story)).unwrap(); - let focus = serde_json::to_value(s.sel.focus.clone()).unwrap(); - run(&mut s, "select.range", json!({"anchor": anchor, "focus": focus})); - run(&mut s, "insert.link", json!({"url": "https://example.org/", "text": "Hob"})); - assert!(bold_run(&s, "Hob"), "select.range"); -} - -#[test] -fn a_link_over_two_paragraphs_keeps_their_text() { - // Word shows <> for such a selection: the text to display is ignored. - let mut s = typed("one\ntwo"); - run(&mut s, "select.all", json!({})); - run(&mut s, "insert.link", json!({"url": "https://example.org/", "text": "both"})); - assert_eq!(texts(&s), ["one", "two"]); - assert_eq!(links(&s), [("One".into(), false), ("two".into(), false)]); -} - -#[test] -fn a_retyped_link_text_is_tracked() { - let mut s = typed("go see kiln now"); - run(&mut s, "review.trackChanges", json!({"value": true})); - run(&mut s, "select.text", json!({"text": "kiln"})); - run(&mut s, "insert.link", json!({"url": "https://example.org/", "text": "Oven"})); - let p = s.doc.body.iter().find_map(|b| b.as_para()).unwrap(); - assert!(p.run_ranges().any(|(r, c)| &p.text[r] == "kiln" && c.del.is_some()), "the old text stays as a deletion"); - assert!(p.run_ranges().any(|(r, c)| &p.text[r] == "Oven" && c.ins.is_some() && c.link.is_some())); -} - -#[test] -fn a_link_keeps_a_selection_it_cannot_retype() { - use wordcraft_doc::props::CharProps; - use wordcraft_doc::{InlineObject, Path}; - let url = "https://example.org/"; - // A second retyped link keeps its own selection's formatting: the first one's left no - // formatting pending for it. - let mut s = typed("go see kiln and vat now"); - run(&mut s, "select.text", json!({"text": "kiln"})); - run(&mut s, "insert.link", json!({"url": url, "text": "Oven"})); - run(&mut s, "select.text", json!({"text": "vat"})); - run(&mut s, "format.bold", json!({})); - run(&mut s, "insert.link", json!({"url": url, "text": "Tub"})); - let p = s.doc.body.iter().find_map(|b| b.as_para()).unwrap(); - assert!(p.run_ranges().any(|(r, c)| &p.text[r] == "Tub" && c.bold == Some(true) && c.link.is_some()), "{:?}", p.text); - // A selection holding a manual line break keeps both lines. - let mut s = typed("one"); - run(&mut s, "text.lineBreak", json!({})); - typ(&mut s, "two"); - run(&mut s, "select.all", json!({})); - run(&mut s, "insert.link", json!({"url": url, "text": "both"})); - assert_eq!(s.doc.plain_text(StoryRef::Body), "one\ntwo"); - // A selection in a field's result is only linked; the field's next update owns that text. - let mut doc = wordcraft_doc::Document::from_text("See "); - let c = CharProps::default(); - let p = doc.para_mut(StoryRef::Body, &Path::top(0)).unwrap(); - let start = p.len(); - p.insert_object(start, InlineObject::FieldStart { instr: "ADDIN ZOTERO_ITEM CSL_CITATION {}".into(), locked: false, dirty: false }, &c).unwrap(); - let n = p.insert_text(p.len(), "(Smith 2020)", &c).unwrap(); - p.insert_object(start + 3 + n, InlineObject::FieldEnd, &c).unwrap(); - let mut s = Session::new(doc); - run(&mut s, "select.text", json!({"text": "Smith"})); - run(&mut s, "insert.link", json!({"url": url, "text": "Jones"})); - assert_eq!(s.doc.plain_text(StoryRef::Body), "See (Smith 2020)"); - assert_eq!(s.doc.field_ranges(StoryRef::Body).len(), 1); -} - -#[test] -fn typing_then_selecting_then_typing_are_two_undo_steps() { - // #419: a selection that moves the insertion point ends the typing group, like a caret move. - let mut s = typed("alpha"); - run(&mut s, "select.text", json!({"text": "ph"})); - run(&mut s, "text.insert", json!({"text": "X"})); - assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().text, "alXa"); - run(&mut s, "edit.undo", json!({})); - assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().text, "alpha"); - assert_eq!(s.selected_text(), "ph"); - assert!(s.can_undo()); - run(&mut s, "edit.undo", json!({})); - assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().text, ""); - // Uninterrupted typing (a read-only command in between included) is still one step. - let mut s = typed("alpha"); - run(&mut s, "document.inspect", json!({})); - typ(&mut s, "X"); - run(&mut s, "edit.undo", json!({})); - assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().text, ""); -} - -#[test] -fn backspace_at_the_start_of_the_document_is_not_an_edit() { - // #23: a key that changes nothing adds no undo step, marks no unsaved changes and - // leaves the redo stack alone. - let mut s = typed("alpha"); - run(&mut s, "caret.set", json!({"pos": {"block": 0, "off": 0}})); - s.dirty = false; - let depth = s.undo_depth(); - typ(&mut s, "\u{8}"); - assert_eq!(s.undo_depth(), depth, "no undo step"); - assert!(!s.dirty, "no unsaved changes"); - assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().text, "alpha"); -} - -#[test] -fn undoing_bold_at_the_caret_turns_bold_typing_off_again() { - // #424: Bold at a caret in an empty paragraph, Undo, type: the text isn't bold. - let mut s = s(); - run(&mut s, "format.bold", json!({"value": true})); - run(&mut s, "edit.undo", json!({})); - assert_ne!(s.typing_props().bold, Some(true)); - typ(&mut s, "abc"); - let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); - assert_eq!(p.text, "abc"); - assert_ne!(p.props_of_char(0).bold, Some(true)); - // Redo brings bold typing back. - let mut s = Session::new(wordcraft_doc::Document::new()); - run(&mut s, "format.bold", json!({"value": true})); - run(&mut s, "edit.undo", json!({})); - run(&mut s, "edit.redo", json!({})); - typ(&mut s, "abc"); - assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().props_of_char(0).bold, Some(true)); -} - -/// Myanmar (#604): typed in memory order (consonant, medial, then the prevowel ေ that is drawn -/// before it), the caret steps over whole syllables and Backspace removes one sign at a time. -#[test] -fn myanmar_typing_steps_by_syllable_and_backspaces_by_sign() { - // ကြေး ("money"): က + ြ + ေ + း, then မြန်မာ. - let word = "\u{1000}\u{103C}\u{1031}\u{1038}"; - let mut s = typed(word); - assert_eq!(paras(&s), vec![para(word, None)]); - // Backspace takes the visarga, then the vowel sign: the consonant and medial stay. - typ(&mut s, "\u{8}"); - assert_eq!(paras(&s)[0].0, "\u{1000}\u{103C}\u{1031}"); - typ(&mut s, "\u{8}\u{1031}\u{1038}"); - assert_eq!(paras(&s)[0].0, word, "retyped"); - // Left from the end skips the whole syllable (the visarga and aa signs aren't grapheme - // extenders, so a grapheme step would stop inside it). - let mut s = typed("\u{1019}\u{102C}\u{1005}\u{102C}"); // မာစာ - run(&mut s, "caret.left", json!({})); - assert_eq!(s.sel.focus.off, 6); - run(&mut s, "caret.left", json!({})); - assert_eq!(s.sel.focus.off, 0); - run(&mut s, "caret.right", json!({})); - assert_eq!(s.sel.focus.off, 6); - // Delete removes the whole syllable after the caret. - run(&mut s, "text.delete", json!({})); - assert_eq!(paras(&s)[0].0, "\u{1019}\u{102C}"); -} - -/// Devanagari and Khmer: Backspace removes the last character (a matra), not the conjunct. -#[test] -fn indic_and_khmer_backspace_removes_one_character() { - let mut s = typed("\u{0915}\u{094D}\u{0937}\u{093F}\u{8}"); // क्षि ⌫ - assert_eq!(paras(&s)[0].0, "\u{0915}\u{094D}\u{0937}"); - typ(&mut s, "\u{8}"); - assert_eq!(paras(&s)[0].0, "\u{0915}\u{094D}"); - let s = typed("\u{1781}\u{17D2}\u{1798}\u{17C2}\u{8}"); // ខ្មែ ⌫ - assert_eq!(paras(&s)[0].0, "\u{1781}\u{17D2}\u{1798}"); - // Latin keeps grapheme Backspace: e + combining acute goes as one. - let s = typed("ae\u{301}\u{8}"); - assert_eq!(paras(&s)[0].0, "a"); -} - -/// Arabic vowel and Quranic marks are individually editable without retyping their base (#4757). -#[test] -fn arabic_backspace_removes_one_mark_and_keeps_the_base() { - for mark in [ - '\u{0610}', - '\u{064B}', - '\u{064C}', - '\u{064D}', - '\u{064E}', - '\u{064F}', - '\u{0650}', - '\u{0651}', - '\u{0652}', - '\u{065F}', - '\u{0670}', - '\u{06D6}', - '\u{06DF}', - '\u{06E7}', - '\u{06EA}', - '\u{0897}', - '\u{089F}', - '\u{08CA}', - '\u{08E1}', - '\u{08E3}', - '\u{08FF}', - '\u{10EFA}', - '\u{10EFF}', - ] { - let original = format!("Λ ب{mark}"); - let mut s = typed(&original); - typ(&mut s, "\u{8}"); - assert_eq!(paras(&s)[0].0, "Λ ب", "mark U+{:04X}", u32::from(mark)); - assert_eq!(s.sel.focus.off, "Λ ب".len()); - run(&mut s, "edit.undo", json!({})); - assert_eq!(paras(&s)[0].0, original); - run(&mut s, "edit.redo", json!({})); - typ(&mut s, "\u{0650}"); - assert_eq!(paras(&s)[0].0, "Λ ب\u{0650}"); - } -} - -#[test] -fn arabic_backspace_uses_stored_mark_order_but_delete_keeps_the_cluster() { - for marks in ["\u{0651}\u{064E}", "\u{064E}\u{0651}"] { - let mut s = typed(&format!("ب{marks}")); - typ(&mut s, "\u{8}"); - assert_eq!(paras(&s)[0].0, format!("ب{}", marks.chars().next().unwrap())); - typ(&mut s, "\u{8}"); - assert_eq!(paras(&s)[0].0, "ب"); - typ(&mut s, "\u{8}"); - assert!(paras(&s)[0].0.is_empty()); - let original = format!("ب{marks}ت"); - let mut s = typed(&original); - // Arabic arrows move visually. They still visit only whole-cluster boundaries. - let boundaries = [0, original.len() - "ت".len(), original.len()]; - let mut visited = vec![s.sel.focus.off]; - for command in ["caret.right", "caret.right", "caret.left", "caret.left"] { - run(&mut s, command, json!({})); - assert!(boundaries.contains(&s.sel.focus.off)); - visited.push(s.sel.focus.off); - } - assert!(boundaries.iter().all(|off| visited.contains(off))); - s.sel = crate::Selection::caret(Pos::body(0, 0)); - run(&mut s, "text.delete", json!({})); - assert_eq!(paras(&s)[0].0, "ت"); - } - for cluster in ["e\u{0301}", "👩‍💻", "👍🏽", "🇵🇱", "ב\u{05B7}"] { - let s = typed(&format!("a{cluster}\u{8}")); - assert_eq!(paras(&s)[0].0, "a", "unchanged grapheme {cluster}"); - } -} - -/// Press a shortcut the way the keyboard does: run the command bound to it. -fn press(s: &mut Session, combo: &str) -> Result { - let id = s.registry.by_shortcut(combo).map(|c| c.id).unwrap_or_else(|| panic!("nothing bound to {combo}")); - s.run(id, &json!({})) -} - -/// Word's outline keys: Alt+Shift+arrows, ⌃⇧+arrows on macOS. -fn outline_key(arrow: &str) -> String { - if cfg!(target_os = "macos") { format!("Ctrl+Shift+{arrow}") } else { format!("Alt+Shift+{arrow}") } -} - -#[test] -fn alt_shift_down_then_up_moves_a_paragraph_and_back_and_undoes() { - let mut s = typed("One\nTwo\nThree"); - run(&mut s, "caret.docStart", json!({})); - press(&mut s, &outline_key("Down")).unwrap(); - assert_eq!(texts(&s), ["two", "one", "three"]); - assert_eq!(s.sel.focus, Pos::body(1, 0), "the caret moves with the paragraph"); - press(&mut s, &outline_key("Up")).unwrap(); - assert_eq!(texts(&s), ["one", "two", "three"]); - assert!(press(&mut s, &outline_key("Up")).is_err(), "nothing above the first paragraph"); - run(&mut s, "edit.undo", json!({})); - assert_eq!(texts(&s), ["two", "one", "three"], "each move is one undo step"); - run(&mut s, "edit.undo", json!({})); - assert_eq!(texts(&s), ["one", "two", "three"]); - // A selected paragraph (with its mark) moves alone; list items keep their list. - let mut s = typed("1. First\nSecond\n\nAfter"); - run(&mut s, "select.paragraph", json!({})); - press(&mut s, &outline_key("Up")).unwrap(); - assert_eq!(paras(&s), vec![para("First", Some(0)), para("After", None), para("Second", Some(0))]); - assert!(press(&mut s, &outline_key("Down")).is_ok()); - assert!(press(&mut s, &outline_key("Down")).is_err(), "nothing below the last paragraph"); -} - -fn style_of(s: &Session, i: usize) -> Option { - s.doc.body.get(i).and_then(|b| b.as_para()).and_then(|p| p.props.style.clone()) -} - -#[test] -fn alt_shift_left_and_right_promote_and_demote_headings_and_list_items() { - let mut s = typed("Intro\nBody"); - run(&mut s, "caret.docStart", json!({})); - run(&mut s, "para.heading2", json!({})); - run(&mut s, "caret.docEnd", json!({})); - // Promoted body text takes the level of the heading before it; then one level at a time. - press(&mut s, &outline_key("Left")).unwrap(); - assert_eq!(style_of(&s, 1).as_deref(), Some("Heading2")); - press(&mut s, &outline_key("Right")).unwrap(); - assert_eq!(style_of(&s, 1).as_deref(), Some("Heading3")); - press(&mut s, &outline_key("Left")).unwrap(); - press(&mut s, &outline_key("Left")).unwrap(); - assert_eq!(style_of(&s, 1).as_deref(), Some("Heading1")); - assert!(press(&mut s, &outline_key("Left")).is_err(), "Heading 1 is the top level"); - for _ in 0..8 { - press(&mut s, &outline_key("Right")).unwrap(); - } - assert_eq!(style_of(&s, 1).as_deref(), Some("Heading9")); - assert!(press(&mut s, &outline_key("Right")).is_err(), "Heading 9 is the lowest heading"); - // In a list they change the item's level, like Tab and Shift+Tab. - let mut s = typed("* One\nTwo"); - press(&mut s, &outline_key("Right")).unwrap(); - assert_eq!(paras(&s), vec![para("One", Some(0)), para("Two", Some(1))]); - press(&mut s, &outline_key("Left")).unwrap(); - assert_eq!(paras(&s), vec![para("One", Some(0)), para("Two", Some(0))]); -} - -#[test] -fn down_and_up_from_a_line_start_beside_a_table_go_into_it() { - let mut s = typed("Intro line\n"); - run(&mut s, "insert.table", json!({"rows": 2, "cols": 2})); - let (table, _, _) = s.sel.focus.path.cell().unwrap(); - typ(&mut s, "a"); - let cell = |r: u32, c: u32| { - let mut p = table.0.clone(); - p.extend([r, c, 0]); - wordcraft_doc::Path(p) - }; - // From the start of the line above, left of the cells' text: the first cell, then the one - // below, not the paragraph after the table. - run(&mut s, "caret.docStart", json!({})); - run(&mut s, "caret.down", json!({})); - assert_eq!(s.sel.focus.path, cell(0, 0)); - run(&mut s, "caret.down", json!({})); - assert_eq!(s.sel.focus.path, cell(1, 0), "then the cell below"); - // Up from the start of the paragraph after it: the last row's first cell. - run(&mut s, "caret.docEnd", json!({})); - run(&mut s, "caret.up", json!({})); - assert_eq!(s.sel.focus.path, cell(1, 0)); - // Shift+Down from the line above takes the first row, not the whole table. - run(&mut s, "caret.docStart", json!({})); - run(&mut s, "caret.down", json!({"extend": true})); - assert_eq!(s.sel.focus.path, cell(0, 1)); -} - -#[test] -fn alt_x_toggles_between_a_character_and_its_code() { - if !cfg!(target_os = "macos") { - assert_eq!(s().registry.by_shortcut("Alt+X").map(|c| c.id), Some("text.toggleUnicode")); - } - let mut s = s(); - run(&mut s, "text.insert", json!({"text": "x 00e9"})); - run(&mut s, "text.toggleUnicode", json!({})); - assert_eq!(texts(&s), ["x é"]); - run(&mut s, "text.toggleUnicode", json!({})); - assert_eq!(texts(&s), ["x 00e9"]); - run(&mut s, "edit.undo", json!({})); - assert_eq!(texts(&s), ["x é"], "one undo step"); - run(&mut s, "text.insert", json!({"text": " U+1F600"})); - run(&mut s, "text.toggleUnicode", json!({})); - assert_eq!(texts(&s), ["x é 😀"]); - // A selected code converts too. - run(&mut s, "text.insert", json!({"text": " 2014"})); - run(&mut s, "select.text", json!({"text": "2014"})); - run(&mut s, "text.toggleUnicode", json!({})); - assert_eq!(texts(&s), ["x é 😀 —"]); -} - -#[test] -fn alt_numpad_codes_map_like_word_on_windows() { - use crate::cmd::text::{alt_code_char, oem_code_page}; - let c = |code| alt_code_char(code, 1252, 437); - // Leading 0: Windows-1252. - assert_eq!(c("0233"), Some('é')); - assert_eq!(c("0128"), Some('€')); - assert_eq!(c("0150"), Some('–')); - assert_eq!(c("065"), Some('A')); - assert_eq!(c("0032"), Some(' ')); - assert_eq!(c("0255"), Some('ÿ')); - // No leading 0: the OEM code page (437 here, 850 in Western Europe). - assert_eq!(c("233"), Some('Θ')); - // The issue said 233 → é in 850; the CP850 mapping file has Ú at 0xE9 (é is 130 in both). - assert_eq!(alt_code_char("233", 1252, 850), Some('Ú')); - assert_eq!(alt_code_char("130", 1252, 850), Some('é')); - assert_eq!(c("130"), Some('é')); - assert_eq!(alt_code_char("155", 1252, 850), Some('ø')); - assert_eq!(c("155"), Some('¢')); - assert_eq!(c("65"), Some('A')); - assert_eq!(c("1"), Some('☺')); - assert_eq!(c("7"), Some('•')); - assert_eq!(c("127"), Some('⌂')); - assert_eq!(c("176"), Some('░')); - // Above 255: the Unicode character with that decimal value. - assert_eq!(c("8364"), Some('€')); - assert_eq!(c("09733"), Some('★')); - assert_eq!(c("128512"), Some('😀')); - // Nothing for junk, 0, control characters, surrogates or values past Unicode. - for bad in ["", "0", "000", "0009", "0013", "0127", "0129", "x1", "-5", "55296", "1114112", "99999999999999999999", "4294967361"] { - assert_eq!(c(bad), None, "{bad:?}"); - } - assert_eq!(oem_code_page(None), 437); - assert_eq!(oem_code_page(Some("en-US")), 437); - assert_eq!(oem_code_page(Some("en_GB.UTF-8")), 850); - assert_eq!(oem_code_page(Some("fr-FR")), 850); - assert_eq!(oem_code_page(Some("de")), 850); - assert_eq!(oem_code_page(Some("ru-RU")), 437, "only 437/850 without the shell's code pages"); -} - -#[test] -fn alt_numpad_codes_follow_the_system_code_pages() { - use crate::cmd::text::{alt_code_char, windows_code_pages}; - // Polish (1250/852): Alt+0185 and Alt+165 are both ą, not ¹ / Ñ. - assert_eq!(alt_code_char("0185", 1250, 852), Some('ą')); - assert_eq!(alt_code_char("165", 1250, 852), Some('ą')); - assert_eq!(alt_code_char("0185", 1252, 437), Some('¹')); - // Russian (1251/866), Greek (1253/737), Turkish (1254/857). - assert_eq!(alt_code_char("0207", 1251, 866), Some('П')); - assert_eq!(alt_code_char("143", 1251, 866), Some('П')); - assert_eq!(alt_code_char("0193", 1253, 737), Some('Α')); - assert_eq!(alt_code_char("128", 1253, 737), Some('Α')); - assert_eq!(alt_code_char("0253", 1254, 857), Some('ı')); - assert_eq!(alt_code_char("141", 1254, 857), Some('ı')); - // Above 255 the code page doesn't matter. - assert_eq!(alt_code_char("8364", 1251, 866), Some('€')); - // A byte the code page leaves unassigned types nothing. - assert_eq!(alt_code_char("213", 1254, 857), None); - assert_eq!(alt_code_char("0219", 874, 437), None); - assert_eq!(windows_code_pages(None), (1252, 437)); - assert_eq!(windows_code_pages(Some("en-US")), (1252, 437)); - assert_eq!(windows_code_pages(Some("de-DE")), (1252, 850)); - assert_eq!(windows_code_pages(Some("pl-PL")), (1250, 852)); - assert_eq!(windows_code_pages(Some("sr-Latn-RS")), (1250, 852)); - assert_eq!(windows_code_pages(Some("ru_RU.UTF-8")), (1251, 866)); - assert_eq!(windows_code_pages(Some("el-GR")), (1253, 737)); - assert_eq!(windows_code_pages(Some("tr-TR")), (1254, 857)); - assert_eq!(windows_code_pages(Some("ja-JP")), (1252, 437)); -} - -#[test] -fn alt_numpad_code_types_one_character() { - // `texts` lowercases; the capitals matter here. - let raw = |s: &Session| paras(s).into_iter().map(|p| p.0).collect::>(); - let mut s = s(); - run(&mut s, "text.insert", json!({"text": "caf"})); - run(&mut s, "text.typeCode", json!({"code": "0233"})); - run(&mut s, "text.insert", json!({"text": " "})); - run(&mut s, "text.typeCode", json!({"code": "233", "oem": 437})); - run(&mut s, "text.insert", json!({"text": " "})); - run(&mut s, "text.typeCode", json!({"code": "155", "oem": 850})); - assert_eq!(raw(&s), ["Café Θ ø"]); - run(&mut s, "edit.undo", json!({})); - assert_eq!(raw(&s), ["Café Θ "], "one undo step per code"); - assert!(s.run("text.typeCode", &json!({"code": "0"})).is_err()); - assert!(s.run("text.typeCode", &json!({"code": "65", "oem": 855})).is_err()); - assert!(s.run("text.typeCode", &json!({"code": "065", "ansi": 932})).is_err()); - assert!(s.run("text.typeCode", &json!({"code": "065", "ansi": 70000})).is_err()); - assert_eq!(raw(&s), ["Café Θ "]); - // The system's code pages: Polish Alt+0185 and Alt+165 both type ą. - run(&mut s, "text.typeCode", json!({"code": "0185", "ansi": 1250, "oem": 852})); - run(&mut s, "text.typeCode", json!({"code": "165", "ansi": 1250, "oem": 852})); - run(&mut s, "text.typeCode", json!({"code": "143", "oem": 866})); - assert_eq!(raw(&s), ["Café Θ ąąП"]); -} - -#[test] -fn shift_f5_goes_back_through_the_last_edits() { - let mut s = typed("One\nTwo\nThree"); - run(&mut s, "caret.docStart", json!({})); - let mut back = || { - press(&mut s, "Shift+F5").unwrap(); - s.sel.focus.clone() - }; - assert_eq!(back(), Pos::body(2, 5), "the last edit first"); - assert_eq!(back(), Pos::body(1, 3)); - assert_eq!(back(), Pos::body(0, 3)); - assert_eq!(back(), Pos::body(2, 5), "then round again"); - assert!(Session::new(wordcraft_doc::Document::new()).run("edit.goBack", &json!({})).is_err(), "nothing edited yet"); -} - -#[test] -fn shift_right_at_the_end_of_a_cell_selects_the_cell_first() { - let mut s = s(); - run(&mut s, "insert.table", json!({"rows": 2, "cols": 2})); - let (table, _, _) = s.sel.focus.path.cell().unwrap(); - typ(&mut s, "ab"); - run(&mut s, "caret.left", json!({})); - let shift_right = |s: &mut Session| { - run(s, "caret.right", json!({"extend": true})); - s.cell_range().map(|r| (r.rows, r.cols)) - }; - assert_eq!(shift_right(&mut s), None, "the letter first"); - assert_eq!((s.sel.anchor.off, s.sel.focus.off), (1, 2)); - assert_eq!(shift_right(&mut s), Some(((0, 0), (0, 0))), "then the cell alone"); - assert_eq!(shift_right(&mut s), Some(((0, 0), (0, 1))), "then the next cell with it"); - // In an empty cell the first press takes it. - let mut empty = table.0.clone(); - empty.extend([1, 0, 0]); - let pos = Pos { story: StoryRef::Body, path: wordcraft_doc::Path(empty), off: 0 }; - run(&mut s, "caret.set", json!({"pos": pos})); - assert_eq!(shift_right(&mut s), Some(((1, 1), (0, 0)))); -} - -#[test] -fn two_initial_capitals_are_corrected() { - let s = typed("the WOrd and THe end, not IDs or CDs. TV is fine "); - assert_eq!(s.doc.plain_text(StoryRef::Body), "The Word and The end, not IDs or CDs. TV is fine "); - // "TEh" loses its second capital, then the replacement list fixes the typo. - assert_eq!(typed("TEh ").doc.plain_text(StoryRef::Body), "The "); - // A user exception. - let mut s = typed(""); - run(&mut s, "tools.autocorrect", json!({"addInitialCapsException": "WCraft"})); - typ(&mut s, "WCraft "); - assert_eq!(s.doc.plain_text(StoryRef::Body), "WCraft "); -} - -#[test] -fn accidental_caps_lock_is_corrected() { - let s = typed("a wORD and hELLO there. iOS stays "); - assert_eq!(s.doc.plain_text(StoryRef::Body), "A Word and Hello there. iOS stays "); -} - -#[test] -fn capitalisation_fixes_undo_and_turn_off() { - let mut s = typed("WOrd"); - typ(&mut s, " "); - assert_eq!(s.doc.plain_text(StoryRef::Body), "Word "); - run(&mut s, "edit.undo", json!({})); - assert_eq!(s.doc.plain_text(StoryRef::Body), "WOrd "); - let mut s = typed("x wORD"); - typ(&mut s, " "); - run(&mut s, "edit.undo", json!({})); - assert_eq!(s.doc.plain_text(StoryRef::Body), "X wORD "); - let mut s = typed(""); - run(&mut s, "tools.autocorrect", json!({"twoInitialCaps": false, "capsLock": false})); - typ(&mut s, "x WOrd wORD "); - assert_eq!(s.doc.plain_text(StoryRef::Body), "X WOrd wORD "); -} - -#[test] -fn first_letter_of_a_table_cell_is_capitalised() { - let cell_text = |s: &Session, c: usize| { - s.doc.body.iter().find_map(|b| b.as_table()).and_then(|t| t.rows.first()?.cells.get(c)?.blocks.first()?.as_para().map(|p| p.text.clone())) - }; - let mut s = typed(""); - run(&mut s, "insert.table", json!({"rows": 1, "cols": 2})); - typ(&mut s, "apples and pears\tplums "); - assert_eq!(cell_text(&s, 0).as_deref(), Some("Apples and pears")); - assert_eq!(cell_text(&s, 1).as_deref(), Some("Plums ")); - run(&mut s, "edit.undo", json!({})); - assert_eq!(cell_text(&s, 1).as_deref(), Some("plums ")); - let mut s = typed(""); - run(&mut s, "tools.autocorrect", json!({"capCells": false})); - run(&mut s, "insert.table", json!({"rows": 1, "cols": 1})); - typ(&mut s, "plums "); - assert_eq!(cell_text(&s, 0).as_deref(), Some("plums ")); -} - -/// File › Options › Advanced, "Typing replaces selected text" (#485): on (Word's default) the -/// typed text replaces the selection; off, it goes in front of the selection and keeps it. -#[test] -fn typing_over_a_selection_follows_the_replace_selection_option() { - let mut s = typed("One two"); - run(&mut s, "select.text", json!({"text": "two"})); - typ(&mut s, "X"); - assert_eq!(s.doc.plain_text(StoryRef::Body), "One X"); - let mut s = typed("One two"); - run(&mut s, "file.options", json!({"typingReplacesSelection": false})); - run(&mut s, "select.text", json!({"text": "two"})); - typ(&mut s, "XY"); - assert_eq!(s.doc.plain_text(StoryRef::Body), "One XYtwo"); -} - -/// Overtype (#485): typed characters replace the ones after the caret, never past the end of the -/// paragraph; the Insert key toggles it only with "Use the Insert key to control overtype" on -/// (the UI checks that option; `text.overtype` is the switch). -#[test] -fn overtype_replaces_the_characters_after_the_caret() { - let mut s = typed("Abcdef\nXy"); - run(&mut s, "caret.set", json!({"pos": Pos::body(0, 1)})); - run(&mut s, "text.overtype", json!({"value": true})); - typ(&mut s, "XY"); - assert_eq!(s.doc.plain_text(StoryRef::Body), "AXYdef\nXy"); - run(&mut s, "caret.set", json!({"pos": Pos::body(0, 5)})); - typ(&mut s, "123"); - assert_eq!(s.doc.plain_text(StoryRef::Body), "AXYde123\nXy", "the paragraph mark isn't typed over"); - run(&mut s, "text.overtype", json!({})); - run(&mut s, "caret.set", json!({"pos": Pos::body(0, 0)})); - typ(&mut s, "Z"); - assert_eq!(s.doc.plain_text(StoryRef::Body), "ZAXYde123\nXy", "insert mode again"); -} - -#[test] -fn reject_all_takes_out_the_paragraph_breaks_a_tracked_paste_inserted() { - // Issue #417: Paste's paragraph marks weren't tracked, so Reject All left the split. - for text in ["X\nY", "\n"] { - let mut s = s(); - run(&mut s, "text.insert", json!({"text": "AB", "raw": true})); - run(&mut s, "caret.set", json!({"pos": {"block": 0, "off": 1}})); - run(&mut s, "review.trackChanges", json!({"value": true})); - run(&mut s, "edit.paste", json!({"text": text})); - let (head, tail) = text.split_once('\n').unwrap_or_default(); - assert_eq!(texts(&s), [format!("a{}", head.to_lowercase()), format!("{}b", tail.to_lowercase())], "{text:?}"); - let pasted = texts(&s); - run(&mut s, "review.rejectAll", json!({})); - assert_eq!(texts(&s), ["ab"], "{text:?}"); - assert!(s.doc.revisions.is_empty(), "{text:?}"); - run(&mut s, "edit.undo", json!({})); - run(&mut s, "review.acceptAll", json!({})); - assert_eq!(texts(&s), pasted, "{text:?}: accepting keeps the break"); - } -} - -#[test] -fn plain_text_paste_keeps_an_empty_paragraph_in_a_list() { - // Issue #422: the pasted paragraph separators went through Enter, which ends the list on an - // empty item, so `A\n\nB` lost its empty paragraph. - // Paste of plain text from another program goes the same way. - for (id, v) in [ - ("edit.pasteText", json!({"text": "A\n\nB"})), - ("edit.pasteSpecial", json!({"as": "text", "text": "A\n\nB"})), - ("edit.paste", json!({"text": "A\n\nB"})), - ] { - let mut s = s(); - run(&mut s, "para.bullets", json!({})); - run(&mut s, id, v); - assert_eq!(paras(&s), vec![para("A", Some(0)), para("", Some(0)), para("B", Some(0))], "{id}"); - } - // Enter twice still ends the list. - let mut s = s(); - run(&mut s, "para.bullets", json!({})); - typ(&mut s, "A\n\nB"); - assert_eq!(paras(&s), vec![para("A", Some(0)), para("B", None)]); -} - -/// Each body paragraph's text and paragraph formatting. -fn para_formats(s: &Session) -> Vec<(String, wordcraft_doc::props::ParaProps)> { - s.doc.body.iter().filter_map(|b| b.as_para()).map(|p| (p.text.clone(), p.props.clone())).collect() -} - -/// Plain text from another program pastes as Keep Text Only does, as in Word: every pasted line -/// becomes a paragraph formatted like the paragraph it lands in. Paste gave the lines after the -/// first default formatting, and the text after the caret went with the last line, so a -/// right-to-left paragraph came out left-to-right from its second line on. -#[test] -fn plain_text_paste_gives_every_line_the_formatting_of_its_paragraph() { - let mut s = s(); - run(&mut s, "text.insert", json!({"text": "بداية نهاية", "raw": true})); - run(&mut s, "para.rtl", json!({})); - run(&mut s, "para.indents", json!({"left": 18, "right": 36})); - run(&mut s, "para.spacing", json!({"after": 12})); - let before = para_formats(&s); - let [(_, props)] = &before[..] else { panic!("{before:?}") }; - assert_eq!(props.bidi, Some(true)); - let off = "بداية ".len(); - for id in ["edit.paste", "edit.pasteText"] { - run(&mut s, "caret.set", json!({"pos": {"block": 0, "off": off}})); - run(&mut s, id, json!({"text": "سطر أول\r\nسطر ثان\r\nسطر ثالث"})); - let want: Vec<(String, _)> = ["بداية سطر أول", "سطر ثان", "سطر ثالثنهاية"].iter().map(|t| (t.to_string(), props.clone())).collect(); - assert_eq!(para_formats(&s), want, "{id}"); - // One undo step takes the whole paste out again. - run(&mut s, "edit.undo", json!({})); - assert_eq!(para_formats(&s), before, "{id}"); - } -} - -/// Empty lines in pasted plain text come through as empty paragraphs, formatted like the rest, -/// and so does a line break at the end of the text. The text takes the formatting before the -/// caret. -#[test] -fn plain_text_paste_keeps_empty_lines_as_paragraphs() { - let mut s = s(); - run(&mut s, "text.insert", json!({"text": "Lead ", "raw": true})); - run(&mut s, "select.all", json!({})); - run(&mut s, "format.bold", json!({})); - run(&mut s, "caret.docEnd", json!({})); - run(&mut s, "para.alignCenter", json!({})); - run(&mut s, "para.spacing", json!({"before": 6, "after": 18})); - let props = para_formats(&s)[0].1.clone(); - run(&mut s, "edit.paste", json!({"text": "Alpha one.\n\nAlpha two.\n\n\nAlpha three.\n"})); - let got = para_formats(&s); - let texts: Vec<&str> = got.iter().map(|(t, _)| t.as_str()).collect(); - assert_eq!(texts, ["Lead Alpha one.", "", "Alpha two.", "", "", "Alpha three.", ""]); - assert!(got.iter().all(|(_, p)| *p == props), "{got:?}"); - assert_eq!(s.sel.focus, Pos::body(6, 0), "the caret ends after the pasted text"); - let bold = s.doc.body.iter().filter_map(|b| b.as_para()).all(|p| p.runs.iter().all(|r| r.props.bold == Some(true))); - assert!(bold, "the pasted text takes the bold before the caret"); -} - -/// #713: Arabic or Hebrew typed into an empty paragraph makes it right-to-left; a paragraph that -/// already has letters, or whose style sets a direction, keeps its own. -#[test] -fn typing_rtl_letters_into_an_empty_paragraph_makes_it_rtl() { - let bidi = |s: &Session, i: usize| s.doc.body.iter().filter_map(|b| b.as_para()).nth(i).and_then(|p| p.props.bidi); - // Empty paragraph: the first Arabic letter flips it, the rest keep it. - let s = typed("سلام"); - assert_eq!(bidi(&s, 0), Some(true)); - // Digits first, then Arabic: still no letters before, so it flips. - let s = typed("1. سلام"); - assert_eq!(bidi(&s, 0), Some(true)); - // Latin first: Arabic typed later doesn't flip the paragraph. - let s = typed("Hello سلام"); - assert_eq!(bidi(&s, 0), None); - // A new paragraph after an RTL one starts its own detection. - let s = typed("سلام\nHello"); - assert_eq!(bidi(&s, 1), Some(true), "Enter carries the paragraph's direction on, as in Word"); - // A style that sets left-to-right wins. - let mut doc = Session::new(wordcraft_doc::Document::new()); - if let Some(st) = Arc::make_mut(&mut doc.doc.styles).get_mut("Normal") { - st.para.bidi = Some(false); - } - typ(&mut doc, "سلام"); - assert_eq!(bidi(&doc, 0), None); - // An opened paragraph without w:bidi lays out left-to-right whatever its text: the caret - // after the Arabic word sits near the left margin, not the right one. - let mut doc = typed("سلام"); - let path = doc.sel.focus.path.clone(); - if let Ok(p) = doc.doc.para_mut(StoryRef::Body, &path) { - p.props.bidi = None; - p.touch(); - } - let x = doc.layout().caret(&doc.sel.focus).map(|c| c.x).unwrap_or(f32::MAX); - assert!(x < 300.0, "left-to-right paragraph: caret at {x}"); -} - -/// Select All then Delete or Backspace empties the document, tables included (Word, #1198): -/// whether the text starts with a table or with a paragraph. -#[test] -fn select_all_then_delete_removes_tables() { - let tables = |s: &Session| s.doc.body.iter().filter(|b| b.as_table().is_some()).count(); - // With the caret in a table, Select All grows the selection a step at a time (cell, row, - // table, document; #1478): press it until the whole document is selected. - let select_whole_document = |s: &mut Session| { - for _ in 0..6 { - let before = s.sel.clone(); - run(s, "select.all", json!({})); - if s.sel == before { - break; - } - } - }; - for key in ["text.delete", "text.backspace"] { - let mut s = typed(""); - run(&mut s, "insert.table", json!({"rows": 2, "cols": 2})); - typ(&mut s, "cell"); - assert_eq!(tables(&s), 1); - select_whole_document(&mut s); - run(&mut s, key, json!({})); - assert_eq!(tables(&s), 0, "{key}: a table at the start of the document goes"); - assert_eq!(s.doc.plain_text(StoryRef::Body), "", "{key}"); - - let mut s = typed("before\n"); - run(&mut s, "insert.table", json!({"rows": 2, "cols": 2})); - typ(&mut s, "cell"); - assert_eq!(tables(&s), 1); - select_whole_document(&mut s); - run(&mut s, key, json!({})); - assert_eq!(tables(&s), 0, "{key}: a table after text goes"); - assert_eq!(s.doc.plain_text(StoryRef::Body), "", "{key}"); - } -} - -/// #898: a click just after a citation at the end of a paragraph puts the caret before the -/// citation's hidden field end; what is typed there goes after the citation, as in Word, instead -/// of into its result (Zotero then reports the citation as edited by hand). -#[test] -fn typing_after_a_citation_goes_outside_its_field() { - use wordcraft_doc::props::CharProps; - use wordcraft_doc::{InlineObject, Path}; - let mut doc = wordcraft_doc::Document::from_text("Cats purr "); - let c = CharProps::default(); - let p = doc.para_mut(StoryRef::Body, &Path::top(0)).unwrap(); - let start = p.len(); - p.insert_object(start, InlineObject::FieldStart { instr: "ADDIN ZOTERO_ITEM CSL_CITATION {}".into(), locked: false, dirty: false }, &c).unwrap(); - let n = p.insert_text(p.len(), "(Smith 2020)", &c).unwrap(); - p.insert_object(start + 3 + n, InlineObject::FieldEnd, &c).unwrap(); - let mut s = Session::new(doc); - let end = Pos::body(0, s.doc.para(StoryRef::Body, &Path::top(0)).unwrap().len()); - let at = s.layout().caret(&end).unwrap(); - let hit = s.layout().hit(at.page, at.x + 20.0, at.top + at.height / 2.0, StoryRef::Body).unwrap(); - s.sel = crate::Selection::caret(hit); - typ(&mut s, ". Dogs bark."); - assert_eq!(s.doc.plain_text(StoryRef::Body), "Cats purr (Smith 2020). Dogs bark."); - let f = s.doc.field_ranges(StoryRef::Body); - assert_eq!(f.len(), 1); - let inside = Pos { off: f[0].start.off + wordcraft_doc::para::OBJ.len_utf8(), ..f[0].start.clone() }; - assert_eq!(s.doc.copy_range(&inside, &f[0].end).plain_text(), "(Smith 2020)"); -} - -/// Tab in a table typed in right-to-left text (#2634): like Word, a table inserted in a -/// right-to-left paragraph is a right-to-left table, so its first cell stands at the right and -/// Tab moves to the cell on the left (the next one in reading order); Shift+Tab comes back. A -/// table inserted in a left-to-right paragraph is unchanged: Tab moves right. -#[test] -fn tab_in_a_table_inserted_in_right_to_left_text_moves_left() { - // The caret's cell column and the x of that cell on the page. - fn caret_cell_x(s: &mut Session) -> (usize, f32) { - let (_, r, c) = s.sel.focus.path.cell().expect("caret in a cell"); - let x = s - .layout() - .pages - .iter() - .flat_map(|p| p.items.iter()) - .find_map(|it| match it { - wordcraft_layout::Placed::Cell { rect, row, cell, covered: false, .. } if *row == r && *cell == c => Some(rect.x), - _ => None, - }) - .expect("cell laid out"); - (c, x) - } - for (keys, rtl) in [("مرحبا\n", true), ("Hello\n", false)] { - let mut s = s(); - typ(&mut s, keys); - run(&mut s, "insert.table", json!({"rows": 1, "cols": 3})); - let t = s.doc.body.iter().find_map(|b| b.as_table()).expect("table"); - assert_eq!(t.props.bidi_visual, rtl, "{keys:?}: the table takes the paragraph's direction"); - let (c0, x0) = caret_cell_x(&mut s); - typ(&mut s, "\t"); - let (c1, x1) = caret_cell_x(&mut s); - assert_eq!((c0, c1), (0, 1), "{keys:?}: Tab goes to the next cell in reading order"); - assert_eq!(x1 < x0, rtl, "{keys:?}: Tab moves {} ({x0} -> {x1})", if rtl { "left" } else { "right" }); - run(&mut s, "text.backTab", json!({})); - assert_eq!(caret_cell_x(&mut s), (0, x0), "{keys:?}: Shift+Tab comes back"); - } -} - -#[test] -fn ctrl_tab_inserts_a_tab_inside_a_table_cell() { - let mut s = s(); - run(&mut s, "insert.table", json!({"rows": 1, "cols": 2})); - typ(&mut s, "A"); - run(&mut s, "text.insertTab", json!({})); - typ(&mut s, "B"); - let (_, r, c) = s.sel.focus.path.cell().expect("caret in a cell"); - assert_eq!((r, c), (0, 0), "Ctrl+Tab stays in the cell"); - assert_eq!(s.doc.para_at(&s.sel.focus).map(|p| p.text.as_str()), Some("A\tB")); - let key = if cfg!(target_os = "macos") { "Alt+Tab" } else { "Mod+Tab" }; - assert_eq!(s.keymap.resolve(&s.registry, key).map(|c| c.id), Some("text.insertTab")); -} - -/// A .docx of two paragraphs in two sections, as Word writes it: the first section's properties -/// in the first paragraph's mark (portrait), the last section's in the body (landscape, starting -/// as `kind`). -fn two_section_docx(kind: &str) -> Vec { - const W: &str = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"; - let files = [ - ( - "[Content_Types].xml", - r#""#.to_string(), - ), - ( - "_rels/.rels", - r#""#.to_string(), - ), - ( - "word/document.xml", - format!( - r#"One.Two."# - ), - ), - ]; - let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); - for (name, data) in files { - zip.start_file(name, zip::write::SimpleFileOptions::default()).unwrap(); - std::io::Write::write_all(&mut zip, data.as_bytes()).unwrap(); - } - zip.finish().unwrap().into_inner() -} - -/// #2127: Backspace just after a section break, or Delete just before it, removes the break, as -/// in Word: the two paragraphs join, the text before the break takes the following section's -/// page setup, and Undo brings the break back. Imported breaks and ones made here alike. -#[test] -fn backspace_and_delete_remove_a_section_break() { - let made_here = || { - let mut s = typed("One.Two."); - s.sel = crate::Selection::caret(Pos::body(0, 4)); - run(&mut s, "layout.break", json!({"kind": "nextPage"})); - s - }; - for source in ["nextPage", "continuous", "made here"] { - for (key, caret) in [("text.backspace", Pos::body(1, 0)), ("text.delete", Pos::body(0, 4))] { - let mut s = match source { - "made here" => made_here(), - kind => Session::new(crate::io::open_bytes("s.docx", &two_section_docx(kind)).unwrap()), - }; - assert_eq!(s.doc.sections().len(), 2, "{source}"); - assert_eq!(s.doc.plain_text(StoryRef::Body), "One.\nTwo.", "{source}"); - let after = s.doc.sections().last().map(|(_, sp)| (**sp).clone()).unwrap(); - s.sel = crate::Selection::caret(caret); - run(&mut s, key, json!({})); - assert_eq!(s.doc.plain_text(StoryRef::Body), "One.Two.", "{source} {key}"); - let sections = s.doc.sections(); - assert_eq!(sections.len(), 1, "{source} {key}: the break is gone"); - assert_eq!(*sections[0].1, after, "{source} {key}: the following section's setup"); - run(&mut s, "edit.undo", json!({})); - assert_eq!(s.doc.plain_text(StoryRef::Body), "One.\nTwo.", "{source} {key}: undo"); - assert_eq!(s.doc.sections().len(), 2, "{source} {key}: undo brings the break back"); - } - } -} - -/// A .docx laid out like the one attached to #2127: a continuous section break, with the -/// document's only header, on the mark of an empty paragraph after "One.", and the last -/// section (nextPage, no header of its own, so linked to the previous one) in the body. -/// `then` adds a paragraph after the break; without it the break is on the last paragraph and -/// the last section has none. -fn break_on_the_last_paragraph_docx(then: Option<&str>) -> Vec { - const W: &str = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"; - const R: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; - let then = then.map(|t| format!("{t}")).unwrap_or_default(); - let files = [ - ( - "[Content_Types].xml", - r#""#.to_string(), - ), - ( - "_rels/.rels", - r#""#.to_string(), - ), - ( - "word/_rels/document.xml.rels", - r#""#.to_string(), - ), - ("word/header1.xml", format!(r#"Lab report"#)), - ( - "word/document.xml", - format!( - r#"One.{then}"# - ), - ), - ]; - let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); - for (name, data) in files { - zip.start_file(name, zip::write::SimpleFileOptions::default()).unwrap(); - std::io::Write::write_all(&mut zip, data.as_bytes()).unwrap(); - } - zip.finish().unwrap().into_inner() -} - -/// #2127: Delete at the end of the last paragraph removes a section break on its mark too, -/// though there's no paragraph after it to join. The text takes the last section's setup, and -/// the header the last section showed by linking to the removed one stays. Removing the break -/// by joining two paragraphs keeps that header as well. Undo brings the break back. -#[test] -fn delete_removes_a_section_break_on_the_last_paragraph() { - for then in [None, Some("Two.")] { - let mut s = Session::new(crate::io::open_bytes("s.docx", &break_on_the_last_paragraph_docx(then)).unwrap()); - let sections = s.doc.sections(); - assert_eq!(sections.len(), 2, "{then:?}"); - let header = sections[0].1.headers.default; - assert!(header.is_some() && sections[1].1.headers.default.is_none(), "{then:?}: the header is on the break's section"); - let mut after = sections[1].1.clone(); - after.headers.default = header; - s.sel = crate::Selection::caret(Pos::body(1, 0)); - run(&mut s, "text.delete", json!({})); - let text = if then.is_some() { "One.\nTwo." } else { "One.\n" }; - assert_eq!(s.doc.plain_text(StoryRef::Body), text, "{then:?}"); - let sections = s.doc.sections(); - assert_eq!(sections.len(), 1, "{then:?}: the break is gone"); - assert_eq!(*sections[0].1, after, "{then:?}: the last section's setup, with the header"); - run(&mut s, "edit.undo", json!({})); - let sections = s.doc.sections(); - assert_eq!(sections.len(), 2, "{then:?}: undo brings the break back"); - assert_eq!((sections[0].1.headers.default, sections[1].1.headers.default), (header, None), "{then:?}: undo"); - } -} - -fn first_letter_list(s: &Session) -> Vec { - s.prefs.autocorrect.exceptions.clone() -} - -/// Undoing an automatic sentence capital adds the word before it to the First Letter exceptions -/// (Word: "Automatically add words to list", #1857); typing the line again isn't capitalised. -#[test] -fn undoing_a_sentence_capital_adds_a_first_letter_exception() { - let mut s = typed("hello there. world "); - assert_eq!(s.doc.plain_text(StoryRef::Body), "Hello there. World "); - run(&mut s, "edit.undo", json!({})); - assert_eq!(s.doc.plain_text(StoryRef::Body), "Hello there. world "); - assert_eq!(first_letter_list(&s), ["there."]); - run(&mut s, "text.newParagraph", json!({})); - typ(&mut s, "x there. world "); - assert_eq!(s.doc.plain_text(StoryRef::Body), "Hello there. world \nX there. world "); -} - -#[test] -fn backspacing_a_sentence_capital_and_retyping_adds_a_first_letter_exception() { - let mut s = typed("hello there. world "); - typ(&mut s, "\u{8}\u{8}\u{8}\u{8}\u{8}\u{8}world "); - assert_eq!(s.doc.plain_text(StoryRef::Body), "Hello there. world "); - assert_eq!(first_letter_list(&s), ["there."]); -} - -#[test] -fn automatic_exceptions_can_be_turned_off() { - let mut s = typed(""); - run(&mut s, "tools.autocorrect", json!({"autoAddFirstLetter": false})); - typ(&mut s, "hello there. world "); - run(&mut s, "edit.undo", json!({})); - assert!(first_letter_list(&s).is_empty()); - let mut s = typed(""); - run(&mut s, "tools.autocorrect", json!({"autoAddFirstLetter": false})); - typ(&mut s, "hello there. world \u{8}\u{8}\u{8}\u{8}\u{8}\u{8}world "); - assert_eq!(s.doc.plain_text(StoryRef::Body), "Hello there. World "); - assert!(first_letter_list(&s).is_empty()); -} - -#[test] -fn undoing_a_replacement_adds_no_exception() { - let mut s = typed("so teh "); - assert_eq!(s.doc.plain_text(StoryRef::Body), "So the "); - run(&mut s, "edit.undo", json!({})); - assert_eq!(s.doc.plain_text(StoryRef::Body), "So teh "); - assert!(first_letter_list(&s).is_empty()); - assert!(s.prefs.autocorrect.entries.is_empty() && s.prefs.autocorrect.removed.is_empty()); - typ(&mut s, "and teh "); - assert_eq!(s.doc.plain_text(StoryRef::Body), "So teh and the "); -} - -/// Checked in Word for Mac: the same rule as First Letter. -#[test] -fn undoing_an_initial_caps_fix_adds_an_initial_caps_exception() { - let mut s = typed("so TWo "); - run(&mut s, "edit.undo", json!({})); - assert_eq!(s.prefs.autocorrect.initial_caps_exceptions, ["TWo"]); - typ(&mut s, "and TWo "); - assert_eq!(s.doc.plain_text(StoryRef::Body), "So TWo and TWo "); -} - -#[test] -fn undoing_other_edits_after_a_capital_adds_no_exception() { - // Typing more, then one Undo, and a second Undo that reverses the capital's step later. - let mut s = typed("hello there. world more text"); - run(&mut s, "edit.undo", json!({})); - assert!(first_letter_list(&s).is_empty()); - run(&mut s, "edit.undo", json!({})); - assert!(first_letter_list(&s).is_empty(), "only the Undo that reverses the capital itself counts"); -} - -#[test] -fn a_full_undo_history_does_not_match_later_undos() { - let mut s = typed(""); - for _ in 0..300 { - typ(&mut s, "x\n"); - } - typ(&mut s, "hello there. world more text\nand more"); - run(&mut s, "edit.undo", json!({})); - assert!(first_letter_list(&s).is_empty()); -} - -#[test] -fn a_new_document_forgets_the_capital() { - let mut s = typed("hello there. world "); - run(&mut s, "file.new", json!({})); - typ(&mut s, "abc\ndef\nghi\njkl"); - for _ in 0..8 { - let _ = s.run("edit.undo", &json!({})); - } - assert!(first_letter_list(&s).is_empty()); -} - -#[test] -fn an_overlong_word_is_not_added_as_an_exception() { - let mut s = typed(&format!("hi {}. world ", "a".repeat(5000))); - run(&mut s, "edit.undo", json!({})); - assert!(first_letter_list(&s).is_empty()); -} - -/// Each body paragraph's laid-out number (`None`: none) and its style. -fn numbers(s: &mut Session) -> Vec<(Option, String)> { - let l = s.layout(); - let labels: Vec> = l - .pages - .iter() - .flat_map(|p| &p.items) - .filter_map(|i| { - if let wordcraft_layout::Placed::Lines { para, l0: 0, .. } = i { Some(para.label.as_ref().map(|lb| lb.text.clone())) } else { None } - }) - .collect(); - let styles = s.doc.body.iter().filter_map(|b| b.as_para()).map(|p| p.props.style.clone().unwrap_or_else(|| "Normal".into())); - labels.into_iter().zip(styles).collect() -} - -fn num(label: Option<&str>, style: &str) -> (Option, String) { - (label.map(str::to_string), style.to_string()) -} - -/// #1793 (Word for Mac, 2026-10-15): after the "1 / 1.1 / 1.1.1" heading list, a new Heading 2 -/// typed after "Three" is numbered 2.1 on its own; the body text after it stays unnumbered. -#[test] -fn a_new_heading_after_a_heading_linked_list_is_numbered() { - let mut s = s(); - typ(&mut s, "One"); - run(&mut s, "para.heading1", json!({})); - typ(&mut s, "\nTwo"); - run(&mut s, "para.heading2", json!({})); - typ(&mut s, "\nThree"); - run(&mut s, "para.heading1", json!({})); - // The caret in "Three" (a Heading 1, the list's first level). - run(&mut s, "list.multilevelPreset", json!({"preset": "headings"})); - run(&mut s, "caret.docEnd", json!({})); - typ(&mut s, "\n"); - run(&mut s, "para.heading2", json!({})); - typ(&mut s, "Four\nBody"); - assert_eq!( - numbers(&mut s), - [num(Some("1"), "Heading1"), num(Some("1.1"), "Heading2"), num(Some("2"), "Heading1"), num(Some("2.1"), "Heading2"), num(None, "Normal")] - ); -} - -/// #1793: plain Numbering on a Heading 1 numbers it "1." and keeps Heading 1 (Word: "1. One"). -#[test] -fn numbering_a_heading_keeps_the_heading_style() { - let mut s = s(); - typ(&mut s, "One"); - run(&mut s, "para.heading1", json!({})); - run(&mut s, "para.numbering", json!({})); - assert_eq!(numbers(&mut s), [num(Some("1."), "Heading1")]); -} - -/// #1793: applying a heading style that carries list numbering numbers the paragraph, even one -/// whose numbering was switched off before. -#[test] -fn applying_a_numbered_heading_style_numbers_the_paragraph() { - let mut s = s(); - typ(&mut s, "One"); - run(&mut s, "para.heading1", json!({})); - run(&mut s, "list.multilevelPreset", json!({"preset": "headings"})); - typ(&mut s, "\nTwo"); - run(&mut s, "para.numbering", json!({})); - run(&mut s, "para.numbering", json!({"off": true})); - assert_eq!(numbers(&mut s)[1].0, None); - run(&mut s, "para.heading2", json!({})); - assert_eq!(numbers(&mut s), [num(Some("1"), "Heading1"), num(Some("1.1"), "Heading2")]); -} - -/// #1793: "1. " typed at the start of a heading numbers it and keeps the heading style; only -/// body text becomes List Paragraph. -#[test] -fn typing_1_dot_in_a_heading_keeps_the_heading_style() { - let mut s = s(); - run(&mut s, "para.heading1", json!({})); - typ(&mut s, "1. One\n"); - run(&mut s, "para.normal", json!({})); - typ(&mut s, "1. Body"); - assert_eq!(numbers(&mut s), [num(Some("1."), "Heading1"), num(Some("1."), "ListParagraph")]); - for (style, keys) in [("Heading2", "a) Part"), ("Subtitle", "1. Item")] { - let mut s = self::s(); - run(&mut s, "para.style", json!({"style": style})); - typ(&mut s, keys); - let got = numbers(&mut s); - assert_eq!(got.len(), 1); - assert!(got[0].0.is_some(), "{style}: a list"); - assert_eq!(got[0].1, style); - } -} - -// Backspace and Delete on selected tables, rows and columns (Word for Mac, #1844). - -/// "before", a 3×3 table with cells `a1`…`c3` (column letter, row number), "after"; the caret -/// in the cell at row `r`, column `c`. -fn table_between(r: u32, c: u32) -> Session { - let mut s = typed("before\n"); - run(&mut s, "insert.table", json!({"rows": 3, "cols": 3})); - typ(&mut s, "a1\tb1\tc1\ta2\tb2\tc2\ta3\tb3\tc3"); - run(&mut s, "caret.docEnd", json!({})); - typ(&mut s, "after"); - let shape: Vec<&str> = s.doc.body.iter().map(|b| if b.as_table().is_some() { "table" } else { "para" }).collect(); - assert_eq!(shape, ["para", "table", "para"], "setup"); - run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [1, r, c, 0], "off": 0}})); - s -} - -fn tables(s: &Session) -> usize { - s.doc.body.iter().filter(|b| b.as_table().is_some()).count() -} - -/// The first top-level table's cells, row by row (tracked deletions included). -fn table_cells(s: &Session) -> Vec> { - s.doc - .body - .iter() - .find_map(|b| b.as_table()) - .map(|t| { - t.rows - .iter() - .map(|r| r.cells.iter().map(|c| c.blocks.iter().filter_map(|b| b.as_para()).map(|p| p.plain_text()).collect()).collect()) - .collect() - }) - .unwrap_or_default() -} - -#[test] -fn backspace_with_the_whole_table_selected_removes_the_table() { - let mut s = table_between(1, 1); - run(&mut s, "table.selectTable", json!({})); - run(&mut s, "text.backspace", json!({})); - assert_eq!(tables(&s), 0, "the table stays: {:?}", table_cells(&s)); - assert_eq!(texts(&s), ["before", "after"]); - // The caret is in the paragraph that followed the table. - assert_eq!(s.sel.focus, Pos { story: StoryRef::Body, path: wordcraft_doc::Path::top(1), off: 0 }); - assert!(s.sel.is_collapsed()); - // One undo brings it back. - run(&mut s, "edit.undo", json!({})); - assert_eq!(tables(&s), 1); - assert_eq!(table_cells(&s), [["a1", "b1", "c1"], ["a2", "b2", "c2"], ["a3", "b3", "c3"]]); -} - -#[test] -fn delete_with_the_whole_table_selected_clears_the_cells_and_keeps_the_table() { - let mut s = table_between(0, 0); - run(&mut s, "table.selectTable", json!({})); - run(&mut s, "text.delete", json!({})); - assert_eq!(tables(&s), 1); - assert_eq!(table_cells(&s), [["", "", ""], ["", "", ""], ["", "", ""]]); - assert_eq!(texts(&s), ["before", "after"]); -} - -#[test] -fn delete_or_backspace_with_the_table_and_the_paragraph_after_selected_removes_the_table() { - for key in ["text.delete", "text.backspace"] { - // From the first cell to the end of the document: Word's rule widens it to whole rows. - let mut s = table_between(0, 0); - run(&mut s, "caret.docEnd", json!({"extend": true})); - run(&mut s, key, json!({})); - assert_eq!(tables(&s), 0, "{key}: {:?}", table_cells(&s)); - assert_eq!(texts(&s), ["before", ""], "{key}"); - } -} - -#[test] -fn delete_or_backspace_with_the_paragraph_before_and_the_table_selected_removes_the_table() { - for key in ["text.delete", "text.backspace"] { - // From the start of "before" down into the last row: the rows are taken whole. - let mut s = table_between(0, 0); - run(&mut s, "caret.docStart", json!({})); - for _ in 0..3 { - run(&mut s, "caret.down", json!({"extend": true})); - } - assert_eq!(s.sel.focus.path.cell().map(|(_, r, _)| r), Some(2), "{key}: the selection reaches the last row"); - run(&mut s, key, json!({})); - assert_eq!(tables(&s), 0, "{key}: {:?}", table_cells(&s)); - assert_eq!(texts(&s), ["after"], "{key}"); - assert_eq!(s.sel.focus, Pos { story: StoryRef::Body, path: wordcraft_doc::Path::top(0), off: 0 }, "{key}"); - } -} - -#[test] -fn backspace_deletes_selected_rows_and_delete_clears_them() { - let mut s = table_between(1, 0); - run(&mut s, "table.selectRow", json!({})); - run(&mut s, "text.backspace", json!({})); - assert_eq!(table_cells(&s), [["a1", "b1", "c1"], ["a3", "b3", "c3"]]); - - let mut s = table_between(1, 0); - run(&mut s, "table.selectRow", json!({})); - run(&mut s, "text.delete", json!({})); - assert_eq!(table_cells(&s), [["a1", "b1", "c1"], ["", "", ""], ["a3", "b3", "c3"]]); -} - -#[test] -fn backspace_deletes_selected_columns_and_delete_clears_them() { - let mut s = table_between(0, 1); - run(&mut s, "table.selectColumn", json!({})); - run(&mut s, "text.backspace", json!({})); - assert_eq!(table_cells(&s), [["a1", "c1"], ["a2", "c2"], ["a3", "c3"]]); - - let mut s = table_between(0, 1); - run(&mut s, "table.selectColumn", json!({})); - run(&mut s, "text.delete", json!({})); - assert_eq!(table_cells(&s), [["a1", "", "c1"], ["a2", "", "c2"], ["a3", "", "c3"]]); -} - -/// The first top-level table's cells, row by row, one entry per block: a paragraph's text, or -/// `` for a nested table. -fn cell_blocks(s: &Session) -> Vec>> { - let block = |b: &std::sync::Arc| b.as_para().map_or_else(|| "
".to_string(), |p| p.plain_text()); - s.doc - .body - .iter() - .find_map(|b| b.as_table()) - .map(|t| t.rows.iter().map(|r| r.cells.iter().map(|c| c.blocks.iter().map(block).collect()).collect()).collect()) - .unwrap_or_default() -} - -#[test] -fn delete_on_whole_cells_leaves_one_empty_paragraph_in_each() { - // a1 starts with a nested table and holds three paragraphs (centred, centred, right-aligned), - // b2 holds a nested table after its text (#2802). - let setup = || { - let mut s = table_between(0, 0); - run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [1, 0, 0, 0], "off": 2}})); - run(&mut s, "para.alignCenter", json!({})); - typ(&mut s, "\nX\nY"); - run(&mut s, "para.alignRight", json!({})); - run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [1, 1, 1, 0], "off": 2}})); - run(&mut s, "text.newParagraph", json!({})); - run(&mut s, "insert.table", json!({"rows": 1, "cols": 2})); - typ(&mut s, "n"); - let b2 = |s: &Session| s.doc.body.get(1).and_then(|b| b.as_table()).map(|t| t.rows[1].cells[1].blocks.clone()).unwrap_or_default(); - let nested = b2(&s).iter().find(|b| b.as_table().is_some()).map(|b| (**b).clone()).expect("setup: a table nested in b2"); - s.doc.insert_block(StoryRef::Body, &wordcraft_doc::Path(vec![1, 0, 0, 0]), nested).unwrap(); - s - }; - let all: Vec<(usize, usize)> = (0..3).flat_map(|r| (0..3).map(move |c| (r, c))).collect(); - let cases: [(&str, (usize, usize), &str, Vec<(usize, usize)>); 5] = [ - ("table.selectRow", (0, 0), "text.delete", vec![(0, 0), (0, 1), (0, 2)]), - ("table.selectColumn", (0, 0), "text.delete", vec![(0, 0), (1, 0), (2, 0)]), - ("table.selectRow", (1, 1), "text.delete", vec![(1, 0), (1, 1), (1, 2)]), - ("table.selectTable", (0, 0), "text.delete", all), - // One cell of several: Backspace clears it too. - ("table.selectCell", (0, 0), "text.backspace", vec![(0, 0)]), - ]; - for (select, (r, c), key, cleared) in cases { - let mut s = setup(); - let before = cell_blocks(&s); - assert_eq!(before[0][0], ["
", "a1", "X", "Y"], "setup"); - assert!(before[1][1].len() > 2 && before[1][1].contains(&"
".to_string()), "setup: {:?}", before[1][1]); - // a1's first paragraph is after the nested table. - let first = usize::from((r, c) == (0, 0)); - run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [1, r, c, first], "off": 0}})); - run(&mut s, select, json!({})); - run(&mut s, key, json!({})); - let mut want = before.clone(); - for &(r, c) in &cleared { - want[r][c] = vec![String::new()]; - } - assert_eq!(cell_blocks(&s), want, "{select} {key}"); - // The paragraph left keeps the cell's last paragraph's formatting (its mark ends the cell). - if cleared.contains(&(0, 0)) { - let p = s.doc.para(StoryRef::Body, &wordcraft_doc::Path(vec![1, 0, 0, 0])).map(|p| p.props.align); - assert_eq!(p, Some(Some(wordcraft_doc::props::Align::Right)), "{select} {key}"); - } - assert!(s.sel.is_collapsed(), "{select} {key}"); - assert_eq!(s.sel.focus.path.cell().map(|(_, r, c)| (r, c)), cleared.first().copied(), "{select} {key}"); - // One undo brings everything back. - run(&mut s, "edit.undo", json!({})); - assert_eq!(cell_blocks(&s), before, "{select} {key}: undo"); - } -} - -#[test] -fn backspace_on_a_selected_nested_table_removes_only_it() { - let mut s = table_between(1, 1); - run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [1, 1, 1, 0], "off": 2}})); - run(&mut s, "text.newParagraph", json!({})); - run(&mut s, "insert.table", json!({"rows": 2, "cols": 2})); - typ(&mut s, "x"); - let cell_blocks = |s: &Session| { - s.doc - .body - .iter() - .find_map(|b| b.as_table()) - .and_then(|t| t.rows.get(1)?.cells.get(1)) - .map(|c| c.blocks.iter().filter(|b| b.as_table().is_some()).count()) - }; - assert_eq!(cell_blocks(&s), Some(1), "setup: a table nested in b2"); - run(&mut s, "table.selectTable", json!({})); - run(&mut s, "text.backspace", json!({})); - assert_eq!(cell_blocks(&s), Some(0), "the nested table stays"); - assert_eq!(tables(&s), 1, "the outer table goes too"); - // The caret is in the cell's paragraph after where the nested table was. - assert_eq!(s.sel.focus.path.cell().map(|(t, r, c)| (t.0, r, c)), Some((vec![1], 1, 1))); - assert!(s.doc.para_at(&s.sel.focus).is_some()); -} - -#[test] -fn backspace_on_a_selected_table_that_is_the_only_block_leaves_an_empty_paragraph() { - let mut s = s(); - run(&mut s, "insert.table", json!({"rows": 2, "cols": 2})); - typ(&mut s, "x"); - s.doc.body.retain(|b| b.as_table().is_some()); - assert_eq!(s.doc.body.len(), 1, "setup: the table is the only block"); - run(&mut s, "table.selectTable", json!({})); - run(&mut s, "text.backspace", json!({})); - assert_eq!(tables(&s), 0); - assert_eq!(texts(&s), [""]); - assert!(s.doc.para_at(&s.sel.focus).is_some(), "the caret is in a paragraph: {:?}", s.sel.focus); -} - -#[test] -fn tracked_backspace_on_a_selected_table_marks_its_text_deleted_like_delete_table() { - let mut s = table_between(0, 0); - run(&mut s, "review.trackChanges", json!({"value": true})); - run(&mut s, "table.selectTable", json!({})); - run(&mut s, "text.backspace", json!({})); - // Like Table Layout › Delete Table with tracking on: the structure stays, the text is marked deleted. - assert_eq!(tables(&s), 1); - assert_eq!(table_cells(&s), [["a1", "b1", "c1"], ["a2", "b2", "c2"], ["a3", "b3", "c3"]]); - assert_eq!(s.doc.final_text(StoryRef::Body).replace(['\t', '\n'], ""), "Beforeafter"); - run(&mut s, "review.rejectAll", json!({})); - assert_eq!(s.doc.final_text(StoryRef::Body).replace(['\t', '\n'], ""), "Beforea1b1c1a2b2c2a3b3c3after"); - assert!(s.doc.revisions.is_empty()); -} - -/// One selected cell isn't a whole row or column, even in a one-row or one-column table: -/// Backspace clears it (Word's cell selection has no end-of-row mark). -#[test] -fn backspace_on_one_selected_cell_of_a_one_row_or_one_column_table_clears_it() { - for (rows, cols, at) in [(1, 3, [0, 1]), (3, 1, [1, 0])] { - let mut s = typed("before\n"); - run(&mut s, "insert.table", json!({"rows": rows, "cols": cols})); - typ(&mut s, "x\ty\tz"); - run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [1, at[0], at[1], 0], "off": 0}})); - run(&mut s, "table.selectCell", json!({})); - run(&mut s, "text.backspace", json!({})); - let want: Vec> = - if rows == 1 { vec![vec!["x".into(), "".into(), "z".into()]] } else { vec![vec!["x".into()], vec!["".into()], vec!["z".into()]] }; - assert_eq!(table_cells(&s), want, "{rows}×{cols}"); - } -} - -/// #1670: smart quotes in Hebrew follow the characters around the quote in logical order, as -/// Word does (Word for Mac 16.113.4): an opening “ after a space or at the start, a closing ” -/// after a letter, and ” inside an acronym (צה"ל), not gershayim; ' after a letter is ’. -#[test] -fn smart_quotes_in_hebrew_use_logical_order() { - let bidi = |s: &Session| s.doc.body.iter().filter_map(|b| b.as_para()).next().and_then(|p| p.props.bidi); - let s = typed("הוא אמר \"שלום\" לי"); - assert_eq!(bidi(&s), Some(true), "a right-to-left paragraph"); - assert_eq!(s.doc.plain_text(StoryRef::Body), "הוא אמר “שלום” לי"); - let s = typed("\"שלום\""); - assert_eq!(s.doc.plain_text(StoryRef::Body), "“שלום”"); - let s = typed("צה\"ל "); - assert_eq!(s.doc.plain_text(StoryRef::Body), "צה”ל "); - let s = typed("ג'ירפה 'כן'"); - assert_eq!(s.doc.plain_text(StoryRef::Body), "ג’ירפה ‘כן’"); -} - -/// #1923: Turkish and Azerbaijani case mapping (i↔İ, ı↔I) in AutoCorrect, checked against Word -/// with Turkish proofing; other languages keep the Unicode default. -#[test] -fn autocorrect_capitalisation_follows_turkic_case_mapping() { - for tag in ["tr-TR", "az-AZ"] { - let mut s = s(); - run(&mut s, "review.language", json!({"lang": tag, "default": true})); - typ(&mut s, "istanbul güzel. "); - assert_eq!(s.doc.plain_text(StoryRef::Body), "İstanbul güzel. ", "{tag}: i capitalises to İ"); - let mut s = self::s(); - run(&mut s, "review.language", json!({"lang": tag, "default": true})); - typ(&mut s, "İNgilizce "); - assert_eq!(s.doc.plain_text(StoryRef::Body), "İngilizce ", "{tag}: İ kept, N lowercased, no combining dot"); - let mut s = self::s(); - run(&mut s, "review.language", json!({"lang": tag, "default": true})); - typ(&mut s, "ılık ırmak. "); - assert_eq!(s.doc.plain_text(StoryRef::Body), "Ilık ırmak. ", "{tag}: ı capitalises to I"); - let mut s = self::s(); - run(&mut s, "review.language", json!({"lang": tag, "default": true})); - typ(&mut s, "iSTANBUL "); - assert_eq!(s.doc.plain_text(StoryRef::Body), "İstanbul ", "{tag}: cAPS LOCK fix"); - let mut s = self::s(); - run(&mut s, "review.language", json!({"lang": tag, "default": true})); - typ(&mut s, "kızım. TIklamak "); - assert_eq!(s.doc.plain_text(StoryRef::Body), "Kızım. Tıklamak ", "{tag}: TWo INitial CApitals lowercases I to ı"); - } - // Other languages are unchanged; a stray İ still lowercases to a single "i". - let mut s = s(); - run(&mut s, "review.language", json!({"lang": "en-US", "default": true})); - typ(&mut s, "istanbul güzel. "); - assert_eq!(s.doc.plain_text(StoryRef::Body), "Istanbul güzel. "); - let mut s = self::s(); - run(&mut s, "review.language", json!({"lang": "en-US", "default": true})); - typ(&mut s, "İNgilizce "); - assert_eq!(s.doc.plain_text(StoryRef::Body), "İngilizce ", "no combining dot in any language"); -} - -/// #3479: Format > Change Case follows the same Turkic case mapping as AutoCorrect, using -/// each run's own language; other languages keep the Unicode default. -#[test] -fn change_case_follows_turkic_case_mapping() { - for tag in ["tr-TR", "az-AZ"] { - for (mode, input, want) in [ - ("upper", "istanbul", "İSTANBUL"), - ("lower", "IŞIK", "ışık"), - ("title", "istanbul güzel", "İstanbul Güzel"), - ("sentence", "istanbul. izmir", "İstanbul. İzmir"), - ("toggle", "iI", "İı"), - ] { - let mut s = s(); - run(&mut s, "review.language", json!({"lang": tag, "default": true})); - run(&mut s, "text.insert", json!({"text": input, "raw": true})); - run(&mut s, "select.all", json!({})); - run(&mut s, "format.changeCase", json!({"mode": mode})); - assert_eq!(s.doc.plain_text(StoryRef::Body), want, "{tag} {mode}"); - } - } - // Other languages keep the Unicode default. - let mut s = self::s(); - run(&mut s, "text.insert", json!({"text": "istanbul", "raw": true})); - run(&mut s, "select.all", json!({})); - run(&mut s, "format.changeCase", json!({"mode": "title"})); - assert_eq!(s.doc.plain_text(StoryRef::Body), "Istanbul", "en-US"); - // Mixed runs: each run's own language decides. - let mut s = self::s(); - run(&mut s, "text.insert", json!({"text": "IŞIK IŞIK", "raw": true})); - run(&mut s, "caret.set", json!({"pos": Pos::body(0, 0)})); - run(&mut s, "caret.set", json!({"pos": Pos::body(0, 5), "extend": true})); - run(&mut s, "review.language", json!({"lang": "tr-TR"})); - run(&mut s, "caret.set", json!({"pos": Pos::body(0, 6)})); - run(&mut s, "caret.set", json!({"pos": Pos::body(0, 11), "extend": true})); - run(&mut s, "review.language", json!({"lang": "en-US"})); - run(&mut s, "select.all", json!({})); - run(&mut s, "format.changeCase", json!({"mode": "lower"})); - assert_eq!(s.doc.plain_text(StoryRef::Body), "ışık işik", "mixed runs"); -} - -// Spelling as you type (#1552): the word being typed gets no red squiggle until the caret leaves -// it; F7 and the Spelling pane still report it. See docs/typing-parity.md. Paragraphs start with -// a correct word so AutoCorrect's sentence capital doesn't change the misspelled one. - -/// The words body paragraph `i` has a spelling squiggle under, in order. -fn squiggles(s: &mut Session, i: usize) -> Vec { - let path = wordcraft_doc::Path::top(i); - let text = s.doc.para(StoryRef::Body, &path).map(|p| p.text.clone()).unwrap_or_default(); - let l = s.layout(); - l.pages - .iter() - .flat_map(|pg| pg.items.iter()) - .filter_map(|it| match it { - wordcraft_layout::Placed::Lines { para, path: p, l0: 0, .. } if *p == path => { - Some(para.issues.iter().filter(|i| !i.2).filter_map(|i| text.get(i.0..i.1).map(str::to_string)).collect::>()) - } - _ => None, - }) - .flatten() - .collect() -} - -/// The misspelled words `review.issues` (the Spelling pane) reports, in order. -fn reported(s: &mut Session) -> Vec { - let issues = s.run("review.issues", &json!({})).unwrap(); - issues.as_array().unwrap().iter().filter(|i| i["kind"] == "spelling").map(|i| i["text"].as_str().unwrap().to_string()).collect() -} - -#[test] -fn the_word_being_typed_is_not_marked_until_it_ends() { - let mut s = typed("The helo"); - assert_eq!(squiggles(&mut s, 0), Vec::::new(), "no squiggle while the word is typed"); - // F7 and the Spelling pane still see it. - assert_eq!(reported(&mut s), ["helo"]); - let next = s.run("review.spelling", &json!({})).unwrap(); - assert_eq!(next["text"], "helo"); - // A space ends the word. - let mut s = typed("The helo "); - assert_eq!(squiggles(&mut s, 0), ["helo"]); - typ(&mut s, "wrld"); - assert_eq!(squiggles(&mut s, 0), ["helo"], "the next word is being typed"); - assert_eq!(reported(&mut s), ["helo", "wrld"]); - // So does punctuation. - typ(&mut s, "."); - assert_eq!(squiggles(&mut s, 0), ["helo", "wrld"]); - // Enter and Tab too. - let mut s = typed("The helo\n"); - assert_eq!(squiggles(&mut s, 0), ["helo"]); - let mut s = typed("The helo\t"); - assert_eq!(squiggles(&mut s, 0), ["helo"]); -} - -#[test] -fn moving_the_caret_out_of_the_typed_word_marks_it() { - for key in ["caret.left", "caret.home", "caret.wordLeft", "caret.docStart"] { - let mut s = typed("The helo"); - run(&mut s, key, json!({})); - assert_eq!(squiggles(&mut s, 0), ["helo"], "{key}"); - // Coming back to the end of the word doesn't hide the squiggle again. - run(&mut s, "caret.end", json!({})); - assert_eq!(s.sel.focus, Pos::body(0, 8)); - assert_eq!(squiggles(&mut s, 0), ["helo"], "{key} then End"); - } - // A selection ends it too. - let mut s = typed("The helo"); - run(&mut s, "select.all", json!({})); - assert_eq!(squiggles(&mut s, 0), ["helo"]); -} - -#[test] -fn a_click_elsewhere_marks_the_typed_word_and_a_click_back_keeps_the_mark() { - let mut s = typed("Fine\nThe helo"); - assert_eq!(squiggles(&mut s, 1), Vec::::new()); - // The canvas sets the caret directly on a click. - s.sel = crate::Selection::caret(Pos::body(0, 2)); - assert_eq!(squiggles(&mut s, 1), ["helo"]); - s.sel = crate::Selection::caret(Pos::body(1, 8)); - assert_eq!(squiggles(&mut s, 1), ["helo"], "an existing misspelled word keeps its squiggle"); - // Typing into it makes it the word being typed again. - typ(&mut s, "o"); - assert_eq!(s.doc.plain_text(StoryRef::Body), "Fine\nThe heloo"); - assert_eq!(squiggles(&mut s, 1), Vec::::new()); - typ(&mut s, " "); - assert_eq!(squiggles(&mut s, 1), ["heloo"]); -} - -#[test] -fn backspace_into_a_finished_word_edits_it_again() { - let mut s = typed("The helo "); - assert_eq!(squiggles(&mut s, 0), ["helo"]); - typ(&mut s, "\u{8}"); - assert_eq!(squiggles(&mut s, 0), Vec::::new()); - typ(&mut s, "\u{8}\u{8}llo "); - assert_eq!(s.doc.plain_text(StoryRef::Body), "The hello "); - assert_eq!(squiggles(&mut s, 0), Vec::::new()); -} - -#[test] -fn autocorrect_and_inserted_text_are_marked_as_usual() { - // AutoCorrect fixes the word as the space ends it: nothing to mark. - let mut s = typed("I typed teh "); - assert_eq!(s.doc.plain_text(StoryRef::Body), "I typed the "); - assert_eq!(squiggles(&mut s, 0), Vec::::new()); - // Text inserted whole (a paste, an agent): only the word the caret ends in is being typed. - let mut s = typed(""); - run(&mut s, "text.insert", json!({"text": "The helo wrld"})); - assert_eq!(squiggles(&mut s, 0), ["helo"]); - // Undo moves the caret: whatever is left is marked, as the Spelling pane reports it. - let mut s = typed("The helo"); - run(&mut s, "caret.left", json!({})); - typ(&mut s, "x"); - assert_eq!(s.doc.plain_text(StoryRef::Body), "The helxo"); - assert_eq!(squiggles(&mut s, 0), Vec::::new()); - run(&mut s, "edit.undo", json!({})); - assert_eq!(s.doc.plain_text(StoryRef::Body), "The helo"); - assert_eq!(squiggles(&mut s, 0), ["helo"]); - assert_eq!(reported(&mut s), ["helo"]); -} - -#[test] -fn apostrophes_and_hyphens_stay_part_of_the_word_being_typed() { - // Smart quotes turn the apostrophe into ’, which the spelling check keeps in the word. - let mut s = typed("It doesn'"); - assert_eq!(s.doc.plain_text(StoryRef::Body), "It doesn’"); - assert_eq!(reported(&mut s), ["doesn"], "the stem alone is a misspelling"); - assert_eq!(squiggles(&mut s, 0), Vec::::new()); - let mut s = typed("It doesn't "); - assert_eq!(squiggles(&mut s, 0), Vec::::new()); - // A hyphen joins the parts of a compound being typed. - let mut s = typed("The nonn-"); - assert_eq!(reported(&mut s), ["nonn"]); - assert_eq!(squiggles(&mut s, 0), Vec::::new()); - typ(&mut s, "exist"); - assert_eq!(squiggles(&mut s, 0), Vec::::new()); - typ(&mut s, " "); - assert!(!squiggles(&mut s, 0).is_empty()); - assert_eq!(squiggles(&mut s, 0), reported(&mut s)); - // A hyphen after a space starts no word. - let mut s = typed("The helo -"); - assert_eq!(squiggles(&mut s, 0), ["helo"]); -} - -#[test] -fn text_commands_that_edit_nothing_leave_an_existing_word_marked() { - let mut s = typed("Fine\nThe helo"); - s.sel = crate::Selection::caret(Pos::body(0, 2)); - assert_eq!(squiggles(&mut s, 1), ["helo"]); - s.sel = crate::Selection::caret(Pos::body(1, 8)); - // Insert switches overtype; Delete at the end of the document and Shift+Tab change no text. - run(&mut s, "text.overtype", json!({})); - assert_eq!(squiggles(&mut s, 1), ["helo"], "Insert"); - run(&mut s, "text.overtype", json!({})); - let _ = s.run("text.delete", &json!({})); - assert_eq!(squiggles(&mut s, 1), ["helo"], "Delete with nothing after the caret"); - let _ = s.run("text.backTab", &json!({})); - assert_eq!(squiggles(&mut s, 1), ["helo"], "Shift+Tab"); - assert_eq!(s.doc.plain_text(StoryRef::Body), "Fine\nThe helo"); -} - -#[test] -fn typing_over_backward_selection_keeps_its_font() { - // As in Word, text typed over a selection takes the first selected character's formatting, - // whichever way the selection was made (#2879). - for (anchor, focus) in [(6, 11), (11, 6)] { - let mut s = s(); - typ(&mut s, "Hello world"); - run(&mut s, "select.range", json!({"anchor": {"block": 0, "off": 6}, "focus": {"block": 0, "off": 11}})); - run(&mut s, "format.font", json!({"name": "Courier New"})); - run(&mut s, "select.range", json!({"anchor": {"block": 0, "off": anchor}, "focus": {"block": 0, "off": focus}})); - typ(&mut s, "X"); - let p = s.doc.body.first().and_then(|b| b.as_para()).unwrap(); - assert_eq!(p.text, "Hello X", "anchor={anchor} focus={focus}"); - assert_eq!(p.props_of_char(6).font.as_deref(), Some("Courier New"), "anchor={anchor} focus={focus}"); - assert_eq!(p.props_of_char(0).font, None, "anchor={anchor} focus={focus}"); - } -} - -#[test] -fn right_arrow_moves_through_a_ligature_one_letter_at_a_time() { - // A font that draws "ffi" as one glyph (bundled, so the test doesn't depend on the installed - // fonts); each Right press moves the drawn caret (#1974). - let mut s = s(); - run(&mut s, "format.font", json!({"name": "Source Serif 4"})); - typ(&mut s, "ffi"); - let drawn_as_one_glyph = s - .layout() - .pages - .iter() - .flat_map(|p| p.items.iter()) - .any(|it| matches!(it, wordcraft_layout::Placed::Lines { para, .. } if para.glyphs.len() < para.clusters.len())); - assert!(drawn_as_one_glyph, "Source Serif 4 should draw ffi as a ligature"); - run(&mut s, "caret.home", json!({})); - let mut xs = Vec::new(); - for _ in 0..4 { - let l = s.layout(); - xs.push((s.sel.focus.off, l.caret(&s.sel.focus).unwrap().x)); - run(&mut s, "caret.right", json!({})); - } - let offs: Vec = xs.iter().map(|p| p.0).collect(); - assert_eq!(offs, [0, 1, 2, 3]); - assert!(xs.windows(2).all(|w| w[0].1 < w[1].1), "caret x after each Right press: {xs:?}"); -} - -/// Two original paragraphs whose list comes from a style, optionally through a basedOn chain. -fn style_inherited_list(kind: wordcraft_doc::numbering::ListKind, chain: bool) -> Session { - use wordcraft_doc::props::NumRef; - use wordcraft_doc::styles::Style; - let mut doc = wordcraft_doc::Document::from_text("Alpha\nBeta"); - let num = Arc::make_mut(&mut doc.numbering).try_add_list(kind).unwrap(); - Arc::make_mut(&mut doc.styles).styles.push(Style { - id: "ListBase".into(), - name: "Original List".into(), - para: wordcraft_doc::ParaProps { numbering: Some(NumRef { num, level: 0 }), ..Default::default() }, - ..Default::default() - }); - if chain { - Arc::make_mut(&mut doc.styles).styles.push(Style { - id: "ListChild".into(), - name: "Original Derived".into(), - based_on: Some("ListBase".into()), - ..Default::default() - }); - } - for i in 0..2 { - doc.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(i)).unwrap().props.style = Some(if chain { "ListChild" } else { "ListBase" }.into()); - } - Session::new(doc) -} - -#[test] -fn toggle_style_inherited_list_off_preserves_other_paragraphs_and_round_trips() { - use wordcraft_doc::numbering::ListKind; - for (kind, command) in [(ListKind::Bullet, "para.bullets"), (ListKind::Numbered, "para.numbering")] { - for chain in [false, true] { - let mut s = style_inherited_list(kind, chain); - let before = s.doc.clone(); - s.sel = crate::Selection::caret(Pos::body(0, 2)); - run(&mut s, command, json!({})); - let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); - assert_eq!(p.props.numbering.unwrap().num, 0, "{command}, chain={chain}"); - assert_eq!(p.props.style, before.para_at(&Pos::body(0, 0)).unwrap().props.style); - assert_eq!(s.doc.para_at(&Pos::body(1, 0)), before.para_at(&Pos::body(1, 0))); - assert_eq!(s.doc.styles, before.styles); - assert_eq!(s.doc.numbering, before.numbering); - let after = s.doc.clone(); - run(&mut s, "edit.undo", json!({})); - assert_eq!(s.doc, before); - run(&mut s, "edit.redo", json!({})); - assert_eq!(s.doc, after); - let bytes = crate::io::save_bytes("list.docx", &s.doc).unwrap(); - let saved = crate::io::open_bytes("list.docx", &bytes).unwrap(); - let a = saved.para_at(&Pos::body(0, 0)).unwrap(); - let b = saved.para_at(&Pos::body(1, 0)).unwrap(); - assert_eq!(a.props.numbering.unwrap().num, 0); - assert!(saved.styles.resolve_para(&a.props).numbering.is_none()); - assert!(saved.styles.resolve_para(&b.props).numbering.is_some_and(|n| n.num != 0)); - assert_eq!(a.text, "Alpha"); - assert_eq!(b.text, "Beta"); - } - } -} - -#[test] -fn explicit_list_off_override_is_not_toggled_off_again() { - use wordcraft_doc::numbering::ListKind; - use wordcraft_doc::props::NumRef; - for (kind, command) in [(ListKind::Bullet, "para.bullets"), (ListKind::Numbered, "para.numbering")] { - let mut s = style_inherited_list(kind, true); - s.doc.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().props.numbering = Some(NumRef { num: 0, level: 0 }); - s.sel = crate::Selection::caret(Pos::body(0, 2)); - run(&mut s, command, json!({})); - assert!(s.doc.para_at(&Pos::body(0, 0)).unwrap().props.numbering.is_some_and(|n| n.num != 0)); - // A mixed selection must turn the list on, rather than remove the inherited item. - s.doc.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().props.numbering = Some(NumRef { num: 0, level: 0 }); - s.sel = crate::Selection { anchor: Pos::body(0, 0), focus: Pos::body(1, 4) }; - run(&mut s, command, json!({})); - for i in 0..2 { - let p = s.doc.para_at(&Pos::body(i, 0)).unwrap(); - assert!(s.doc.styles.resolve_para(&p.props).numbering.is_some_and(|n| n.num != 0)); - } - } -} - -#[test] -fn list_toggle_uses_effective_level_and_override() { - use wordcraft_doc::numbering::{ListKind, levels_for}; - use wordcraft_doc::props::NumRef; - for (kind, command, wrong) in [(ListKind::Bullet, "para.bullets", "para.numbering"), (ListKind::Numbered, "para.numbering", "para.bullets")] { - for override_level in [false, true] { - for explicit_kind in [false, true] { - let mut s = style_inherited_list(kind, true); - let opposite = if kind == ListKind::Bullet { ListKind::Numbered } else { ListKind::Bullet }; - let num = Arc::make_mut(&mut s.doc.styles).styles.iter_mut().find(|x| x.id == "ListBase").unwrap().para.numbering.as_mut().unwrap(); - num.level = 1; - let num_id = num.num; - Arc::make_mut(&mut s.doc.numbering).abstracts[0].levels[0] = levels_for(opposite)[0].clone(); - if override_level { - Arc::make_mut(&mut s.doc.numbering).abstracts[0].levels[1] = levels_for(opposite)[1].clone(); - Arc::make_mut(&mut s.doc.numbering).nums[0].level_overrides.push((1, levels_for(kind)[1].clone())); - } - let before = s.doc.clone(); - let params = if explicit_kind { json!({"kind": if kind == ListKind::Bullet { "bullet" } else { "numbered" }}) } else { json!({}) }; - run(&mut s, command, params); - assert_eq!( - s.doc.para_at(&Pos::body(0, 0)).unwrap().props.numbering, - Some(NumRef { num: 0, level: 0 }), - "{command}, override={override_level}, explicit={explicit_kind}" - ); - assert_eq!(s.doc.styles, before.styles); - assert_eq!(s.doc.numbering, before.numbering); - assert_eq!(s.doc.para_at(&Pos::body(1, 0)), before.para_at(&Pos::body(1, 0))); - let mut other = Session::new(before); - run(&mut other, wrong, json!({})); - assert!(other.doc.para_at(&Pos::body(0, 0)).unwrap().props.numbering.is_some_and(|n| n.num != 0)); - assert!(other.doc.numbering.level(num_id, 1).is_some()); - } - } - } -} - -#[test] -fn list_paragraph_style_survives_inherited_toggle_off() { - use wordcraft_doc::numbering::ListKind; - use wordcraft_doc::props::{Align, ParaProps}; - use wordcraft_doc::styles::Style; - for kind in [ListKind::Bullet, ListKind::Numbered] { - let mut s = style_inherited_list(kind, false); - *Arc::make_mut(&mut s.doc.styles).styles.iter_mut().find(|x| x.id == "ListParagraph").unwrap() = Style { - id: "ListParagraph".into(), - name: "Original formatted list".into(), - based_on: Some("ListBase".into()), - para: ParaProps { align: Some(Align::Right), space_after: Some(17.0), ..Default::default() }, - ..Default::default() - }; - s.doc.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().props.style = Some("ListParagraph".into()); - let before = s.doc.clone(); - run(&mut s, if kind == ListKind::Bullet { "para.bullets" } else { "para.numbering" }, json!({})); - let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); - assert_eq!(p.props.style.as_deref(), Some("ListParagraph")); - let effective = s.doc.styles.resolve_para(&p.props); - assert!(effective.numbering.is_none()); - assert_eq!(effective.align, Align::Right); - assert_eq!(effective.space_after, 17.0); - assert_eq!(s.doc.styles, before.styles); - assert_eq!(s.doc.numbering, before.numbering); - assert_eq!(s.doc.para_at(&Pos::body(1, 0)), before.para_at(&Pos::body(1, 0))); - let after = s.doc.clone(); - run(&mut s, "edit.undo", json!({})); - assert_eq!(s.doc, before); - run(&mut s, "edit.redo", json!({})); - assert_eq!(s.doc, after); - let bytes = crate::io::save_bytes("list.docx", &s.doc).unwrap(); - let saved = crate::io::open_bytes("list.docx", &bytes).unwrap(); - let p = saved.para_at(&Pos::body(0, 0)).unwrap(); - assert_eq!(p.props.style.as_deref(), Some("ListParagraph")); - let effective = saved.styles.resolve_para(&p.props); - assert!(effective.numbering.is_none()); - assert_eq!(effective.align, Align::Right); - assert_eq!(effective.space_after, 17.0); - } -} - -#[test] -fn direct_list_toggle_still_clears_the_automatic_list_paragraph_style() { - for command in ["para.bullets", "para.numbering"] { - let mut s = Session::new(wordcraft_doc::Document::from_text("Direct list")); - run(&mut s, command, json!({})); - assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().props.style.as_deref(), Some("ListParagraph")); - run(&mut s, command, json!({})); - let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); - assert!(p.props.style.is_none()); - assert!(s.doc.styles.resolve_para(&p.props).numbering.is_none()); - } -} - -#[test] -fn a_new_list_uses_the_requested_kind_after_a_mixed_format_list() { - use wordcraft_doc::numbering::{ListKind, levels_for}; - use wordcraft_doc::props::{NumRef, ParaProps}; - for (kind, command) in [(ListKind::Bullet, "para.bullets"), (ListKind::Numbered, "para.numbering")] { - let mut s = style_inherited_list(kind, false); - let opposite = if kind == ListKind::Bullet { ListKind::Numbered } else { ListKind::Bullet }; - Arc::make_mut(&mut s.doc.numbering).abstracts[0].levels[0] = levels_for(opposite)[0].clone(); - let num = s.doc.numbering.nums[0].id; - s.doc.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().props.numbering = Some(NumRef { num, level: 1 }); - s.doc.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(1)).unwrap().props = ParaProps::default(); - let before = s.doc.para_at(&Pos::body(0, 0)).unwrap().clone(); - s.sel = crate::Selection::caret(Pos::body(1, 0)); - run(&mut s, command, json!({})); - let n = s.doc.para_at(&Pos::body(1, 0)).unwrap().props.numbering.unwrap(); - assert_eq!(n.level, 0); - let level = s.doc.numbering.level(n.num, n.level).unwrap(); - assert_eq!(level.format == wordcraft_doc::section::NumFormat::Bullet, kind == ListKind::Bullet); - assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap(), &before); - } -} - -/// #4085: the registered shortcut toggles the last selected highlight, while explicit -/// colour commands remain idempotent setters for the palette, CLI and automation. -#[test] -#[cfg(not(target_os = "macos"))] -fn highlight_shortcut_toggles_the_last_colour() { - let mut s = typed("Alpha Beta"); - run(&mut s, "select.all", json!({})); - let colour = |s: &mut Session| s.run("format.state", &json!({})).unwrap()["highlight"].clone(); - press(&mut s, "Mod+Alt+H").unwrap(); - assert_eq!(colour(&mut s), json!("FFFF00")); - press(&mut s, "Mod+Alt+H").unwrap(); - assert_eq!(colour(&mut s), serde_json::Value::Null); - run(&mut s, "edit.undo", json!({})); - assert_eq!(colour(&mut s), json!("FFFF00")); - run(&mut s, "edit.redo", json!({})); - assert_eq!(colour(&mut s), serde_json::Value::Null); - run(&mut s, "format.highlight", json!({"color":"blue"})); - run(&mut s, "format.highlight", json!({"color":"blue"})); - assert_eq!(colour(&mut s), json!("0000FF")); - press(&mut s, "Mod+Alt+H").unwrap(); - assert_eq!(colour(&mut s), serde_json::Value::Null); - press(&mut s, "Mod+Alt+H").unwrap(); - assert_eq!(colour(&mut s), json!("0000FF")); - run(&mut s, "format.highlight", json!({"color":"none"})); - press(&mut s, "Mod+Alt+H").unwrap(); - assert_eq!(colour(&mut s), serde_json::Value::Null); - assert_eq!(s.doc.body[0].as_para().unwrap().text, "Alpha Beta"); -} - -/// #4085: mixed selections take one colour; removing an inherited colour writes an -/// explicit No Color, and a collapsed caret toggles pending formatting for future typing. -#[test] -fn highlight_toggle_handles_mixed_inherited_and_pending_formatting() { - let mut s = typed("Alpha Beta "); - run(&mut s, "select.text", json!({"text":"Alpha"})); - run(&mut s, "format.highlight", json!({"color":"blue"})); - run(&mut s, "select.all", json!({})); - run(&mut s, "format.highlight", json!({})); - assert_eq!(s.run("format.state", &json!({})).unwrap()["highlight"], json!("0000FF")); - run(&mut s, "format.highlight", json!({})); - assert_eq!(s.run("format.state", &json!({})).unwrap()["highlight"], serde_json::Value::Null); - Arc::make_mut(&mut s.doc.styles).default_chr.highlight = Some(wordcraft_doc::props::Highlight::Blue); - run(&mut s, "format.highlight", json!({"color":"blue"})); - run(&mut s, "format.highlight", json!({})); - assert!(s.doc.body[0].as_para().unwrap().runs.iter().all(|r| r.props.highlight == Some(wordcraft_doc::props::Highlight::None))); - run(&mut s, "caret.docEnd", json!({})); - run(&mut s, "format.highlight", json!({})); - assert_eq!(s.pending.as_ref().unwrap().highlight, Some(wordcraft_doc::props::Highlight::Blue)); - run(&mut s, "format.highlight", json!({})); - assert_eq!(s.pending.as_ref().unwrap().highlight, Some(wordcraft_doc::props::Highlight::None)); - run(&mut s, "text.insert", json!({"text":"X"})); - run(&mut s, "select.text", json!({"text":"X"})); - assert_eq!(s.run("format.state", &json!({})).unwrap()["highlight"], serde_json::Value::Null); - let before = serde_json::to_value(&s.doc).unwrap(); - assert!(s.run("format.highlight", &json!({"color":123})).is_err()); - assert_eq!(serde_json::to_value(&s.doc).unwrap(), before); -} - -/// #4086: a caret inside a word highlights what is typed next, not the word; Bold still -/// formats the word. -#[test] -fn highlight_at_a_caret_leaves_the_word_alone() { - let mut s = typed("Alpha Beta"); - run(&mut s, "caret.docStart", json!({})); - run(&mut s, "caret.right", json!({})); - run(&mut s, "format.highlight", json!({"color":"yellow"})); - assert!(s.doc.body[0].as_para().unwrap().runs.iter().all(|r| r.props.highlight.is_none())); - assert_eq!(s.pending.as_ref().unwrap().highlight, Some(wordcraft_doc::props::Highlight::Yellow)); - run(&mut s, "text.insert", json!({"text":"X"})); - run(&mut s, "select.text", json!({"text":"X"})); - assert_eq!(s.run("format.state", &json!({})).unwrap()["highlight"], json!("FFFF00")); - run(&mut s, "select.text", json!({"text":"Beta"})); - assert_eq!(s.run("format.state", &json!({})).unwrap()["highlight"], serde_json::Value::Null); - // The toggle reads the pending colour, not the word's: a second press turns it off. - run(&mut s, "caret.docStart", json!({})); - run(&mut s, "caret.right", json!({})); - run(&mut s, "format.highlight", json!({})); - assert_eq!(s.pending.as_ref().unwrap().highlight, Some(wordcraft_doc::props::Highlight::Yellow)); - run(&mut s, "format.highlight", json!({})); - assert_eq!(s.pending.as_ref().unwrap().highlight, Some(wordcraft_doc::props::Highlight::None)); - // Bold after Highlight formats the word and keeps both for what is typed next. - run(&mut s, "format.highlight", json!({})); - run(&mut s, "format.bold", json!({})); - run(&mut s, "text.insert", json!({"text":"Y"})); - run(&mut s, "select.text", json!({"text":"Y"})); - let state = s.run("format.state", &json!({})).unwrap(); - assert_eq!((state["highlight"].clone(), state["bold"].clone()), (json!("FFFF00"), json!(true))); -} - -/// A differently coloured final paragraph must remain visible to the shortcut beyond -/// the formatting-state query limit, including a style-inherited tail and Undo/Redo. -#[cfg(not(target_os = "macos"))] -#[test] -fn highlight_toggle_checks_past_the_resolved_run_limit() { - use wordcraft_doc::para::Run; - use wordcraft_doc::props::{CharProps, Highlight}; - for count in [10_000, 10_001, 10_002] { - for inherited in [false, true] { - let mut doc = wordcraft_doc::Document::from_text(&format!("{}\nZ", "x".repeat(count))); - doc.para_mut(StoryRef::Body, &Pos::body(0, 0).path).unwrap().runs = (0..count) - .map(|i| Run { len: 1, props: CharProps { highlight: Some(Highlight::Blue), bold: Some(i % 2 == 0), ..Default::default() } }) - .collect(); - if inherited { - Arc::make_mut(&mut doc.styles).default_chr.highlight = Some(Highlight::Yellow); - } else { - doc.para_mut(StoryRef::Body, &Pos::body(1, 0).path).unwrap().runs[0].props.highlight = Some(Highlight::Yellow); - } - doc.para_mut(StoryRef::Body, &Pos::body(0, 0).path).unwrap().mark.highlight = Some(Highlight::Blue); - let original = doc.clone(); - let mut s = Session::new(doc); - s.last_highlight = Highlight::Blue; - run(&mut s, "select.all", json!({})); - press(&mut s, "Mod+Alt+H").unwrap(); - assert!( - s.doc.body.iter().filter_map(|b| b.as_para()).flat_map(|p| &p.runs).all(|r| r.props.highlight == Some(Highlight::Blue)), - "count={count}, inherited={inherited}" - ); - press(&mut s, "Mod+Alt+H").unwrap(); - assert!(s.doc.body.iter().filter_map(|b| b.as_para()).flat_map(|p| &p.runs).all(|r| r.props.highlight == Some(Highlight::None))); - run(&mut s, "edit.undo", json!({})); - assert!(s.doc.body[1].as_para().unwrap().runs.iter().all(|r| r.props.highlight == Some(Highlight::Blue))); - run(&mut s, "edit.undo", json!({})); - assert_eq!(serde_json::to_value(&s.doc).unwrap(), serde_json::to_value(&original).unwrap()); - run(&mut s, "edit.redo", json!({})); - assert!(s.doc.body[1].as_para().unwrap().runs.iter().all(|r| r.props.highlight == Some(Highlight::Blue))); - } - } -} - -/// A command preview uses the real session's remembered colour without changing its -/// document, colour choice or undo history when the preview toggles highlighting off. -#[test] -fn highlight_preview_preserves_the_session_colour_and_document() { - use wordcraft_doc::props::Highlight; - let mut s = typed("Alpha"); - run(&mut s, "select.all", json!({})); - run(&mut s, "format.highlight", json!({"color":"blue"})); - let original = serde_json::to_value(&s.doc).unwrap(); - let depth = s.undo_depth(); - let (preview, _, _) = s.preview_layout("format.highlight", &json!({})).unwrap(); - assert!(preview.body[0].as_para().unwrap().runs.iter().all(|r| r.props.highlight == Some(Highlight::None))); - assert_eq!(serde_json::to_value(&s.doc).unwrap(), original); - assert_eq!(s.last_highlight, Highlight::Blue); - assert_eq!(s.undo_depth(), depth); -} - -/// Whole-cell column selections include paragraph marks, including empty cells and -/// inherited mark colours; adjacent unselected cells and undo/redo keep their formatting. -#[test] -fn highlight_toggle_checks_whole_cell_marks() { - use wordcraft_doc::props::Highlight; - use wordcraft_doc::{CharProps, Paragraph}; - for inherited in [false, true] { - for empty in [false, true] { - let mut s = s(); - run(&mut s, "insert.table", json!({"rows":2,"cols":2})); - let (table, _, _) = s.sel.focus.path.cell().unwrap(); - let pos = |row, col| { - let mut path = table.clone(); - path.0.extend([row, col, 0]); - Pos::new(StoryRef::Body, path, 0) - }; - if inherited { - Arc::make_mut(&mut s.doc.styles).default_chr.highlight = Some(Highlight::Yellow); - } - for row in 0..2 { - for col in 0..2 { - let p = s.doc.para_mut(StoryRef::Body, &pos(row, col).path).unwrap(); - let colour = if col == 0 { Highlight::Blue } else { Highlight::Red }; - *p = Paragraph::with_text( - if empty && row == 1 && col == 0 { "" } else { "AB" }, - CharProps { highlight: Some(colour), ..Default::default() }, - ); - p.mark.highlight = Some(colour); - if row == 1 && col == 0 { - p.mark.highlight = if inherited { None } else { Some(Highlight::Yellow) }; - } - } - } - s.last_highlight = Highlight::Blue; - run(&mut s, "caret.set", json!({"pos":pos(0,0)})); - run(&mut s, "table.selectColumn", json!({})); - assert!(!s.cell_range().unwrap().stream); - let original = serde_json::to_value(&s.doc).unwrap(); - run(&mut s, "format.highlight", json!({})); - for row in 0..2 { - let p = s.doc.para_at(&pos(row, 0)).unwrap(); - assert_eq!(p.mark.highlight, Some(Highlight::Blue), "inherited={inherited}, empty={empty}"); - assert!(p.runs.iter().all(|r| r.props.highlight == Some(Highlight::Blue))); - } - run(&mut s, "format.highlight", json!({})); - for row in 0..2 { - let p = s.doc.para_at(&pos(row, 0)).unwrap(); - assert_eq!(p.mark.highlight, Some(Highlight::None)); - assert!(p.runs.iter().all(|r| r.props.highlight == Some(Highlight::None))); - let other = s.doc.para_at(&pos(row, 1)).unwrap(); - assert_eq!(other.mark.highlight, Some(Highlight::Red)); - assert!(other.runs.iter().all(|r| r.props.highlight == Some(Highlight::Red))); - } - run(&mut s, "edit.undo", json!({})); - run(&mut s, "edit.undo", json!({})); - assert_eq!(serde_json::to_value(&s.doc).unwrap(), original); - run(&mut s, "edit.redo", json!({})); - assert_eq!(s.doc.para_at(&pos(1, 0)).unwrap().mark.highlight, Some(Highlight::Blue)); - } - } -} - -/// Fully selected marks participate even when the selection has no text runs or an -/// empty paragraph between blue runs; repeated toggles and undo preserve these targets. -#[test] -fn highlight_toggle_checks_ordinary_paragraph_marks() { - use wordcraft_doc::props::Highlight; - for text in ["AB", "AB\n\nAB", "\n"] { - let mut doc = wordcraft_doc::Document::from_text(text); - Arc::make_mut(&mut doc.styles).default_chr.highlight = Some(Highlight::Blue); - let paths = doc.para_paths(StoryRef::Body); - for path in &paths { - let p = doc.para_mut(StoryRef::Body, path).unwrap(); - p.mark.highlight = Some(Highlight::Blue); - for r in &mut p.runs { - r.props.highlight = Some(Highlight::Blue); - } - } - let mismatch = if text == "AB\n\nAB" { 1 } else { 0 }; - doc.para_mut(StoryRef::Body, &paths[mismatch]).unwrap().mark.highlight = Some(Highlight::Yellow); - let original = serde_json::to_value(&doc).unwrap(); - let mut s = Session::new(doc); - s.last_highlight = Highlight::Blue; - run(&mut s, "select.all", json!({})); - run(&mut s, "format.highlight", json!({})); - for path in &paths { - let p = s.doc.para(StoryRef::Body, path).unwrap(); - assert_eq!(p.mark.highlight, Some(Highlight::Blue), "text={text:?}"); - assert!(p.runs.iter().all(|r| r.props.highlight == Some(Highlight::Blue))); - } - run(&mut s, "format.highlight", json!({})); - assert!(paths.iter().all(|p| s.doc.para(StoryRef::Body, p).unwrap().mark.highlight == Some(Highlight::None))); - run(&mut s, "edit.undo", json!({})); - run(&mut s, "edit.undo", json!({})); - assert_eq!(serde_json::to_value(&s.doc).unwrap(), original); - } -} - -/// A partial text column excludes paragraph marks and text beyond its selected offsets, -/// so a different mark colour must not prevent clearing the selected blue characters. -#[test] -fn highlight_partial_column_ignores_unselected_marks() { - use wordcraft_doc::props::Highlight; - let mut doc = wordcraft_doc::Document::from_text("AB\nAB"); - for path in doc.para_paths(StoryRef::Body) { - let p = doc.para_mut(StoryRef::Body, &path).unwrap(); - p.mark.highlight = Some(Highlight::Yellow); - for r in &mut p.runs { - r.props.highlight = Some(Highlight::Blue); - } - } - let mut s = Session::new(doc); - s.last_highlight = Highlight::Blue; - run(&mut s, "select.column", json!({"anchor":Pos::body(0,0),"focus":Pos::body(1,1)})); - run(&mut s, "format.highlight", json!({})); - for row in 0..2 { - let p = s.doc.para_at(&Pos::body(row, 0)).unwrap(); - assert_eq!(p.props_of_char(0).highlight, Some(Highlight::None)); - assert_eq!(p.props_of_char(1).highlight, Some(Highlight::Blue)); - assert_eq!(p.mark.highlight, Some(Highlight::Yellow)); - } -} - -#[test] -fn tab_with_a_whole_paragraph_selected_indents_it() { - let mut s = typed("Hello world"); - run(&mut s, "select.paragraph", json!({})); - run(&mut s, "text.tab", json!({})); - assert_eq!(paras(&s), vec![para("Hello world", None)]); - assert_eq!(indents(&s).0, Some(36.0)); -} - -#[test] -fn tab_with_a_partial_selection_still_inserts_a_tab() { - let mut s = typed("Hello world"); - run(&mut s, "select.text", json!({"text": "ello"})); - run(&mut s, "text.tab", json!({})); - assert_eq!(paras(&s), vec![para("H\t world", None)]); -} - -/// Tab with a whole paragraph selected indents it as one Undo step: the selection stays while -/// the indent is applied, and Undo restores both the indentation and that same selection. A -/// partial selection still types a tab character (the test above), and Tab in a table cell still -/// navigates (the table tests above). -#[test] -fn tab_with_a_whole_paragraph_selected_undoes_indent_and_selection_together() { - let mut s = typed("Hello world"); - run(&mut s, "select.paragraph", json!({})); - let selected = s.sel.clone(); - let depth = s.undo_depth(); - run(&mut s, "text.tab", json!({})); - assert_eq!(s.doc.plain_text(StoryRef::Body), "Hello world", "Tab indents, it types no tab"); - assert_eq!(s.sel, selected, "the whole paragraph stays selected"); - assert_eq!(indents(&s), (Some(36.0), None)); - assert_eq!(s.undo_depth(), depth + 1, "one paragraph, one Undo step"); - run(&mut s, "edit.undo", json!({})); - assert_eq!(indents(&s), (None, None), "Undo takes the indent back"); - assert_eq!(s.sel, selected, "and restores the selection the indent was applied to"); - assert_eq!(paras(&s), vec![para("Hello world", None)]); -} - -/// #2848: "Add a period with double-space" (an AutoFormat As You Type switch, off by default): -/// a second Space after a word, a number or a closing bracket or quote turns the first into a -/// period. Undo puts the two spaces back; other punctuation, a lone space and a run of three -/// spaces are left as typed. -#[test] -fn double_space_adds_a_period_when_asked() { - let with_option = |keys: &str| { - let mut s = s(); - run(&mut s, "tools.autocorrect", json!({"doubleSpacePeriod": true})); - typ(&mut s, keys); - s - }; - let text = |s: &Session| s.doc.plain_text(StoryRef::Body); - // Off unless asked for. - assert_eq!(text(&typed("Hello world")), "Hello world"); - assert!(!s().prefs.autocorrect.double_space_period); - // On: the second space makes the period, and the next sentence starts with a capital. - assert_eq!(text(&with_option("Hello world")), "Hello. world"); - assert_eq!(text(&with_option("Hello world ")), "Hello. World "); - assert_eq!(text(&with_option("See page 5 Then")), "See page 5. Then"); - assert_eq!(text(&with_option("(it is done) Next")), "(it is done). Next"); - assert_eq!(text(&with_option("He said \"stop\" Next")), "He said “stop”. Next"); - // Undo brings the two spaces back, in one step. - let mut t = with_option("Hello "); - assert_eq!(text(&t), "Hello. "); - run(&mut t, "edit.undo", json!({})); - assert_eq!(text(&t), "Hello "); - // Left alone: other punctuation before the spaces, a lone space, spaces at the start of a - // paragraph or after a tab, and a third space. - for keys in ["Hello. x", "Hello, x", "Hello? x", "Hello; x", "Hello: x", "Hello x", " x", "\n x", "a\t x"] { - let want = typed(keys); - assert_eq!(text(&with_option(keys)), text(&want), "{keys:?}"); - } - assert_eq!(text(&with_option("Hello x")), "Hello. x"); - // A selection typed over, and the master switch. - let mut t = with_option("Hello world"); - run(&mut t, "select.all", json!({})); - run(&mut t, "text.insert", json!({"text": " "})); - assert_eq!(text(&t), " "); - let mut t = s(); - run(&mut t, "tools.autocorrect", json!({"doubleSpacePeriod": true, "enabled": false})); - typ(&mut t, "Hello world"); - assert_eq!(text(&t), "Hello world"); -} - -#[test] -fn backspace_after_a_floating_shape_anchor_joins_paragraphs_and_keeps_the_shape() { - let mut s = typed("Line one\nLine two"); - run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [1], "off": 0}})); - run(&mut s, "insert.textBox", json!({"text": "Box"})); - let (a, f) = (Pos::body(1, 0), Pos::body(1, 3)); - run(&mut s, "select.range", json!({"anchor": a, "focus": f})); - run(&mut s, "arrange.wrap", json!({"wrap": "inFrontOfText"})); - let objects = |s: &Session| s.doc.para_at(&Pos::body(1, 0)).map(|p| p.object_offsets().len()); - assert_eq!(objects(&s), Some(1)); - run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [1], "off": 3}})); - run(&mut s, "text.backspace", json!({})); - assert_eq!(s.doc.body.iter().filter(|b| b.as_para().is_some()).count(), 1); - assert_eq!(objects(&s), None); - assert_eq!( - s.doc.para_at(&Pos::body(0, 0)).map(|p| (p.text.replace('\u{FFFC}', ""), p.object_offsets().len())), - Some(("Line oneLine two".to_string(), 1)) - ); -} - -#[test] -fn backspace_after_marker_and_floating_anchors_still_joins_paragraphs() { - let mut s = typed("Line one\nLine two"); - run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [1], "off": 0}})); - run(&mut s, "insert.textBox", json!({"text": "Box"})); - let (a, f) = (Pos::body(1, 0), Pos::body(1, 3)); - run(&mut s, "select.range", json!({"anchor": a, "focus": f})); - run(&mut s, "arrange.wrap", json!({"wrap": "inFrontOfText"})); - s.doc.insert_object(&Pos::body(1, 0), wordcraft_doc::InlineObject::BookmarkStart { name: "anchor".into() }, &Default::default()).unwrap(); - s.doc.insert_object(&Pos::body(1, 0), wordcraft_doc::InlineObject::CommentStart { id: 1 }, &Default::default()).unwrap(); - run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [1], "off": 9}})); - run(&mut s, "text.backspace", json!({})); - assert_eq!(s.doc.body.iter().filter(|b| b.as_para().is_some()).count(), 1); - assert_eq!(s.doc.para_at(&Pos::body(0, 0)).map(|p| p.text.replace('\u{FFFC}', "")), Some("Line oneLine two".to_string())); -} - -#[test] -fn backspace_cannot_join_into_locked_content_control() { - let mut s = typed("Above\nInside"); - run(&mut s, "select.range", json!({"anchor": {"story": "body", "path": [1], "off": 0}, "focus": {"story": "body", "path": [1], "off": 6}})); - run(&mut s, "developer.richText", json!({"tag": "locked"})); - run(&mut s, "developer.properties", json!({"control": "locked", "lockEdit": true})); - run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [1], "off": 0}})); - let before = s.doc.clone(); - assert!(s.run("text.backspace", &json!({})).is_err()); - assert_eq!(s.doc, before); -} - -// #3693 is an additional editing shortcut, not a claim of Word's default key behaviour. -#[test] -fn delete_previous_sentence_respects_caret_boundaries_and_unicode() { - for (text, before, expected) in [ - ("First sentence. Second sentence.", "First sentence. Second sentence.", "First sentence. "), - ("First sentence. Second sentence.", "First sentence. Sec", "First sentence. ond sentence."), - ("First sentence. Second sentence.", "First sentence. ", "Second sentence."), - ("Grüße! Überall café.", "Grüße! Überall café.", "Grüße! "), - ("No punctuation here", "No punctuation", " here"), - ("One. Two.", "", "One. Two."), - ("", "", ""), - ] { - let mut s = s(); - run(&mut s, "text.insert", json!({"text": text, "raw": true})); - let mut pos = s.sel.focus.clone(); - pos.off = before.len(); - s.sel = crate::Selection::caret(pos); - run(&mut s, "text.deleteSentenceBack", json!({})); - assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().text, expected, "{text:?} at {before:?}"); - if expected != text { - run(&mut s, "edit.undo", json!({})); - assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().text, text); - run(&mut s, "edit.redo", json!({})); - assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().text, expected); - } - } -} - -#[test] -fn delete_previous_sentence_keeps_paragraph_boundaries_and_deletes_selections() { - let mut s = s(); - run(&mut s, "text.insert", json!({"text": "First.\nSecond sentence.", "raw": true, "paragraphs": true})); - let end = s.sel.focus.clone(); - s.sel = crate::Selection::caret(Pos { off: 0, ..end.clone() }); - let count = s.doc.body.len(); - run(&mut s, "text.deleteSentenceBack", json!({})); - assert_eq!(s.doc.body.len(), count); - assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().text, "Second sentence."); - s.sel = crate::Selection { anchor: Pos { off: 0, ..end.clone() }, focus: Pos { off: 6, ..end } }; - run(&mut s, "text.deleteSentenceBack", json!({})); - assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().text, " sentence."); - run(&mut s, "edit.undo", json!({})); - assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().text, "Second sentence."); -} - -#[test] -fn delete_previous_sentence_tracks_changes_and_respects_read_only() { - for accept in [true, false] { - let mut s = s(); - run(&mut s, "text.insert", json!({"text": "One. Two.", "raw": true})); - run(&mut s, "review.trackChanges", json!({"value": true})); - run(&mut s, "text.deleteSentenceBack", json!({})); - let changes = s.run("review.changes", &json!({})).unwrap(); - assert!(changes.as_array().unwrap().iter().any(|c| c["kind"] == "delete")); - run(&mut s, if accept { "review.acceptAll" } else { "review.rejectAll" }, json!({})); - assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().text, if accept { "One. " } else { "One. Two." }); - } - let mut s = s(); - run(&mut s, "text.insert", json!({"text": "Keep this.", "raw": true})); - run(&mut s, "review.restrict", json!({"mode": "readOnly"})); - assert!(s.run("text.deleteSentenceBack", &json!({})).is_err()); - assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().text, "Keep this."); -} - -#[test] -fn delete_previous_sentence_does_not_cross_a_table_cell_boundary() { - let mut s = s(); - run(&mut s, "insert.table", json!({"rows": 1, "cols": 2})); - run(&mut s, "text.insert", json!({"text": "First cell.", "raw": true})); - run(&mut s, "text.tab", json!({})); - run(&mut s, "text.insert", json!({"text": "Second cell.", "raw": true})); - let end = s.sel.focus.clone(); - s.sel = crate::Selection::caret(Pos { off: 0, ..end.clone() }); - let before = s.doc.plain_text(StoryRef::Body); - run(&mut s, "text.deleteSentenceBack", json!({})); - assert_eq!(s.doc.plain_text(StoryRef::Body), before); - s.sel = crate::Selection::caret(end); - run(&mut s, "text.deleteSentenceBack", json!({})); - assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().text, ""); - assert!(s.doc.plain_text(StoryRef::Body).contains("First cell.")); -} - -// English spelling from SCOWL 2026 (CP2): each English reads its own SCOWL dictionary, with -// `en-variants.txt` deciding the other dialect's spellings. Typed text, as a person types it. - -/// The spelling words typed text gets in `lang` (the whole document's language), with the first -/// suggestion of each. -fn typed_spelling_in(lang: &str, keys: &str) -> Vec<(String, String)> { - let mut s = s(); - run(&mut s, "review.language", json!({"lang": lang, "default": true})); - typ(&mut s, keys); - let issues = s.run("review.issues", &json!({})).unwrap(); - issues - .as_array() - .unwrap() - .iter() - .filter(|i| i["kind"] == "spelling") - .map(|i| (i["text"].as_str().unwrap().to_string(), i["suggestions"][0].as_str().unwrap_or("").to_string())) - .collect() -} - -/// #40: the British spellings the issue lists are right in en-GB, and the American ones are -/// flagged with the British form first. -#[test] -fn issue_40_british_spellings_are_right_in_en_gb() { - if super::no_language_data("issue_40_british_spellings_are_right_in_en_gb") { - return; - } - let none: Vec<(String, String)> = Vec::new(); - let text = "The judgement on the defence licence put the programme in the catalogue. "; - assert_eq!(typed_spelling_in("en-GB", text), none); - assert_eq!(typed_spelling_in("en-GB", "Its colour and centre, organised and organized, travelled. "), none); - let flagged = typed_spelling_in("en-GB", "The color of the center. "); - assert_eq!(flagged, [("color".to_string(), "colour".to_string()), ("center".to_string(), "centre".to_string())]); -} - -/// #520: common modern words are right (SCOWL plus our supplement), and real misspellings of -/// them are still caught. -#[test] -fn issue_520_common_words_are_right() { - if super::no_language_data("issue_520_common_words_are_right") { - return; - } - let none: Vec<(String, String)> = Vec::new(); - assert_eq!(typed_spelling_in("en-US", "My desktop email has downloads, a smartphone and a website. "), none); - // (`recieved` would be AutoCorrected as it is typed.) - let flagged = typed_spelling_in("en-US", "My desktpo emial. "); - assert_eq!(flagged.iter().map(|f| f.0.as_str()).collect::>(), ["desktpo", "emial"]); - assert_eq!(flagged[0].1, "desktop"); -} - -/// #1281 and #1045: Canadian and Australian English (and the other British-spelling Englishes) -/// proof by their own rules; ordinals are words in every English. -#[test] -fn issues_1281_1045_english_dialects_proof_typed_text() { - if super::no_language_data("issues_1281_1045_english_dialects_proof_typed_text") { - return; - } - let none: Vec<(String, String)> = Vec::new(); - // Canada: British -our and -re, American -ize. - assert_eq!(typed_spelling_in("en-CA", "We organize the colour in the centre. "), none); - assert_eq!( - typed_spelling_in("en-CA", "We organise the color. "), - [("organise".to_string(), "organize".to_string()), ("color".to_string(), "colour".to_string())] - ); - // Australia, New Zealand, India: British spelling. - for lang in ["en-AU", "en-NZ", "en-IN"] { - assert_eq!(typed_spelling_in(lang, "We analyse the colour of the theatre. "), none, "{lang}"); - assert_eq!(typed_spelling_in(lang, "We analyze it. "), [("analyze".to_string(), "analyse".to_string())], "{lang}"); - } - // US English flags British spellings with the American form first. - assert_eq!(typed_spelling_in("en-US", "The colour. "), [("colour".to_string(), "color".to_string())]); - // SCOWL's ordinals (COMPOUNDRULE) are words, even where numbers are checked. - let mut s = s(); - run(&mut s, "file.options", json!({"ignoreNumbers": false})); - typ(&mut s, "The 21st and 112th times, not the 21th. "); - let issues = s.run("review.issues", &json!({})).unwrap(); - 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, ["21th"]); -} +//! Typing behaviour checked side by side against Word: keystrokes go through the same commands +//! as the keyboard (one `text.insert` per character, Enter, Tab, Backspace), and the expected +//! results are what Word produces for the same keys. + +use serde_json::json; +use std::sync::Arc; +use wordcraft_doc::props::{BorderStyle, VertAlign}; +use wordcraft_doc::{Pos, StoryRef}; + +use crate::Session; + +/// Leaf #152: deleting the empty heading above body text must not promote that text to a heading. +/// Deleting the empty heading mark keeps the next paragraph as body text. +#[test] +fn deleting_a_preceding_empty_heading_keeps_the_body_paragraph_style() { + let mut s = Session::new(wordcraft_doc::Document::from_text("\nBody paragraph.")); + run(&mut s, "para.style", json!({"style": "Heading1"})); + run(&mut s, "text.delete", json!({})); + let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); + assert_eq!(p.text, "Body paragraph."); + assert_eq!(p.props.style, None); + assert_eq!(wordcraft_layout::outline::levels(&s.doc), [wordcraft_layout::outline::BODY]); +} + +/// Create an empty session for command-driven typing fixtures. +fn s() -> Session { + Session::new(wordcraft_doc::Document::new()) +} + +/// Execute a registry command and report its id on a test failure. +fn run(s: &mut Session, id: &str, v: serde_json::Value) { + s.run(id, &v).unwrap_or_else(|e| panic!("{id}: {e}")); +} + +/// Type like a person: `\n` is Enter, `\t` is Tab, `\u{8}` is Backspace, everything else one +/// character at a time. +fn typ(s: &mut Session, keys: &str) { + for c in keys.chars() { + match c { + '\n' => run(s, "text.newParagraph", json!({})), + '\t' => run(s, "text.tab", json!({})), + '\u{8}' => run(s, "text.backspace", json!({})), + c => run(s, "text.insert", json!({"text": c.to_string()})), + } + } +} + +/// Body paragraphs as (text, list level if in a list, in "List Paragraph"). +fn paras(s: &Session) -> Vec<(String, Option, bool)> { + s.doc + .body + .iter() + .filter_map(|b| b.as_para()) + .map(|p| (p.text.clone(), p.props.numbering.filter(|n| n.num != 0).map(|n| n.level), p.props.style.as_deref() == Some("ListParagraph"))) + .collect() +} + +/// Expected paragraph text and list membership for typing assertions. +fn para(text: &str, level: Option) -> (String, Option, bool) { + (text.to_string(), level, level.is_some()) +} + +/// Create a session and enter its fixture through the actual typing commands. +fn typed(keys: &str) -> Session { + let mut s = s(); + typ(&mut s, keys); + s +} + +#[test] +fn sample_text_formulas_expand_on_enter() { + for name in ["rand", "lorem"] { + for (args, paragraphs, sentences) in [ + ("", 5, 3), + ("2", 2, 3), + (" 2 ", 2, 3), + ("2,4", 2, 4), + ("2, 4", 2, 4), + (" 2 , 4 ", 2, 4), + ("1,1", 1, 1), + ("200,1", 200, 1), + ("1,200", 1, 200), + ] { + let mut s = typed(&format!("={name}({args})")); + let depth = s.undo_depth(); + typ(&mut s, "\n"); + let generated: Vec<_> = paras(&s).into_iter().map(|p| p.0).collect(); + assert_eq!(generated.len(), paragraphs + 1, "{name}({args})"); + for text in generated.iter().take(paragraphs) { + assert_eq!(text.matches('.').count(), sentences); + assert!(text.starts_with(if name == "lorem" { "Lorem ipsum" } else { "A blank page" }), "{name}({args}): {text:?}"); + } + assert_eq!(generated.last().unwrap(), ""); + assert_eq!(s.sel.focus, Pos::body(paragraphs, 0)); + assert_eq!(s.undo_depth(), depth + 1); + let expanded = s.doc.clone(); + run(&mut s, "edit.undo", json!({})); + assert_eq!(texts(&s), [format!("={name}({args})")]); + run(&mut s, "edit.redo", json!({})); + assert_eq!(s.doc, expanded); + } + } +} + +#[test] +fn sample_text_invalid_parameters_use_defaults() { + for name in ["rand", "lorem"] { + for args in ["0", "-1", "201", "abc", "1.5", "99999999999999999999999999999", "2,0", "0,4", "2,201", "2,no", "2,", ",4", "1,2,3"] { + let s = typed(&format!("={name}({args})\n")); + let texts = texts(&s); + assert_eq!(texts.len(), 6, "{name}({args})"); + assert!(texts.iter().take(5).all(|text| text.matches('.').count() == 3)); + } + } +} + +#[test] +fn sample_text_requires_an_exact_paragraph_and_caret_at_end() { + for formula in [" =rand()", "=rand() ", "prefix =rand()", "=lorem() suffix", "=other()", "=rand(", "=rand(())"] { + assert_eq!(texts(&typed(&format!("{formula}\n"))), [formula, ""]); + } + let mut s = typed("=rand()"); + run(&mut s, "caret.home", json!({})); + typ(&mut s, "\n"); + assert_eq!(texts(&s), ["", "=rand()"]); + let mut s = typed("=rand()suffix"); + s.sel = crate::Selection { anchor: Pos::body(0, 7), focus: Pos::body(0, 13) }; + typ(&mut s, "\n"); + assert_eq!(texts(&s), ["=rand()", ""]); +} + +#[test] +fn sample_text_preserves_typing_and_paragraph_formatting() { + let mut s = s(); + run(&mut s, "format.bold", json!({})); + run(&mut s, "para.alignCenter", json!({})); + typ(&mut s, "=rand(2,1)\n"); + for p in s.doc.body.iter().filter_map(|b| b.as_para()).take(2) { + assert_eq!(p.props.align, Some(wordcraft_doc::props::Align::Center)); + assert_eq!(p.props_at(0).bold, Some(true)); + } +} + +#[test] +fn sample_text_replacement_tracks_changes() { + let mut s = typed("=lorem(2,1)"); + run(&mut s, "review.trackChanges", json!({})); + typ(&mut s, "\n"); + assert!(s.doc.body.iter().filter_map(|b| b.as_para()).flat_map(|p| &p.runs).any(|r| r.props.ins.is_some())); + run(&mut s, "review.rejectAll", json!({})); + assert_eq!(texts(&s), ["=lorem(2,1)"]); + run(&mut s, "edit.undo", json!({})); + run(&mut s, "review.acceptAll", json!({})); + assert_eq!(texts(&s).len(), 3); + assert!(!s.doc.plain_text(StoryRef::Body).contains("=lorem")); +} + +#[test] +fn enter_twice_after_a_list_item_ends_the_list() { + let s = typed("* item\n\ntext"); + assert_eq!(paras(&s), vec![para("Item", Some(0)), para("text", None)]); +} + +#[test] +fn more_enters_after_a_list_make_blank_paragraphs() { + // The "stuck Enter" bug: after the list ended, further Enters did nothing. + let s = typed("* item\n\n\ntext"); + assert_eq!(paras(&s), vec![para("Item", Some(0)), para("", None), para("text", None)]); + let s = typed("1. item\n\n\n\nafter"); + assert_eq!(paras(&s), vec![para("Item", Some(0)), para("", None), para("", None), para("after", None)]); +} + +#[test] +fn the_paragraph_after_a_list_is_normal_text() { + let mut s = typed("* Apple\nBanana\n\nAfter list"); + let p = s.doc.para_at(&s.sel.focus).unwrap(); + assert_eq!(p.props.style, None, "back to Normal, not an indented List Paragraph"); + assert_eq!(p.props.numbering, None); + // And the next paragraph too. + typ(&mut s, "\nMore"); + assert_eq!(paras(&s).last(), Some(¶("More", None))); +} + +#[test] +fn enter_on_an_empty_nested_item_moves_it_up_a_level_first() { + let s = typed("* a\n\tb\n\ntext"); + assert_eq!(paras(&s), vec![para("A", Some(0)), para("B", Some(1)), para("text", Some(0))]); + let s = typed("* a\n\tb\n\n\ntext"); + assert_eq!(paras(&s), vec![para("A", Some(0)), para("B", Some(1)), para("text", None)]); + let s = typed("* a\n\tb\n\n\n\ntext"); + assert_eq!(paras(&s), vec![para("A", Some(0)), para("B", Some(1)), para("", None), para("text", None)]); + let s = typed("* a\n\tb\n\tc\n\n\n\ntext"); + assert_eq!(paras(&s), vec![para("A", Some(0)), para("B", Some(1)), para("C", Some(2)), para("text", None)]); + let s = typed("1. a\n\tb\n\n\ntext"); + assert_eq!(paras(&s), vec![para("A", Some(0)), para("B", Some(1)), para("text", None)]); +} + +/// #2143: in a Word file whose list comes from the paragraph styles (List Number, List Number 2 +/// with `w:numPr` in styles.xml, none on the paragraphs), Enter on an empty nested item moves it +/// up a level and then ends the list, as with direct numbering. +#[test] +fn enter_on_an_empty_item_numbered_by_its_style_moves_it_up_a_level() { + use std::io::Write; + let w = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main""#; + let style = |id: &str, name: &str, ilvl: u8| { + format!( + r#""# + ) + }; + let styles = format!( + r#"{}{}"#, + style("ListNumber", "List Number", 0), + style("ListNumber2", "List Number 2", 1) + ); + let numbering = format!( + r#""# + ); + let p = |st: &str, text: &str| format!(r#"{text}"#); + let body: String = + [p("ListNumber", "Lorem"), p("ListNumber", "Ipsum"), p("ListNumber2", "Latin"), p("ListNumber2", "Nonsense"), p("ListNumber2", "")].concat(); + let document = format!(r#"{body}"#); + let rel = |id: &str, kind: &str, target: &str| { + format!(r#""#) + }; + let rels_ns = r#"xmlns="http://schemas.openxmlformats.org/package/2006/relationships""#; + let main = format!("{}", rel("rId1", "officeDocument", "word/document.xml")); + let doc_rels = + format!("{}{}", rel("rId1", "styles", "styles.xml"), rel("rId2", "numbering", "numbering.xml")); + let ct = r#""#; + let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + let o = zip::write::SimpleFileOptions::default(); + for (name, data) in [ + ("[Content_Types].xml", ct), + ("_rels/.rels", main.as_str()), + ("word/_rels/document.xml.rels", doc_rels.as_str()), + ("word/document.xml", document.as_str()), + ("word/styles.xml", styles.as_str()), + ("word/numbering.xml", numbering.as_str()), + ] { + z.start_file(name, o).unwrap(); + z.write_all(data.as_bytes()).unwrap(); + } + let doc = crate::io::open_bytes("t.docx", &z.finish().unwrap().into_inner()).unwrap(); + let mut s = Session::new(doc); + // The list level each paragraph shows, wherever its numbering comes from. + let levels = |s: &Session| -> Vec> { + s.doc + .body + .iter() + .filter_map(|b| b.as_para()) + .map(|p| s.doc.styles.resolve_para(&p.props).numbering.filter(|n| n.num != 0).map(|n| n.level)) + .collect() + }; + assert_eq!(levels(&s), [Some(0), Some(0), Some(1), Some(1), Some(1)]); + s.sel = crate::Selection::caret(Pos::body(4, 0)); + typ(&mut s, "\n"); + assert_eq!(levels(&s), [Some(0), Some(0), Some(1), Some(1), Some(0)]); + assert_eq!(s.sel.focus, Pos::body(4, 0)); + typ(&mut s, "\n"); + assert_eq!(levels(&s), [Some(0), Some(0), Some(1), Some(1), None]); + run(&mut s, "edit.undo", json!({})); + run(&mut s, "edit.undo", json!({})); + assert_eq!(levels(&s), [Some(0), Some(0), Some(1), Some(1), Some(1)]); +} + +#[test] +fn shift_tab_and_tab_change_list_levels() { + let mut s = typed("1. One\nTwo\n\tSub a\nSub b\n"); + run(&mut s, "text.backTab", json!({})); + typ(&mut s, "Three\n\nDone"); + assert_eq!( + paras(&s), + vec![para("One", Some(0)), para("Two", Some(0)), para("Sub a", Some(1)), para("Sub b", Some(1)), para("Three", Some(0)), para("Done", None)] + ); +} + +#[test] +fn backspace_at_the_start_of_an_item_removes_the_number_then_the_indent() { + let mut s = typed("* One\nTwo"); + run(&mut s, "caret.home", json!({})); + let text_x = |s: &mut Session| s.layout().caret(&Pos::body(1, 0)).map(|c| c.x); + let before = text_x(&mut s); + typ(&mut s, "\u{8}"); + // Number gone, still indented with the list: the text stays where it was. + assert_eq!(paras(&s)[1], ("Two".to_string(), None, true)); + assert_eq!(text_x(&mut s), before); + typ(&mut s, "\u{8}|"); + // Second Backspace: Normal paragraph, not joined to the one above. + assert_eq!(paras(&s), vec![para("One", Some(0)), para("|Two", None)]); +} + +#[test] +fn backspace_on_an_empty_item_keeps_the_paragraph() { + let s = typed("* One\n\u{8}Z"); + assert_eq!(paras(&s), vec![para("One", Some(0)), ("Z".to_string(), None, true)]); +} + +#[test] +fn enter_in_the_middle_or_at_the_start_of_an_item() { + let mut s = typed("* Hello world"); + for _ in 0..5 { + run(&mut s, "caret.left", json!({})); + } + typ(&mut s, "\nX"); + assert_eq!(paras(&s), vec![para("Hello ", Some(0)), para("Xworld", Some(0))]); + let mut s = typed("* first\nsecond"); + run(&mut s, "caret.home", json!({})); + typ(&mut s, "\nY"); + assert_eq!(paras(&s), vec![para("First", Some(0)), para("", Some(0)), para("Ysecond", Some(0))]); +} + +fn label_at(s: &Session, i: usize) -> String { + let n = s.doc.para_at(&Pos::body(i, 0)).and_then(|p| p.props.numbering).unwrap(); + s.doc.numbering.level(n.num, n.level).map(|l| l.text.clone()).unwrap_or_default() +} + +/// #3352 follow-up (CodeRabbit): a heading numbered only through its style's linked Multilevel +/// List (#1793) — `props.numbering` itself unset — isn't papered over by a fresh bullet when "- " +/// is typed at its start; the trigger stays as plain text and the heading's own number is kept. +#[test] +fn list_autoformat_does_not_override_a_style_linked_heading_number() { + let mut s = s(); + typ(&mut s, "One"); + run(&mut s, "para.heading1", json!({})); + run(&mut s, "list.multilevelPreset", json!({"preset": "headings"})); + typ(&mut s, "\nTwo"); + run(&mut s, "para.heading2", json!({})); + assert_eq!(numbers(&mut s), [num(Some("1"), "Heading1"), num(Some("1.1"), "Heading2")]); + assert_eq!(s.doc.para_at(&Pos::body(1, 0)).unwrap().props.numbering, None, "numbered through the style, not directly"); + run(&mut s, "caret.home", json!({})); + typ(&mut s, "- "); + assert_eq!(numbers(&mut s), [num(Some("1"), "Heading1"), num(Some("1.1"), "Heading2")], "the heading number survives"); + assert_eq!(paras(&s)[1].0, "- Two", "the trigger stays as typed text, not stripped into a bullet"); +} + +/// A heading numbered only through its style's linked Multilevel List keeps "---" and "+--+--+" +/// as text on Enter, as a paragraph numbered directly does: no border, no table, and at the top +/// of the document the heading keeps its number. +#[test] +fn border_and_table_lines_stay_text_in_a_style_numbered_heading() { + for (above, keys) in [("", "---"), ("Above\n", "---"), ("", "+--+--+"), ("Above\n", "+--+--+")] { + let mut s = s(); + typ(&mut s, &format!("{above}{keys}")); + run(&mut s, "para.heading1", json!({})); + run(&mut s, "list.multilevelPreset", json!({"preset": "headings"})); + let at = Pos::body(s.doc.body.len() - 1, 0); + assert_eq!(s.doc.para_at(&at).unwrap().props.numbering, None, "numbered through the style, not directly"); + typ(&mut s, "\nText"); + assert_eq!(s.doc.plain_text(StoryRef::Body), format!("{above}{keys}\nText"), "{above:?}{keys}"); + assert!(s.doc.body.iter().all(|b| b.as_para().is_some_and(|p| p.props.borders.is_none())), "{above:?}{keys}"); + let mut want = vec![num(Some("1"), "Heading1"), num(None, "Normal")]; + if !above.is_empty() { + want.insert(0, num(None, "Normal")); + } + assert_eq!(numbers(&mut s), want, "{above:?}{keys}"); + } +} + +#[test] +fn list_autoformat_triggers() { + let s = typed("- Item\nNext"); + assert_eq!(label_at(&s, 0), "-"); + assert_eq!(paras(&s), vec![para("Item", Some(0)), para("Next", Some(0))]); + let s = typed("1) a\nb"); + assert_eq!(label_at(&s, 0), "%1)"); + let s = typed("a. alpha\nbeta"); + assert_eq!(paras(&s), vec![para("Alpha", Some(0)), para("beta", Some(0))]); + assert_eq!(label_at(&s, 0), "%1."); + let n = s.doc.para_at(&Pos::body(0, 0)).and_then(|p| p.props.numbering).unwrap(); + assert_eq!(s.doc.numbering.level(n.num, 0).map(|l| l.format), Some(wordcraft_doc::section::NumFormat::LowerLetter)); +} + +/// #3352: typing the trigger at the start of an already-written paragraph (not only a fresh, +/// empty one) still turns it into a list item, keeping the rest of the paragraph's text — as in +/// Word, confirmed in Word for Windows 2026-10-21. +#[test] +fn list_autoformat_triggers_in_front_of_existing_text() { + let mut s = s(); + typ(&mut s, "On peut faire mieux"); + run(&mut s, "caret.home", json!({})); + typ(&mut s, "- "); + assert_eq!(paras(&s), vec![para("On peut faire mieux", Some(0))]); + assert_eq!(label_at(&s, 0), "-"); + // Undo right after takes only the AutoFormat back, as for a fresh paragraph. + run(&mut s, "edit.undo", json!({})); + assert_eq!(paras(&s), vec![("- On peut faire mieux".to_string(), None, false)]); +} + +#[test] +fn typing_1_dot_starts_numbering_again() { + let s = typed("1. a\nb\n\nmid\n1. c\nd"); + let num = |i: usize| s.doc.para_at(&Pos::body(i, 0)).and_then(|p| p.props.numbering).map(|n| n.num); + assert_eq!(num(0), num(1)); + assert_eq!(num(3), num(4)); + assert_ne!(num(0), num(3), "a new list restarts at 1"); +} + +#[test] +fn undo_right_after_autoformat_undoes_only_the_autoformat() { + let mut s = typed("* "); + assert!(paras(&s)[0].1.is_some()); + run(&mut s, "edit.undo", json!({})); + typ(&mut s, "x"); + assert_eq!(paras(&s), vec![("* x".to_string(), None, false)]); + // Same for AutoCorrect. + let mut s = typed("teh "); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc.plain_text(StoryRef::Body), "teh "); +} + +#[test] +fn autocorrect_as_you_type() { + let s = typed("teh cat said hello. world (c) \"quoted\" don't -- yes wait... i think so. e.g. this. "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "The cat said hello. World © “quoted” don’t – yes wait… I think so. E.g. this. "); + // Abbreviations and initials don't end sentences. + let s = typed("see e.g. the map. Ask J. smith or Dr. jones "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "See e.g. the map. Ask J. smith or Dr. jones "); + // Fractions; "iPhone" and file names stay as typed. + let s = typed("1/2 cup. iPhone. see readme.txt "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "½ cup. iPhone. See readme.txt "); +} + +#[test] +fn smart_quotes_nested_and_opening_delimiters() { + let s = typed("\"'nested'\" '\"inner\"' (\"'parens'\")"); + assert_eq!(s.doc.plain_text(StoryRef::Body), "“‘nested’” ‘“inner”’ (“‘parens’”)"); + // Empty quotes preserve open then close + let s = typed("\"\" ''"); + assert_eq!(s.doc.plain_text(StoryRef::Body), "“” ‘’"); + // After opening guillemets and low quotes + let s = typed("«'french'» „'german'“"); + assert_eq!(s.doc.plain_text(StoryRef::Body), "«‘french’» „‘german’“"); +} + +/// #2223: the double-hyphen arrow is a replacement, not the existing arrow bullet. +#[test] +fn double_hyphen_arrow_autocorrect_preserves_typing_scope_and_undo() { + let mut s = typed("go --> "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "Go → "); + assert_eq!(paras(&s)[0].1, None); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc.plain_text(StoryRef::Body), "Go --> "); + let s = typed("go --> here"); + assert_eq!(s.doc.plain_text(StoryRef::Body), "Go → here"); + let s = typed("--> "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "→ "); + assert_eq!(paras(&s)[0].1, None); +} + +#[test] +fn double_hyphen_arrow_respects_autocorrect_preferences() { + for prefs in [json!({"enabled": false}), json!({"replaceText": false}), json!({"delete": "-->"})] { + let mut s = s(); + run(&mut s, "tools.autocorrect", prefs); + typ(&mut s, "go --> here"); + assert!(s.doc.plain_text(StoryRef::Body).ends_with(" --> here")); + } + let mut s = s(); + run(&mut s, "review.language", json!({"lang": "pl-PL", "default": true})); + typ(&mut s, "go --> here"); + assert_eq!(s.doc.plain_text(StoryRef::Body), "Go → here"); + let mut s = self::s(); + run(&mut s, "tools.autocorrect", json!({"add": {"from": "-->", "to": "custom"}})); + typ(&mut s, "go --> here"); + assert_eq!(s.doc.plain_text(StoryRef::Body), "Go custom here"); +} + +/// #835: the English replacement list applies only to English text. Polish "i" ("and") stays +/// lowercase; symbols still change; English and untagged text still get "I". +#[test] +fn autocorrect_replacement_list_follows_the_texts_language() { + // Polish as the document's default language. + let mut s = s(); + run(&mut s, "review.language", json!({"lang": "pl-PL", "default": true})); + typ(&mut s, "Ala i kot. kawa i herbata teh (c) "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "Ala i kot. Kawa i herbata teh © "); + // Polish on the run only: the word's own language counts. + let mut s = self::s(); + typ(&mut s, "Ala"); + run(&mut s, "select.all", json!({})); + run(&mut s, "review.language", json!({"lang": "pl-PL"})); + run(&mut s, "caret.docEnd", json!({})); + typ(&mut s, " i kot "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "Ala i kot "); + // English (any region) and untagged text (the default proofing language is English). + let mut s = self::s(); + run(&mut s, "review.language", json!({"lang": "en-GB", "default": true})); + typ(&mut s, "so i think teh end "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "So I think the end "); + let s = typed("so i think teh end "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "So I think the end "); + // A Polish interface: a new document is Polish by its default language, and text with no + // language at all (a .docx without `w:lang`) follows the editing language too. + let mut s = self::s(); + s.set_editing_language(Some("pl-PL")); + typ(&mut s, "Ala i kot "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "Ala i kot ", "document default from the editing language"); + let mut s = self::s(); + s.set_editing_language(Some("pl-PL")); + Arc::make_mut(&mut s.doc.styles).default_chr.lang = None; + typ(&mut s, "Ala i kot "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "Ala i kot ", "untagged text in the editing language"); + // `eng` is English too, as for proofing. + let mut s = self::s(); + run(&mut s, "review.language", json!({"lang": "eng", "default": true})); + typ(&mut s, "so i think "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "So I think "); +} + +#[test] +fn autocorrect_options_change_what_typing_does() { + let mut s = s(); + // A user entry, a deleted built-in entry, an added exception; names of days. + run(&mut s, "tools.autocorrect", json!({"add": {"from": "wc", "to": "WordCraft"}})); + run(&mut s, "tools.autocorrect", json!({"delete": "teh"})); + run(&mut s, "tools.autocorrect", json!({"addException": "approx."})); + typ(&mut s, "wc ships on monday. teh end is approx. three weeks "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "WordCraft ships on Monday. Teh end is approx. three weeks "); + // AutoFormat options off: straight quotes, no dashes, no list; sentences not capitalised. + let mut s = self::s(); + run(&mut s, "tools.autocorrect", json!({"smartQuotes": false, "dashes": false, "bullets": false, "capSentences": false, "capDays": false})); + typ(&mut s, "* \"hi\" a -- b sunday "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "* \"hi\" a -- b sunday "); + assert_eq!(paras(&s)[0].1, None); +} + +#[test] +fn enter_finishes_a_word_for_autocorrect() { + let s = typed("hello\nteh end\n"); + assert_eq!(s.doc.plain_text(StoryRef::Body), "Hello\nThe end\n"); +} + +/// A word after a no-break space or an ideographic space is corrected as after a plain space: +/// replacements, sentence capitals, ordinals and links. +#[test] +fn autocorrect_finds_the_word_after_a_wide_space() { + let s = typed("a\u{a0}teh x\u{3000}teh "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "a\u{a0}the x\u{3000}the "); + // A narrow no-break space and a figure space too. + let s = typed("a\u{202f}teh x\u{2007}teh "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "a\u{202f}the x\u{2007}the "); + let s = typed("end.\u{a0}hello end.\u{3000}world "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "End.\u{a0}Hello end.\u{3000}World "); + let s = typed("the\u{a0}1st and\u{3000}22nd "); + let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); + let sup = |t: &str| p.props_of_char(p.text.find(t).unwrap() + t.len() - 2).vert_align == Some(VertAlign::Superscript); + assert!(sup("1st") && sup("22nd")); + let s = typed("see\u{a0}https://example.org/log, and\u{3000}https://example.net/day, then "); + let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); + let link = |t: &str| p.props_of_char(p.text.find(t).unwrap()).link.clone(); + assert_eq!(link("https://example.org").as_deref(), Some("https://example.org/log")); + assert_eq!(link("https://example.net").as_deref(), Some("https://example.net/day")); +} + +#[test] +fn ordinals_become_superscript() { + let s = typed("the 1st and 22nd and 13th but not 1th "); + let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); + let sup = |t: &str| { + let i = p.text.find(t).unwrap() + t.len() - 2; + p.props_of_char(i).vert_align == Some(VertAlign::Superscript) + }; + assert!(sup("1st") && sup("22nd") && sup("13th")); + assert!(!sup("1th")); +} + +#[test] +fn bold_and_italic_marks_become_formatting() { + let mut s = s(); + run(&mut s, "tools.autocorrect", json!({"boldItalic": true, "capSentences": false})); + typ(&mut s, "say *very* nice, _quite_ good *two words*. ok\n*end*\nnext"); + assert_eq!(s.doc.plain_text(StoryRef::Body), "say very nice, quite good two words. ok\nend\nnext"); + let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); + let at = |t: &str| p.props_of_char(p.text.find(t).unwrap()); + assert_eq!((at("very").bold, at("very").italic), (Some(true), None)); + assert_eq!((at("quite").italic, at("quite").bold), (Some(true), None)); + // Complex-script text (Arabic, Hebrew) takes the formatting too. + assert_eq!((at("very").bold_cs, at("quite").italic_cs), (Some(true), Some(true))); + assert_eq!(at("two").bold, Some(true)); + assert_eq!(at("words").bold, Some(true)); + for plain in ["say", " nice", ", ", " good", ". ok"] { + assert_eq!((at(plain).bold, at(plain).italic), (None, None), "{plain:?}"); + } + // Enter finishes the marked word too, and the next paragraph is not bold. + let p = s.doc.para_at(&Pos::body(1, 0)).unwrap(); + assert_eq!((p.props_of_char(0).bold, p.mark.bold), (Some(true), None)); + let p = s.doc.para_at(&Pos::body(2, 0)).unwrap(); + assert_eq!((p.props_of_char(0).bold, p.mark.bold), (None, None)); + // With Track Changes on, the paragraph marks carry the same revisions as for unmarked text. + let marks = |keys: &str| { + let mut s = self::s(); + run(&mut s, "tools.autocorrect", json!({"boldItalic": true})); + run(&mut s, "review.trackChanges", json!({"value": true})); + typ(&mut s, keys); + s.doc.body.iter().filter_map(|b| b.as_para()).map(|p| (p.mark.ins.is_some(), p.mark.del.is_some())).collect::>() + }; + assert_eq!(marks("x *end*\n"), marks("x end\n")); + // After a no-break space too. + let mut s = self::s(); + run(&mut s, "tools.autocorrect", json!({"boldItalic": true})); + typ(&mut s, "a\u{a0}*word* "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "a\u{a0}word "); + // A red word between plain marks: the next paragraph takes the closing mark's formatting. + let mut s = self::s(); + run(&mut s, "tools.autocorrect", json!({"boldItalic": true})); + typ(&mut s, "*"); + run(&mut s, "format.color", json!({"color": "FF0000"})); + typ(&mut s, "red"); + run(&mut s, "format.color", json!({"color": "auto"})); + typ(&mut s, "*\nnext"); + let red = s.doc.para_at(&Pos::body(0, 0)).unwrap().props_of_char(0).color; + let p = s.doc.para_at(&Pos::body(1, 0)).unwrap(); + assert!(red.is_some()); + assert_ne!(p.mark.color, red); + assert_ne!(p.props_of_char(0).color, red); + // A heading's next style starts afresh after Enter, as without the marks. + let mut s = self::s(); + run(&mut s, "tools.autocorrect", json!({"boldItalic": true})); + run(&mut s, "para.heading1", json!({})); + run(&mut s, "format.bold", json!({})); + typ(&mut s, "*title*\n"); + let p = s.doc.para_at(&Pos::body(1, 0)).unwrap(); + assert_eq!((p.props.style.as_deref(), p.mark.bold), (Some("Normal"), None)); + // Marks typed before Track Changes was turned on stay: removing them would not be tracked. + let mut s = self::s(); + run(&mut s, "tools.autocorrect", json!({"boldItalic": true})); + typ(&mut s, "x *end*"); + run(&mut s, "review.trackChanges", json!({"value": true})); + typ(&mut s, " "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "X *end* "); + // Marks the same author typed with Track Changes on are converted. + let mut s = self::s(); + run(&mut s, "tools.autocorrect", json!({"boldItalic": true})); + run(&mut s, "review.trackChanges", json!({"value": true})); + typ(&mut s, "x *end* "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "X end "); + // Marks another author inserted stay. + let mut s = self::s(); + run(&mut s, "tools.autocorrect", json!({"boldItalic": true})); + run(&mut s, "review.trackChanges", json!({"value": true})); + run(&mut s, "file.setAuthor", json!({"name": "Ana"})); + typ(&mut s, "x *end*"); + run(&mut s, "file.setAuthor", json!({"name": "Ben"})); + typ(&mut s, " "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "X *end* "); + // Undo right after brings the marks back. + let mut s = self::s(); + run(&mut s, "tools.autocorrect", json!({"boldItalic": true})); + typ(&mut s, "a *b* "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "A b "); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc.plain_text(StoryRef::Body), "A *b* "); + // After Enter, Undo brings the marks back and the Enter stays. + let mut s = self::s(); + run(&mut s, "tools.autocorrect", json!({"boldItalic": true})); + typ(&mut s, "a *b*\n"); + assert_eq!(s.doc.plain_text(StoryRef::Body), "A b\n"); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc.plain_text(StoryRef::Body), "A *b*\n"); +} + +#[test] +fn autocorrect_goes_on_after_bold_and_italic_marks() { + // After a space or punctuation, the word without its marks is replaced and capitalized. + let mut s = s(); + run(&mut s, "tools.autocorrect", json!({"boldItalic": true, "add": {"from": "wc", "to": "WordCraft"}})); + typ(&mut s, "*teh* end. _wc_."); + assert_eq!(s.doc.plain_text(StoryRef::Body), "The end. WordCraft."); + let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); + let at = |t: &str| p.props_of_char(p.text.find(t).unwrap()).clone(); + assert_eq!((at("The").bold, at("WordCraft").italic, at(" end").bold), (Some(true), Some(true), None)); + // One Undo brings the marks back as typed. + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc.plain_text(StoryRef::Body), "The end. _wc_."); + // A marked web address becomes a link, and the text typed after it is neither bold nor italic. + let mut s = self::s(); + run(&mut s, "tools.autocorrect", json!({"boldItalic": true})); + typ(&mut s, "see *www.example.org* and _www.example.com_ next "); + let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); + let at = |t: &str| p.props_of_char(p.text.find(t).unwrap()).clone(); + assert_eq!((at("www.example.org").bold, at("www.example.org").link.is_some()), (Some(true), true)); + assert_eq!((at("www.example.com").italic, at("www.example.com").link.is_some()), (Some(true), true)); + for plain in [" and", " next"] { + assert_eq!((at(plain).bold, at(plain).italic, at(plain).link.is_some()), (None, None, false), "{plain:?}"); + } + // After Enter too; a longer replacement moves the Enter with it, and Undo keeps the Enter. + let mut s = self::s(); + run(&mut s, "tools.autocorrect", json!({"boldItalic": true, "add": {"from": "wc", "to": "WordCraft"}})); + typ(&mut s, "*teh*\nok. *wc*\nnext"); + assert_eq!(s.doc.plain_text(StoryRef::Body), "The\nOk. WordCraft\nnext"); + let p = s.doc.para_at(&Pos::body(1, 0)).unwrap(); + assert_eq!(p.props_of_char(p.text.find("WordCraft").unwrap()).bold, Some(true)); + let mut s = self::s(); + run(&mut s, "tools.autocorrect", json!({"boldItalic": true})); + typ(&mut s, "*teh*\n"); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc.plain_text(StoryRef::Body), "*teh*\n"); + // In a span of several words, the first word is replaced and capitalized as it is typed. + let mut s = self::s(); + run(&mut s, "tools.autocorrect", json!({"boldItalic": true})); + typ(&mut s, "*teh end* and _teh rest_ "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "The end and the rest "); + let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); + let at = |t: &str| p.props_of_char(p.text.find(t).unwrap()).clone(); + assert_eq!((at("The end").bold, at("the rest").italic, at(" and").bold), (Some(true), Some(true), None)); +} + +#[test] +fn bold_and_italic_marks_stay_as_typed_unless_they_enclose_words() { + let mut s = s(); + run(&mut s, "tools.autocorrect", json!({"boldItalic": true, "capSentences": false})); + let keys = "2*3* file_name_ * x* *x * ** __ *?!* _-_ "; + typ(&mut s, keys); + assert_eq!(s.doc.plain_text(StoryRef::Body), keys); + // Marks stay while formatting is limited to the document's styles. + let mut s = self::s(); + run(&mut s, "tools.autocorrect", json!({"boldItalic": true})); + run(&mut s, "review.restrict", json!({"mode": "none", "formatting": true})); + typ(&mut s, "*word* "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "*word* "); + // Marks around an object (here a check box content control) stay. + let mut s = self::s(); + run(&mut s, "tools.autocorrect", json!({"boldItalic": true})); + typ(&mut s, "x *"); + run(&mut s, "developer.checkBox", json!({})); + run(&mut s, "caret.docEnd", json!({})); + typ(&mut s, "* "); + let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); + assert_eq!(p.text.matches('*').count(), 2, "{:?}", p.text); + // Off by default. + let s = typed("*bold* _italic_ "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "*bold* _italic_ "); +} + +#[test] +fn web_addresses_become_links() { + let mut s = typed("see https://example.org/log, then "); + let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); + let at = p.text.find("https").unwrap(); + assert_eq!(p.props_of_char(at).link.as_deref(), Some("https://example.org/log")); + assert_eq!(p.props_of_char(p.text.find(',').unwrap()).link, None, "trailing comma stays outside"); + typ(&mut s, "more"); + let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); + assert_eq!(p.props_of_char(p.text.find("more").unwrap()).link, None); +} + +#[test] +fn dashes() { + let s = typed("a -- b and c - d and e--f and café--g "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "A – b and c – d and e—f and café—g "); +} + +/// #2036: a dash AutoFormat makes right after a symbol is text, not the symbol's font. +#[test] +fn a_dash_after_a_symbol_is_not_in_the_symbol_font() { + let mut s = s(); + typ(&mut s, "e"); + run(&mut s, "insert.symbol", json!({"char": "\u{F0FC}", "font": "Wingdings"})); + typ(&mut s, "--f "); + let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); + let dash = p.text.find('\u{2014}').expect("em dash"); + assert_eq!(p.props_of_char(dash).sym_font, None); +} + +/// #2036: the en dashes of "x -- y" and "x - y" after a symbol (and its symbol-font space) are +/// text, not the symbol's font. +#[test] +fn en_dashes_after_a_symbol_are_not_in_the_symbol_font() { + for keys in ["-- f ", "- f "] { + let mut s = s(); + typ(&mut s, "e"); + run(&mut s, "insert.symbol", json!({"char": "\u{F0FC} ", "font": "Wingdings"})); + typ(&mut s, keys); + let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); + let dash = p.text.find('\u{2013}').unwrap_or_else(|| panic!("en dash for {keys:?}: {:?}", p.text)); + assert_eq!(p.props_of_char(dash).sym_font, None, "{keys:?}"); + } +} + +/// #2036: an AutoCorrect replacement of a word that starts with a symbol-font character is text. +#[test] +fn an_autocorrect_replacement_of_a_symbol_word_is_not_in_the_symbol_font() { + let mut s = s(); + run(&mut s, "insert.symbol", json!({"char": "(", "font": "Symbol"})); + typ(&mut s, "c) "); + let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); + let c = p.text.find('\u{A9}').unwrap_or_else(|| panic!("(c) → ©: {:?}", p.text)); + assert_eq!(p.props_of_char(c).sym_font, None); +} + +/// #2036: a letter AutoCorrect capitalises after a symbol-font character is text, not the symbol's font. +#[test] +fn a_capitalised_word_after_a_symbol_is_not_in_the_symbol_font() { + let mut s = s(); + run(&mut s, "insert.symbol", json!({"char": "m", "font": "Wingdings"})); + typ(&mut s, "onday "); + let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); + let m = p.text.find('M').unwrap_or_else(|| panic!("monday → Monday: {:?}", p.text)); + assert_eq!(p.props_of_char(m).sym_font, None); +} + +/// Whether every character of the body paragraph `i` is marked deleted. +fn all_deleted(s: &Session, i: usize, text: &str) -> bool { + s.doc.para_at(&Pos::body(i, 0)).is_some_and(|p| p.text == text && p.run_ranges().all(|(_, c)| c.del.is_some())) +} + +#[test] +fn a_tracked_border_line_is_a_deletion_and_a_formatting_change() { + let bottom = |s: &Session, i| s.doc.para_at(&Pos::body(i, 0)).and_then(|p| p.props.borders).and_then(|b| b.bottom); + let tracked = |s: &Session, i| s.doc.para_at(&Pos::body(i, 0)).is_some_and(|p| p.props.fmt_change.is_some()); + // Characters that were there before Track Changes was turned on are marked deleted. + let mut s = typed("Above\n---"); + run(&mut s, "review.trackChanges", json!({"value": true})); + typ(&mut s, "\n"); + assert!(bottom(&s, 0).is_some() && tracked(&s, 0)); + assert!(all_deleted(&s, 1, "---"), "{:?}", paras(&s)); + // Enter again after the deleted characters is a plain Enter. + typ(&mut s, "\n"); + assert_eq!(texts(&s), ["above", "---", ""]); + assert_eq!(s.sel.focus, Pos::body(2, 0)); + run(&mut s, "edit.undo", json!({})); + run(&mut s, "review.rejectAll", json!({})); + assert_eq!(texts(&s), ["above", "---"]); + assert!(bottom(&s, 0).is_none()); + run(&mut s, "edit.undo", json!({})); + run(&mut s, "review.acceptAll", json!({})); + assert_eq!(texts(&s), ["above", ""]); + assert!(bottom(&s, 0).is_some()); + // At the top of the document the border goes on the characters' paragraph, and typing + // continues in a new one. + let mut s = typed("---"); + run(&mut s, "review.trackChanges", json!({"value": true})); + typ(&mut s, "\nText"); + assert!(bottom(&s, 0).is_some() && tracked(&s, 0)); + assert!(all_deleted(&s, 0, "---"), "{:?}", paras(&s)); + assert_eq!(texts(&s)[1], "text"); + run(&mut s, "review.rejectAll", json!({})); + assert_eq!(texts(&s), ["---"]); + assert!(bottom(&s, 0).is_none()); + // Typed with Track Changes on, the characters go and the border is still a change. + let mut s = typed(""); + run(&mut s, "review.trackChanges", json!({"value": true})); + typ(&mut s, "---\n"); + assert_eq!(texts(&s), ["", ""]); + assert!(bottom(&s, 0).is_some() && tracked(&s, 0)); + run(&mut s, "review.rejectAll", json!({})); + assert!(bottom(&s, 0).is_none()); +} + +#[test] +fn a_tracked_border_line_at_the_top_supersedes_another_authors_formatting() { + use wordcraft_doc::props::Align; + // Ana centers the characters' paragraph; Ben's Enter gives it the border. The formatting + // change is Ben's and holds Ana's centered paragraph without a border. + let mut s = typed("---"); + run(&mut s, "review.trackChanges", json!({"value": true})); + s.author = "Ana".into(); + run(&mut s, "para.align", json!({"value": "center"})); + s.author = "Ben".into(); + typ(&mut s, "\n"); + let p = s.doc.para_at(&Pos::body(0, 0)).cloned().unwrap_or_default(); + assert!(p.props.borders.and_then(|b| b.bottom).is_some()); + let ch = p.props.fmt_change.as_deref(); + let author = ch.and_then(|c| s.doc.revisions.get(c.rev as usize)).map(|r| r.author.as_str()); + assert_eq!(author, Some("Ben"), "{:?}", s.doc.revisions); + assert_eq!(ch.map(|c| (c.old.align, c.old.borders.and_then(|b| b.bottom).is_some())), Some((Some(Align::Center), false))); + run(&mut s, "review.rejectAll", json!({})); + let p = s.doc.para_at(&Pos::body(0, 0)).cloned().unwrap_or_default(); + assert_eq!((p.props.align, p.props.borders.and_then(|b| b.bottom).is_some()), (Some(Align::Center), false)); +} + +#[test] +fn a_tracked_table_line_keeps_its_characters_as_a_deletion() { + let mut s = typed("+--+------+"); + run(&mut s, "review.trackChanges", json!({"value": true})); + typ(&mut s, "\nA"); + let Some(wordcraft_doc::Block::Table(t)) = s.doc.body.first().map(|b| &**b) else { panic!("no table: {:?}", paras(&s)) }; + assert_eq!(t.rows[0].cells[0].blocks[0].as_para().map(|p| p.text.as_str()), Some("A"), "the caret is in the first cell"); + assert!(all_deleted(&s, 1, "+--+------+"), "{:?}", paras(&s)); + // Enter after the deleted characters makes no second table. + run(&mut s, "caret.set", json!({"pos": {"block": 1, "off": 11}})); + typ(&mut s, "\n"); + assert_eq!(s.doc.body.iter().filter(|b| b.as_para().is_none()).count(), 1, "{:?}", paras(&s)); + run(&mut s, "edit.undo", json!({})); + run(&mut s, "review.rejectAll", json!({})); + assert!(s.doc.body.get(1).and_then(|b| b.as_para()).is_some_and(|p| p.text == "+--+------+")); + // The table was inserted untracked, so Reject All keeps it before the characters. + assert_eq!(s.doc.body.len(), 2, "{:?}", paras(&s)); + assert!(matches!(s.doc.body.first().map(|b| &**b), Some(wordcraft_doc::Block::Table(_)))); +} + +#[test] +fn a_border_line_at_the_top_with_formatting_markup_off_is_not_a_formatting_change() { + // Track Changes on with formatting left out of the markup: the characters are still marked + // deleted, the border is not a formatting change, and Reject All keeps it. + let mut s = typed("---"); + run(&mut s, "review.trackChanges", json!({"value": true})); + s.prefs.markup.track_formatting = false; + typ(&mut s, "\n"); + let p = s.doc.para_at(&Pos::body(0, 0)).cloned().unwrap_or_default(); + assert!(p.props.borders.and_then(|b| b.bottom).is_some()); + assert!(p.props.fmt_change.is_none(), "{:?}", p.props.fmt_change); + assert!(all_deleted(&s, 0, "---"), "{:?}", paras(&s)); + run(&mut s, "review.rejectAll", json!({})); + let p = s.doc.para_at(&Pos::body(0, 0)).cloned().unwrap_or_default(); + assert_eq!(p.text, "---"); + assert!(p.props.borders.and_then(|b| b.bottom).is_some()); +} + +#[test] +fn a_border_line_at_the_top_keeps_the_authors_own_earlier_formatting_change() { + // Ana centers the characters' paragraph, then her Enter gives it the border: the paragraph + // keeps Ana's one formatting change, which holds the paragraph from before both. + let mut s = typed("---"); + run(&mut s, "review.trackChanges", json!({"value": true})); + s.author = "Ana".into(); + run(&mut s, "para.align", json!({"value": "center"})); + let rev = s.doc.para_at(&Pos::body(0, 0)).and_then(|p| p.props.fmt_change.as_deref()).map(|c| c.rev); + typ(&mut s, "\n"); + let p = s.doc.para_at(&Pos::body(0, 0)).cloned().unwrap_or_default(); + assert!(p.props.borders.and_then(|b| b.bottom).is_some()); + let ch = p.props.fmt_change.as_deref(); + assert_eq!(ch.map(|c| c.rev), rev); + assert_eq!(ch.map(|c| (c.old.align, c.old.borders.and_then(|b| b.bottom).is_some())), Some((None, false))); + run(&mut s, "review.rejectAll", json!({})); + let p = s.doc.para_at(&Pos::body(0, 0)).cloned().unwrap_or_default(); + assert_eq!((p.text.as_str(), p.props.align, p.props.borders.and_then(|b| b.bottom).is_some()), ("---", None, false)); +} + +#[test] +fn a_border_line_the_content_control_locks_refuse_to_delete_stays_as_backspace_leaves_it() { + // "---" inside an editable rich text control that sits in one locked against editing: + // Backspace can't delete its characters, so Enter is refused too, untracked, and the line + // stays as it is. + let mut s = Session::new(wordcraft_doc::Document::from_text("A\nB\n---\nC\nD")); + s.sel = crate::Selection { anchor: Pos::body(0, 0), focus: Pos::body(4, 1) }; + run(&mut s, "developer.richText", json!({"tag": "outer"})); + s.sel = crate::Selection { anchor: Pos::body(1, 0), focus: Pos::body(3, 1) }; + run(&mut s, "developer.richText", json!({"tag": "inner"})); + run(&mut s, "developer.properties", json!({"control": "outer", "lockEdit": true})); + let before = s.doc.clone(); + for key in ["text.backspace", "text.newParagraph"] { + s.sel = crate::Selection::caret(Pos::body(2, 3)); + assert!(s.run(key, &json!({})).is_err(), "{key}"); + assert_eq!(s.doc, before, "{key}"); + } + assert!(s.doc.para_at(&Pos::body(2, 0)).is_some_and(|p| p.text == "---" && p.props.borders.is_none())); +} + +#[test] +fn border_line_autoformat() { + let s = typed("Above\n---\nBelow"); + assert_eq!(s.doc.plain_text(StoryRef::Body), "Above\nBelow"); + let b = s.doc.para_at(&Pos::body(0, 0)).and_then(|p| p.props.borders).and_then(|b| b.bottom).unwrap(); + assert_eq!(b.style, BorderStyle::Single); + assert!(s.doc.para_at(&Pos::body(1, 0)).unwrap().props.borders.is_none()); + for (keys, style) in [("===", BorderStyle::Double), ("***", BorderStyle::Dotted), ("___", BorderStyle::Thick)] { + let s = typed(&format!("x\n{keys}\n")); + let b = s.doc.para_at(&Pos::body(0, 0)).and_then(|p| p.props.borders).and_then(|b| b.bottom); + assert_eq!(b.map(|b| b.style), Some(style), "{keys}"); + } + // At the top of the document the line goes under that paragraph and typing continues below. + let s = typed("---\nText"); + assert_eq!(s.doc.plain_text(StoryRef::Body), "\nText"); + assert!(s.doc.para_at(&Pos::body(0, 0)).and_then(|p| p.props.borders).is_some()); + assert!(s.doc.para_at(&Pos::body(1, 0)).unwrap().props.borders.is_none()); +} + +#[test] +fn undo_right_after_a_border_line_brings_the_characters_back() { + let mut s = typed("Above\n---\n"); + assert_eq!(s.doc.plain_text(StoryRef::Body), "Above\n"); + run(&mut s, "edit.undo", json!({})); + // The AutoFormat is undone on its own: the characters are back, the Enter stays. + assert_eq!(s.doc.plain_text(StoryRef::Body), "Above\n---\n"); + assert!(s.doc.body.iter().filter_map(|b| b.as_para()).all(|p| p.props.borders.is_none())); + assert_eq!(s.sel.focus, Pos::body(2, 0)); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc.plain_text(StoryRef::Body), "Above\n---"); +} + +/// The first paragraph's (left, first-line) indents as set on the paragraph itself. +fn indents(s: &Session) -> (Option, Option) { + let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); + (p.props.indent_left, p.props.indent_first) +} + +#[test] +fn tab_and_backspace_at_a_paragraph_start_set_its_indents() { + let mut s = s(); + run(&mut s, "tools.autocorrect", json!({"tabIndent": true})); + typ(&mut s, "Some text"); + run(&mut s, "caret.docStart", json!({})); + // The first Tab indents the first line, the next one the whole paragraph. + typ(&mut s, "\t"); + assert_eq!(indents(&s), (None, Some(36.0))); + typ(&mut s, "\t"); + assert_eq!(indents(&s), (Some(36.0), Some(36.0))); + assert_eq!(s.doc.plain_text(StoryRef::Body), "Some text"); + // Backspace takes the first-line indent back, then the left indent, and stops there. + typ(&mut s, "\u{8}"); + assert_eq!(indents(&s), (Some(36.0), Some(0.0))); + typ(&mut s, "\u{8}\u{8}"); + assert_eq!(indents(&s), (Some(0.0), Some(0.0))); + assert_eq!(s.doc.plain_text(StoryRef::Body), "Some text"); + // Each Tab is one undo step. + typ(&mut s, "\t"); + run(&mut s, "edit.undo", json!({})); + assert_eq!(indents(&s), (Some(0.0), Some(0.0))); + // A left indent in characters goes back one tab stop too, instead of the paragraphs joining: + // six characters of 11 or 12 points are 66 or 72 points, two tab stops. + let mut s = self::s(); + run(&mut s, "tools.autocorrect", json!({"tabIndent": true})); + typ(&mut s, "One\nTwo"); + let second = Pos::body(1, 0); + s.doc.para_mut(StoryRef::Body, &second.path).unwrap().props.indent_left_chars = Some(6.0); + s.sel = crate::Selection::caret(second.clone()); + typ(&mut s, "\u{8}"); + assert_eq!(s.doc.plain_text(StoryRef::Body), "One\nTwo"); + let p = s.doc.para_at(&second).unwrap(); + assert_eq!((p.props.indent_left, p.props.indent_left_chars), (Some(36.0), Some(0.0))); + // With formatting limited to the document's styles the option is off: Tab types a tab + // character at the start of the first line and of a later one, and Backspace at the start + // keeps the paragraph's left indent. + let mut s = self::s(); + run(&mut s, "tools.autocorrect", json!({"tabIndent": true})); + typ(&mut s, "One"); + run(&mut s, "text.lineBreak", json!({})); + typ(&mut s, "Two"); + run(&mut s, "review.restrict", json!({"mode": "none", "formatting": true})); + run(&mut s, "caret.home", json!({})); + typ(&mut s, "\t"); + run(&mut s, "caret.docStart", json!({})); + typ(&mut s, "\t"); + assert_eq!(s.doc.plain_text(StoryRef::Body), "\tOne\n\tTwo"); + assert_eq!(indents(&s), (None, None)); + s.doc.para_mut(StoryRef::Body, &Pos::body(0, 0).path).unwrap().props.indent_left = Some(36.0); + typ(&mut s, "\u{8}\u{8}"); + assert_eq!(s.doc.plain_text(StoryRef::Body), "One\n\tTwo"); + assert_eq!(indents(&s), (Some(36.0), None)); + // An empty paragraph takes a tab character. + let mut s = self::s(); + run(&mut s, "tools.autocorrect", json!({"tabIndent": true})); + typ(&mut s, "\tText"); + assert_eq!(s.doc.plain_text(StoryRef::Body), "\tText"); + assert_eq!(indents(&s), (None, None)); + // At the start of a list item numbered by its style, Tab types a tab character, as with the + // option off. + let mut s = style_inherited_list(wordcraft_doc::numbering::ListKind::Numbered, false); + run(&mut s, "tools.autocorrect", json!({"tabIndent": true})); + run(&mut s, "caret.docStart", json!({})); + typ(&mut s, "\t"); + assert_eq!(s.doc.plain_text(StoryRef::Body), "\tAlpha\nBeta"); + assert_eq!(indents(&s), (None, None)); + // With the option off (the default), Tab types a tab character. + let mut s = self::s(); + typ(&mut s, "Some text"); + run(&mut s, "caret.docStart", json!({})); + typ(&mut s, "\t"); + assert_eq!(s.doc.plain_text(StoryRef::Body), "\tSome text"); + assert_eq!(indents(&s), (None, None)); +} + +#[test] +fn tab_before_a_later_line_of_a_paragraph_indents_the_whole_paragraph() { + let mut s = s(); + run(&mut s, "tools.autocorrect", json!({"tabIndent": true})); + typ(&mut s, &"Words that run on past the end of one line. ".repeat(6)); + run(&mut s, "caret.docStart", json!({})); + run(&mut s, "caret.down", json!({})); + run(&mut s, "caret.home", json!({})); + assert!(s.sel.focus.off > 0, "the caret is on the second line"); + typ(&mut s, "\t"); + assert_eq!(indents(&s), (Some(36.0), None)); + assert!(!s.doc.plain_text(StoryRef::Body).contains('\t')); + // After a line break too; inside a line Tab still types a tab character. + let mut s = self::s(); + run(&mut s, "tools.autocorrect", json!({"tabIndent": true})); + typ(&mut s, "One"); + run(&mut s, "text.lineBreak", json!({})); + typ(&mut s, "Two"); + run(&mut s, "caret.home", json!({})); + typ(&mut s, "\t"); + assert_eq!(indents(&s), (Some(36.0), None)); + run(&mut s, "caret.right", json!({})); + typ(&mut s, "\t"); + assert_eq!(s.doc.plain_text(StoryRef::Body), "One\nT\two"); + // Before a later line of a list item numbered by its style, Tab takes it down a level, as + // for a list item numbered directly. + let mut s = style_inherited_list(wordcraft_doc::numbering::ListKind::Numbered, false); + run(&mut s, "tools.autocorrect", json!({"tabIndent": true})); + run(&mut s, "caret.docStart", json!({})); + run(&mut s, "caret.end", json!({})); + run(&mut s, "text.lineBreak", json!({})); + typ(&mut s, "More"); + run(&mut s, "caret.home", json!({})); + typ(&mut s, "\t"); + assert!(!s.doc.plain_text(StoryRef::Body).contains('\t')); + let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); + assert_eq!(p.props.numbering.map(|n| n.level), Some(1)); + assert_eq!(indents(&s), (None, None)); + // A list level past the last one, from a style or set directly, goes to the last level. + let mut s = style_inherited_list(wordcraft_doc::numbering::ListKind::Numbered, false); + let style = Arc::make_mut(&mut s.doc.styles).styles.iter_mut().find(|x| x.id == "ListBase").unwrap(); + style.para.numbering = style.para.numbering.map(|n| wordcraft_doc::props::NumRef { level: u8::MAX, ..n }); + run(&mut s, "tools.autocorrect", json!({"tabIndent": true})); + run(&mut s, "caret.docStart", json!({})); + run(&mut s, "caret.end", json!({})); + run(&mut s, "text.lineBreak", json!({})); + typ(&mut s, "More"); + run(&mut s, "caret.home", json!({})); + typ(&mut s, "\t"); + assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().props.numbering.map(|n| n.level), Some(8)); + let num = s.doc.para_at(&Pos::body(0, 0)).unwrap().props.numbering.unwrap().num; + s.doc.para_mut(StoryRef::Body, &Pos::body(1, 0).path).unwrap().props.numbering = Some(wordcraft_doc::props::NumRef { num, level: u8::MAX }); + s.sel = crate::Selection::caret(Pos::body(1, 0)); + typ(&mut s, "\t"); + assert_eq!(s.doc.para_at(&Pos::body(1, 0)).unwrap().props.numbering.map(|n| n.level), Some(8)); + s.doc.para_mut(StoryRef::Body, &Pos::body(0, 0).path).unwrap().props.numbering = Some(wordcraft_doc::props::NumRef { num, level: u8::MAX }); + run(&mut s, "caret.docStart", json!({})); + run(&mut s, "caret.down", json!({})); + run(&mut s, "caret.home", json!({})); + assert_eq!((s.sel.focus.path.clone(), s.sel.focus.off), (Pos::body(0, 0).path, 6), "the caret is before \"More\""); + typ(&mut s, "\t"); + assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().props.numbering.map(|n| n.level), Some(8)); + s.doc.para_mut(StoryRef::Body, &Pos::body(0, 0).path).unwrap().props.numbering = Some(wordcraft_doc::props::NumRef { num, level: u8::MAX }); + run(&mut s, "outline.demote", json!({})); + assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().props.numbering.map(|n| n.level), Some(8)); +} + +#[test] +fn with_tab_indents_on_tab_in_a_table_cell_moves_to_the_next_cell() { + let mut s = typed("+--+--+\nA"); + run(&mut s, "tools.autocorrect", json!({"tabIndent": true})); + run(&mut s, "caret.home", json!({})); + typ(&mut s, "\t"); + assert_eq!(s.sel.focus.path.cell().map(|(_, r, c)| (r, c)), Some((0, 1))); + let Some(wordcraft_doc::Block::Table(t)) = s.doc.body.first().map(|b| &**b) else { panic!("no table: {:?}", paras(&s)) }; + let a = t.rows[0].cells[0].blocks[0].as_para().unwrap(); + assert_eq!((a.text.as_str(), a.props.indent_left, a.props.indent_first), ("A", None, None)); +} + +#[test] +fn with_tab_indents_off_backspace_before_an_indented_paragraph_joins_it() { + // The paragraph's own left indent, in points or in characters, doesn't stop the join. + for chars in [false, true] { + let mut s = typed("One\nTwo"); + let second = Pos::body(1, 0); + let p = s.doc.para_mut(StoryRef::Body, &second.path).unwrap(); + if chars { + p.props.indent_left_chars = Some(6.0); + } else { + p.props.indent_left = Some(36.0); + } + s.sel = crate::Selection::caret(second); + typ(&mut s, "\u{8}"); + assert_eq!(s.doc.plain_text(StoryRef::Body), "OneTwo", "indent in characters: {chars}"); + } +} + +#[test] +fn plus_and_hyphen_line_becomes_a_table() { + let mut s = typed("+--+------+\nA"); + let Some(wordcraft_doc::Block::Table(t)) = s.doc.body.first().map(|b| &**b) else { panic!("no table: {:?}", paras(&s)) }; + assert_eq!(t.rows.len(), 1); + assert_eq!(t.grid.len(), 2); + assert!((t.grid[1] / t.grid[0] - 3.0).abs() < 0.01, "widths follow the hyphens: {:?}", t.grid); + assert_eq!(t.rows[0].cells[0].blocks[0].as_para().map(|p| p.text.as_str()), Some("A"), "the caret is in the first cell"); + assert!(s.doc.body.get(1).and_then(|b| b.as_para()).is_some_and(|p| p.is_empty()), "a paragraph follows the table"); + // Undo right after brings the typed characters back, the Enter stays. + run(&mut s, "edit.undo", json!({})); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc.plain_text(StoryRef::Body), "+--+------+\n"); + assert!(s.doc.body.iter().all(|b| b.as_para().is_some())); + // Not a table: text around it, or more columns than a table can hold. + let s = typed("x +--+--+\n"); + assert_eq!(s.doc.plain_text(StoryRef::Body), "X +--+--+\n"); + let s = typed(&format!("+{}\n", "-+".repeat(64))); + assert!(s.doc.body.iter().all(|b| b.as_para().is_some())); +} + +/// Body paragraphs as (text, paragraph style). +fn styled(s: &Session) -> Vec<(String, Option)> { + s.doc.body.iter().filter_map(|b| b.as_para()).map(|p| (p.text.clone(), p.props.style.clone())).collect() +} + +fn with_heading_styles(keys: &str) -> Session { + let mut s = s(); + run(&mut s, "tools.autocorrect", json!({"headingStyles": true})); + typ(&mut s, keys); + s +} + +#[test] +fn two_enters_after_a_short_line_make_it_a_heading() { + let h = |t: &str, st: Option<&str>| (t.to_string(), st.map(str::to_string)); + // No tab: Heading 1; one or two tabs: Heading 2 or 3, without the tabs. Typing goes on in + // the paragraph after the heading. + for (keys, style) in [("Introduction\n\nText", "Heading1"), ("\tOur methods\n\nText", "Heading2"), ("\t\tStep 2\n\nText", "Heading3")] { + let s = with_heading_styles(keys); + let text = keys.trim_start_matches('\t').split('\n').next().unwrap_or_default(); + assert_eq!(styled(&s), [h(text, Some(style)), h("Text", None)], "{keys:?}"); + } + // Five words is the most; one Enter only ends the line. + let s = with_heading_styles("One two three four five\n\n"); + assert_eq!(s.doc.body.first().and_then(|b| b.as_para()).and_then(|p| p.props.style.as_deref()), Some("Heading1")); + assert_eq!(styled(&with_heading_styles("Introduction\nText")), [h("Introduction", None), h("Text", None)]); +} + +#[test] +fn a_line_that_is_not_a_short_line_of_body_text_stays_as_it_is() { + // Not a heading: six words, a closing punctuation mark, three tabs, a list item, a style. + for keys in ["One two three four five six\n\n", "Dear Sir,\n\n", "Is it?\n\n", "\t\t\tDeep\n\n", "* Item\n\n"] { + let s = with_heading_styles(keys); + assert!( + s.doc.body.iter().filter_map(|b| b.as_para()).all(|p| p.props.style.as_deref().is_none_or(|st| !st.starts_with("Heading"))), + "{keys:?}: {:?}", + styled(&s) + ); + } + let mut q = self::s(); + run(&mut q, "tools.autocorrect", json!({"headingStyles": true})); + run(&mut q, "para.style", json!({"style": "Quote"})); + typ(&mut q, "Quoted\n\n"); + assert_eq!(q.doc.body.first().and_then(|b| b.as_para()).and_then(|p| p.props.style.as_deref()), Some("Quote")); + // A list item numbered by the Normal style is not body text either. + let mut q = self::s(); + run(&mut q, "tools.autocorrect", json!({"headingStyles": true})); + let num = Arc::make_mut(&mut q.doc.numbering).try_add_list(wordcraft_doc::ListKind::Bullet).unwrap(); + if let Some(normal) = Arc::make_mut(&mut q.doc.styles).get_mut("Normal") { + normal.para.numbering = Some(wordcraft_doc::props::NumRef { num, level: 0 }); + } + typ(&mut q, "Item\n\n"); + assert!(styled(&q).iter().all(|(_, st)| st.as_deref().is_none_or(|st| !st.starts_with("Heading"))), "{:?}", styled(&q)); + // A line with an outline level stays; the body-text level (9 and above) is no outline level. + for (level, style) in [(0, None), (8, None), (9, Some("Heading1")), (u8::MAX, Some("Heading1"))] { + let mut q = with_heading_styles("Title\n"); + q.doc.para_mut(StoryRef::Body, &Pos::body(0, 0).path).unwrap().props.outline_level = Some(level); + typ(&mut q, "\n"); + assert_eq!(styled(&q).first().and_then(|(_, st)| st.as_deref()), style, "outline level {level}"); + } +} + +#[test] +fn built_in_heading_styles_are_off_by_default() { + let s = typed("Introduction\n\n"); + let h = |t: &str| (t.to_string(), None); + assert_eq!(styled(&s), [h("Introduction"), h(""), h("")]); +} + +#[test] +fn undo_right_after_a_heading_autoformat_keeps_the_enter() { + let mut s = with_heading_styles("\tMethods\n\n"); + assert_eq!(styled(&s), [("Methods".to_string(), Some("Heading2".to_string())), (String::new(), None)]); + assert_eq!(s.sel.focus, Pos::body(1, 0)); + run(&mut s, "edit.undo", json!({})); + // The AutoFormat is undone on its own: the tab and Normal text are back, the Enter stays. + assert_eq!(s.doc.plain_text(StoryRef::Body), "\tMethods\n\n"); + assert!(styled(&s).iter().all(|(_, st)| st.is_none())); + assert_eq!(s.sel.focus, Pos::body(2, 0)); +} + +#[test] +fn a_tracked_heading_autoformat_can_be_rejected() { + // Enter in the empty paragraph turns a zero-tab line into Heading 1. + let mut s = with_heading_styles("Title\n"); + run(&mut s, "review.trackChanges", json!({"value": true})); + s.author = "Ana".into(); + typ(&mut s, "\n"); + let p = s.doc.para_at(&Pos::body(0, 0)).cloned().unwrap_or_default(); + assert_eq!((p.text.as_str(), p.props.style.as_deref()), ("Title", Some("Heading1"))); + let ch = p.props.fmt_change.as_deref(); + assert_eq!(ch.and_then(|c| s.doc.revisions.get(c.rev as usize)).map(|r| r.author.as_str()), Some("Ana")); + run(&mut s, "review.rejectAll", json!({})); + assert_eq!(styled(&s).first(), Some(&("Title".to_string(), None))); + + // With Track Changes on, the tab is marked deleted and the style change is a formatting + // revision, so Reject All brings the line back as it was. + let mut s = with_heading_styles("\tMethods\n"); + run(&mut s, "review.trackChanges", json!({"value": true})); + typ(&mut s, "\n"); + let p = s.doc.para_at(&Pos::body(0, 0)).cloned().unwrap_or_default(); + assert_eq!((p.text.as_str(), p.props.style.as_deref()), ("\tMethods", Some("Heading2"))); + assert!(p.run_ranges().any(|(r, c)| r.start == 0 && c.del.is_some()), "the tab is marked deleted"); + assert!(p.props.fmt_change.is_some(), "the style change is tracked"); + run(&mut s, "review.rejectAll", json!({})); + assert_eq!(styled(&s).first(), Some(&("\tMethods".to_string(), None))); + // A tab this author inserted is removed outright, and the style change is still tracked. + let mut s = with_heading_styles("Title\n"); + run(&mut s, "review.trackChanges", json!({"value": true})); + run(&mut s, "caret.set", json!({"pos": {"block": 0, "off": 0}})); + typ(&mut s, "\t"); + run(&mut s, "caret.set", json!({"pos": {"block": 1, "off": 0}})); + typ(&mut s, "\n"); + let p = s.doc.para_at(&Pos::body(0, 0)).cloned().unwrap_or_default(); + assert_eq!((p.text.as_str(), p.props.style.as_deref()), ("Title", Some("Heading2"))); + assert!(p.props.fmt_change.is_some(), "the style change is tracked"); + run(&mut s, "review.rejectAll", json!({})); + assert_eq!(styled(&s).first(), Some(&("Title".to_string(), None))); + // A line holding a tracked deletion is left alone. + let mut s = with_heading_styles("Old title\n"); + run(&mut s, "review.trackChanges", json!({"value": true})); + run(&mut s, "caret.set", json!({"pos": {"block": 0, "off": 3}})); + typ(&mut s, "\u{8}\u{8}\u{8}"); + run(&mut s, "caret.set", json!({"pos": {"block": 1, "off": 0}})); + typ(&mut s, "\n"); + assert!(styled(&s).iter().all(|(_, st)| st.is_none()), "{:?}", styled(&s)); +} + +#[test] +fn a_tracked_heading_autoformat_supersedes_another_authors_formatting() { + use wordcraft_doc::props::Align; + // Ana centers a zero-tab line; Ben's Enter makes it Heading 1. + let mut s = with_heading_styles("Title\n"); + run(&mut s, "review.trackChanges", json!({"value": true})); + s.author = "Ana".into(); + run(&mut s, "caret.set", json!({"pos": {"block": 0, "off": 0}})); + run(&mut s, "para.align", json!({"value": "center"})); + s.author = "Ben".into(); + run(&mut s, "caret.set", json!({"pos": {"block": 1, "off": 0}})); + typ(&mut s, "\n"); + let p = s.doc.para_at(&Pos::body(0, 0)).cloned().unwrap_or_default(); + assert_eq!((p.text.as_str(), p.props.style.as_deref()), ("Title", Some("Heading1"))); + let ch = p.props.fmt_change.as_deref(); + assert_eq!(ch.and_then(|c| s.doc.revisions.get(c.rev as usize)).map(|r| r.author.as_str()), Some("Ben")); + assert_eq!(ch.map(|c| (c.old.style.as_deref(), c.old.align)), Some((None, Some(Align::Center)))); + run(&mut s, "review.rejectAll", json!({})); + let p = s.doc.para_at(&Pos::body(0, 0)).cloned().unwrap_or_default(); + assert_eq!((p.text.as_str(), p.props.style.as_deref(), p.props.align), ("Title", None, Some(Align::Center))); + + // Ana centers the line; Ben types a tab before it and makes it Heading 2. Ben's tab is + // removed, and the heading's formatting change is Ben's, holding Ana's centered Normal line. + let mut s = with_heading_styles("Title\n"); + run(&mut s, "review.trackChanges", json!({"value": true})); + s.author = "Ana".into(); + run(&mut s, "caret.set", json!({"pos": {"block": 0, "off": 0}})); + run(&mut s, "para.align", json!({"value": "center"})); + s.author = "Ben".into(); + typ(&mut s, "\t"); + run(&mut s, "caret.set", json!({"pos": {"block": 1, "off": 0}})); + typ(&mut s, "\n"); + let p = s.doc.para_at(&Pos::body(0, 0)).cloned().unwrap_or_default(); + assert_eq!((p.text.as_str(), p.props.style.as_deref()), ("Title", Some("Heading2"))); + let ch = p.props.fmt_change.as_deref(); + let author = ch.and_then(|c| s.doc.revisions.get(c.rev as usize)).map(|r| r.author.as_str()); + assert_eq!(author, Some("Ben"), "{:?}", s.doc.revisions); + assert_eq!(ch.map(|c| (c.old.style.as_deref(), c.old.align)), Some((None, Some(Align::Center)))); + // Rejecting it brings back Ana's centered Normal line. + run(&mut s, "review.rejectAll", json!({})); + let p = s.doc.para_at(&Pos::body(0, 0)).cloned().unwrap_or_default(); + assert_eq!((p.text.as_str(), p.props.style.as_deref(), p.props.align), ("Title", None, Some(Align::Center))); + // The author's own earlier change keeps the formatting before it. + let mut s = with_heading_styles("Title\n"); + run(&mut s, "review.trackChanges", json!({"value": true})); + s.author = "Ana".into(); + run(&mut s, "caret.set", json!({"pos": {"block": 0, "off": 0}})); + run(&mut s, "para.align", json!({"value": "center"})); + typ(&mut s, "\t"); + run(&mut s, "caret.set", json!({"pos": {"block": 1, "off": 0}})); + typ(&mut s, "\n"); + let p = s.doc.para_at(&Pos::body(0, 0)).cloned().unwrap_or_default(); + let ch = p.props.fmt_change.as_deref(); + assert_eq!(ch.and_then(|c| s.doc.revisions.get(c.rev as usize)).map(|r| r.author.as_str()), Some("Ana")); + assert_eq!(ch.map(|c| c.old.style.as_deref()), Some(None)); + assert!(ch.is_some_and(|c| c.old.align != Some(Align::Center)), "{ch:?}"); +} + +#[test] +fn a_locked_heading_style_is_not_applied_as_you_type() { + let mut s = s(); + run(&mut s, "tools.autocorrect", json!({"headingStyles": true})); + run(&mut s, "review.restrict", json!({"mode": "none", "formatting": true, "lockedStyles": ["Heading1"]})); + typ(&mut s, "Locked\n\n\tOpen\n\n"); + let st = styled(&s); + assert_eq!(st.first(), Some(&("Locked".to_string(), None)), "{st:?}"); + assert!(st.contains(&("Open".to_string(), Some("Heading2".to_string()))), "{st:?}"); +} + +#[test] +fn enter_in_an_editable_region_after_a_protected_short_line_breaks_the_paragraph() { + // Read only, with an editable region from the end of "Title" to the start of "End.": Enter + // twice in the region breaks the paragraph and leaves the protected line Normal text, and a + // border line typed there goes under the paragraph above it, inside the region. + let mut s = Session::new(wordcraft_doc::Document::from_text("Title\nX\nEnd.")); + run(&mut s, "tools.autocorrect", json!({"headingStyles": true})); + s.sel = crate::Selection { anchor: Pos::body(0, 5), focus: Pos::body(2, 0) }; + run(&mut s, "review.restrict", json!({"exception": "add"})); + run(&mut s, "review.restrict", json!({"mode": "readOnly"})); + run(&mut s, "select.text", json!({"text": "X"})); + typ(&mut s, "\n\n"); + assert_eq!(s.doc.body.len(), 5, "{:?}", styled(&s)); + assert!(styled(&s).iter().all(|(_, st)| st.is_none()), "{:?}", styled(&s)); + typ(&mut s, "---\n"); + assert!(s.doc.para_at(&Pos::body(2, 0)).and_then(|p| p.props.borders).and_then(|b| b.bottom).is_some()); + assert_eq!(s.doc.plain_text(StoryRef::Body).replace(wordcraft_doc::para::OBJ, ""), "Title\n\n\n\nEnd."); +} + +#[test] +fn arrow_bullets() { + for (keys, bullet) in [("> ", "\u{27A2}"), ("-> ", "\u{2794}"), ("=> ", "\u{21E8}")] { + let s = typed(&format!("{keys}item\nnext")); + assert_eq!(paras(&s), vec![para("Item", Some(0)), para("next", Some(0))], "{keys}"); + assert_eq!(label_at(&s, 0), bullet, "{keys}"); + } + // An arrow list doesn't join a plain bullet list. + let s = typed("* a\n\n> b"); + assert_eq!(label_at(&s, 0), "\u{2022}"); + assert_eq!(label_at(&s, 1), "\u{27A2}"); +} + +#[test] +fn smileys_are_replaced_inside_words_too() { + let s = typed("so ok:) sad:( meh:| :) fine:-) "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "So ok\u{263A} sad\u{2639} meh\u{1F610} \u{263A} fine\u{263A} "); +} + +#[test] +fn a_large_mixed_document_types_like_word() { + // The corpus typed into Word and WordCraft side by side; the paragraph structure must match. + let mut s = s(); + let prose = "The keeper's log runs to four hundred pages, and almost every one begins with the weather. "; + typ(&mut s, &format!("Notes\n{}\n", prose.repeat(20))); + typ(&mut s, "The supplies were:\n* Lamp oil, 1/2 barrel\nWicks of the 1st quality\n\tSpare chimneys\nSpare springs\n"); + run(&mut s, "text.backTab", json!({})); + typ(&mut s, "Salt beef\n\nThe schedule:\n1. Signal the boat\nLower the stores\n\tCheck the net\n"); + run(&mut s, "text.backTab", json!({})); + typ(&mut s, "Sign the manifest\n\n---\nIn March the storm broke -- and he slept.\n"); + typ(&mut s, "a. Inspect the glass\nReplace the seals\n\n"); + typ(&mut s, &format!("Afterword. teh keeper retired. {}\n* one more\nsecond\n\n\nFinal.", prose.repeat(10))); + let got: Vec<(String, Option)> = paras(&s).into_iter().map(|(t, l, _)| (t.chars().take(24).collect(), l)).collect(); + let want: Vec<(&str, Option)> = vec![ + ("Notes", None), + ("The keeper’s log runs to", None), + ("The supplies were:", None), + ("Lamp oil, ½ barrel", Some(0)), + ("Wicks of the 1st quality", Some(0)), + ("Spare chimneys", Some(1)), + ("Spare springs", Some(1)), + ("Salt beef", Some(0)), + ("The schedule:", None), + ("Signal the boat", Some(0)), + ("Lower the stores", Some(0)), + ("Check the net", Some(1)), + ("Sign the manifest", Some(0)), + ("In March the storm broke", None), + ("Inspect the glass", Some(0)), + ("Replace the seals", Some(0)), + ("Afterword. The keeper re", None), + ("One more", Some(0)), + ("Second", Some(0)), + ("", None), + ("Final.", None), + ]; + let want: Vec<(String, Option)> = want.into_iter().map(|(t, l)| (t.to_string(), l)).collect(); + assert_eq!(got, want); + // Every non-list paragraph is Normal (no list indent left behind). + for (t, l, lp) in paras(&s) { + assert!(l.is_some() || !lp, "{t:?} still a List Paragraph"); + } +} + +#[test] +fn enter_right_after_starting_a_list_cancels_it() { + let s = typed("* \nPlain"); + assert_eq!(paras(&s), vec![para("Plain", None)]); +} + +#[test] +fn shift_enter_stays_inside_the_list_item() { + let mut s = typed("* line one"); + run(&mut s, "text.lineBreak", json!({})); + typ(&mut s, "cont\ntwo"); + assert_eq!(paras(&s), vec![para("Line one\ncont", Some(0)), para("two", Some(0))]); +} + +/// Body paragraph texts, lower-cased (AutoCorrect may capitalise a sentence start). +fn texts(s: &Session) -> Vec { + paras(s).into_iter().map(|p| p.0.to_lowercase()).collect() +} + +#[test] +fn tracked_enter_and_backspace_track_the_paragraph_mark() { + // Issue #229: with Track Changes on, Enter inserts a tracked paragraph mark that Reject All + // takes out again; Backspace over your own Enter removes it outright. + let mut s = typed("one"); + run(&mut s, "review.trackChanges", json!({"value": true})); + typ(&mut s, "\ntwo"); + assert_eq!(texts(&s), ["one", "two"]); + typ(&mut s, "\u{8}\u{8}\u{8}\u{8}"); + assert_eq!(texts(&s), ["one"]); + typ(&mut s, "\n"); + assert_eq!(texts(&s), ["one", ""]); + run(&mut s, "review.rejectAll", json!({})); + assert_eq!(texts(&s), ["one"]); + // A break that was already there isn't removed: Backspace marks it deleted and moves before it. + let mut s = typed("one\ntwo"); + run(&mut s, "review.trackChanges", json!({"value": true})); + run(&mut s, "caret.set", json!({"pos": {"block": 1, "off": 0}})); + typ(&mut s, "\u{8}"); + assert_eq!(texts(&s), ["one", "two"]); + assert_eq!(s.sel.focus, Pos::body(0, 3)); + assert!(s.doc.para_at(&Pos::body(0, 0)).is_some_and(|p| p.mark.del.is_some())); +} + +/// Linked text in each body paragraph, and whether its paragraph mark carries a link. +fn links(s: &Session) -> Vec<(String, bool)> { + s.doc + .body + .iter() + .filter_map(|b| b.as_para()) + .map(|p| { + let linked = p.run_ranges().filter(|(_, c)| c.link.is_some()).filter_map(|(r, _)| p.text.get(r)).collect(); + (linked, p.mark.link.is_some()) + }) + .collect() +} + +#[test] +fn a_link_covers_only_its_own_text() { + // Issue #413: linking a whole paragraph also linked its mark, so Enter carried the link into + // the next paragraph and everything typed there (or hovered below it) became the link. + let mut s = typed("hi\nhello"); + run(&mut s, "select.text", json!({"text": "hello"})); + run(&mut s, "insert.link", json!({"url": "https://example.org/"})); + run(&mut s, "caret.set", json!({"pos": {"block": 1, "off": 5}})); + typ(&mut s, "\nnext"); + run(&mut s, "caret.set", json!({"pos": {"block": 1, "off": 0}})); + typ(&mut s, "\n"); + run(&mut s, "caret.set", json!({"pos": {"block": 1, "off": 0}})); + typ(&mut s, "above"); + assert_eq!(texts(&s), ["hi", "above", "hello", "next"]); + let unlinked = (String::new(), false); + assert_eq!(links(&s), [unlinked.clone(), unlinked.clone(), ("Hello".into(), false), unlinked]); +} + +#[test] +fn typing_at_either_end_of_a_link_is_not_linked() { + // As in Word: a link typed in with Ctrl+K, then text typed after it, at its end later on, + // at its start, or after Enter, stays outside the link; typing inside it extends it. + let mut s = typed("see "); + run(&mut s, "insert.link", json!({"url": "https://example.org/", "text": "site"})); + typ(&mut s, " now"); + run(&mut s, "caret.set", json!({"pos": {"block": 0, "off": 8}})); + typ(&mut s, "!"); + run(&mut s, "caret.set", json!({"pos": {"block": 0, "off": 4}})); + typ(&mut s, "a "); + run(&mut s, "caret.set", json!({"pos": {"block": 0, "off": 8}})); + typ(&mut s, "x"); + run(&mut s, "caret.set", json!({"pos": {"block": 0, "off": 11}})); + typ(&mut s, "\nmore"); + assert_eq!(texts(&s), ["see a sixte", "more! now"]); + assert_eq!(links(&s), [("sixte".into(), false), (String::new(), false)]); +} + +#[test] +fn a_link_on_a_selection_shows_the_text_to_display() { + // Ctrl+K on selected text with a new Text to display: the selection is retyped with it and + // linked, keeping its formatting; typing after it stays outside the link (as in Word). + let mut s = typed("go see kiln now"); + run(&mut s, "select.text", json!({"text": "kiln"})); + run(&mut s, "format.bold", json!({})); + run(&mut s, "insert.link", json!({"url": "https://example.org/", "text": "Oven"})); + typ(&mut s, "!"); + assert_eq!(texts(&s), ["go see oven! now"]); + assert_eq!(links(&s), [("Oven".into(), false)]); + let p = s.doc.body.iter().find_map(|b| b.as_para()).unwrap(); + assert!(p.run_ranges().any(|(r, c)| &p.text[r] == "Oven" && c.bold == Some(true)), "the new text keeps the selection's formatting"); + // The same text, or text that can't stand in one line, leaves the selection as it is. + run(&mut s, "select.text", json!({"text": "see"})); + run(&mut s, "insert.link", json!({"url": "https://example.org/", "text": "see"})); + run(&mut s, "select.text", json!({"text": "now"})); + run(&mut s, "insert.link", json!({"url": "https://example.org/", "text": "two\nlines"})); + assert_eq!(texts(&s), ["go see oven! now"]); +} + +#[test] +fn a_new_selection_drops_the_formatting_left_after_a_link() { + // A link leaves plain formatting for what is typed after it. A selection made next doesn't + // take it: retyping that selection keeps its own formatting. + let bold_run = |s: &Session, text: &str| { + let p = s.doc.body.iter().find_map(|b| b.as_para()).unwrap(); + p.run_ranges().any(|(r, c)| &p.text[r] == text && c.bold == Some(true)) + }; + let mut s = typed("kiln"); + run(&mut s, "select.text", json!({"text": "kiln"})); + run(&mut s, "format.bold", json!({})); + run(&mut s, "insert.link", json!({"url": "https://example.org/", "text": "Oven"})); + run(&mut s, "select.all", json!({})); + run(&mut s, "insert.link", json!({"url": "https://example.org/", "text": "Stove"})); + assert!(bold_run(&s, "Stove"), "select.all"); + run(&mut s, "select.word", json!({})); + run(&mut s, "insert.link", json!({"url": "https://example.org/", "text": "Range"})); + assert!(bold_run(&s, "Range"), "select.word"); + let anchor = serde_json::to_value(s.doc.start_of(s.sel.focus.story)).unwrap(); + let focus = serde_json::to_value(s.sel.focus.clone()).unwrap(); + run(&mut s, "select.range", json!({"anchor": anchor, "focus": focus})); + run(&mut s, "insert.link", json!({"url": "https://example.org/", "text": "Hob"})); + assert!(bold_run(&s, "Hob"), "select.range"); +} + +#[test] +fn a_link_over_two_paragraphs_keeps_their_text() { + // Word shows <> for such a selection: the text to display is ignored. + let mut s = typed("one\ntwo"); + run(&mut s, "select.all", json!({})); + run(&mut s, "insert.link", json!({"url": "https://example.org/", "text": "both"})); + assert_eq!(texts(&s), ["one", "two"]); + assert_eq!(links(&s), [("One".into(), false), ("two".into(), false)]); +} + +#[test] +fn a_retyped_link_text_is_tracked() { + let mut s = typed("go see kiln now"); + run(&mut s, "review.trackChanges", json!({"value": true})); + run(&mut s, "select.text", json!({"text": "kiln"})); + run(&mut s, "insert.link", json!({"url": "https://example.org/", "text": "Oven"})); + let p = s.doc.body.iter().find_map(|b| b.as_para()).unwrap(); + assert!(p.run_ranges().any(|(r, c)| &p.text[r] == "kiln" && c.del.is_some()), "the old text stays as a deletion"); + assert!(p.run_ranges().any(|(r, c)| &p.text[r] == "Oven" && c.ins.is_some() && c.link.is_some())); +} + +#[test] +fn a_link_keeps_a_selection_it_cannot_retype() { + use wordcraft_doc::props::CharProps; + use wordcraft_doc::{InlineObject, Path}; + let url = "https://example.org/"; + // A second retyped link keeps its own selection's formatting: the first one's left no + // formatting pending for it. + let mut s = typed("go see kiln and vat now"); + run(&mut s, "select.text", json!({"text": "kiln"})); + run(&mut s, "insert.link", json!({"url": url, "text": "Oven"})); + run(&mut s, "select.text", json!({"text": "vat"})); + run(&mut s, "format.bold", json!({})); + run(&mut s, "insert.link", json!({"url": url, "text": "Tub"})); + let p = s.doc.body.iter().find_map(|b| b.as_para()).unwrap(); + assert!(p.run_ranges().any(|(r, c)| &p.text[r] == "Tub" && c.bold == Some(true) && c.link.is_some()), "{:?}", p.text); + // A selection holding a manual line break keeps both lines. + let mut s = typed("one"); + run(&mut s, "text.lineBreak", json!({})); + typ(&mut s, "two"); + run(&mut s, "select.all", json!({})); + run(&mut s, "insert.link", json!({"url": url, "text": "both"})); + assert_eq!(s.doc.plain_text(StoryRef::Body), "one\ntwo"); + // A selection in a field's result is only linked; the field's next update owns that text. + let mut doc = wordcraft_doc::Document::from_text("See "); + let c = CharProps::default(); + let p = doc.para_mut(StoryRef::Body, &Path::top(0)).unwrap(); + let start = p.len(); + p.insert_object(start, InlineObject::FieldStart { instr: "ADDIN ZOTERO_ITEM CSL_CITATION {}".into(), locked: false, dirty: false }, &c).unwrap(); + let n = p.insert_text(p.len(), "(Smith 2020)", &c).unwrap(); + p.insert_object(start + 3 + n, InlineObject::FieldEnd, &c).unwrap(); + let mut s = Session::new(doc); + run(&mut s, "select.text", json!({"text": "Smith"})); + run(&mut s, "insert.link", json!({"url": url, "text": "Jones"})); + assert_eq!(s.doc.plain_text(StoryRef::Body), "See (Smith 2020)"); + assert_eq!(s.doc.field_ranges(StoryRef::Body).len(), 1); +} + +#[test] +fn typing_then_selecting_then_typing_are_two_undo_steps() { + // #419: a selection that moves the insertion point ends the typing group, like a caret move. + let mut s = typed("alpha"); + run(&mut s, "select.text", json!({"text": "ph"})); + run(&mut s, "text.insert", json!({"text": "X"})); + assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().text, "alXa"); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().text, "alpha"); + assert_eq!(s.selected_text(), "ph"); + assert!(s.can_undo()); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().text, ""); + // Uninterrupted typing (a read-only command in between included) is still one step. + let mut s = typed("alpha"); + run(&mut s, "document.inspect", json!({})); + typ(&mut s, "X"); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().text, ""); +} + +#[test] +fn backspace_at_the_start_of_the_document_is_not_an_edit() { + // #23: a key that changes nothing adds no undo step, marks no unsaved changes and + // leaves the redo stack alone. + let mut s = typed("alpha"); + run(&mut s, "caret.set", json!({"pos": {"block": 0, "off": 0}})); + s.dirty = false; + let depth = s.undo_depth(); + typ(&mut s, "\u{8}"); + assert_eq!(s.undo_depth(), depth, "no undo step"); + assert!(!s.dirty, "no unsaved changes"); + assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().text, "alpha"); +} + +#[test] +fn undoing_bold_at_the_caret_turns_bold_typing_off_again() { + // #424: Bold at a caret in an empty paragraph, Undo, type: the text isn't bold. + let mut s = s(); + run(&mut s, "format.bold", json!({"value": true})); + run(&mut s, "edit.undo", json!({})); + assert_ne!(s.typing_props().bold, Some(true)); + typ(&mut s, "abc"); + let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); + assert_eq!(p.text, "abc"); + assert_ne!(p.props_of_char(0).bold, Some(true)); + // Redo brings bold typing back. + let mut s = Session::new(wordcraft_doc::Document::new()); + run(&mut s, "format.bold", json!({"value": true})); + run(&mut s, "edit.undo", json!({})); + run(&mut s, "edit.redo", json!({})); + typ(&mut s, "abc"); + assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().props_of_char(0).bold, Some(true)); +} + +/// Myanmar (#604): typed in memory order (consonant, medial, then the prevowel ေ that is drawn +/// before it), the caret steps over whole syllables and Backspace removes one sign at a time. +#[test] +fn myanmar_typing_steps_by_syllable_and_backspaces_by_sign() { + // ကြေး ("money"): က + ြ + ေ + း, then မြန်မာ. + let word = "\u{1000}\u{103C}\u{1031}\u{1038}"; + let mut s = typed(word); + assert_eq!(paras(&s), vec![para(word, None)]); + // Backspace takes the visarga, then the vowel sign: the consonant and medial stay. + typ(&mut s, "\u{8}"); + assert_eq!(paras(&s)[0].0, "\u{1000}\u{103C}\u{1031}"); + typ(&mut s, "\u{8}\u{1031}\u{1038}"); + assert_eq!(paras(&s)[0].0, word, "retyped"); + // Left from the end skips the whole syllable (the visarga and aa signs aren't grapheme + // extenders, so a grapheme step would stop inside it). + let mut s = typed("\u{1019}\u{102C}\u{1005}\u{102C}"); // မာစာ + run(&mut s, "caret.left", json!({})); + assert_eq!(s.sel.focus.off, 6); + run(&mut s, "caret.left", json!({})); + assert_eq!(s.sel.focus.off, 0); + run(&mut s, "caret.right", json!({})); + assert_eq!(s.sel.focus.off, 6); + // Delete removes the whole syllable after the caret. + run(&mut s, "text.delete", json!({})); + assert_eq!(paras(&s)[0].0, "\u{1019}\u{102C}"); +} + +/// Devanagari and Khmer: Backspace removes the last character (a matra), not the conjunct. +#[test] +fn indic_and_khmer_backspace_removes_one_character() { + let mut s = typed("\u{0915}\u{094D}\u{0937}\u{093F}\u{8}"); // क्षि ⌫ + assert_eq!(paras(&s)[0].0, "\u{0915}\u{094D}\u{0937}"); + typ(&mut s, "\u{8}"); + assert_eq!(paras(&s)[0].0, "\u{0915}\u{094D}"); + let s = typed("\u{1781}\u{17D2}\u{1798}\u{17C2}\u{8}"); // ខ្មែ ⌫ + assert_eq!(paras(&s)[0].0, "\u{1781}\u{17D2}\u{1798}"); + // Latin keeps grapheme Backspace: e + combining acute goes as one. + let s = typed("ae\u{301}\u{8}"); + assert_eq!(paras(&s)[0].0, "a"); +} + +/// Arabic vowel and Quranic marks are individually editable without retyping their base (#4757). +#[test] +fn arabic_backspace_removes_one_mark_and_keeps_the_base() { + for mark in [ + '\u{0610}', + '\u{064B}', + '\u{064C}', + '\u{064D}', + '\u{064E}', + '\u{064F}', + '\u{0650}', + '\u{0651}', + '\u{0652}', + '\u{065F}', + '\u{0670}', + '\u{06D6}', + '\u{06DF}', + '\u{06E7}', + '\u{06EA}', + '\u{0897}', + '\u{089F}', + '\u{08CA}', + '\u{08E1}', + '\u{08E3}', + '\u{08FF}', + '\u{10EFA}', + '\u{10EFF}', + ] { + let original = format!("Λ ب{mark}"); + let mut s = typed(&original); + typ(&mut s, "\u{8}"); + assert_eq!(paras(&s)[0].0, "Λ ب", "mark U+{:04X}", u32::from(mark)); + assert_eq!(s.sel.focus.off, "Λ ب".len()); + run(&mut s, "edit.undo", json!({})); + assert_eq!(paras(&s)[0].0, original); + run(&mut s, "edit.redo", json!({})); + typ(&mut s, "\u{0650}"); + assert_eq!(paras(&s)[0].0, "Λ ب\u{0650}"); + } +} + +#[test] +fn arabic_backspace_uses_stored_mark_order_but_delete_keeps_the_cluster() { + for marks in ["\u{0651}\u{064E}", "\u{064E}\u{0651}"] { + let mut s = typed(&format!("ب{marks}")); + typ(&mut s, "\u{8}"); + assert_eq!(paras(&s)[0].0, format!("ب{}", marks.chars().next().unwrap())); + typ(&mut s, "\u{8}"); + assert_eq!(paras(&s)[0].0, "ب"); + typ(&mut s, "\u{8}"); + assert!(paras(&s)[0].0.is_empty()); + let original = format!("ب{marks}ت"); + let mut s = typed(&original); + // Arabic arrows move visually. They still visit only whole-cluster boundaries. + let boundaries = [0, original.len() - "ت".len(), original.len()]; + let mut visited = vec![s.sel.focus.off]; + for command in ["caret.right", "caret.right", "caret.left", "caret.left"] { + run(&mut s, command, json!({})); + assert!(boundaries.contains(&s.sel.focus.off)); + visited.push(s.sel.focus.off); + } + assert!(boundaries.iter().all(|off| visited.contains(off))); + s.sel = crate::Selection::caret(Pos::body(0, 0)); + run(&mut s, "text.delete", json!({})); + assert_eq!(paras(&s)[0].0, "ت"); + } + for cluster in ["e\u{0301}", "👩‍💻", "👍🏽", "🇵🇱", "ב\u{05B7}"] { + let s = typed(&format!("a{cluster}\u{8}")); + assert_eq!(paras(&s)[0].0, "a", "unchanged grapheme {cluster}"); + } +} + +/// Press a shortcut the way the keyboard does: run the command bound to it. +fn press(s: &mut Session, combo: &str) -> Result { + let id = s.registry.by_shortcut(combo).map(|c| c.id).unwrap_or_else(|| panic!("nothing bound to {combo}")); + s.run(id, &json!({})) +} + +/// Word's outline keys: Alt+Shift+arrows, ⌃⇧+arrows on macOS. +fn outline_key(arrow: &str) -> String { + if cfg!(target_os = "macos") { format!("Ctrl+Shift+{arrow}") } else { format!("Alt+Shift+{arrow}") } +} + +#[test] +fn alt_shift_down_then_up_moves_a_paragraph_and_back_and_undoes() { + let mut s = typed("One\nTwo\nThree"); + run(&mut s, "caret.docStart", json!({})); + press(&mut s, &outline_key("Down")).unwrap(); + assert_eq!(texts(&s), ["two", "one", "three"]); + assert_eq!(s.sel.focus, Pos::body(1, 0), "the caret moves with the paragraph"); + press(&mut s, &outline_key("Up")).unwrap(); + assert_eq!(texts(&s), ["one", "two", "three"]); + assert!(press(&mut s, &outline_key("Up")).is_err(), "nothing above the first paragraph"); + run(&mut s, "edit.undo", json!({})); + assert_eq!(texts(&s), ["two", "one", "three"], "each move is one undo step"); + run(&mut s, "edit.undo", json!({})); + assert_eq!(texts(&s), ["one", "two", "three"]); + // A selected paragraph (with its mark) moves alone; list items keep their list. + let mut s = typed("1. First\nSecond\n\nAfter"); + run(&mut s, "select.paragraph", json!({})); + press(&mut s, &outline_key("Up")).unwrap(); + assert_eq!(paras(&s), vec![para("First", Some(0)), para("After", None), para("Second", Some(0))]); + assert!(press(&mut s, &outline_key("Down")).is_ok()); + assert!(press(&mut s, &outline_key("Down")).is_err(), "nothing below the last paragraph"); +} + +fn style_of(s: &Session, i: usize) -> Option { + s.doc.body.get(i).and_then(|b| b.as_para()).and_then(|p| p.props.style.clone()) +} + +#[test] +fn alt_shift_left_and_right_promote_and_demote_headings_and_list_items() { + let mut s = typed("Intro\nBody"); + run(&mut s, "caret.docStart", json!({})); + run(&mut s, "para.heading2", json!({})); + run(&mut s, "caret.docEnd", json!({})); + // Promoted body text takes the level of the heading before it; then one level at a time. + press(&mut s, &outline_key("Left")).unwrap(); + assert_eq!(style_of(&s, 1).as_deref(), Some("Heading2")); + press(&mut s, &outline_key("Right")).unwrap(); + assert_eq!(style_of(&s, 1).as_deref(), Some("Heading3")); + press(&mut s, &outline_key("Left")).unwrap(); + press(&mut s, &outline_key("Left")).unwrap(); + assert_eq!(style_of(&s, 1).as_deref(), Some("Heading1")); + assert!(press(&mut s, &outline_key("Left")).is_err(), "Heading 1 is the top level"); + for _ in 0..8 { + press(&mut s, &outline_key("Right")).unwrap(); + } + assert_eq!(style_of(&s, 1).as_deref(), Some("Heading9")); + assert!(press(&mut s, &outline_key("Right")).is_err(), "Heading 9 is the lowest heading"); + // In a list they change the item's level, like Tab and Shift+Tab. + let mut s = typed("* One\nTwo"); + press(&mut s, &outline_key("Right")).unwrap(); + assert_eq!(paras(&s), vec![para("One", Some(0)), para("Two", Some(1))]); + press(&mut s, &outline_key("Left")).unwrap(); + assert_eq!(paras(&s), vec![para("One", Some(0)), para("Two", Some(0))]); +} + +#[test] +fn down_and_up_from_a_line_start_beside_a_table_go_into_it() { + let mut s = typed("Intro line\n"); + run(&mut s, "insert.table", json!({"rows": 2, "cols": 2})); + let (table, _, _) = s.sel.focus.path.cell().unwrap(); + typ(&mut s, "a"); + let cell = |r: u32, c: u32| { + let mut p = table.0.clone(); + p.extend([r, c, 0]); + wordcraft_doc::Path(p) + }; + // From the start of the line above, left of the cells' text: the first cell, then the one + // below, not the paragraph after the table. + run(&mut s, "caret.docStart", json!({})); + run(&mut s, "caret.down", json!({})); + assert_eq!(s.sel.focus.path, cell(0, 0)); + run(&mut s, "caret.down", json!({})); + assert_eq!(s.sel.focus.path, cell(1, 0), "then the cell below"); + // Up from the start of the paragraph after it: the last row's first cell. + run(&mut s, "caret.docEnd", json!({})); + run(&mut s, "caret.up", json!({})); + assert_eq!(s.sel.focus.path, cell(1, 0)); + // Shift+Down from the line above takes the first row, not the whole table. + run(&mut s, "caret.docStart", json!({})); + run(&mut s, "caret.down", json!({"extend": true})); + assert_eq!(s.sel.focus.path, cell(0, 1)); +} + +#[test] +fn alt_x_toggles_between_a_character_and_its_code() { + if !cfg!(target_os = "macos") { + assert_eq!(s().registry.by_shortcut("Alt+X").map(|c| c.id), Some("text.toggleUnicode")); + } + let mut s = s(); + run(&mut s, "text.insert", json!({"text": "x 00e9"})); + run(&mut s, "text.toggleUnicode", json!({})); + assert_eq!(texts(&s), ["x é"]); + run(&mut s, "text.toggleUnicode", json!({})); + assert_eq!(texts(&s), ["x 00e9"]); + run(&mut s, "edit.undo", json!({})); + assert_eq!(texts(&s), ["x é"], "one undo step"); + run(&mut s, "text.insert", json!({"text": " U+1F600"})); + run(&mut s, "text.toggleUnicode", json!({})); + assert_eq!(texts(&s), ["x é 😀"]); + // A selected code converts too. + run(&mut s, "text.insert", json!({"text": " 2014"})); + run(&mut s, "select.text", json!({"text": "2014"})); + run(&mut s, "text.toggleUnicode", json!({})); + assert_eq!(texts(&s), ["x é 😀 —"]); +} + +#[test] +fn alt_numpad_codes_map_like_word_on_windows() { + use crate::cmd::text::{alt_code_char, oem_code_page}; + let c = |code| alt_code_char(code, 1252, 437); + // Leading 0: Windows-1252. + assert_eq!(c("0233"), Some('é')); + assert_eq!(c("0128"), Some('€')); + assert_eq!(c("0150"), Some('–')); + assert_eq!(c("065"), Some('A')); + assert_eq!(c("0032"), Some(' ')); + assert_eq!(c("0255"), Some('ÿ')); + // No leading 0: the OEM code page (437 here, 850 in Western Europe). + assert_eq!(c("233"), Some('Θ')); + // The issue said 233 → é in 850; the CP850 mapping file has Ú at 0xE9 (é is 130 in both). + assert_eq!(alt_code_char("233", 1252, 850), Some('Ú')); + assert_eq!(alt_code_char("130", 1252, 850), Some('é')); + assert_eq!(c("130"), Some('é')); + assert_eq!(alt_code_char("155", 1252, 850), Some('ø')); + assert_eq!(c("155"), Some('¢')); + assert_eq!(c("65"), Some('A')); + assert_eq!(c("1"), Some('☺')); + assert_eq!(c("7"), Some('•')); + assert_eq!(c("127"), Some('⌂')); + assert_eq!(c("176"), Some('░')); + // Above 255: the Unicode character with that decimal value. + assert_eq!(c("8364"), Some('€')); + assert_eq!(c("09733"), Some('★')); + assert_eq!(c("128512"), Some('😀')); + // Nothing for junk, 0, control characters, surrogates or values past Unicode. + for bad in ["", "0", "000", "0009", "0013", "0127", "0129", "x1", "-5", "55296", "1114112", "99999999999999999999", "4294967361"] { + assert_eq!(c(bad), None, "{bad:?}"); + } + assert_eq!(oem_code_page(None), 437); + assert_eq!(oem_code_page(Some("en-US")), 437); + assert_eq!(oem_code_page(Some("en_GB.UTF-8")), 850); + assert_eq!(oem_code_page(Some("fr-FR")), 850); + assert_eq!(oem_code_page(Some("de")), 850); + assert_eq!(oem_code_page(Some("ru-RU")), 437, "only 437/850 without the shell's code pages"); +} + +#[test] +fn alt_numpad_codes_follow_the_system_code_pages() { + use crate::cmd::text::{alt_code_char, windows_code_pages}; + // Polish (1250/852): Alt+0185 and Alt+165 are both ą, not ¹ / Ñ. + assert_eq!(alt_code_char("0185", 1250, 852), Some('ą')); + assert_eq!(alt_code_char("165", 1250, 852), Some('ą')); + assert_eq!(alt_code_char("0185", 1252, 437), Some('¹')); + // Russian (1251/866), Greek (1253/737), Turkish (1254/857). + assert_eq!(alt_code_char("0207", 1251, 866), Some('П')); + assert_eq!(alt_code_char("143", 1251, 866), Some('П')); + assert_eq!(alt_code_char("0193", 1253, 737), Some('Α')); + assert_eq!(alt_code_char("128", 1253, 737), Some('Α')); + assert_eq!(alt_code_char("0253", 1254, 857), Some('ı')); + assert_eq!(alt_code_char("141", 1254, 857), Some('ı')); + // Above 255 the code page doesn't matter. + assert_eq!(alt_code_char("8364", 1251, 866), Some('€')); + // A byte the code page leaves unassigned types nothing. + assert_eq!(alt_code_char("213", 1254, 857), None); + assert_eq!(alt_code_char("0219", 874, 437), None); + assert_eq!(windows_code_pages(None), (1252, 437)); + assert_eq!(windows_code_pages(Some("en-US")), (1252, 437)); + assert_eq!(windows_code_pages(Some("de-DE")), (1252, 850)); + assert_eq!(windows_code_pages(Some("pl-PL")), (1250, 852)); + assert_eq!(windows_code_pages(Some("sr-Latn-RS")), (1250, 852)); + assert_eq!(windows_code_pages(Some("ru_RU.UTF-8")), (1251, 866)); + assert_eq!(windows_code_pages(Some("el-GR")), (1253, 737)); + assert_eq!(windows_code_pages(Some("tr-TR")), (1254, 857)); + assert_eq!(windows_code_pages(Some("ja-JP")), (1252, 437)); +} + +#[test] +fn alt_numpad_code_types_one_character() { + // `texts` lowercases; the capitals matter here. + let raw = |s: &Session| paras(s).into_iter().map(|p| p.0).collect::>(); + let mut s = s(); + run(&mut s, "text.insert", json!({"text": "caf"})); + run(&mut s, "text.typeCode", json!({"code": "0233"})); + run(&mut s, "text.insert", json!({"text": " "})); + run(&mut s, "text.typeCode", json!({"code": "233", "oem": 437})); + run(&mut s, "text.insert", json!({"text": " "})); + run(&mut s, "text.typeCode", json!({"code": "155", "oem": 850})); + assert_eq!(raw(&s), ["Café Θ ø"]); + run(&mut s, "edit.undo", json!({})); + assert_eq!(raw(&s), ["Café Θ "], "one undo step per code"); + assert!(s.run("text.typeCode", &json!({"code": "0"})).is_err()); + assert!(s.run("text.typeCode", &json!({"code": "65", "oem": 855})).is_err()); + assert!(s.run("text.typeCode", &json!({"code": "065", "ansi": 932})).is_err()); + assert!(s.run("text.typeCode", &json!({"code": "065", "ansi": 70000})).is_err()); + assert_eq!(raw(&s), ["Café Θ "]); + // The system's code pages: Polish Alt+0185 and Alt+165 both type ą. + run(&mut s, "text.typeCode", json!({"code": "0185", "ansi": 1250, "oem": 852})); + run(&mut s, "text.typeCode", json!({"code": "165", "ansi": 1250, "oem": 852})); + run(&mut s, "text.typeCode", json!({"code": "143", "oem": 866})); + assert_eq!(raw(&s), ["Café Θ ąąП"]); +} + +#[test] +fn shift_f5_goes_back_through_the_last_edits() { + let mut s = typed("One\nTwo\nThree"); + run(&mut s, "caret.docStart", json!({})); + let mut back = || { + press(&mut s, "Shift+F5").unwrap(); + s.sel.focus.clone() + }; + assert_eq!(back(), Pos::body(2, 5), "the last edit first"); + assert_eq!(back(), Pos::body(1, 3)); + assert_eq!(back(), Pos::body(0, 3)); + assert_eq!(back(), Pos::body(2, 5), "then round again"); + assert!(Session::new(wordcraft_doc::Document::new()).run("edit.goBack", &json!({})).is_err(), "nothing edited yet"); +} + +#[test] +fn shift_right_at_the_end_of_a_cell_selects_the_cell_first() { + let mut s = s(); + run(&mut s, "insert.table", json!({"rows": 2, "cols": 2})); + let (table, _, _) = s.sel.focus.path.cell().unwrap(); + typ(&mut s, "ab"); + run(&mut s, "caret.left", json!({})); + let shift_right = |s: &mut Session| { + run(s, "caret.right", json!({"extend": true})); + s.cell_range().map(|r| (r.rows, r.cols)) + }; + assert_eq!(shift_right(&mut s), None, "the letter first"); + assert_eq!((s.sel.anchor.off, s.sel.focus.off), (1, 2)); + assert_eq!(shift_right(&mut s), Some(((0, 0), (0, 0))), "then the cell alone"); + assert_eq!(shift_right(&mut s), Some(((0, 0), (0, 1))), "then the next cell with it"); + // In an empty cell the first press takes it. + let mut empty = table.0.clone(); + empty.extend([1, 0, 0]); + let pos = Pos { story: StoryRef::Body, path: wordcraft_doc::Path(empty), off: 0 }; + run(&mut s, "caret.set", json!({"pos": pos})); + assert_eq!(shift_right(&mut s), Some(((1, 1), (0, 0)))); +} + +#[test] +fn two_initial_capitals_are_corrected() { + let s = typed("the WOrd and THe end, not IDs or CDs. TV is fine "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "The Word and The end, not IDs or CDs. TV is fine "); + // "TEh" loses its second capital, then the replacement list fixes the typo. + assert_eq!(typed("TEh ").doc.plain_text(StoryRef::Body), "The "); + // A user exception. + let mut s = typed(""); + run(&mut s, "tools.autocorrect", json!({"addInitialCapsException": "WCraft"})); + typ(&mut s, "WCraft "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "WCraft "); +} + +#[test] +fn accidental_caps_lock_is_corrected() { + let s = typed("a wORD and hELLO there. iOS stays "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "A Word and Hello there. iOS stays "); +} + +#[test] +fn capitalisation_fixes_undo_and_turn_off() { + let mut s = typed("WOrd"); + typ(&mut s, " "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "Word "); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc.plain_text(StoryRef::Body), "WOrd "); + let mut s = typed("x wORD"); + typ(&mut s, " "); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc.plain_text(StoryRef::Body), "X wORD "); + let mut s = typed(""); + run(&mut s, "tools.autocorrect", json!({"twoInitialCaps": false, "capsLock": false})); + typ(&mut s, "x WOrd wORD "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "X WOrd wORD "); +} + +#[test] +fn first_letter_of_a_table_cell_is_capitalised() { + let cell_text = |s: &Session, c: usize| { + s.doc.body.iter().find_map(|b| b.as_table()).and_then(|t| t.rows.first()?.cells.get(c)?.blocks.first()?.as_para().map(|p| p.text.clone())) + }; + let mut s = typed(""); + run(&mut s, "insert.table", json!({"rows": 1, "cols": 2})); + typ(&mut s, "apples and pears\tplums "); + assert_eq!(cell_text(&s, 0).as_deref(), Some("Apples and pears")); + assert_eq!(cell_text(&s, 1).as_deref(), Some("Plums ")); + run(&mut s, "edit.undo", json!({})); + assert_eq!(cell_text(&s, 1).as_deref(), Some("plums ")); + let mut s = typed(""); + run(&mut s, "tools.autocorrect", json!({"capCells": false})); + run(&mut s, "insert.table", json!({"rows": 1, "cols": 1})); + typ(&mut s, "plums "); + assert_eq!(cell_text(&s, 0).as_deref(), Some("plums ")); +} + +/// File › Options › Advanced, "Typing replaces selected text" (#485): on (Word's default) the +/// typed text replaces the selection; off, it goes in front of the selection and keeps it. +#[test] +fn typing_over_a_selection_follows_the_replace_selection_option() { + let mut s = typed("One two"); + run(&mut s, "select.text", json!({"text": "two"})); + typ(&mut s, "X"); + assert_eq!(s.doc.plain_text(StoryRef::Body), "One X"); + let mut s = typed("One two"); + run(&mut s, "file.options", json!({"typingReplacesSelection": false})); + run(&mut s, "select.text", json!({"text": "two"})); + typ(&mut s, "XY"); + assert_eq!(s.doc.plain_text(StoryRef::Body), "One XYtwo"); +} + +/// Overtype (#485): typed characters replace the ones after the caret, never past the end of the +/// paragraph; the Insert key toggles it only with "Use the Insert key to control overtype" on +/// (the UI checks that option; `text.overtype` is the switch). +#[test] +fn overtype_replaces_the_characters_after_the_caret() { + let mut s = typed("Abcdef\nXy"); + run(&mut s, "caret.set", json!({"pos": Pos::body(0, 1)})); + run(&mut s, "text.overtype", json!({"value": true})); + typ(&mut s, "XY"); + assert_eq!(s.doc.plain_text(StoryRef::Body), "AXYdef\nXy"); + run(&mut s, "caret.set", json!({"pos": Pos::body(0, 5)})); + typ(&mut s, "123"); + assert_eq!(s.doc.plain_text(StoryRef::Body), "AXYde123\nXy", "the paragraph mark isn't typed over"); + run(&mut s, "text.overtype", json!({})); + run(&mut s, "caret.set", json!({"pos": Pos::body(0, 0)})); + typ(&mut s, "Z"); + assert_eq!(s.doc.plain_text(StoryRef::Body), "ZAXYde123\nXy", "insert mode again"); +} + +#[test] +fn reject_all_takes_out_the_paragraph_breaks_a_tracked_paste_inserted() { + // Issue #417: Paste's paragraph marks weren't tracked, so Reject All left the split. + for text in ["X\nY", "\n"] { + let mut s = s(); + run(&mut s, "text.insert", json!({"text": "AB", "raw": true})); + run(&mut s, "caret.set", json!({"pos": {"block": 0, "off": 1}})); + run(&mut s, "review.trackChanges", json!({"value": true})); + run(&mut s, "edit.paste", json!({"text": text})); + let (head, tail) = text.split_once('\n').unwrap_or_default(); + assert_eq!(texts(&s), [format!("a{}", head.to_lowercase()), format!("{}b", tail.to_lowercase())], "{text:?}"); + let pasted = texts(&s); + run(&mut s, "review.rejectAll", json!({})); + assert_eq!(texts(&s), ["ab"], "{text:?}"); + assert!(s.doc.revisions.is_empty(), "{text:?}"); + run(&mut s, "edit.undo", json!({})); + run(&mut s, "review.acceptAll", json!({})); + assert_eq!(texts(&s), pasted, "{text:?}: accepting keeps the break"); + } +} + +#[test] +fn plain_text_paste_keeps_an_empty_paragraph_in_a_list() { + // Issue #422: the pasted paragraph separators went through Enter, which ends the list on an + // empty item, so `A\n\nB` lost its empty paragraph. + // Paste of plain text from another program goes the same way. + for (id, v) in [ + ("edit.pasteText", json!({"text": "A\n\nB"})), + ("edit.pasteSpecial", json!({"as": "text", "text": "A\n\nB"})), + ("edit.paste", json!({"text": "A\n\nB"})), + ] { + let mut s = s(); + run(&mut s, "para.bullets", json!({})); + run(&mut s, id, v); + assert_eq!(paras(&s), vec![para("A", Some(0)), para("", Some(0)), para("B", Some(0))], "{id}"); + } + // Enter twice still ends the list. + let mut s = s(); + run(&mut s, "para.bullets", json!({})); + typ(&mut s, "A\n\nB"); + assert_eq!(paras(&s), vec![para("A", Some(0)), para("B", None)]); +} + +/// Each body paragraph's text and paragraph formatting. +fn para_formats(s: &Session) -> Vec<(String, wordcraft_doc::props::ParaProps)> { + s.doc.body.iter().filter_map(|b| b.as_para()).map(|p| (p.text.clone(), p.props.clone())).collect() +} + +/// Plain text from another program pastes as Keep Text Only does, as in Word: every pasted line +/// becomes a paragraph formatted like the paragraph it lands in. Paste gave the lines after the +/// first default formatting, and the text after the caret went with the last line, so a +/// right-to-left paragraph came out left-to-right from its second line on. +#[test] +fn plain_text_paste_gives_every_line_the_formatting_of_its_paragraph() { + let mut s = s(); + run(&mut s, "text.insert", json!({"text": "بداية نهاية", "raw": true})); + run(&mut s, "para.rtl", json!({})); + run(&mut s, "para.indents", json!({"left": 18, "right": 36})); + run(&mut s, "para.spacing", json!({"after": 12})); + let before = para_formats(&s); + let [(_, props)] = &before[..] else { panic!("{before:?}") }; + assert_eq!(props.bidi, Some(true)); + let off = "بداية ".len(); + for id in ["edit.paste", "edit.pasteText"] { + run(&mut s, "caret.set", json!({"pos": {"block": 0, "off": off}})); + run(&mut s, id, json!({"text": "سطر أول\r\nسطر ثان\r\nسطر ثالث"})); + let want: Vec<(String, _)> = ["بداية سطر أول", "سطر ثان", "سطر ثالثنهاية"].iter().map(|t| (t.to_string(), props.clone())).collect(); + assert_eq!(para_formats(&s), want, "{id}"); + // One undo step takes the whole paste out again. + run(&mut s, "edit.undo", json!({})); + assert_eq!(para_formats(&s), before, "{id}"); + } +} + +/// Empty lines in pasted plain text come through as empty paragraphs, formatted like the rest, +/// and so does a line break at the end of the text. The text takes the formatting before the +/// caret. +#[test] +fn plain_text_paste_keeps_empty_lines_as_paragraphs() { + let mut s = s(); + run(&mut s, "text.insert", json!({"text": "Lead ", "raw": true})); + run(&mut s, "select.all", json!({})); + run(&mut s, "format.bold", json!({})); + run(&mut s, "caret.docEnd", json!({})); + run(&mut s, "para.alignCenter", json!({})); + run(&mut s, "para.spacing", json!({"before": 6, "after": 18})); + let props = para_formats(&s)[0].1.clone(); + run(&mut s, "edit.paste", json!({"text": "Alpha one.\n\nAlpha two.\n\n\nAlpha three.\n"})); + let got = para_formats(&s); + let texts: Vec<&str> = got.iter().map(|(t, _)| t.as_str()).collect(); + assert_eq!(texts, ["Lead Alpha one.", "", "Alpha two.", "", "", "Alpha three.", ""]); + assert!(got.iter().all(|(_, p)| *p == props), "{got:?}"); + assert_eq!(s.sel.focus, Pos::body(6, 0), "the caret ends after the pasted text"); + let bold = s.doc.body.iter().filter_map(|b| b.as_para()).all(|p| p.runs.iter().all(|r| r.props.bold == Some(true))); + assert!(bold, "the pasted text takes the bold before the caret"); +} + +/// #713: Arabic or Hebrew typed into an empty paragraph makes it right-to-left; a paragraph that +/// already has letters, or whose style sets a direction, keeps its own. +#[test] +fn typing_rtl_letters_into_an_empty_paragraph_makes_it_rtl() { + let bidi = |s: &Session, i: usize| s.doc.body.iter().filter_map(|b| b.as_para()).nth(i).and_then(|p| p.props.bidi); + // Empty paragraph: the first Arabic letter flips it, the rest keep it. + let s = typed("سلام"); + assert_eq!(bidi(&s, 0), Some(true)); + // Digits first, then Arabic: still no letters before, so it flips. + let s = typed("1. سلام"); + assert_eq!(bidi(&s, 0), Some(true)); + // Latin first: Arabic typed later doesn't flip the paragraph. + let s = typed("Hello سلام"); + assert_eq!(bidi(&s, 0), None); + // A new paragraph after an RTL one starts its own detection. + let s = typed("سلام\nHello"); + assert_eq!(bidi(&s, 1), Some(true), "Enter carries the paragraph's direction on, as in Word"); + // A style that sets left-to-right wins. + let mut doc = Session::new(wordcraft_doc::Document::new()); + if let Some(st) = Arc::make_mut(&mut doc.doc.styles).get_mut("Normal") { + st.para.bidi = Some(false); + } + typ(&mut doc, "سلام"); + assert_eq!(bidi(&doc, 0), None); + // An opened paragraph without w:bidi lays out left-to-right whatever its text: the caret + // after the Arabic word sits near the left margin, not the right one. + let mut doc = typed("سلام"); + let path = doc.sel.focus.path.clone(); + if let Ok(p) = doc.doc.para_mut(StoryRef::Body, &path) { + p.props.bidi = None; + p.touch(); + } + let x = doc.layout().caret(&doc.sel.focus).map(|c| c.x).unwrap_or(f32::MAX); + assert!(x < 300.0, "left-to-right paragraph: caret at {x}"); +} + +/// Select All then Delete or Backspace empties the document, tables included (Word, #1198): +/// whether the text starts with a table or with a paragraph. +#[test] +fn select_all_then_delete_removes_tables() { + let tables = |s: &Session| s.doc.body.iter().filter(|b| b.as_table().is_some()).count(); + // With the caret in a table, Select All grows the selection a step at a time (cell, row, + // table, document; #1478): press it until the whole document is selected. + let select_whole_document = |s: &mut Session| { + for _ in 0..6 { + let before = s.sel.clone(); + run(s, "select.all", json!({})); + if s.sel == before { + break; + } + } + }; + for key in ["text.delete", "text.backspace"] { + let mut s = typed(""); + run(&mut s, "insert.table", json!({"rows": 2, "cols": 2})); + typ(&mut s, "cell"); + assert_eq!(tables(&s), 1); + select_whole_document(&mut s); + run(&mut s, key, json!({})); + assert_eq!(tables(&s), 0, "{key}: a table at the start of the document goes"); + assert_eq!(s.doc.plain_text(StoryRef::Body), "", "{key}"); + + let mut s = typed("before\n"); + run(&mut s, "insert.table", json!({"rows": 2, "cols": 2})); + typ(&mut s, "cell"); + assert_eq!(tables(&s), 1); + select_whole_document(&mut s); + run(&mut s, key, json!({})); + assert_eq!(tables(&s), 0, "{key}: a table after text goes"); + assert_eq!(s.doc.plain_text(StoryRef::Body), "", "{key}"); + } +} + +/// #898: a click just after a citation at the end of a paragraph puts the caret before the +/// citation's hidden field end; what is typed there goes after the citation, as in Word, instead +/// of into its result (Zotero then reports the citation as edited by hand). +#[test] +fn typing_after_a_citation_goes_outside_its_field() { + use wordcraft_doc::props::CharProps; + use wordcraft_doc::{InlineObject, Path}; + let mut doc = wordcraft_doc::Document::from_text("Cats purr "); + let c = CharProps::default(); + let p = doc.para_mut(StoryRef::Body, &Path::top(0)).unwrap(); + let start = p.len(); + p.insert_object(start, InlineObject::FieldStart { instr: "ADDIN ZOTERO_ITEM CSL_CITATION {}".into(), locked: false, dirty: false }, &c).unwrap(); + let n = p.insert_text(p.len(), "(Smith 2020)", &c).unwrap(); + p.insert_object(start + 3 + n, InlineObject::FieldEnd, &c).unwrap(); + let mut s = Session::new(doc); + let end = Pos::body(0, s.doc.para(StoryRef::Body, &Path::top(0)).unwrap().len()); + let at = s.layout().caret(&end).unwrap(); + let hit = s.layout().hit(at.page, at.x + 20.0, at.top + at.height / 2.0, StoryRef::Body).unwrap(); + s.sel = crate::Selection::caret(hit); + typ(&mut s, ". Dogs bark."); + assert_eq!(s.doc.plain_text(StoryRef::Body), "Cats purr (Smith 2020). Dogs bark."); + let f = s.doc.field_ranges(StoryRef::Body); + assert_eq!(f.len(), 1); + let inside = Pos { off: f[0].start.off + wordcraft_doc::para::OBJ.len_utf8(), ..f[0].start.clone() }; + assert_eq!(s.doc.copy_range(&inside, &f[0].end).plain_text(), "(Smith 2020)"); +} + +/// Tab in a table typed in right-to-left text (#2634): like Word, a table inserted in a +/// right-to-left paragraph is a right-to-left table, so its first cell stands at the right and +/// Tab moves to the cell on the left (the next one in reading order); Shift+Tab comes back. A +/// table inserted in a left-to-right paragraph is unchanged: Tab moves right. +#[test] +fn tab_in_a_table_inserted_in_right_to_left_text_moves_left() { + // The caret's cell column and the x of that cell on the page. + fn caret_cell_x(s: &mut Session) -> (usize, f32) { + let (_, r, c) = s.sel.focus.path.cell().expect("caret in a cell"); + let x = s + .layout() + .pages + .iter() + .flat_map(|p| p.items.iter()) + .find_map(|it| match it { + wordcraft_layout::Placed::Cell { rect, row, cell, covered: false, .. } if *row == r && *cell == c => Some(rect.x), + _ => None, + }) + .expect("cell laid out"); + (c, x) + } + for (keys, rtl) in [("مرحبا\n", true), ("Hello\n", false)] { + let mut s = s(); + typ(&mut s, keys); + run(&mut s, "insert.table", json!({"rows": 1, "cols": 3})); + let t = s.doc.body.iter().find_map(|b| b.as_table()).expect("table"); + assert_eq!(t.props.bidi_visual, rtl, "{keys:?}: the table takes the paragraph's direction"); + let (c0, x0) = caret_cell_x(&mut s); + typ(&mut s, "\t"); + let (c1, x1) = caret_cell_x(&mut s); + assert_eq!((c0, c1), (0, 1), "{keys:?}: Tab goes to the next cell in reading order"); + assert_eq!(x1 < x0, rtl, "{keys:?}: Tab moves {} ({x0} -> {x1})", if rtl { "left" } else { "right" }); + run(&mut s, "text.backTab", json!({})); + assert_eq!(caret_cell_x(&mut s), (0, x0), "{keys:?}: Shift+Tab comes back"); + } +} + +#[test] +fn ctrl_tab_inserts_a_tab_inside_a_table_cell() { + let mut s = s(); + run(&mut s, "insert.table", json!({"rows": 1, "cols": 2})); + typ(&mut s, "A"); + run(&mut s, "text.insertTab", json!({})); + typ(&mut s, "B"); + let (_, r, c) = s.sel.focus.path.cell().expect("caret in a cell"); + assert_eq!((r, c), (0, 0), "Ctrl+Tab stays in the cell"); + assert_eq!(s.doc.para_at(&s.sel.focus).map(|p| p.text.as_str()), Some("A\tB")); + let key = if cfg!(target_os = "macos") { "Alt+Tab" } else { "Mod+Tab" }; + assert_eq!(s.keymap.resolve(&s.registry, key).map(|c| c.id), Some("text.insertTab")); +} + +/// A .docx of two paragraphs in two sections, as Word writes it: the first section's properties +/// in the first paragraph's mark (portrait), the last section's in the body (landscape, starting +/// as `kind`). +fn two_section_docx(kind: &str) -> Vec { + const W: &str = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"; + let files = [ + ( + "[Content_Types].xml", + r#""#.to_string(), + ), + ( + "_rels/.rels", + r#""#.to_string(), + ), + ( + "word/document.xml", + format!( + r#"One.Two."# + ), + ), + ]; + let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + for (name, data) in files { + zip.start_file(name, zip::write::SimpleFileOptions::default()).unwrap(); + std::io::Write::write_all(&mut zip, data.as_bytes()).unwrap(); + } + zip.finish().unwrap().into_inner() +} + +/// #2127: Backspace just after a section break, or Delete just before it, removes the break, as +/// in Word: the two paragraphs join, the text before the break takes the following section's +/// page setup, and Undo brings the break back. Imported breaks and ones made here alike. +#[test] +fn backspace_and_delete_remove_a_section_break() { + let made_here = || { + let mut s = typed("One.Two."); + s.sel = crate::Selection::caret(Pos::body(0, 4)); + run(&mut s, "layout.break", json!({"kind": "nextPage"})); + s + }; + for source in ["nextPage", "continuous", "made here"] { + for (key, caret) in [("text.backspace", Pos::body(1, 0)), ("text.delete", Pos::body(0, 4))] { + let mut s = match source { + "made here" => made_here(), + kind => Session::new(crate::io::open_bytes("s.docx", &two_section_docx(kind)).unwrap()), + }; + assert_eq!(s.doc.sections().len(), 2, "{source}"); + assert_eq!(s.doc.plain_text(StoryRef::Body), "One.\nTwo.", "{source}"); + let after = s.doc.sections().last().map(|(_, sp)| (**sp).clone()).unwrap(); + s.sel = crate::Selection::caret(caret); + run(&mut s, key, json!({})); + assert_eq!(s.doc.plain_text(StoryRef::Body), "One.Two.", "{source} {key}"); + let sections = s.doc.sections(); + assert_eq!(sections.len(), 1, "{source} {key}: the break is gone"); + assert_eq!(*sections[0].1, after, "{source} {key}: the following section's setup"); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc.plain_text(StoryRef::Body), "One.\nTwo.", "{source} {key}: undo"); + assert_eq!(s.doc.sections().len(), 2, "{source} {key}: undo brings the break back"); + } + } +} + +/// A .docx laid out like the one attached to #2127: a continuous section break, with the +/// document's only header, on the mark of an empty paragraph after "One.", and the last +/// section (nextPage, no header of its own, so linked to the previous one) in the body. +/// `then` adds a paragraph after the break; without it the break is on the last paragraph and +/// the last section has none. +fn break_on_the_last_paragraph_docx(then: Option<&str>) -> Vec { + const W: &str = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"; + const R: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; + let then = then.map(|t| format!("{t}")).unwrap_or_default(); + let files = [ + ( + "[Content_Types].xml", + r#""#.to_string(), + ), + ( + "_rels/.rels", + r#""#.to_string(), + ), + ( + "word/_rels/document.xml.rels", + r#""#.to_string(), + ), + ("word/header1.xml", format!(r#"Lab report"#)), + ( + "word/document.xml", + format!( + r#"One.{then}"# + ), + ), + ]; + let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + for (name, data) in files { + zip.start_file(name, zip::write::SimpleFileOptions::default()).unwrap(); + std::io::Write::write_all(&mut zip, data.as_bytes()).unwrap(); + } + zip.finish().unwrap().into_inner() +} + +/// #2127: Delete at the end of the last paragraph removes a section break on its mark too, +/// though there's no paragraph after it to join. The text takes the last section's setup, and +/// the header the last section showed by linking to the removed one stays. Removing the break +/// by joining two paragraphs keeps that header as well. Undo brings the break back. +#[test] +fn delete_removes_a_section_break_on_the_last_paragraph() { + for then in [None, Some("Two.")] { + let mut s = Session::new(crate::io::open_bytes("s.docx", &break_on_the_last_paragraph_docx(then)).unwrap()); + let sections = s.doc.sections(); + assert_eq!(sections.len(), 2, "{then:?}"); + let header = sections[0].1.headers.default; + assert!(header.is_some() && sections[1].1.headers.default.is_none(), "{then:?}: the header is on the break's section"); + let mut after = sections[1].1.clone(); + after.headers.default = header; + s.sel = crate::Selection::caret(Pos::body(1, 0)); + run(&mut s, "text.delete", json!({})); + let text = if then.is_some() { "One.\nTwo." } else { "One.\n" }; + assert_eq!(s.doc.plain_text(StoryRef::Body), text, "{then:?}"); + let sections = s.doc.sections(); + assert_eq!(sections.len(), 1, "{then:?}: the break is gone"); + assert_eq!(*sections[0].1, after, "{then:?}: the last section's setup, with the header"); + run(&mut s, "edit.undo", json!({})); + let sections = s.doc.sections(); + assert_eq!(sections.len(), 2, "{then:?}: undo brings the break back"); + assert_eq!((sections[0].1.headers.default, sections[1].1.headers.default), (header, None), "{then:?}: undo"); + } +} + +fn first_letter_list(s: &Session) -> Vec { + s.prefs.autocorrect.exceptions.clone() +} + +/// Undoing an automatic sentence capital adds the word before it to the First Letter exceptions +/// (Word: "Automatically add words to list", #1857); typing the line again isn't capitalised. +#[test] +fn undoing_a_sentence_capital_adds_a_first_letter_exception() { + let mut s = typed("hello there. world "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "Hello there. World "); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc.plain_text(StoryRef::Body), "Hello there. world "); + assert_eq!(first_letter_list(&s), ["there."]); + run(&mut s, "text.newParagraph", json!({})); + typ(&mut s, "x there. world "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "Hello there. world \nX there. world "); +} + +#[test] +fn backspacing_a_sentence_capital_and_retyping_adds_a_first_letter_exception() { + let mut s = typed("hello there. world "); + typ(&mut s, "\u{8}\u{8}\u{8}\u{8}\u{8}\u{8}world "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "Hello there. world "); + assert_eq!(first_letter_list(&s), ["there."]); +} + +#[test] +fn automatic_exceptions_can_be_turned_off() { + let mut s = typed(""); + run(&mut s, "tools.autocorrect", json!({"autoAddFirstLetter": false})); + typ(&mut s, "hello there. world "); + run(&mut s, "edit.undo", json!({})); + assert!(first_letter_list(&s).is_empty()); + let mut s = typed(""); + run(&mut s, "tools.autocorrect", json!({"autoAddFirstLetter": false})); + typ(&mut s, "hello there. world \u{8}\u{8}\u{8}\u{8}\u{8}\u{8}world "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "Hello there. World "); + assert!(first_letter_list(&s).is_empty()); +} + +#[test] +fn undoing_a_replacement_adds_no_exception() { + let mut s = typed("so teh "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "So the "); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc.plain_text(StoryRef::Body), "So teh "); + assert!(first_letter_list(&s).is_empty()); + assert!(s.prefs.autocorrect.entries.is_empty() && s.prefs.autocorrect.removed.is_empty()); + typ(&mut s, "and teh "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "So teh and the "); +} + +/// Checked in Word for Mac: the same rule as First Letter. +#[test] +fn undoing_an_initial_caps_fix_adds_an_initial_caps_exception() { + let mut s = typed("so TWo "); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.prefs.autocorrect.initial_caps_exceptions, ["TWo"]); + typ(&mut s, "and TWo "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "So TWo and TWo "); +} + +#[test] +fn undoing_other_edits_after_a_capital_adds_no_exception() { + // Typing more, then one Undo, and a second Undo that reverses the capital's step later. + let mut s = typed("hello there. world more text"); + run(&mut s, "edit.undo", json!({})); + assert!(first_letter_list(&s).is_empty()); + run(&mut s, "edit.undo", json!({})); + assert!(first_letter_list(&s).is_empty(), "only the Undo that reverses the capital itself counts"); +} + +#[test] +fn a_full_undo_history_does_not_match_later_undos() { + let mut s = typed(""); + for _ in 0..300 { + typ(&mut s, "x\n"); + } + typ(&mut s, "hello there. world more text\nand more"); + run(&mut s, "edit.undo", json!({})); + assert!(first_letter_list(&s).is_empty()); +} + +#[test] +fn a_new_document_forgets_the_capital() { + let mut s = typed("hello there. world "); + run(&mut s, "file.new", json!({})); + typ(&mut s, "abc\ndef\nghi\njkl"); + for _ in 0..8 { + let _ = s.run("edit.undo", &json!({})); + } + assert!(first_letter_list(&s).is_empty()); +} + +#[test] +fn an_overlong_word_is_not_added_as_an_exception() { + let mut s = typed(&format!("hi {}. world ", "a".repeat(5000))); + run(&mut s, "edit.undo", json!({})); + assert!(first_letter_list(&s).is_empty()); +} + +/// Each body paragraph's laid-out number (`None`: none) and its style. +fn numbers(s: &mut Session) -> Vec<(Option, String)> { + let l = s.layout(); + let labels: Vec> = l + .pages + .iter() + .flat_map(|p| &p.items) + .filter_map(|i| { + if let wordcraft_layout::Placed::Lines { para, l0: 0, .. } = i { Some(para.label.as_ref().map(|lb| lb.text.clone())) } else { None } + }) + .collect(); + let styles = s.doc.body.iter().filter_map(|b| b.as_para()).map(|p| p.props.style.clone().unwrap_or_else(|| "Normal".into())); + labels.into_iter().zip(styles).collect() +} + +fn num(label: Option<&str>, style: &str) -> (Option, String) { + (label.map(str::to_string), style.to_string()) +} + +/// #1793 (Word for Mac, 2026-10-15): after the "1 / 1.1 / 1.1.1" heading list, a new Heading 2 +/// typed after "Three" is numbered 2.1 on its own; the body text after it stays unnumbered. +#[test] +fn a_new_heading_after_a_heading_linked_list_is_numbered() { + let mut s = s(); + typ(&mut s, "One"); + run(&mut s, "para.heading1", json!({})); + typ(&mut s, "\nTwo"); + run(&mut s, "para.heading2", json!({})); + typ(&mut s, "\nThree"); + run(&mut s, "para.heading1", json!({})); + // The caret in "Three" (a Heading 1, the list's first level). + run(&mut s, "list.multilevelPreset", json!({"preset": "headings"})); + run(&mut s, "caret.docEnd", json!({})); + typ(&mut s, "\n"); + run(&mut s, "para.heading2", json!({})); + typ(&mut s, "Four\nBody"); + assert_eq!( + numbers(&mut s), + [num(Some("1"), "Heading1"), num(Some("1.1"), "Heading2"), num(Some("2"), "Heading1"), num(Some("2.1"), "Heading2"), num(None, "Normal")] + ); +} + +/// #1793: plain Numbering on a Heading 1 numbers it "1." and keeps Heading 1 (Word: "1. One"). +#[test] +fn numbering_a_heading_keeps_the_heading_style() { + let mut s = s(); + typ(&mut s, "One"); + run(&mut s, "para.heading1", json!({})); + run(&mut s, "para.numbering", json!({})); + assert_eq!(numbers(&mut s), [num(Some("1."), "Heading1")]); +} + +/// #1793: applying a heading style that carries list numbering numbers the paragraph, even one +/// whose numbering was switched off before. +#[test] +fn applying_a_numbered_heading_style_numbers_the_paragraph() { + let mut s = s(); + typ(&mut s, "One"); + run(&mut s, "para.heading1", json!({})); + run(&mut s, "list.multilevelPreset", json!({"preset": "headings"})); + typ(&mut s, "\nTwo"); + run(&mut s, "para.numbering", json!({})); + run(&mut s, "para.numbering", json!({"off": true})); + assert_eq!(numbers(&mut s)[1].0, None); + run(&mut s, "para.heading2", json!({})); + assert_eq!(numbers(&mut s), [num(Some("1"), "Heading1"), num(Some("1.1"), "Heading2")]); +} + +/// #1793: "1. " typed at the start of a heading numbers it and keeps the heading style; only +/// body text becomes List Paragraph. +#[test] +fn typing_1_dot_in_a_heading_keeps_the_heading_style() { + let mut s = s(); + run(&mut s, "para.heading1", json!({})); + typ(&mut s, "1. One\n"); + run(&mut s, "para.normal", json!({})); + typ(&mut s, "1. Body"); + assert_eq!(numbers(&mut s), [num(Some("1."), "Heading1"), num(Some("1."), "ListParagraph")]); + for (style, keys) in [("Heading2", "a) Part"), ("Subtitle", "1. Item")] { + let mut s = self::s(); + run(&mut s, "para.style", json!({"style": style})); + typ(&mut s, keys); + let got = numbers(&mut s); + assert_eq!(got.len(), 1); + assert!(got[0].0.is_some(), "{style}: a list"); + assert_eq!(got[0].1, style); + } +} + +// Backspace and Delete on selected tables, rows and columns (Word for Mac, #1844). + +/// "before", a 3×3 table with cells `a1`…`c3` (column letter, row number), "after"; the caret +/// in the cell at row `r`, column `c`. +fn table_between(r: u32, c: u32) -> Session { + let mut s = typed("before\n"); + run(&mut s, "insert.table", json!({"rows": 3, "cols": 3})); + typ(&mut s, "a1\tb1\tc1\ta2\tb2\tc2\ta3\tb3\tc3"); + run(&mut s, "caret.docEnd", json!({})); + typ(&mut s, "after"); + let shape: Vec<&str> = s.doc.body.iter().map(|b| if b.as_table().is_some() { "table" } else { "para" }).collect(); + assert_eq!(shape, ["para", "table", "para"], "setup"); + run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [1, r, c, 0], "off": 0}})); + s +} + +fn tables(s: &Session) -> usize { + s.doc.body.iter().filter(|b| b.as_table().is_some()).count() +} + +/// The first top-level table's cells, row by row (tracked deletions included). +fn table_cells(s: &Session) -> Vec> { + s.doc + .body + .iter() + .find_map(|b| b.as_table()) + .map(|t| { + t.rows + .iter() + .map(|r| r.cells.iter().map(|c| c.blocks.iter().filter_map(|b| b.as_para()).map(|p| p.plain_text()).collect()).collect()) + .collect() + }) + .unwrap_or_default() +} + +#[test] +fn backspace_with_the_whole_table_selected_removes_the_table() { + let mut s = table_between(1, 1); + run(&mut s, "table.selectTable", json!({})); + run(&mut s, "text.backspace", json!({})); + assert_eq!(tables(&s), 0, "the table stays: {:?}", table_cells(&s)); + assert_eq!(texts(&s), ["before", "after"]); + // The caret is in the paragraph that followed the table. + assert_eq!(s.sel.focus, Pos { story: StoryRef::Body, path: wordcraft_doc::Path::top(1), off: 0 }); + assert!(s.sel.is_collapsed()); + // One undo brings it back. + run(&mut s, "edit.undo", json!({})); + assert_eq!(tables(&s), 1); + assert_eq!(table_cells(&s), [["a1", "b1", "c1"], ["a2", "b2", "c2"], ["a3", "b3", "c3"]]); +} + +#[test] +fn delete_with_the_whole_table_selected_clears_the_cells_and_keeps_the_table() { + let mut s = table_between(0, 0); + run(&mut s, "table.selectTable", json!({})); + run(&mut s, "text.delete", json!({})); + assert_eq!(tables(&s), 1); + assert_eq!(table_cells(&s), [["", "", ""], ["", "", ""], ["", "", ""]]); + assert_eq!(texts(&s), ["before", "after"]); +} + +#[test] +fn delete_or_backspace_with_the_table_and_the_paragraph_after_selected_removes_the_table() { + for key in ["text.delete", "text.backspace"] { + // From the first cell to the end of the document: Word's rule widens it to whole rows. + let mut s = table_between(0, 0); + run(&mut s, "caret.docEnd", json!({"extend": true})); + run(&mut s, key, json!({})); + assert_eq!(tables(&s), 0, "{key}: {:?}", table_cells(&s)); + assert_eq!(texts(&s), ["before", ""], "{key}"); + } +} + +#[test] +fn delete_or_backspace_with_the_paragraph_before_and_the_table_selected_removes_the_table() { + for key in ["text.delete", "text.backspace"] { + // From the start of "before" down into the last row: the rows are taken whole. + let mut s = table_between(0, 0); + run(&mut s, "caret.docStart", json!({})); + for _ in 0..3 { + run(&mut s, "caret.down", json!({"extend": true})); + } + assert_eq!(s.sel.focus.path.cell().map(|(_, r, _)| r), Some(2), "{key}: the selection reaches the last row"); + run(&mut s, key, json!({})); + assert_eq!(tables(&s), 0, "{key}: {:?}", table_cells(&s)); + assert_eq!(texts(&s), ["after"], "{key}"); + assert_eq!(s.sel.focus, Pos { story: StoryRef::Body, path: wordcraft_doc::Path::top(0), off: 0 }, "{key}"); + } +} + +#[test] +fn backspace_deletes_selected_rows_and_delete_clears_them() { + let mut s = table_between(1, 0); + run(&mut s, "table.selectRow", json!({})); + run(&mut s, "text.backspace", json!({})); + assert_eq!(table_cells(&s), [["a1", "b1", "c1"], ["a3", "b3", "c3"]]); + + let mut s = table_between(1, 0); + run(&mut s, "table.selectRow", json!({})); + run(&mut s, "text.delete", json!({})); + assert_eq!(table_cells(&s), [["a1", "b1", "c1"], ["", "", ""], ["a3", "b3", "c3"]]); +} + +#[test] +fn backspace_deletes_selected_columns_and_delete_clears_them() { + let mut s = table_between(0, 1); + run(&mut s, "table.selectColumn", json!({})); + run(&mut s, "text.backspace", json!({})); + assert_eq!(table_cells(&s), [["a1", "c1"], ["a2", "c2"], ["a3", "c3"]]); + + let mut s = table_between(0, 1); + run(&mut s, "table.selectColumn", json!({})); + run(&mut s, "text.delete", json!({})); + assert_eq!(table_cells(&s), [["a1", "", "c1"], ["a2", "", "c2"], ["a3", "", "c3"]]); +} + +/// The first top-level table's cells, row by row, one entry per block: a paragraph's text, or +/// `
` for a nested table. +fn cell_blocks(s: &Session) -> Vec>> { + let block = |b: &std::sync::Arc| b.as_para().map_or_else(|| "
".to_string(), |p| p.plain_text()); + s.doc + .body + .iter() + .find_map(|b| b.as_table()) + .map(|t| t.rows.iter().map(|r| r.cells.iter().map(|c| c.blocks.iter().map(block).collect()).collect()).collect()) + .unwrap_or_default() +} + +#[test] +fn delete_on_whole_cells_leaves_one_empty_paragraph_in_each() { + // a1 starts with a nested table and holds three paragraphs (centred, centred, right-aligned), + // b2 holds a nested table after its text (#2802). + let setup = || { + let mut s = table_between(0, 0); + run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [1, 0, 0, 0], "off": 2}})); + run(&mut s, "para.alignCenter", json!({})); + typ(&mut s, "\nX\nY"); + run(&mut s, "para.alignRight", json!({})); + run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [1, 1, 1, 0], "off": 2}})); + run(&mut s, "text.newParagraph", json!({})); + run(&mut s, "insert.table", json!({"rows": 1, "cols": 2})); + typ(&mut s, "n"); + let b2 = |s: &Session| s.doc.body.get(1).and_then(|b| b.as_table()).map(|t| t.rows[1].cells[1].blocks.clone()).unwrap_or_default(); + let nested = b2(&s).iter().find(|b| b.as_table().is_some()).map(|b| (**b).clone()).expect("setup: a table nested in b2"); + s.doc.insert_block(StoryRef::Body, &wordcraft_doc::Path(vec![1, 0, 0, 0]), nested).unwrap(); + s + }; + let all: Vec<(usize, usize)> = (0..3).flat_map(|r| (0..3).map(move |c| (r, c))).collect(); + let cases: [(&str, (usize, usize), &str, Vec<(usize, usize)>); 5] = [ + ("table.selectRow", (0, 0), "text.delete", vec![(0, 0), (0, 1), (0, 2)]), + ("table.selectColumn", (0, 0), "text.delete", vec![(0, 0), (1, 0), (2, 0)]), + ("table.selectRow", (1, 1), "text.delete", vec![(1, 0), (1, 1), (1, 2)]), + ("table.selectTable", (0, 0), "text.delete", all), + // One cell of several: Backspace clears it too. + ("table.selectCell", (0, 0), "text.backspace", vec![(0, 0)]), + ]; + for (select, (r, c), key, cleared) in cases { + let mut s = setup(); + let before = cell_blocks(&s); + assert_eq!(before[0][0], ["
", "a1", "X", "Y"], "setup"); + assert!(before[1][1].len() > 2 && before[1][1].contains(&"
".to_string()), "setup: {:?}", before[1][1]); + // a1's first paragraph is after the nested table. + let first = usize::from((r, c) == (0, 0)); + run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [1, r, c, first], "off": 0}})); + run(&mut s, select, json!({})); + run(&mut s, key, json!({})); + let mut want = before.clone(); + for &(r, c) in &cleared { + want[r][c] = vec![String::new()]; + } + assert_eq!(cell_blocks(&s), want, "{select} {key}"); + // The paragraph left keeps the cell's last paragraph's formatting (its mark ends the cell). + if cleared.contains(&(0, 0)) { + let p = s.doc.para(StoryRef::Body, &wordcraft_doc::Path(vec![1, 0, 0, 0])).map(|p| p.props.align); + assert_eq!(p, Some(Some(wordcraft_doc::props::Align::Right)), "{select} {key}"); + } + assert!(s.sel.is_collapsed(), "{select} {key}"); + assert_eq!(s.sel.focus.path.cell().map(|(_, r, c)| (r, c)), cleared.first().copied(), "{select} {key}"); + // One undo brings everything back. + run(&mut s, "edit.undo", json!({})); + assert_eq!(cell_blocks(&s), before, "{select} {key}: undo"); + } +} + +#[test] +fn backspace_on_a_selected_nested_table_removes_only_it() { + let mut s = table_between(1, 1); + run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [1, 1, 1, 0], "off": 2}})); + run(&mut s, "text.newParagraph", json!({})); + run(&mut s, "insert.table", json!({"rows": 2, "cols": 2})); + typ(&mut s, "x"); + let cell_blocks = |s: &Session| { + s.doc + .body + .iter() + .find_map(|b| b.as_table()) + .and_then(|t| t.rows.get(1)?.cells.get(1)) + .map(|c| c.blocks.iter().filter(|b| b.as_table().is_some()).count()) + }; + assert_eq!(cell_blocks(&s), Some(1), "setup: a table nested in b2"); + run(&mut s, "table.selectTable", json!({})); + run(&mut s, "text.backspace", json!({})); + assert_eq!(cell_blocks(&s), Some(0), "the nested table stays"); + assert_eq!(tables(&s), 1, "the outer table goes too"); + // The caret is in the cell's paragraph after where the nested table was. + assert_eq!(s.sel.focus.path.cell().map(|(t, r, c)| (t.0, r, c)), Some((vec![1], 1, 1))); + assert!(s.doc.para_at(&s.sel.focus).is_some()); +} + +#[test] +fn backspace_on_a_selected_table_that_is_the_only_block_leaves_an_empty_paragraph() { + let mut s = s(); + run(&mut s, "insert.table", json!({"rows": 2, "cols": 2})); + typ(&mut s, "x"); + s.doc.body.retain(|b| b.as_table().is_some()); + assert_eq!(s.doc.body.len(), 1, "setup: the table is the only block"); + run(&mut s, "table.selectTable", json!({})); + run(&mut s, "text.backspace", json!({})); + assert_eq!(tables(&s), 0); + assert_eq!(texts(&s), [""]); + assert!(s.doc.para_at(&s.sel.focus).is_some(), "the caret is in a paragraph: {:?}", s.sel.focus); +} + +#[test] +fn tracked_backspace_on_a_selected_table_marks_its_text_deleted_like_delete_table() { + let mut s = table_between(0, 0); + run(&mut s, "review.trackChanges", json!({"value": true})); + run(&mut s, "table.selectTable", json!({})); + run(&mut s, "text.backspace", json!({})); + // Like Table Layout › Delete Table with tracking on: the structure stays, the text is marked deleted. + assert_eq!(tables(&s), 1); + assert_eq!(table_cells(&s), [["a1", "b1", "c1"], ["a2", "b2", "c2"], ["a3", "b3", "c3"]]); + assert_eq!(s.doc.final_text(StoryRef::Body).replace(['\t', '\n'], ""), "Beforeafter"); + run(&mut s, "review.rejectAll", json!({})); + assert_eq!(s.doc.final_text(StoryRef::Body).replace(['\t', '\n'], ""), "Beforea1b1c1a2b2c2a3b3c3after"); + assert!(s.doc.revisions.is_empty()); +} + +/// One selected cell isn't a whole row or column, even in a one-row or one-column table: +/// Backspace clears it (Word's cell selection has no end-of-row mark). +#[test] +fn backspace_on_one_selected_cell_of_a_one_row_or_one_column_table_clears_it() { + for (rows, cols, at) in [(1, 3, [0, 1]), (3, 1, [1, 0])] { + let mut s = typed("before\n"); + run(&mut s, "insert.table", json!({"rows": rows, "cols": cols})); + typ(&mut s, "x\ty\tz"); + run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [1, at[0], at[1], 0], "off": 0}})); + run(&mut s, "table.selectCell", json!({})); + run(&mut s, "text.backspace", json!({})); + let want: Vec> = + if rows == 1 { vec![vec!["x".into(), "".into(), "z".into()]] } else { vec![vec!["x".into()], vec!["".into()], vec!["z".into()]] }; + assert_eq!(table_cells(&s), want, "{rows}×{cols}"); + } +} + +/// #1670: smart quotes in Hebrew follow the characters around the quote in logical order, as +/// Word does (Word for Mac 16.113.4): an opening “ after a space or at the start, a closing ” +/// after a letter, and ” inside an acronym (צה"ל), not gershayim; ' after a letter is ’. +#[test] +fn smart_quotes_in_hebrew_use_logical_order() { + let bidi = |s: &Session| s.doc.body.iter().filter_map(|b| b.as_para()).next().and_then(|p| p.props.bidi); + let s = typed("הוא אמר \"שלום\" לי"); + assert_eq!(bidi(&s), Some(true), "a right-to-left paragraph"); + assert_eq!(s.doc.plain_text(StoryRef::Body), "הוא אמר “שלום” לי"); + let s = typed("\"שלום\""); + assert_eq!(s.doc.plain_text(StoryRef::Body), "“שלום”"); + let s = typed("צה\"ל "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "צה”ל "); + let s = typed("ג'ירפה 'כן'"); + assert_eq!(s.doc.plain_text(StoryRef::Body), "ג’ירפה ‘כן’"); +} + +/// #1923: Turkish and Azerbaijani case mapping (i↔İ, ı↔I) in AutoCorrect, checked against Word +/// with Turkish proofing; other languages keep the Unicode default. +#[test] +fn autocorrect_capitalisation_follows_turkic_case_mapping() { + for tag in ["tr-TR", "az-AZ"] { + let mut s = s(); + run(&mut s, "review.language", json!({"lang": tag, "default": true})); + typ(&mut s, "istanbul güzel. "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "İstanbul güzel. ", "{tag}: i capitalises to İ"); + let mut s = self::s(); + run(&mut s, "review.language", json!({"lang": tag, "default": true})); + typ(&mut s, "İNgilizce "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "İngilizce ", "{tag}: İ kept, N lowercased, no combining dot"); + let mut s = self::s(); + run(&mut s, "review.language", json!({"lang": tag, "default": true})); + typ(&mut s, "ılık ırmak. "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "Ilık ırmak. ", "{tag}: ı capitalises to I"); + let mut s = self::s(); + run(&mut s, "review.language", json!({"lang": tag, "default": true})); + typ(&mut s, "iSTANBUL "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "İstanbul ", "{tag}: cAPS LOCK fix"); + let mut s = self::s(); + run(&mut s, "review.language", json!({"lang": tag, "default": true})); + typ(&mut s, "kızım. TIklamak "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "Kızım. Tıklamak ", "{tag}: TWo INitial CApitals lowercases I to ı"); + } + // Other languages are unchanged; a stray İ still lowercases to a single "i". + let mut s = s(); + run(&mut s, "review.language", json!({"lang": "en-US", "default": true})); + typ(&mut s, "istanbul güzel. "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "Istanbul güzel. "); + let mut s = self::s(); + run(&mut s, "review.language", json!({"lang": "en-US", "default": true})); + typ(&mut s, "İNgilizce "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "İngilizce ", "no combining dot in any language"); +} + +/// #3479: Format > Change Case follows the same Turkic case mapping as AutoCorrect, using +/// each run's own language; other languages keep the Unicode default. +#[test] +fn change_case_follows_turkic_case_mapping() { + for tag in ["tr-TR", "az-AZ"] { + for (mode, input, want) in [ + ("upper", "istanbul", "İSTANBUL"), + ("lower", "IŞIK", "ışık"), + ("title", "istanbul güzel", "İstanbul Güzel"), + ("sentence", "istanbul. izmir", "İstanbul. İzmir"), + ("toggle", "iI", "İı"), + ] { + let mut s = s(); + run(&mut s, "review.language", json!({"lang": tag, "default": true})); + run(&mut s, "text.insert", json!({"text": input, "raw": true})); + run(&mut s, "select.all", json!({})); + run(&mut s, "format.changeCase", json!({"mode": mode})); + assert_eq!(s.doc.plain_text(StoryRef::Body), want, "{tag} {mode}"); + } + } + // Other languages keep the Unicode default. + let mut s = self::s(); + run(&mut s, "text.insert", json!({"text": "istanbul", "raw": true})); + run(&mut s, "select.all", json!({})); + run(&mut s, "format.changeCase", json!({"mode": "title"})); + assert_eq!(s.doc.plain_text(StoryRef::Body), "Istanbul", "en-US"); + // Mixed runs: each run's own language decides. + let mut s = self::s(); + run(&mut s, "text.insert", json!({"text": "IŞIK IŞIK", "raw": true})); + run(&mut s, "caret.set", json!({"pos": Pos::body(0, 0)})); + run(&mut s, "caret.set", json!({"pos": Pos::body(0, 5), "extend": true})); + run(&mut s, "review.language", json!({"lang": "tr-TR"})); + run(&mut s, "caret.set", json!({"pos": Pos::body(0, 6)})); + run(&mut s, "caret.set", json!({"pos": Pos::body(0, 11), "extend": true})); + run(&mut s, "review.language", json!({"lang": "en-US"})); + run(&mut s, "select.all", json!({})); + run(&mut s, "format.changeCase", json!({"mode": "lower"})); + assert_eq!(s.doc.plain_text(StoryRef::Body), "ışık işik", "mixed runs"); +} + +// Spelling as you type (#1552): the word being typed gets no red squiggle until the caret leaves +// it; F7 and the Spelling pane still report it. See docs/typing-parity.md. Paragraphs start with +// a correct word so AutoCorrect's sentence capital doesn't change the misspelled one. + +/// The words body paragraph `i` has a spelling squiggle under, in order. +fn squiggles(s: &mut Session, i: usize) -> Vec { + let path = wordcraft_doc::Path::top(i); + let text = s.doc.para(StoryRef::Body, &path).map(|p| p.text.clone()).unwrap_or_default(); + let l = s.layout(); + l.pages + .iter() + .flat_map(|pg| pg.items.iter()) + .filter_map(|it| match it { + wordcraft_layout::Placed::Lines { para, path: p, l0: 0, .. } if *p == path => { + Some(para.issues.iter().filter(|i| !i.2).filter_map(|i| text.get(i.0..i.1).map(str::to_string)).collect::>()) + } + _ => None, + }) + .flatten() + .collect() +} + +/// The misspelled words `review.issues` (the Spelling pane) reports, in order. +fn reported(s: &mut Session) -> Vec { + let issues = s.run("review.issues", &json!({})).unwrap(); + issues.as_array().unwrap().iter().filter(|i| i["kind"] == "spelling").map(|i| i["text"].as_str().unwrap().to_string()).collect() +} + +#[test] +fn the_word_being_typed_is_not_marked_until_it_ends() { + let mut s = typed("The helo"); + assert_eq!(squiggles(&mut s, 0), Vec::::new(), "no squiggle while the word is typed"); + // F7 and the Spelling pane still see it. + assert_eq!(reported(&mut s), ["helo"]); + let next = s.run("review.spelling", &json!({})).unwrap(); + assert_eq!(next["text"], "helo"); + // A space ends the word. + let mut s = typed("The helo "); + assert_eq!(squiggles(&mut s, 0), ["helo"]); + typ(&mut s, "wrld"); + assert_eq!(squiggles(&mut s, 0), ["helo"], "the next word is being typed"); + assert_eq!(reported(&mut s), ["helo", "wrld"]); + // So does punctuation. + typ(&mut s, "."); + assert_eq!(squiggles(&mut s, 0), ["helo", "wrld"]); + // Enter and Tab too. + let mut s = typed("The helo\n"); + assert_eq!(squiggles(&mut s, 0), ["helo"]); + let mut s = typed("The helo\t"); + assert_eq!(squiggles(&mut s, 0), ["helo"]); +} + +#[test] +fn moving_the_caret_out_of_the_typed_word_marks_it() { + for key in ["caret.left", "caret.home", "caret.wordLeft", "caret.docStart"] { + let mut s = typed("The helo"); + run(&mut s, key, json!({})); + assert_eq!(squiggles(&mut s, 0), ["helo"], "{key}"); + // Coming back to the end of the word doesn't hide the squiggle again. + run(&mut s, "caret.end", json!({})); + assert_eq!(s.sel.focus, Pos::body(0, 8)); + assert_eq!(squiggles(&mut s, 0), ["helo"], "{key} then End"); + } + // A selection ends it too. + let mut s = typed("The helo"); + run(&mut s, "select.all", json!({})); + assert_eq!(squiggles(&mut s, 0), ["helo"]); +} + +#[test] +fn a_click_elsewhere_marks_the_typed_word_and_a_click_back_keeps_the_mark() { + let mut s = typed("Fine\nThe helo"); + assert_eq!(squiggles(&mut s, 1), Vec::::new()); + // The canvas sets the caret directly on a click. + s.sel = crate::Selection::caret(Pos::body(0, 2)); + assert_eq!(squiggles(&mut s, 1), ["helo"]); + s.sel = crate::Selection::caret(Pos::body(1, 8)); + assert_eq!(squiggles(&mut s, 1), ["helo"], "an existing misspelled word keeps its squiggle"); + // Typing into it makes it the word being typed again. + typ(&mut s, "o"); + assert_eq!(s.doc.plain_text(StoryRef::Body), "Fine\nThe heloo"); + assert_eq!(squiggles(&mut s, 1), Vec::::new()); + typ(&mut s, " "); + assert_eq!(squiggles(&mut s, 1), ["heloo"]); +} + +#[test] +fn backspace_into_a_finished_word_edits_it_again() { + let mut s = typed("The helo "); + assert_eq!(squiggles(&mut s, 0), ["helo"]); + typ(&mut s, "\u{8}"); + assert_eq!(squiggles(&mut s, 0), Vec::::new()); + typ(&mut s, "\u{8}\u{8}llo "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "The hello "); + assert_eq!(squiggles(&mut s, 0), Vec::::new()); +} + +#[test] +fn autocorrect_and_inserted_text_are_marked_as_usual() { + // AutoCorrect fixes the word as the space ends it: nothing to mark. + let mut s = typed("I typed teh "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "I typed the "); + assert_eq!(squiggles(&mut s, 0), Vec::::new()); + // Text inserted whole (a paste, an agent): only the word the caret ends in is being typed. + let mut s = typed(""); + run(&mut s, "text.insert", json!({"text": "The helo wrld"})); + assert_eq!(squiggles(&mut s, 0), ["helo"]); + // Undo moves the caret: whatever is left is marked, as the Spelling pane reports it. + let mut s = typed("The helo"); + run(&mut s, "caret.left", json!({})); + typ(&mut s, "x"); + assert_eq!(s.doc.plain_text(StoryRef::Body), "The helxo"); + assert_eq!(squiggles(&mut s, 0), Vec::::new()); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc.plain_text(StoryRef::Body), "The helo"); + assert_eq!(squiggles(&mut s, 0), ["helo"]); + assert_eq!(reported(&mut s), ["helo"]); +} + +#[test] +fn apostrophes_and_hyphens_stay_part_of_the_word_being_typed() { + // Smart quotes turn the apostrophe into ’, which the spelling check keeps in the word. + let mut s = typed("It doesn'"); + assert_eq!(s.doc.plain_text(StoryRef::Body), "It doesn’"); + assert_eq!(reported(&mut s), ["doesn"], "the stem alone is a misspelling"); + assert_eq!(squiggles(&mut s, 0), Vec::::new()); + let mut s = typed("It doesn't "); + assert_eq!(squiggles(&mut s, 0), Vec::::new()); + // A hyphen joins the parts of a compound being typed. + let mut s = typed("The nonn-"); + assert_eq!(reported(&mut s), ["nonn"]); + assert_eq!(squiggles(&mut s, 0), Vec::::new()); + typ(&mut s, "exist"); + assert_eq!(squiggles(&mut s, 0), Vec::::new()); + typ(&mut s, " "); + assert!(!squiggles(&mut s, 0).is_empty()); + assert_eq!(squiggles(&mut s, 0), reported(&mut s)); + // A hyphen after a space starts no word. + let mut s = typed("The helo -"); + assert_eq!(squiggles(&mut s, 0), ["helo"]); +} + +#[test] +fn text_commands_that_edit_nothing_leave_an_existing_word_marked() { + let mut s = typed("Fine\nThe helo"); + s.sel = crate::Selection::caret(Pos::body(0, 2)); + assert_eq!(squiggles(&mut s, 1), ["helo"]); + s.sel = crate::Selection::caret(Pos::body(1, 8)); + // Insert switches overtype; Delete at the end of the document and Shift+Tab change no text. + run(&mut s, "text.overtype", json!({})); + assert_eq!(squiggles(&mut s, 1), ["helo"], "Insert"); + run(&mut s, "text.overtype", json!({})); + let _ = s.run("text.delete", &json!({})); + assert_eq!(squiggles(&mut s, 1), ["helo"], "Delete with nothing after the caret"); + let _ = s.run("text.backTab", &json!({})); + assert_eq!(squiggles(&mut s, 1), ["helo"], "Shift+Tab"); + assert_eq!(s.doc.plain_text(StoryRef::Body), "Fine\nThe helo"); +} + +#[test] +fn typing_over_backward_selection_keeps_its_font() { + // As in Word, text typed over a selection takes the first selected character's formatting, + // whichever way the selection was made (#2879). + for (anchor, focus) in [(6, 11), (11, 6)] { + let mut s = s(); + typ(&mut s, "Hello world"); + run(&mut s, "select.range", json!({"anchor": {"block": 0, "off": 6}, "focus": {"block": 0, "off": 11}})); + run(&mut s, "format.font", json!({"name": "Courier New"})); + run(&mut s, "select.range", json!({"anchor": {"block": 0, "off": anchor}, "focus": {"block": 0, "off": focus}})); + typ(&mut s, "X"); + let p = s.doc.body.first().and_then(|b| b.as_para()).unwrap(); + assert_eq!(p.text, "Hello X", "anchor={anchor} focus={focus}"); + assert_eq!(p.props_of_char(6).font.as_deref(), Some("Courier New"), "anchor={anchor} focus={focus}"); + assert_eq!(p.props_of_char(0).font, None, "anchor={anchor} focus={focus}"); + } +} + +#[test] +fn right_arrow_moves_through_a_ligature_one_letter_at_a_time() { + // A font that draws "ffi" as one glyph (bundled, so the test doesn't depend on the installed + // fonts); each Right press moves the drawn caret (#1974). + let mut s = s(); + run(&mut s, "format.font", json!({"name": "Source Serif 4"})); + typ(&mut s, "ffi"); + let drawn_as_one_glyph = s + .layout() + .pages + .iter() + .flat_map(|p| p.items.iter()) + .any(|it| matches!(it, wordcraft_layout::Placed::Lines { para, .. } if para.glyphs.len() < para.clusters.len())); + assert!(drawn_as_one_glyph, "Source Serif 4 should draw ffi as a ligature"); + run(&mut s, "caret.home", json!({})); + let mut xs = Vec::new(); + for _ in 0..4 { + let l = s.layout(); + xs.push((s.sel.focus.off, l.caret(&s.sel.focus).unwrap().x)); + run(&mut s, "caret.right", json!({})); + } + let offs: Vec = xs.iter().map(|p| p.0).collect(); + assert_eq!(offs, [0, 1, 2, 3]); + assert!(xs.windows(2).all(|w| w[0].1 < w[1].1), "caret x after each Right press: {xs:?}"); +} + +/// Two original paragraphs whose list comes from a style, optionally through a basedOn chain. +fn style_inherited_list(kind: wordcraft_doc::numbering::ListKind, chain: bool) -> Session { + use wordcraft_doc::props::NumRef; + use wordcraft_doc::styles::Style; + let mut doc = wordcraft_doc::Document::from_text("Alpha\nBeta"); + let num = Arc::make_mut(&mut doc.numbering).try_add_list(kind).unwrap(); + Arc::make_mut(&mut doc.styles).styles.push(Style { + id: "ListBase".into(), + name: "Original List".into(), + para: wordcraft_doc::ParaProps { numbering: Some(NumRef { num, level: 0 }), ..Default::default() }, + ..Default::default() + }); + if chain { + Arc::make_mut(&mut doc.styles).styles.push(Style { + id: "ListChild".into(), + name: "Original Derived".into(), + based_on: Some("ListBase".into()), + ..Default::default() + }); + } + for i in 0..2 { + doc.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(i)).unwrap().props.style = Some(if chain { "ListChild" } else { "ListBase" }.into()); + } + Session::new(doc) +} + +#[test] +fn toggle_style_inherited_list_off_preserves_other_paragraphs_and_round_trips() { + use wordcraft_doc::numbering::ListKind; + for (kind, command) in [(ListKind::Bullet, "para.bullets"), (ListKind::Numbered, "para.numbering")] { + for chain in [false, true] { + let mut s = style_inherited_list(kind, chain); + let before = s.doc.clone(); + s.sel = crate::Selection::caret(Pos::body(0, 2)); + run(&mut s, command, json!({})); + let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); + assert_eq!(p.props.numbering.unwrap().num, 0, "{command}, chain={chain}"); + assert_eq!(p.props.style, before.para_at(&Pos::body(0, 0)).unwrap().props.style); + assert_eq!(s.doc.para_at(&Pos::body(1, 0)), before.para_at(&Pos::body(1, 0))); + assert_eq!(s.doc.styles, before.styles); + assert_eq!(s.doc.numbering, before.numbering); + let after = s.doc.clone(); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc, before); + run(&mut s, "edit.redo", json!({})); + assert_eq!(s.doc, after); + let bytes = crate::io::save_bytes("list.docx", &s.doc).unwrap(); + let saved = crate::io::open_bytes("list.docx", &bytes).unwrap(); + let a = saved.para_at(&Pos::body(0, 0)).unwrap(); + let b = saved.para_at(&Pos::body(1, 0)).unwrap(); + assert_eq!(a.props.numbering.unwrap().num, 0); + assert!(saved.styles.resolve_para(&a.props).numbering.is_none()); + assert!(saved.styles.resolve_para(&b.props).numbering.is_some_and(|n| n.num != 0)); + assert_eq!(a.text, "Alpha"); + assert_eq!(b.text, "Beta"); + } + } +} + +#[test] +fn explicit_list_off_override_is_not_toggled_off_again() { + use wordcraft_doc::numbering::ListKind; + use wordcraft_doc::props::NumRef; + for (kind, command) in [(ListKind::Bullet, "para.bullets"), (ListKind::Numbered, "para.numbering")] { + let mut s = style_inherited_list(kind, true); + s.doc.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().props.numbering = Some(NumRef { num: 0, level: 0 }); + s.sel = crate::Selection::caret(Pos::body(0, 2)); + run(&mut s, command, json!({})); + assert!(s.doc.para_at(&Pos::body(0, 0)).unwrap().props.numbering.is_some_and(|n| n.num != 0)); + // A mixed selection must turn the list on, rather than remove the inherited item. + s.doc.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().props.numbering = Some(NumRef { num: 0, level: 0 }); + s.sel = crate::Selection { anchor: Pos::body(0, 0), focus: Pos::body(1, 4) }; + run(&mut s, command, json!({})); + for i in 0..2 { + let p = s.doc.para_at(&Pos::body(i, 0)).unwrap(); + assert!(s.doc.styles.resolve_para(&p.props).numbering.is_some_and(|n| n.num != 0)); + } + } +} + +#[test] +fn list_toggle_uses_effective_level_and_override() { + use wordcraft_doc::numbering::{ListKind, levels_for}; + use wordcraft_doc::props::NumRef; + for (kind, command, wrong) in [(ListKind::Bullet, "para.bullets", "para.numbering"), (ListKind::Numbered, "para.numbering", "para.bullets")] { + for override_level in [false, true] { + for explicit_kind in [false, true] { + let mut s = style_inherited_list(kind, true); + let opposite = if kind == ListKind::Bullet { ListKind::Numbered } else { ListKind::Bullet }; + let num = Arc::make_mut(&mut s.doc.styles).styles.iter_mut().find(|x| x.id == "ListBase").unwrap().para.numbering.as_mut().unwrap(); + num.level = 1; + let num_id = num.num; + Arc::make_mut(&mut s.doc.numbering).abstracts[0].levels[0] = levels_for(opposite)[0].clone(); + if override_level { + Arc::make_mut(&mut s.doc.numbering).abstracts[0].levels[1] = levels_for(opposite)[1].clone(); + Arc::make_mut(&mut s.doc.numbering).nums[0].level_overrides.push((1, levels_for(kind)[1].clone())); + } + let before = s.doc.clone(); + let params = if explicit_kind { json!({"kind": if kind == ListKind::Bullet { "bullet" } else { "numbered" }}) } else { json!({}) }; + run(&mut s, command, params); + assert_eq!( + s.doc.para_at(&Pos::body(0, 0)).unwrap().props.numbering, + Some(NumRef { num: 0, level: 0 }), + "{command}, override={override_level}, explicit={explicit_kind}" + ); + assert_eq!(s.doc.styles, before.styles); + assert_eq!(s.doc.numbering, before.numbering); + assert_eq!(s.doc.para_at(&Pos::body(1, 0)), before.para_at(&Pos::body(1, 0))); + let mut other = Session::new(before); + run(&mut other, wrong, json!({})); + assert!(other.doc.para_at(&Pos::body(0, 0)).unwrap().props.numbering.is_some_and(|n| n.num != 0)); + assert!(other.doc.numbering.level(num_id, 1).is_some()); + } + } + } +} + +#[test] +fn list_paragraph_style_survives_inherited_toggle_off() { + use wordcraft_doc::numbering::ListKind; + use wordcraft_doc::props::{Align, ParaProps}; + use wordcraft_doc::styles::Style; + for kind in [ListKind::Bullet, ListKind::Numbered] { + let mut s = style_inherited_list(kind, false); + *Arc::make_mut(&mut s.doc.styles).styles.iter_mut().find(|x| x.id == "ListParagraph").unwrap() = Style { + id: "ListParagraph".into(), + name: "Original formatted list".into(), + based_on: Some("ListBase".into()), + para: ParaProps { align: Some(Align::Right), space_after: Some(17.0), ..Default::default() }, + ..Default::default() + }; + s.doc.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().props.style = Some("ListParagraph".into()); + let before = s.doc.clone(); + run(&mut s, if kind == ListKind::Bullet { "para.bullets" } else { "para.numbering" }, json!({})); + let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); + assert_eq!(p.props.style.as_deref(), Some("ListParagraph")); + let effective = s.doc.styles.resolve_para(&p.props); + assert!(effective.numbering.is_none()); + assert_eq!(effective.align, Align::Right); + assert_eq!(effective.space_after, 17.0); + assert_eq!(s.doc.styles, before.styles); + assert_eq!(s.doc.numbering, before.numbering); + assert_eq!(s.doc.para_at(&Pos::body(1, 0)), before.para_at(&Pos::body(1, 0))); + let after = s.doc.clone(); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc, before); + run(&mut s, "edit.redo", json!({})); + assert_eq!(s.doc, after); + let bytes = crate::io::save_bytes("list.docx", &s.doc).unwrap(); + let saved = crate::io::open_bytes("list.docx", &bytes).unwrap(); + let p = saved.para_at(&Pos::body(0, 0)).unwrap(); + assert_eq!(p.props.style.as_deref(), Some("ListParagraph")); + let effective = saved.styles.resolve_para(&p.props); + assert!(effective.numbering.is_none()); + assert_eq!(effective.align, Align::Right); + assert_eq!(effective.space_after, 17.0); + } +} + +#[test] +fn direct_list_toggle_still_clears_the_automatic_list_paragraph_style() { + for command in ["para.bullets", "para.numbering"] { + let mut s = Session::new(wordcraft_doc::Document::from_text("Direct list")); + run(&mut s, command, json!({})); + assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().props.style.as_deref(), Some("ListParagraph")); + run(&mut s, command, json!({})); + let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); + assert!(p.props.style.is_none()); + assert!(s.doc.styles.resolve_para(&p.props).numbering.is_none()); + } +} + +#[test] +fn a_new_list_uses_the_requested_kind_after_a_mixed_format_list() { + use wordcraft_doc::numbering::{ListKind, levels_for}; + use wordcraft_doc::props::{NumRef, ParaProps}; + for (kind, command) in [(ListKind::Bullet, "para.bullets"), (ListKind::Numbered, "para.numbering")] { + let mut s = style_inherited_list(kind, false); + let opposite = if kind == ListKind::Bullet { ListKind::Numbered } else { ListKind::Bullet }; + Arc::make_mut(&mut s.doc.numbering).abstracts[0].levels[0] = levels_for(opposite)[0].clone(); + let num = s.doc.numbering.nums[0].id; + s.doc.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().props.numbering = Some(NumRef { num, level: 1 }); + s.doc.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(1)).unwrap().props = ParaProps::default(); + let before = s.doc.para_at(&Pos::body(0, 0)).unwrap().clone(); + s.sel = crate::Selection::caret(Pos::body(1, 0)); + run(&mut s, command, json!({})); + let n = s.doc.para_at(&Pos::body(1, 0)).unwrap().props.numbering.unwrap(); + assert_eq!(n.level, 0); + let level = s.doc.numbering.level(n.num, n.level).unwrap(); + assert_eq!(level.format == wordcraft_doc::section::NumFormat::Bullet, kind == ListKind::Bullet); + assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap(), &before); + } +} + +/// #4085: the registered shortcut toggles the last selected highlight, while explicit +/// colour commands remain idempotent setters for the palette, CLI and automation. +#[test] +#[cfg(not(target_os = "macos"))] +fn highlight_shortcut_toggles_the_last_colour() { + let mut s = typed("Alpha Beta"); + run(&mut s, "select.all", json!({})); + let colour = |s: &mut Session| s.run("format.state", &json!({})).unwrap()["highlight"].clone(); + press(&mut s, "Mod+Alt+H").unwrap(); + assert_eq!(colour(&mut s), json!("FFFF00")); + press(&mut s, "Mod+Alt+H").unwrap(); + assert_eq!(colour(&mut s), serde_json::Value::Null); + run(&mut s, "edit.undo", json!({})); + assert_eq!(colour(&mut s), json!("FFFF00")); + run(&mut s, "edit.redo", json!({})); + assert_eq!(colour(&mut s), serde_json::Value::Null); + run(&mut s, "format.highlight", json!({"color":"blue"})); + run(&mut s, "format.highlight", json!({"color":"blue"})); + assert_eq!(colour(&mut s), json!("0000FF")); + press(&mut s, "Mod+Alt+H").unwrap(); + assert_eq!(colour(&mut s), serde_json::Value::Null); + press(&mut s, "Mod+Alt+H").unwrap(); + assert_eq!(colour(&mut s), json!("0000FF")); + run(&mut s, "format.highlight", json!({"color":"none"})); + press(&mut s, "Mod+Alt+H").unwrap(); + assert_eq!(colour(&mut s), serde_json::Value::Null); + assert_eq!(s.doc.body[0].as_para().unwrap().text, "Alpha Beta"); +} + +/// #4085: mixed selections take one colour; removing an inherited colour writes an +/// explicit No Color, and a collapsed caret toggles pending formatting for future typing. +#[test] +fn highlight_toggle_handles_mixed_inherited_and_pending_formatting() { + let mut s = typed("Alpha Beta "); + run(&mut s, "select.text", json!({"text":"Alpha"})); + run(&mut s, "format.highlight", json!({"color":"blue"})); + run(&mut s, "select.all", json!({})); + run(&mut s, "format.highlight", json!({})); + assert_eq!(s.run("format.state", &json!({})).unwrap()["highlight"], json!("0000FF")); + run(&mut s, "format.highlight", json!({})); + assert_eq!(s.run("format.state", &json!({})).unwrap()["highlight"], serde_json::Value::Null); + Arc::make_mut(&mut s.doc.styles).default_chr.highlight = Some(wordcraft_doc::props::Highlight::Blue); + run(&mut s, "format.highlight", json!({"color":"blue"})); + run(&mut s, "format.highlight", json!({})); + assert!(s.doc.body[0].as_para().unwrap().runs.iter().all(|r| r.props.highlight == Some(wordcraft_doc::props::Highlight::None))); + run(&mut s, "caret.docEnd", json!({})); + run(&mut s, "format.highlight", json!({})); + assert_eq!(s.pending.as_ref().unwrap().highlight, Some(wordcraft_doc::props::Highlight::Blue)); + run(&mut s, "format.highlight", json!({})); + assert_eq!(s.pending.as_ref().unwrap().highlight, Some(wordcraft_doc::props::Highlight::None)); + run(&mut s, "text.insert", json!({"text":"X"})); + run(&mut s, "select.text", json!({"text":"X"})); + assert_eq!(s.run("format.state", &json!({})).unwrap()["highlight"], serde_json::Value::Null); + let before = serde_json::to_value(&s.doc).unwrap(); + assert!(s.run("format.highlight", &json!({"color":123})).is_err()); + assert_eq!(serde_json::to_value(&s.doc).unwrap(), before); +} + +/// #4086: a caret inside a word highlights what is typed next, not the word; Bold still +/// formats the word. +#[test] +fn highlight_at_a_caret_leaves_the_word_alone() { + let mut s = typed("Alpha Beta"); + run(&mut s, "caret.docStart", json!({})); + run(&mut s, "caret.right", json!({})); + run(&mut s, "format.highlight", json!({"color":"yellow"})); + assert!(s.doc.body[0].as_para().unwrap().runs.iter().all(|r| r.props.highlight.is_none())); + assert_eq!(s.pending.as_ref().unwrap().highlight, Some(wordcraft_doc::props::Highlight::Yellow)); + run(&mut s, "text.insert", json!({"text":"X"})); + run(&mut s, "select.text", json!({"text":"X"})); + assert_eq!(s.run("format.state", &json!({})).unwrap()["highlight"], json!("FFFF00")); + run(&mut s, "select.text", json!({"text":"Beta"})); + assert_eq!(s.run("format.state", &json!({})).unwrap()["highlight"], serde_json::Value::Null); + // The toggle reads the pending colour, not the word's: a second press turns it off. + run(&mut s, "caret.docStart", json!({})); + run(&mut s, "caret.right", json!({})); + run(&mut s, "format.highlight", json!({})); + assert_eq!(s.pending.as_ref().unwrap().highlight, Some(wordcraft_doc::props::Highlight::Yellow)); + run(&mut s, "format.highlight", json!({})); + assert_eq!(s.pending.as_ref().unwrap().highlight, Some(wordcraft_doc::props::Highlight::None)); + // Bold after Highlight formats the word and keeps both for what is typed next. + run(&mut s, "format.highlight", json!({})); + run(&mut s, "format.bold", json!({})); + run(&mut s, "text.insert", json!({"text":"Y"})); + run(&mut s, "select.text", json!({"text":"Y"})); + let state = s.run("format.state", &json!({})).unwrap(); + assert_eq!((state["highlight"].clone(), state["bold"].clone()), (json!("FFFF00"), json!(true))); +} + +/// A differently coloured final paragraph must remain visible to the shortcut beyond +/// the formatting-state query limit, including a style-inherited tail and Undo/Redo. +#[cfg(not(target_os = "macos"))] +#[test] +fn highlight_toggle_checks_past_the_resolved_run_limit() { + use wordcraft_doc::para::Run; + use wordcraft_doc::props::{CharProps, Highlight}; + for count in [10_000, 10_001, 10_002] { + for inherited in [false, true] { + let mut doc = wordcraft_doc::Document::from_text(&format!("{}\nZ", "x".repeat(count))); + doc.para_mut(StoryRef::Body, &Pos::body(0, 0).path).unwrap().runs = (0..count) + .map(|i| Run { len: 1, props: CharProps { highlight: Some(Highlight::Blue), bold: Some(i % 2 == 0), ..Default::default() } }) + .collect(); + if inherited { + Arc::make_mut(&mut doc.styles).default_chr.highlight = Some(Highlight::Yellow); + } else { + doc.para_mut(StoryRef::Body, &Pos::body(1, 0).path).unwrap().runs[0].props.highlight = Some(Highlight::Yellow); + } + doc.para_mut(StoryRef::Body, &Pos::body(0, 0).path).unwrap().mark.highlight = Some(Highlight::Blue); + let original = doc.clone(); + let mut s = Session::new(doc); + s.last_highlight = Highlight::Blue; + run(&mut s, "select.all", json!({})); + press(&mut s, "Mod+Alt+H").unwrap(); + assert!( + s.doc.body.iter().filter_map(|b| b.as_para()).flat_map(|p| &p.runs).all(|r| r.props.highlight == Some(Highlight::Blue)), + "count={count}, inherited={inherited}" + ); + press(&mut s, "Mod+Alt+H").unwrap(); + assert!(s.doc.body.iter().filter_map(|b| b.as_para()).flat_map(|p| &p.runs).all(|r| r.props.highlight == Some(Highlight::None))); + run(&mut s, "edit.undo", json!({})); + assert!(s.doc.body[1].as_para().unwrap().runs.iter().all(|r| r.props.highlight == Some(Highlight::Blue))); + run(&mut s, "edit.undo", json!({})); + assert_eq!(serde_json::to_value(&s.doc).unwrap(), serde_json::to_value(&original).unwrap()); + run(&mut s, "edit.redo", json!({})); + assert!(s.doc.body[1].as_para().unwrap().runs.iter().all(|r| r.props.highlight == Some(Highlight::Blue))); + } + } +} + +/// A command preview uses the real session's remembered colour without changing its +/// document, colour choice or undo history when the preview toggles highlighting off. +#[test] +fn highlight_preview_preserves_the_session_colour_and_document() { + use wordcraft_doc::props::Highlight; + let mut s = typed("Alpha"); + run(&mut s, "select.all", json!({})); + run(&mut s, "format.highlight", json!({"color":"blue"})); + let original = serde_json::to_value(&s.doc).unwrap(); + let depth = s.undo_depth(); + let (preview, _, _) = s.preview_layout("format.highlight", &json!({})).unwrap(); + assert!(preview.body[0].as_para().unwrap().runs.iter().all(|r| r.props.highlight == Some(Highlight::None))); + assert_eq!(serde_json::to_value(&s.doc).unwrap(), original); + assert_eq!(s.last_highlight, Highlight::Blue); + assert_eq!(s.undo_depth(), depth); +} + +/// Whole-cell column selections include paragraph marks, including empty cells and +/// inherited mark colours; adjacent unselected cells and undo/redo keep their formatting. +#[test] +fn highlight_toggle_checks_whole_cell_marks() { + use wordcraft_doc::props::Highlight; + use wordcraft_doc::{CharProps, Paragraph}; + for inherited in [false, true] { + for empty in [false, true] { + let mut s = s(); + run(&mut s, "insert.table", json!({"rows":2,"cols":2})); + let (table, _, _) = s.sel.focus.path.cell().unwrap(); + let pos = |row, col| { + let mut path = table.clone(); + path.0.extend([row, col, 0]); + Pos::new(StoryRef::Body, path, 0) + }; + if inherited { + Arc::make_mut(&mut s.doc.styles).default_chr.highlight = Some(Highlight::Yellow); + } + for row in 0..2 { + for col in 0..2 { + let p = s.doc.para_mut(StoryRef::Body, &pos(row, col).path).unwrap(); + let colour = if col == 0 { Highlight::Blue } else { Highlight::Red }; + *p = Paragraph::with_text( + if empty && row == 1 && col == 0 { "" } else { "AB" }, + CharProps { highlight: Some(colour), ..Default::default() }, + ); + p.mark.highlight = Some(colour); + if row == 1 && col == 0 { + p.mark.highlight = if inherited { None } else { Some(Highlight::Yellow) }; + } + } + } + s.last_highlight = Highlight::Blue; + run(&mut s, "caret.set", json!({"pos":pos(0,0)})); + run(&mut s, "table.selectColumn", json!({})); + assert!(!s.cell_range().unwrap().stream); + let original = serde_json::to_value(&s.doc).unwrap(); + run(&mut s, "format.highlight", json!({})); + for row in 0..2 { + let p = s.doc.para_at(&pos(row, 0)).unwrap(); + assert_eq!(p.mark.highlight, Some(Highlight::Blue), "inherited={inherited}, empty={empty}"); + assert!(p.runs.iter().all(|r| r.props.highlight == Some(Highlight::Blue))); + } + run(&mut s, "format.highlight", json!({})); + for row in 0..2 { + let p = s.doc.para_at(&pos(row, 0)).unwrap(); + assert_eq!(p.mark.highlight, Some(Highlight::None)); + assert!(p.runs.iter().all(|r| r.props.highlight == Some(Highlight::None))); + let other = s.doc.para_at(&pos(row, 1)).unwrap(); + assert_eq!(other.mark.highlight, Some(Highlight::Red)); + assert!(other.runs.iter().all(|r| r.props.highlight == Some(Highlight::Red))); + } + run(&mut s, "edit.undo", json!({})); + run(&mut s, "edit.undo", json!({})); + assert_eq!(serde_json::to_value(&s.doc).unwrap(), original); + run(&mut s, "edit.redo", json!({})); + assert_eq!(s.doc.para_at(&pos(1, 0)).unwrap().mark.highlight, Some(Highlight::Blue)); + } + } +} + +/// Fully selected marks participate even when the selection has no text runs or an +/// empty paragraph between blue runs; repeated toggles and undo preserve these targets. +#[test] +fn highlight_toggle_checks_ordinary_paragraph_marks() { + use wordcraft_doc::props::Highlight; + for text in ["AB", "AB\n\nAB", "\n"] { + let mut doc = wordcraft_doc::Document::from_text(text); + Arc::make_mut(&mut doc.styles).default_chr.highlight = Some(Highlight::Blue); + let paths = doc.para_paths(StoryRef::Body); + for path in &paths { + let p = doc.para_mut(StoryRef::Body, path).unwrap(); + p.mark.highlight = Some(Highlight::Blue); + for r in &mut p.runs { + r.props.highlight = Some(Highlight::Blue); + } + } + let mismatch = if text == "AB\n\nAB" { 1 } else { 0 }; + doc.para_mut(StoryRef::Body, &paths[mismatch]).unwrap().mark.highlight = Some(Highlight::Yellow); + let original = serde_json::to_value(&doc).unwrap(); + let mut s = Session::new(doc); + s.last_highlight = Highlight::Blue; + run(&mut s, "select.all", json!({})); + run(&mut s, "format.highlight", json!({})); + for path in &paths { + let p = s.doc.para(StoryRef::Body, path).unwrap(); + assert_eq!(p.mark.highlight, Some(Highlight::Blue), "text={text:?}"); + assert!(p.runs.iter().all(|r| r.props.highlight == Some(Highlight::Blue))); + } + run(&mut s, "format.highlight", json!({})); + assert!(paths.iter().all(|p| s.doc.para(StoryRef::Body, p).unwrap().mark.highlight == Some(Highlight::None))); + run(&mut s, "edit.undo", json!({})); + run(&mut s, "edit.undo", json!({})); + assert_eq!(serde_json::to_value(&s.doc).unwrap(), original); + } +} + +/// A partial text column excludes paragraph marks and text beyond its selected offsets, +/// so a different mark colour must not prevent clearing the selected blue characters. +#[test] +fn highlight_partial_column_ignores_unselected_marks() { + use wordcraft_doc::props::Highlight; + let mut doc = wordcraft_doc::Document::from_text("AB\nAB"); + for path in doc.para_paths(StoryRef::Body) { + let p = doc.para_mut(StoryRef::Body, &path).unwrap(); + p.mark.highlight = Some(Highlight::Yellow); + for r in &mut p.runs { + r.props.highlight = Some(Highlight::Blue); + } + } + let mut s = Session::new(doc); + s.last_highlight = Highlight::Blue; + run(&mut s, "select.column", json!({"anchor":Pos::body(0,0),"focus":Pos::body(1,1)})); + run(&mut s, "format.highlight", json!({})); + for row in 0..2 { + let p = s.doc.para_at(&Pos::body(row, 0)).unwrap(); + assert_eq!(p.props_of_char(0).highlight, Some(Highlight::None)); + assert_eq!(p.props_of_char(1).highlight, Some(Highlight::Blue)); + assert_eq!(p.mark.highlight, Some(Highlight::Yellow)); + } +} + +#[test] +fn tab_with_a_whole_paragraph_selected_indents_it() { + let mut s = typed("Hello world"); + run(&mut s, "select.paragraph", json!({})); + run(&mut s, "text.tab", json!({})); + assert_eq!(paras(&s), vec![para("Hello world", None)]); + assert_eq!(indents(&s).0, Some(36.0)); +} + +#[test] +fn tab_with_a_partial_selection_still_inserts_a_tab() { + let mut s = typed("Hello world"); + run(&mut s, "select.text", json!({"text": "ello"})); + run(&mut s, "text.tab", json!({})); + assert_eq!(paras(&s), vec![para("H\t world", None)]); +} + +/// Tab with a whole paragraph selected indents it as one Undo step: the selection stays while +/// the indent is applied, and Undo restores both the indentation and that same selection. A +/// partial selection still types a tab character (the test above), and Tab in a table cell still +/// navigates (the table tests above). +#[test] +fn tab_with_a_whole_paragraph_selected_undoes_indent_and_selection_together() { + let mut s = typed("Hello world"); + run(&mut s, "select.paragraph", json!({})); + let selected = s.sel.clone(); + let depth = s.undo_depth(); + run(&mut s, "text.tab", json!({})); + assert_eq!(s.doc.plain_text(StoryRef::Body), "Hello world", "Tab indents, it types no tab"); + assert_eq!(s.sel, selected, "the whole paragraph stays selected"); + assert_eq!(indents(&s), (Some(36.0), None)); + assert_eq!(s.undo_depth(), depth + 1, "one paragraph, one Undo step"); + run(&mut s, "edit.undo", json!({})); + assert_eq!(indents(&s), (None, None), "Undo takes the indent back"); + assert_eq!(s.sel, selected, "and restores the selection the indent was applied to"); + assert_eq!(paras(&s), vec![para("Hello world", None)]); +} + +/// #2848: "Add a period with double-space" (an AutoFormat As You Type switch, off by default): +/// a second Space after a word, a number or a closing bracket or quote turns the first into a +/// period. Undo puts the two spaces back; other punctuation, a lone space and a run of three +/// spaces are left as typed. +#[test] +fn double_space_adds_a_period_when_asked() { + let with_option = |keys: &str| { + let mut s = s(); + run(&mut s, "tools.autocorrect", json!({"doubleSpacePeriod": true})); + typ(&mut s, keys); + s + }; + let text = |s: &Session| s.doc.plain_text(StoryRef::Body); + // Off unless asked for. + assert_eq!(text(&typed("Hello world")), "Hello world"); + assert!(!s().prefs.autocorrect.double_space_period); + // On: the second space makes the period, and the next sentence starts with a capital. + assert_eq!(text(&with_option("Hello world")), "Hello. world"); + assert_eq!(text(&with_option("Hello world ")), "Hello. World "); + assert_eq!(text(&with_option("See page 5 Then")), "See page 5. Then"); + assert_eq!(text(&with_option("(it is done) Next")), "(it is done). Next"); + assert_eq!(text(&with_option("He said \"stop\" Next")), "He said “stop”. Next"); + // Undo brings the two spaces back, in one step. + let mut t = with_option("Hello "); + assert_eq!(text(&t), "Hello. "); + run(&mut t, "edit.undo", json!({})); + assert_eq!(text(&t), "Hello "); + // Left alone: other punctuation before the spaces, a lone space, spaces at the start of a + // paragraph or after a tab, and a third space. + for keys in ["Hello. x", "Hello, x", "Hello? x", "Hello; x", "Hello: x", "Hello x", " x", "\n x", "a\t x"] { + let want = typed(keys); + assert_eq!(text(&with_option(keys)), text(&want), "{keys:?}"); + } + assert_eq!(text(&with_option("Hello x")), "Hello. x"); + // A selection typed over, and the master switch. + let mut t = with_option("Hello world"); + run(&mut t, "select.all", json!({})); + run(&mut t, "text.insert", json!({"text": " "})); + assert_eq!(text(&t), " "); + let mut t = s(); + run(&mut t, "tools.autocorrect", json!({"doubleSpacePeriod": true, "enabled": false})); + typ(&mut t, "Hello world"); + assert_eq!(text(&t), "Hello world"); +} + +#[test] +fn backspace_after_a_floating_shape_anchor_joins_paragraphs_and_keeps_the_shape() { + let mut s = typed("Line one\nLine two"); + run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [1], "off": 0}})); + run(&mut s, "insert.textBox", json!({"text": "Box"})); + let (a, f) = (Pos::body(1, 0), Pos::body(1, 3)); + run(&mut s, "select.range", json!({"anchor": a, "focus": f})); + run(&mut s, "arrange.wrap", json!({"wrap": "inFrontOfText"})); + let objects = |s: &Session| s.doc.para_at(&Pos::body(1, 0)).map(|p| p.object_offsets().len()); + assert_eq!(objects(&s), Some(1)); + run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [1], "off": 3}})); + run(&mut s, "text.backspace", json!({})); + assert_eq!(s.doc.body.iter().filter(|b| b.as_para().is_some()).count(), 1); + assert_eq!(objects(&s), None); + assert_eq!( + s.doc.para_at(&Pos::body(0, 0)).map(|p| (p.text.replace('\u{FFFC}', ""), p.object_offsets().len())), + Some(("Line oneLine two".to_string(), 1)) + ); +} + +#[test] +fn backspace_after_marker_and_floating_anchors_still_joins_paragraphs() { + let mut s = typed("Line one\nLine two"); + run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [1], "off": 0}})); + run(&mut s, "insert.textBox", json!({"text": "Box"})); + let (a, f) = (Pos::body(1, 0), Pos::body(1, 3)); + run(&mut s, "select.range", json!({"anchor": a, "focus": f})); + run(&mut s, "arrange.wrap", json!({"wrap": "inFrontOfText"})); + s.doc.insert_object(&Pos::body(1, 0), wordcraft_doc::InlineObject::BookmarkStart { name: "anchor".into() }, &Default::default()).unwrap(); + s.doc.insert_object(&Pos::body(1, 0), wordcraft_doc::InlineObject::CommentStart { id: 1 }, &Default::default()).unwrap(); + run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [1], "off": 9}})); + run(&mut s, "text.backspace", json!({})); + assert_eq!(s.doc.body.iter().filter(|b| b.as_para().is_some()).count(), 1); + assert_eq!(s.doc.para_at(&Pos::body(0, 0)).map(|p| p.text.replace('\u{FFFC}', "")), Some("Line oneLine two".to_string())); +} + +#[test] +fn backspace_cannot_join_into_locked_content_control() { + let mut s = typed("Above\nInside"); + run(&mut s, "select.range", json!({"anchor": {"story": "body", "path": [1], "off": 0}, "focus": {"story": "body", "path": [1], "off": 6}})); + run(&mut s, "developer.richText", json!({"tag": "locked"})); + run(&mut s, "developer.properties", json!({"control": "locked", "lockEdit": true})); + run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [1], "off": 0}})); + let before = s.doc.clone(); + assert!(s.run("text.backspace", &json!({})).is_err()); + assert_eq!(s.doc, before); +} + +// #3693 is an additional editing shortcut, not a claim of Word's default key behaviour. +#[test] +fn delete_previous_sentence_respects_caret_boundaries_and_unicode() { + for (text, before, expected) in [ + ("First sentence. Second sentence.", "First sentence. Second sentence.", "First sentence. "), + ("First sentence. Second sentence.", "First sentence. Sec", "First sentence. ond sentence."), + ("First sentence. Second sentence.", "First sentence. ", "Second sentence."), + ("Grüße! Überall café.", "Grüße! Überall café.", "Grüße! "), + ("No punctuation here", "No punctuation", " here"), + ("One. Two.", "", "One. Two."), + ("", "", ""), + ] { + let mut s = s(); + run(&mut s, "text.insert", json!({"text": text, "raw": true})); + let mut pos = s.sel.focus.clone(); + pos.off = before.len(); + s.sel = crate::Selection::caret(pos); + run(&mut s, "text.deleteSentenceBack", json!({})); + assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().text, expected, "{text:?} at {before:?}"); + if expected != text { + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().text, text); + run(&mut s, "edit.redo", json!({})); + assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().text, expected); + } + } +} + +#[test] +fn delete_previous_sentence_keeps_paragraph_boundaries_and_deletes_selections() { + let mut s = s(); + run(&mut s, "text.insert", json!({"text": "First.\nSecond sentence.", "raw": true, "paragraphs": true})); + let end = s.sel.focus.clone(); + s.sel = crate::Selection::caret(Pos { off: 0, ..end.clone() }); + let count = s.doc.body.len(); + run(&mut s, "text.deleteSentenceBack", json!({})); + assert_eq!(s.doc.body.len(), count); + assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().text, "Second sentence."); + s.sel = crate::Selection { anchor: Pos { off: 0, ..end.clone() }, focus: Pos { off: 6, ..end } }; + run(&mut s, "text.deleteSentenceBack", json!({})); + assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().text, " sentence."); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().text, "Second sentence."); +} + +#[test] +fn delete_previous_sentence_tracks_changes_and_respects_read_only() { + for accept in [true, false] { + let mut s = s(); + run(&mut s, "text.insert", json!({"text": "One. Two.", "raw": true})); + run(&mut s, "review.trackChanges", json!({"value": true})); + run(&mut s, "text.deleteSentenceBack", json!({})); + let changes = s.run("review.changes", &json!({})).unwrap(); + assert!(changes.as_array().unwrap().iter().any(|c| c["kind"] == "delete")); + run(&mut s, if accept { "review.acceptAll" } else { "review.rejectAll" }, json!({})); + assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().text, if accept { "One. " } else { "One. Two." }); + } + let mut s = s(); + run(&mut s, "text.insert", json!({"text": "Keep this.", "raw": true})); + run(&mut s, "review.restrict", json!({"mode": "readOnly"})); + assert!(s.run("text.deleteSentenceBack", &json!({})).is_err()); + assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().text, "Keep this."); +} + +#[test] +fn delete_previous_sentence_does_not_cross_a_table_cell_boundary() { + let mut s = s(); + run(&mut s, "insert.table", json!({"rows": 1, "cols": 2})); + run(&mut s, "text.insert", json!({"text": "First cell.", "raw": true})); + run(&mut s, "text.tab", json!({})); + run(&mut s, "text.insert", json!({"text": "Second cell.", "raw": true})); + let end = s.sel.focus.clone(); + s.sel = crate::Selection::caret(Pos { off: 0, ..end.clone() }); + let before = s.doc.plain_text(StoryRef::Body); + run(&mut s, "text.deleteSentenceBack", json!({})); + assert_eq!(s.doc.plain_text(StoryRef::Body), before); + s.sel = crate::Selection::caret(end); + run(&mut s, "text.deleteSentenceBack", json!({})); + assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().text, ""); + assert!(s.doc.plain_text(StoryRef::Body).contains("First cell.")); +} + +// English spelling from SCOWL 2026 (CP2): each English reads its own SCOWL dictionary, with +// `en-variants.txt` deciding the other dialect's spellings. Typed text, as a person types it. + +/// The spelling words typed text gets in `lang` (the whole document's language), with the first +/// suggestion of each. +fn typed_spelling_in(lang: &str, keys: &str) -> Vec<(String, String)> { + let mut s = s(); + run(&mut s, "review.language", json!({"lang": lang, "default": true})); + typ(&mut s, keys); + let issues = s.run("review.issues", &json!({})).unwrap(); + issues + .as_array() + .unwrap() + .iter() + .filter(|i| i["kind"] == "spelling") + .map(|i| (i["text"].as_str().unwrap().to_string(), i["suggestions"][0].as_str().unwrap_or("").to_string())) + .collect() +} + +/// #40: the British spellings the issue lists are right in en-GB, and the American ones are +/// flagged with the British form first. +#[test] +fn issue_40_british_spellings_are_right_in_en_gb() { + if super::no_language_data("issue_40_british_spellings_are_right_in_en_gb") { + return; + } + let none: Vec<(String, String)> = Vec::new(); + let text = "The judgement on the defence licence put the programme in the catalogue. "; + assert_eq!(typed_spelling_in("en-GB", text), none); + assert_eq!(typed_spelling_in("en-GB", "Its colour and centre, organised and organized, travelled. "), none); + let flagged = typed_spelling_in("en-GB", "The color of the center. "); + assert_eq!(flagged, [("color".to_string(), "colour".to_string()), ("center".to_string(), "centre".to_string())]); +} + +/// #520: common modern words are right (SCOWL plus our supplement), and real misspellings of +/// them are still caught. +#[test] +fn issue_520_common_words_are_right() { + if super::no_language_data("issue_520_common_words_are_right") { + return; + } + let none: Vec<(String, String)> = Vec::new(); + assert_eq!(typed_spelling_in("en-US", "My desktop email has downloads, a smartphone and a website. "), none); + // (`recieved` would be AutoCorrected as it is typed.) + let flagged = typed_spelling_in("en-US", "My desktpo emial. "); + assert_eq!(flagged.iter().map(|f| f.0.as_str()).collect::>(), ["desktpo", "emial"]); + assert_eq!(flagged[0].1, "desktop"); +} + +/// #1281 and #1045: Canadian and Australian English (and the other British-spelling Englishes) +/// proof by their own rules; ordinals are words in every English. +#[test] +fn issues_1281_1045_english_dialects_proof_typed_text() { + if super::no_language_data("issues_1281_1045_english_dialects_proof_typed_text") { + return; + } + let none: Vec<(String, String)> = Vec::new(); + // Canada: British -our and -re, American -ize. + assert_eq!(typed_spelling_in("en-CA", "We organize the colour in the centre. "), none); + assert_eq!( + typed_spelling_in("en-CA", "We organise the color. "), + [("organise".to_string(), "organize".to_string()), ("color".to_string(), "colour".to_string())] + ); + // Australia, New Zealand, India: British spelling. + for lang in ["en-AU", "en-NZ", "en-IN"] { + assert_eq!(typed_spelling_in(lang, "We analyse the colour of the theatre. "), none, "{lang}"); + assert_eq!(typed_spelling_in(lang, "We analyze it. "), [("analyze".to_string(), "analyse".to_string())], "{lang}"); + } + // US English flags British spellings with the American form first. + assert_eq!(typed_spelling_in("en-US", "The colour. "), [("colour".to_string(), "color".to_string())]); + // SCOWL's ordinals (COMPOUNDRULE) are words, even where numbers are checked. + let mut s = s(); + run(&mut s, "file.options", json!({"ignoreNumbers": false})); + typ(&mut s, "The 21st and 112th times, not the 21th. "); + let issues = s.run("review.issues", &json!({})).unwrap(); + 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, ["21th"]); +} diff --git a/crates/engine/src/tests/wordart.rs b/crates/engine/src/tests/wordart.rs index 9a7a9c2c6d..488e2cd9c2 100644 --- a/crates/engine/src/tests/wordart.rs +++ b/crates/engine/src/tests/wordart.rs @@ -1,202 +1,202 @@ -//! WordArt and connectors through the commands. - -use serde_json::json; -use wordcraft_doc::connector::{ConnEnd, ShapeExtra, ends}; -use wordcraft_doc::para::{Anchor, Float, InlineObject, ShapeKind, Wrap}; -use wordcraft_doc::props::CharProps; -use wordcraft_doc::{Document, Paragraph, Pos, StoryRef, para_block}; - -use crate::Session; - -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}")) -} - -/// A page-placed rectangle (id 1) at x 100 and ellipse (id 2) at x 300, 72 × 36 at y 200, and an -/// elbow connector from the rectangle's right side to the ellipse's left side, all in body -/// paragraph 0 after "Body " (objects at 5, 8, 11). -fn joined() -> Session { - let float = |x: f32| Float { wrap: Wrap::InFrontOfText, h_rel: Anchor::Page, v_rel: Anchor::Page, x, y: 200.0, ..Default::default() }; - let shape = |kind, x, extra| InlineObject::Shape { - kind, - w: 72.0, - h: 36.0, - fill: None, - stroke: None, - stroke_width: 1.0, - float: float(x), - story: None, - freeform: None, - effects: Default::default(), - extra, - }; - let link = ShapeExtra { start: Some(ConnEnd { id: 1, site: 3 }), end: Some(ConnEnd { id: 2, site: 2 }), ..Default::default() }; - let mut p = Paragraph::with_text("Body ", CharProps::default()); - for o in [ - shape(ShapeKind::Rectangle, 100.0, ShapeExtra { id: 1, ..Default::default() }), - shape(ShapeKind::Ellipse, 300.0, ShapeExtra { id: 2, ..Default::default() }), - shape(ShapeKind::ElbowConnector, 0.0, link), - ] { - let end = p.len(); - p.insert_object(end, o, &CharProps::default()).unwrap(); - } - let mut d = Document::new(); - d.body = vec![para_block(p)]; - Session::new(d) -} - -fn select(s: &mut Session, off: usize) { - run(s, "select.range", json!({"anchor": Pos::body(0, off), "focus": Pos::body(0, off + 3)})); -} - -/// The connector's start and end on the page. -fn connector_ends(s: &mut Session) -> ((f32, f32), (f32, f32)) { - let pos = Pos::body(0, 11); - let hit = s.layout().object(&pos, 0).unwrap(); - let Some(InlineObject::Shape { float, .. }) = s.doc.para_at(&pos).and_then(|p| p.object_at(pos.off)) else { panic!() }; - let r = hit.rect; - let (a, b) = ends(r.x, r.y, r.w, r.h, float.flip_h, float.flip_v); - let round = |p: (f32, f32)| ((p.0 * 100.0).round() / 100.0, (p.1 * 100.0).round() / 100.0); - (round(a), round(b)) -} - -#[test] -fn moving_a_joined_shape_reroutes_its_connector() { - let mut s = joined(); - select(&mut s, 5); - run(&mut s, "arrange.nudge", json!({"dx": 0, "dy": 30})); - // From the rectangle's right side (172, 248) to the ellipse's left side (300, 218). - assert_eq!(connector_ends(&mut s), ((172.0, 248.0), (300.0, 218.0))); - // Resizing the ellipse moves the end with its site. - select(&mut s, 8); - run(&mut s, "arrange.bounds", json!({"width": 100, "height": 60})); - assert_eq!(connector_ends(&mut s).1, (300.0, 230.0)); - // One undo takes the resize and the re-route back. - run(&mut s, "edit.undo", json!({})); - assert_eq!(connector_ends(&mut s), ((172.0, 248.0), (300.0, 218.0))); -} - -/// A target on another page, or a missing target, leaves the connector's end free. -#[test] -fn rerouting_keeps_off_page_and_missing_targets_at_their_free_ends() { - let mut s = joined(); - let mut connector = s.doc.para_at(&Pos::body(0, 11)).unwrap().object_at(11).unwrap().clone(); - let InlineObject::Shape { extra, .. } = &mut connector else { panic!("connector") }; - extra.end = Some(ConnEnd { id: 404, site: 2 }); - let mut p = Paragraph::new(); - p.props.page_break_before = Some(true); - p.insert_object(0, connector.clone(), &Default::default()).unwrap(); - s.doc.body.push(para_block(p)); - s.touch(); - select(&mut s, 5); - run(&mut s, "arrange.nudge", json!({"dx": 10, "dy": 30})); - assert_eq!(s.layout().pages.len(), 2); - assert_eq!(s.doc.para_at(&Pos::body(1, 0)).unwrap().object_at(0), Some(&connector)); - assert_eq!(connector_ends(&mut s), ((182.0, 248.0), (300.0, 218.0))); - run(&mut s, "edit.undo", json!({})); - assert_eq!(s.doc.para_at(&Pos::body(1, 0)).unwrap().object_at(0), Some(&connector)); -} - -/// A command that changes nothing but reroutes a stale connector is still an edit: the -/// reroute must not be left without an undo step (#23). -#[test] -fn a_rerouted_connector_is_still_undoable() { - let mut s = joined(); - select(&mut s, 5); - let before = connector_ends(&mut s); - // A nudge by nothing moves nothing, but the connector is stale and gets rerouted. - run(&mut s, "arrange.nudge", json!({"dx": 0, "dy": 0})); - assert_ne!(connector_ends(&mut s), before, "the connector was rerouted"); - assert_eq!(s.undo_depth(), 1, "the reroute is an undo step of its own"); - // One undo takes the reroute back. - run(&mut s, "edit.undo", json!({})); - assert_eq!(connector_ends(&mut s), before, "one undo takes the reroute back"); -} - -#[test] -fn dragging_a_connector_end_glues_it_to_the_nearest_site() { - let mut s = joined(); - select(&mut s, 11); - // Dropped near the ellipse's top: glued there. - run(&mut s, "shape.connect", json!({"end": {"x": 340.0, "y": 195.0}})); - let ext = |s: &Session| match s.doc.para_at(&Pos::body(0, 11)).and_then(|p| p.object_at(11)) { - Some(InlineObject::Shape { extra, .. }) => extra.clone(), - o => panic!("{o:?}"), - }; - assert_eq!(ext(&s).end, Some(ConnEnd { id: 2, site: 0 })); - assert_eq!(connector_ends(&mut s).1, (336.0, 200.0)); - // Dropped in the open: a free end there. - run(&mut s, "shape.connect", json!({"start": {"x": 20.0, "y": 30.0}})); - assert_eq!(ext(&s).start, None); - assert_eq!(connector_ends(&mut s).0, (20.0, 30.0)); - // Glued by reference; a site the shape doesn't have is refused. - run(&mut s, "shape.connect", json!({"start": {"path": [0], "off": 5, "site": 1}})); - assert_eq!(connector_ends(&mut s).0, (100.0, 218.0)); - assert!(s.run("shape.connect", &json!({"start": {"path": [0], "off": 5, "site": 40}})).is_err()); - assert!(s.run("shape.connect", &json!({"start": {"path": [0], "off": 11}})).is_err(), "not to itself"); - // A connection to a shape that is gone is ignored. - select(&mut s, 8); - run(&mut s, "text.delete", json!({})); - select(&mut s, 5); - run(&mut s, "arrange.nudge", json!({"dx": 1})); -} - -#[test] -fn wordart_is_inserted_styled_and_bent() { - let mut s = Session::new(Document::new()); - let r = run(&mut s, "insert.wordArt", json!({"text": "Hello", "style": "gradient", "transform": "textArchUp"})); - let id = r["story"].as_u64().unwrap() as u32; - assert_eq!(s.doc.plain_text(StoryRef::Part(id)), "Hello"); - let props = s.doc.para_at(&s.doc.start_of(StoryRef::Part(id))).unwrap().props_at(0).clone(); - assert!(matches!(props.text_effects.as_deref().and_then(|f| f.fill.as_ref()), Some(wordcraft_doc::wordart::TextFill::Gradient { .. }))); - // Its text is drawn from outlines, bent. - let layout = s.layout(); - let draws = wordcraft_layout::display::page_display(&s.doc, &layout.pages[0], &Default::default()); - assert!(draws.iter().any(|d| matches!(d, wordcraft_layout::display::Draw::Art { .. }))); - // The PDF draws the gradient and keeps the text. - let pdf = wordcraft_pdf::export(&s.doc, &Default::default()).unwrap(); - assert!(pdf.starts_with(b"%PDF")); - // Effects and transforms on the selected text box's text; hostile values are refused or capped. - run(&mut s, "format.textEffects", json!({"outline": {"color": "C00000", "width": 1e9}, "glow": 5})); - let props = s.doc.para_at(&s.doc.start_of(StoryRef::Part(id))).unwrap().props_at(0).clone(); - let fx = props.text_effects.unwrap(); - assert_eq!(fx.outline.map(|o| o.width), Some(wordcraft_doc::wordart::MAX_OUTLINE)); - assert!(fx.glow.is_some() && fx.fill.is_some()); - run(&mut s, "wordArt.transform", json!({"preset": "textWave1", "adj": 1e300})); - assert!(s.run("wordArt.transform", &json!({"preset": "textBogus"})).is_err()); - assert!(s.run("format.textEffects", &json!({"style": "word"})).is_err()); - assert!(s.run("format.textEffects", &json!({"glow": "much"})).is_err()); - let warp = s.doc.para_at(&Pos::body(0, 0)).and_then(|p| p.object_at(0)).and_then(|o| match o { - InlineObject::Shape { extra, .. } => extra.warp.clone(), - _ => None, - }); - assert_eq!( - warp.map(|w| (w.adj_value("adj"), w.preset)).map(|(a, p)| (p, a)), - Some(("textWave1".into(), Some(wordcraft_doc::wordart::MAX_ADJ_VALUE))) - ); - run(&mut s, "wordArt.transform", json!({"preset": "none"})); -} - -#[test] -fn wordart_reflection_and_outline_none_options() { - let mut s = Session::new(Document::new()); - let r = run(&mut s, "insert.wordArt", json!({"text": "Reflect", "style": "solid"})); - let id = r["story"].as_u64().unwrap() as u32; - - // Setting reflection - run(&mut s, "format.textEffects", json!({"reflection": true})); - let props = s.doc.para_at(&s.doc.start_of(StoryRef::Part(id))).unwrap().props_at(0).clone(); - assert!(props.text_effects.as_ref().unwrap().reflection.is_some()); - - // Clearing reflection using "none" - run(&mut s, "format.textEffects", json!({"reflection": "none"})); - let props = s.doc.para_at(&s.doc.start_of(StoryRef::Part(id))).unwrap().props_at(0).clone(); - assert!(props.text_effects.as_ref().and_then(|fx| fx.reflection).is_none()); - - // Setting outline with color "none" - run(&mut s, "wordArt.textOutline", json!({"color": "none", "width": 1.5})); - let props = s.doc.para_at(&s.doc.start_of(StoryRef::Part(id))).unwrap().props_at(0).clone(); - let outline = props.text_effects.as_ref().unwrap().outline.unwrap(); - assert_eq!(outline.color, None); - assert_eq!(outline.width, 1.5); -} +//! WordArt and connectors through the commands. + +use serde_json::json; +use wordcraft_doc::connector::{ConnEnd, ShapeExtra, ends}; +use wordcraft_doc::para::{Anchor, Float, InlineObject, ShapeKind, Wrap}; +use wordcraft_doc::props::CharProps; +use wordcraft_doc::{Document, Paragraph, Pos, StoryRef, para_block}; + +use crate::Session; + +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}")) +} + +/// A page-placed rectangle (id 1) at x 100 and ellipse (id 2) at x 300, 72 × 36 at y 200, and an +/// elbow connector from the rectangle's right side to the ellipse's left side, all in body +/// paragraph 0 after "Body " (objects at 5, 8, 11). +fn joined() -> Session { + let float = |x: f32| Float { wrap: Wrap::InFrontOfText, h_rel: Anchor::Page, v_rel: Anchor::Page, x, y: 200.0, ..Default::default() }; + let shape = |kind, x, extra| InlineObject::Shape { + kind, + w: 72.0, + h: 36.0, + fill: None, + stroke: None, + stroke_width: 1.0, + float: float(x), + story: None, + freeform: None, + effects: Default::default(), + extra, + }; + let link = ShapeExtra { start: Some(ConnEnd { id: 1, site: 3 }), end: Some(ConnEnd { id: 2, site: 2 }), ..Default::default() }; + let mut p = Paragraph::with_text("Body ", CharProps::default()); + for o in [ + shape(ShapeKind::Rectangle, 100.0, ShapeExtra { id: 1, ..Default::default() }), + shape(ShapeKind::Ellipse, 300.0, ShapeExtra { id: 2, ..Default::default() }), + shape(ShapeKind::ElbowConnector, 0.0, link), + ] { + let end = p.len(); + p.insert_object(end, o, &CharProps::default()).unwrap(); + } + let mut d = Document::new(); + d.body = vec![para_block(p)]; + Session::new(d) +} + +fn select(s: &mut Session, off: usize) { + run(s, "select.range", json!({"anchor": Pos::body(0, off), "focus": Pos::body(0, off + 3)})); +} + +/// The connector's start and end on the page. +fn connector_ends(s: &mut Session) -> ((f32, f32), (f32, f32)) { + let pos = Pos::body(0, 11); + let hit = s.layout().object(&pos, 0).unwrap(); + let Some(InlineObject::Shape { float, .. }) = s.doc.para_at(&pos).and_then(|p| p.object_at(pos.off)) else { panic!() }; + let r = hit.rect; + let (a, b) = ends(r.x, r.y, r.w, r.h, float.flip_h, float.flip_v); + let round = |p: (f32, f32)| ((p.0 * 100.0).round() / 100.0, (p.1 * 100.0).round() / 100.0); + (round(a), round(b)) +} + +#[test] +fn moving_a_joined_shape_reroutes_its_connector() { + let mut s = joined(); + select(&mut s, 5); + run(&mut s, "arrange.nudge", json!({"dx": 0, "dy": 30})); + // From the rectangle's right side (172, 248) to the ellipse's left side (300, 218). + assert_eq!(connector_ends(&mut s), ((172.0, 248.0), (300.0, 218.0))); + // Resizing the ellipse moves the end with its site. + select(&mut s, 8); + run(&mut s, "arrange.bounds", json!({"width": 100, "height": 60})); + assert_eq!(connector_ends(&mut s).1, (300.0, 230.0)); + // One undo takes the resize and the re-route back. + run(&mut s, "edit.undo", json!({})); + assert_eq!(connector_ends(&mut s), ((172.0, 248.0), (300.0, 218.0))); +} + +/// A target on another page, or a missing target, leaves the connector's end free. +#[test] +fn rerouting_keeps_off_page_and_missing_targets_at_their_free_ends() { + let mut s = joined(); + let mut connector = s.doc.para_at(&Pos::body(0, 11)).unwrap().object_at(11).unwrap().clone(); + let InlineObject::Shape { extra, .. } = &mut connector else { panic!("connector") }; + extra.end = Some(ConnEnd { id: 404, site: 2 }); + let mut p = Paragraph::new(); + p.props.page_break_before = Some(true); + p.insert_object(0, connector.clone(), &Default::default()).unwrap(); + s.doc.body.push(para_block(p)); + s.touch(); + select(&mut s, 5); + run(&mut s, "arrange.nudge", json!({"dx": 10, "dy": 30})); + assert_eq!(s.layout().pages.len(), 2); + assert_eq!(s.doc.para_at(&Pos::body(1, 0)).unwrap().object_at(0), Some(&connector)); + assert_eq!(connector_ends(&mut s), ((182.0, 248.0), (300.0, 218.0))); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc.para_at(&Pos::body(1, 0)).unwrap().object_at(0), Some(&connector)); +} + +/// A command that changes nothing but reroutes a stale connector is still an edit: the +/// reroute must not be left without an undo step (#23). +#[test] +fn a_rerouted_connector_is_still_undoable() { + let mut s = joined(); + select(&mut s, 5); + let before = connector_ends(&mut s); + // A nudge by nothing moves nothing, but the connector is stale and gets rerouted. + run(&mut s, "arrange.nudge", json!({"dx": 0, "dy": 0})); + assert_ne!(connector_ends(&mut s), before, "the connector was rerouted"); + assert_eq!(s.undo_depth(), 1, "the reroute is an undo step of its own"); + // One undo takes the reroute back. + run(&mut s, "edit.undo", json!({})); + assert_eq!(connector_ends(&mut s), before, "one undo takes the reroute back"); +} + +#[test] +fn dragging_a_connector_end_glues_it_to_the_nearest_site() { + let mut s = joined(); + select(&mut s, 11); + // Dropped near the ellipse's top: glued there. + run(&mut s, "shape.connect", json!({"end": {"x": 340.0, "y": 195.0}})); + let ext = |s: &Session| match s.doc.para_at(&Pos::body(0, 11)).and_then(|p| p.object_at(11)) { + Some(InlineObject::Shape { extra, .. }) => extra.clone(), + o => panic!("{o:?}"), + }; + assert_eq!(ext(&s).end, Some(ConnEnd { id: 2, site: 0 })); + assert_eq!(connector_ends(&mut s).1, (336.0, 200.0)); + // Dropped in the open: a free end there. + run(&mut s, "shape.connect", json!({"start": {"x": 20.0, "y": 30.0}})); + assert_eq!(ext(&s).start, None); + assert_eq!(connector_ends(&mut s).0, (20.0, 30.0)); + // Glued by reference; a site the shape doesn't have is refused. + run(&mut s, "shape.connect", json!({"start": {"path": [0], "off": 5, "site": 1}})); + assert_eq!(connector_ends(&mut s).0, (100.0, 218.0)); + assert!(s.run("shape.connect", &json!({"start": {"path": [0], "off": 5, "site": 40}})).is_err()); + assert!(s.run("shape.connect", &json!({"start": {"path": [0], "off": 11}})).is_err(), "not to itself"); + // A connection to a shape that is gone is ignored. + select(&mut s, 8); + run(&mut s, "text.delete", json!({})); + select(&mut s, 5); + run(&mut s, "arrange.nudge", json!({"dx": 1})); +} + +#[test] +fn wordart_is_inserted_styled_and_bent() { + let mut s = Session::new(Document::new()); + let r = run(&mut s, "insert.wordArt", json!({"text": "Hello", "style": "gradient", "transform": "textArchUp"})); + let id = r["story"].as_u64().unwrap() as u32; + assert_eq!(s.doc.plain_text(StoryRef::Part(id)), "Hello"); + let props = s.doc.para_at(&s.doc.start_of(StoryRef::Part(id))).unwrap().props_at(0).clone(); + assert!(matches!(props.text_effects.as_deref().and_then(|f| f.fill.as_ref()), Some(wordcraft_doc::wordart::TextFill::Gradient { .. }))); + // Its text is drawn from outlines, bent. + let layout = s.layout(); + let draws = wordcraft_layout::display::page_display(&s.doc, &layout.pages[0], &Default::default()); + assert!(draws.iter().any(|d| matches!(d, wordcraft_layout::display::Draw::Art { .. }))); + // The PDF draws the gradient and keeps the text. + let pdf = wordcraft_pdf::export(&s.doc, &Default::default()).unwrap(); + assert!(pdf.starts_with(b"%PDF")); + // Effects and transforms on the selected text box's text; hostile values are refused or capped. + run(&mut s, "format.textEffects", json!({"outline": {"color": "C00000", "width": 1e9}, "glow": 5})); + let props = s.doc.para_at(&s.doc.start_of(StoryRef::Part(id))).unwrap().props_at(0).clone(); + let fx = props.text_effects.unwrap(); + assert_eq!(fx.outline.map(|o| o.width), Some(wordcraft_doc::wordart::MAX_OUTLINE)); + assert!(fx.glow.is_some() && fx.fill.is_some()); + run(&mut s, "wordArt.transform", json!({"preset": "textWave1", "adj": 1e300})); + assert!(s.run("wordArt.transform", &json!({"preset": "textBogus"})).is_err()); + assert!(s.run("format.textEffects", &json!({"style": "word"})).is_err()); + assert!(s.run("format.textEffects", &json!({"glow": "much"})).is_err()); + let warp = s.doc.para_at(&Pos::body(0, 0)).and_then(|p| p.object_at(0)).and_then(|o| match o { + InlineObject::Shape { extra, .. } => extra.warp.clone(), + _ => None, + }); + assert_eq!( + warp.map(|w| (w.adj_value("adj"), w.preset)).map(|(a, p)| (p, a)), + Some(("textWave1".into(), Some(wordcraft_doc::wordart::MAX_ADJ_VALUE))) + ); + run(&mut s, "wordArt.transform", json!({"preset": "none"})); +} + +#[test] +fn wordart_reflection_and_outline_none_options() { + let mut s = Session::new(Document::new()); + let r = run(&mut s, "insert.wordArt", json!({"text": "Reflect", "style": "solid"})); + let id = r["story"].as_u64().unwrap() as u32; + + // Setting reflection + run(&mut s, "format.textEffects", json!({"reflection": true})); + let props = s.doc.para_at(&s.doc.start_of(StoryRef::Part(id))).unwrap().props_at(0).clone(); + assert!(props.text_effects.as_ref().unwrap().reflection.is_some()); + + // Clearing reflection using "none" + run(&mut s, "format.textEffects", json!({"reflection": "none"})); + let props = s.doc.para_at(&s.doc.start_of(StoryRef::Part(id))).unwrap().props_at(0).clone(); + assert!(props.text_effects.as_ref().and_then(|fx| fx.reflection).is_none()); + + // Setting outline with color "none" + run(&mut s, "wordArt.textOutline", json!({"color": "none", "width": 1.5})); + let props = s.doc.para_at(&s.doc.start_of(StoryRef::Part(id))).unwrap().props_at(0).clone(); + let outline = props.text_effects.as_ref().unwrap().outline.unwrap(); + assert_eq!(outline.color, None); + assert_eq!(outline.width, 1.5); +} diff --git a/crates/engine/src/view.rs b/crates/engine/src/view.rs index c10d6d204a..f30f723340 100644 --- a/crates/engine/src/view.rs +++ b/crates/engine/src/view.rs @@ -1,221 +1,221 @@ -use serde::{Deserialize, Serialize}; -use wordcraft_layout::{NumeralMode, ViewMode}; - -/// View state that commands can change (ribbon View tab, status bar). -#[derive(Clone, Debug, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ViewState { - pub zoom: f32, - pub mode: ViewMode, - pub read_mode: bool, - /// View › Focus (#1678): the document alone on screen, with a slim bar to drop the ribbon down. - pub focus_mode: bool, - /// Full Screen View (#1678): the pages alone on screen. Never on together with `focus_mode`. - #[serde(default)] - pub full_screen_view: bool, - pub marks: bool, - pub ruler: bool, - /// View › Show › Gridlines: the drawing grid over the page's text area (on screen only). - pub gridlines: bool, - /// Table Layout › View Gridlines: outlines of table cells (on screen only); on by default, as in Word. - #[serde(default = "on")] - pub table_gridlines: bool, - pub nav_pane: bool, - pub styles_pane: bool, - /// Where the Styles pane sits: docked right (the default) or left, or floating (#1756). - /// Kept in the user's preferences, not the document. - #[serde(default)] - pub styles_pane_dock: StylesPaneDock, - /// Style Inspector pane (Home › Styles). - #[serde(default)] - pub style_inspector: bool, - pub comments_pane: bool, - /// The Clipboard pane (Home › Clipboard): items collected by Copy and Cut. - #[serde(default)] - pub clipboard_pane: bool, - /// SmartArt Design › Text Pane: the selected SmartArt graphic's items as an editable outline. - #[serde(default)] - pub smart_art_pane: bool, - /// The Accessibility pane (Review › Check Accessibility): the checker's findings. - #[serde(default)] - pub accessibility_pane: bool, - /// The Alt Text pane (Picture/Shape Format › Alt Text): the selected object's or table's alt text. - #[serde(default)] - pub alt_text_pane: bool, - /// The Format Shape pane (Shape Fill › More Gradients…, a shape's Format Shape…): the - /// selected shapes' fill. - #[serde(default)] - pub format_shape_pane: bool, - pub multi_page: bool, - /// One Page or Page Width was chosen last: keep one page per row when a typed or wheel zoom replaces the fit mode. - #[serde(default)] - pub single_column: bool, - /// Zoom to fit: "pageWidth", "onePage", "multiplePages", or empty. - pub fit: String, - /// Web Layout's text width, which the UI keeps at the window's width (#1876); never saved. - /// Draft and Outline don't use it: they keep a fixed 800 pt column. - #[serde(skip, default = "default_web_width")] - pub web_width: f32, - /// Transient viewport metrics for reading pagination; never saved in a document or preferences. - #[serde(skip, default = "default_read_width")] - pub read_width: f32, - #[serde(skip, default = "default_read_height")] - pub read_height: f32, - /// Reader-only width, size and spacing controls are neither document formatting nor saved UI prefs. - /// `None` chooses responsive column width and keeps the document's line spacing. - #[serde(skip)] - pub read_column_width: Option, - #[serde(skip, default = "default_read_zoom")] - pub read_zoom: f32, - #[serde(skip)] - pub read_line_spacing: Option, - /// Read Mode's Page Color: the paper the reading columns are drawn on. `Prefs` saves its value. - #[serde(skip)] - pub read_page_color: crate::cmd::view::ReadPageColor, - pub dark_mode: bool, - pub show_markup: bool, - pub track_changes_pane: bool, - /// Check spelling and grammar as you type. - pub proofing: bool, - /// How digits look on screen (Word › File › Options › Advanced › Numeral). - #[serde(default)] - pub numeral: NumeralMode, - /// Review › Hide Ink: ink strokes aren't shown on screen (they stay in the document). - #[serde(default)] - pub hide_ink: bool, - /// Draw tab: the tool dragging on the page uses, and each pen's colour and thickness. - #[serde(default)] - pub draw: crate::cmd::draw::DrawState, - /// Show field codes instead of their results (`view.fieldCodes`, Alt+F9). - #[serde(default)] - pub field_codes: bool, - /// Print Layout › Hide White Space: pages run together, the top and bottom margins (with - /// headers and footers) between pages collapse to a thin line. On screen only; the layout - /// and page count are unchanged. - #[serde(default)] - pub hide_white_space: bool, - /// Outline view: collapsed headings, Show Level, first lines, text formatting. - #[serde(default)] - pub outline: crate::cmd::outline::OutlineState, - /// Developer › Design Mode: every content control shows its frame and title. - #[serde(default)] - pub design_mode: bool, - /// View › Window › Full Screen (F11): the window fills the screen (#980). The front end - /// carries it to the window. - #[serde(default)] - pub full_screen: bool, -} - -/// Where the Styles pane sits (`view.stylesPaneDock`, #1756). -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum StylesPaneDock { - #[default] - Right, - Left, - /// A window of its own over the document, which can be moved and resized. - #[serde(rename = "float")] - Floating, -} - -impl StylesPaneDock { - /// `left`, `right` or `float`; anything else is `None`. - pub fn parse(name: &str) -> Option { - match name { - "right" => Some(Self::Right), - "left" => Some(Self::Left), - "float" => Some(Self::Floating), - _ => None, - } - } - - pub fn name(self) -> &'static str { - match self { - Self::Right => "right", - Self::Left => "left", - Self::Floating => "float", - } - } -} - -fn on() -> bool { - true -} - -/// The text column of Draft and Outline, and of Web Layout in a headless session. -pub(super) const DRAFT_WIDTH: f32 = 800.0; - -fn default_web_width() -> f32 { - DRAFT_WIDTH -} - -fn default_read_width() -> f32 { - 300.0 -} - -fn default_read_height() -> f32 { - 500.0 -} - -fn default_read_zoom() -> f32 { - 1.0 -} - -impl ViewState { - /// Take the app-wide settings a view keeps a copy of from the saved preferences: Read Mode's - /// page colour and Check spelling as you type (#5740). A view made active goes through this - /// (a document switched to, opened or made, a window's view put back), so a change made while - /// another view was active holds in this one too. - pub(crate) fn follow_prefs(&mut self, prefs: &crate::prefs::Prefs) { - self.read_page_color = prefs.read_page_color; - self.proofing = prefs.check_spelling; - } -} - -impl Default for ViewState { - fn default() -> Self { - ViewState { - zoom: 1.0, - mode: ViewMode::Print, - read_mode: false, - focus_mode: false, - full_screen_view: false, - marks: false, - ruler: true, - gridlines: false, - table_gridlines: true, - nav_pane: false, - styles_pane: false, - styles_pane_dock: StylesPaneDock::Right, - style_inspector: false, - comments_pane: false, - clipboard_pane: false, - smart_art_pane: false, - accessibility_pane: false, - alt_text_pane: false, - format_shape_pane: false, - multi_page: false, - single_column: false, - fit: String::new(), - web_width: default_web_width(), - read_width: default_read_width(), - read_height: default_read_height(), - read_column_width: None, - read_zoom: default_read_zoom(), - read_line_spacing: None, - read_page_color: Default::default(), - dark_mode: false, - show_markup: true, - track_changes_pane: false, - proofing: true, - numeral: NumeralMode::default(), - hide_ink: false, - draw: Default::default(), - field_codes: false, - hide_white_space: false, - outline: Default::default(), - design_mode: false, - full_screen: false, - } - } -} +use serde::{Deserialize, Serialize}; +use wordcraft_layout::{NumeralMode, ViewMode}; + +/// View state that commands can change (ribbon View tab, status bar). +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ViewState { + pub zoom: f32, + pub mode: ViewMode, + pub read_mode: bool, + /// View › Focus (#1678): the document alone on screen, with a slim bar to drop the ribbon down. + pub focus_mode: bool, + /// Full Screen View (#1678): the pages alone on screen. Never on together with `focus_mode`. + #[serde(default)] + pub full_screen_view: bool, + pub marks: bool, + pub ruler: bool, + /// View › Show › Gridlines: the drawing grid over the page's text area (on screen only). + pub gridlines: bool, + /// Table Layout › View Gridlines: outlines of table cells (on screen only); on by default, as in Word. + #[serde(default = "on")] + pub table_gridlines: bool, + pub nav_pane: bool, + pub styles_pane: bool, + /// Where the Styles pane sits: docked right (the default) or left, or floating (#1756). + /// Kept in the user's preferences, not the document. + #[serde(default)] + pub styles_pane_dock: StylesPaneDock, + /// Style Inspector pane (Home › Styles). + #[serde(default)] + pub style_inspector: bool, + pub comments_pane: bool, + /// The Clipboard pane (Home › Clipboard): items collected by Copy and Cut. + #[serde(default)] + pub clipboard_pane: bool, + /// SmartArt Design › Text Pane: the selected SmartArt graphic's items as an editable outline. + #[serde(default)] + pub smart_art_pane: bool, + /// The Accessibility pane (Review › Check Accessibility): the checker's findings. + #[serde(default)] + pub accessibility_pane: bool, + /// The Alt Text pane (Picture/Shape Format › Alt Text): the selected object's or table's alt text. + #[serde(default)] + pub alt_text_pane: bool, + /// The Format Shape pane (Shape Fill › More Gradients…, a shape's Format Shape…): the + /// selected shapes' fill. + #[serde(default)] + pub format_shape_pane: bool, + pub multi_page: bool, + /// One Page or Page Width was chosen last: keep one page per row when a typed or wheel zoom replaces the fit mode. + #[serde(default)] + pub single_column: bool, + /// Zoom to fit: "pageWidth", "onePage", "multiplePages", or empty. + pub fit: String, + /// Web Layout's text width, which the UI keeps at the window's width (#1876); never saved. + /// Draft and Outline don't use it: they keep a fixed 800 pt column. + #[serde(skip, default = "default_web_width")] + pub web_width: f32, + /// Transient viewport metrics for reading pagination; never saved in a document or preferences. + #[serde(skip, default = "default_read_width")] + pub read_width: f32, + #[serde(skip, default = "default_read_height")] + pub read_height: f32, + /// Reader-only width, size and spacing controls are neither document formatting nor saved UI prefs. + /// `None` chooses responsive column width and keeps the document's line spacing. + #[serde(skip)] + pub read_column_width: Option, + #[serde(skip, default = "default_read_zoom")] + pub read_zoom: f32, + #[serde(skip)] + pub read_line_spacing: Option, + /// Read Mode's Page Color: the paper the reading columns are drawn on. `Prefs` saves its value. + #[serde(skip)] + pub read_page_color: crate::cmd::view::ReadPageColor, + pub dark_mode: bool, + pub show_markup: bool, + pub track_changes_pane: bool, + /// Check spelling and grammar as you type. + pub proofing: bool, + /// How digits look on screen (Word › File › Options › Advanced › Numeral). + #[serde(default)] + pub numeral: NumeralMode, + /// Review › Hide Ink: ink strokes aren't shown on screen (they stay in the document). + #[serde(default)] + pub hide_ink: bool, + /// Draw tab: the tool dragging on the page uses, and each pen's colour and thickness. + #[serde(default)] + pub draw: crate::cmd::draw::DrawState, + /// Show field codes instead of their results (`view.fieldCodes`, Alt+F9). + #[serde(default)] + pub field_codes: bool, + /// Print Layout › Hide White Space: pages run together, the top and bottom margins (with + /// headers and footers) between pages collapse to a thin line. On screen only; the layout + /// and page count are unchanged. + #[serde(default)] + pub hide_white_space: bool, + /// Outline view: collapsed headings, Show Level, first lines, text formatting. + #[serde(default)] + pub outline: crate::cmd::outline::OutlineState, + /// Developer › Design Mode: every content control shows its frame and title. + #[serde(default)] + pub design_mode: bool, + /// View › Window › Full Screen (F11): the window fills the screen (#980). The front end + /// carries it to the window. + #[serde(default)] + pub full_screen: bool, +} + +/// Where the Styles pane sits (`view.stylesPaneDock`, #1756). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum StylesPaneDock { + #[default] + Right, + Left, + /// A window of its own over the document, which can be moved and resized. + #[serde(rename = "float")] + Floating, +} + +impl StylesPaneDock { + /// `left`, `right` or `float`; anything else is `None`. + pub fn parse(name: &str) -> Option { + match name { + "right" => Some(Self::Right), + "left" => Some(Self::Left), + "float" => Some(Self::Floating), + _ => None, + } + } + + pub fn name(self) -> &'static str { + match self { + Self::Right => "right", + Self::Left => "left", + Self::Floating => "float", + } + } +} + +fn on() -> bool { + true +} + +/// The text column of Draft and Outline, and of Web Layout in a headless session. +pub(super) const DRAFT_WIDTH: f32 = 800.0; + +fn default_web_width() -> f32 { + DRAFT_WIDTH +} + +fn default_read_width() -> f32 { + 300.0 +} + +fn default_read_height() -> f32 { + 500.0 +} + +fn default_read_zoom() -> f32 { + 1.0 +} + +impl ViewState { + /// Take the app-wide settings a view keeps a copy of from the saved preferences: Read Mode's + /// page colour and Check spelling as you type (#5740). A view made active goes through this + /// (a document switched to, opened or made, a window's view put back), so a change made while + /// another view was active holds in this one too. + pub(crate) fn follow_prefs(&mut self, prefs: &crate::prefs::Prefs) { + self.read_page_color = prefs.read_page_color; + self.proofing = prefs.check_spelling; + } +} + +impl Default for ViewState { + fn default() -> Self { + ViewState { + zoom: 1.0, + mode: ViewMode::Print, + read_mode: false, + focus_mode: false, + full_screen_view: false, + marks: false, + ruler: true, + gridlines: false, + table_gridlines: true, + nav_pane: false, + styles_pane: false, + styles_pane_dock: StylesPaneDock::Right, + style_inspector: false, + comments_pane: false, + clipboard_pane: false, + smart_art_pane: false, + accessibility_pane: false, + alt_text_pane: false, + format_shape_pane: false, + multi_page: false, + single_column: false, + fit: String::new(), + web_width: default_web_width(), + read_width: default_read_width(), + read_height: default_read_height(), + read_column_width: None, + read_zoom: default_read_zoom(), + read_line_spacing: None, + read_page_color: Default::default(), + dark_mode: false, + show_markup: true, + track_changes_pane: false, + proofing: true, + numeral: NumeralMode::default(), + hide_ink: false, + draw: Default::default(), + field_codes: false, + hide_white_space: false, + outline: Default::default(), + design_mode: false, + full_screen: false, + } + } +} diff --git a/rustfmt.toml b/rustfmt.toml index 3121924d65..3623a1a1b3 100644 --- a/rustfmt.toml +++ b/rustfmt.toml @@ -1,2 +1,2 @@ -max_width = 150 -use_small_heuristics = "Max" +max_width = 150 +use_small_heuristics = "Max" diff --git a/sonar-project.properties b/sonar-project.properties index bf4496425f..aa1e9a7492 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -1,9 +1,9 @@ -sonar.projectKey=storytold_wordcraft -sonar.organization=storytold - -# S4790 (weak hash): SHA-1 is a start-key and "SHA1/1K" checksum algorithm that ODF package -# encryption (ODF 1.3 Part 2 §4.16) names, and older office suites wrote it. It is needed to open -# those files, not chosen for new security: WordCraft writes a SHA-256 start key and checksum. -sonar.issue.ignore.multicriteria=odt_sha1 -sonar.issue.ignore.multicriteria.odt_sha1.ruleKey=rust:S4790 -sonar.issue.ignore.multicriteria.odt_sha1.resourceKey=crates/formats/src/odt/crypt.rs +sonar.projectKey=storytold_wordcraft +sonar.organization=storytold + +# S4790 (weak hash): SHA-1 is a start-key and "SHA1/1K" checksum algorithm that ODF package +# encryption (ODF 1.3 Part 2 §4.16) names, and older office suites wrote it. It is needed to open +# those files, not chosen for new security: WordCraft writes a SHA-256 start key and checksum. +sonar.issue.ignore.multicriteria=odt_sha1 +sonar.issue.ignore.multicriteria.odt_sha1.ruleKey=rust:S4790 +sonar.issue.ignore.multicriteria.odt_sha1.resourceKey=crates/formats/src/odt/crypt.rs From c37baf764dd54d3d65711d6917ad26d54c5d6d6d Mon Sep 17 00:00:00 2001 From: batistalucasv Date: Fri, 13 Nov 2026 16:17:25 -0300 Subject: [PATCH 3/5] arrange: place a moved body object by its paragraph (#5858, #5705) (#6536) The #5858 early return kept page anchoring for any object that does not move with text, which includes a body frame that came from an .odt with text:anchor-type="page". Moving it then skipped the paragraph placement and the save wrote the page anchor and number back, contradicting #5705's an_odt_frame_moved_from_its_page_loses_the_page_number. Only header/footer objects (and drops on a page with no anchoring text) keep page anchoring now, which is what #5812 needed. The body test that pinned the opposite is updated to the #5705 rule. --- crates/engine/src/cmd/objects/arrange.rs | 8 +++----- crates/engine/src/cmd/objects/tests.rs | 22 ++++++++++++---------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/crates/engine/src/cmd/objects/arrange.rs b/crates/engine/src/cmd/objects/arrange.rs index 15cc5555d7..bfae1b31d6 100644 --- a/crates/engine/src/cmd/objects/arrange.rs +++ b/crates/engine/src/cmd/objects/arrange.rs @@ -147,8 +147,8 @@ pub(super) fn move_object(s: &mut Session, pos: Pos, page: Option, x: f32, } /// [`move_object`]'s one placement: move the object's character to the anchor under (x, y) on -/// `page` and offset it from there. Objects in headers/footers, on pages without anchoring text, -/// or configured not to move with text retain page-relative anchoring. +/// `page` and offset it from there. Objects in headers/footers, or on pages without anchoring +/// text, retain page-relative anchoring; a body object is placed by its paragraph (#5705). fn move_object_once(s: &mut Session, pos: &Pos, page: usize, x: f32, y: f32, (w, h): (f32, f32)) -> Result { use wordcraft_doc::para::Anchor; if pos.path.depth() > 0 || !movable_story(s, pos.story) { @@ -165,8 +165,6 @@ fn move_object_once(s: &mut Session, pos: &Pos, page: usize, x: f32, y: f32, (w, Some(path) => (Pos { story, path, off: 0 }, false), None => (nearest_line(&layout, page, y, story).ok_or_else(|| CmdError::Params("no text on that page to anchor to".into()))?, true), }; - let was_with_text = - s.doc.para_at(pos).and_then(|p| p.object_at(pos.off)).and_then(|o| o.frame()).map_or(story == StoryRef::Body, |(_, _, f)| moves_with_text(f)); // Move its character to the anchor, unless it's already in that paragraph and drawn on this // page (one anchored on a later page of it is drawn there, #4250). let at = if anchor.story == pos.story && anchor.path == pos.path && !on_page && layout.object(pos, page).is_some_and(|o| o.page == page) { @@ -199,7 +197,7 @@ fn move_object_once(s: &mut Session, pos: &Pos, page: usize, x: f32, y: f32, (w, f.x = x.clamp(-MAX_OFFSET, MAX_OFFSET); f.y = y.clamp(-MAX_OFFSET, MAX_OFFSET); })?; - if on_page || story != StoryRef::Body || !was_with_text { + if on_page || story != StoryRef::Body { return Ok(at); } // Then offsets from its paragraph, as laid out with the object there. diff --git a/crates/engine/src/cmd/objects/tests.rs b/crates/engine/src/cmd/objects/tests.rs index cf302543ac..5758eb0290 100644 --- a/crates/engine/src/cmd/objects/tests.rs +++ b/crates/engine/src/cmd/objects/tests.rs @@ -1295,10 +1295,11 @@ fn arrange_bounds_on_header_picture_writes_page_anchored_odt_frame() { assert!(styles.contains(r#"svg:x="10pt""#) && styles.contains(r#"svg:y="20pt""#), "{styles}"); } -/// Turning off `moveWithText` on a body picture anchors it to the page, and subsequent -/// `arrange.bounds` retains page-relative anchoring and writes a page-anchored ODT frame. +/// A body picture set to stay on the page (`moveWithText` off) is still placed by its paragraph +/// once moved: the move is the placement, and the file keeps no page number to put it back +/// (#5705). Header and footer pictures stay page-anchored (see above). #[test] -fn body_picture_with_move_with_text_false_retains_page_anchor_and_coordinates() { +fn a_moved_body_picture_is_placed_by_its_paragraph_even_when_fixed_to_the_page() { use wordcraft_doc::para::Anchor; let mut s = Session::new(wordcraft_doc::Document::new()); s.run("text.insert", &json!({"text": "First line of paragraph.\n"})).unwrap(); @@ -1310,24 +1311,25 @@ fn body_picture_with_move_with_text_false_retains_page_anchor_and_coordinates() let float = obj.frame().unwrap().2; assert_eq!((float.h_rel, float.v_rel), (Anchor::Column, Anchor::Column)); - // Turn off moveWithText + // Turn off moveWithText: fixed on the page until it is moved. s.run("arrange.moveWithText", &json!({"value": false})).unwrap(); let (_, obj) = selected(&s).unwrap(); let float = obj.frame().unwrap().2; assert_eq!((float.h_rel, float.v_rel), (Anchor::Column, Anchor::Page)); - // Now call arrange.bounds on a page where a paragraph starts s.run("arrange.bounds", &json!({"page": 0, "x": 50.0, "y": 80.0})).unwrap(); - let (_, obj) = selected(&s).unwrap(); + let (pos, obj) = selected(&s).unwrap(); let float = obj.frame().unwrap().2; - assert_eq!((float.h_rel, float.v_rel), (Anchor::Page, Anchor::Page)); - assert_eq!((float.x, float.y), (50.0, 80.0)); + assert_eq!((float.h_rel, float.v_rel), (Anchor::Column, Anchor::Paragraph)); + assert_eq!(float.page, 0); + let hit = s.layout().object(&pos, 0).expect("laid out"); + assert!((hit.rect.x - 50.0).abs() < 1.0 && (hit.rect.y - 80.0).abs() < 1.0, "dropped: {:?}", hit.rect); let mut z = zip::ZipArchive::new(std::io::Cursor::new(wordcraft_formats::odt::export(&s.doc).unwrap())).unwrap(); let mut content = String::new(); std::io::Read::read_to_string(&mut z.by_name("content.xml").unwrap(), &mut content).unwrap(); - assert!(content.contains(r#"text:anchor-type="page""#), "body picture should export with page anchor: {content}"); - assert!(content.contains(r#"svg:x="50pt""#) && content.contains(r#"svg:y="80pt""#), "{content}"); + assert!(content.contains(r#"text:anchor-type="paragraph""#), "body picture should export with paragraph anchor: {content}"); + assert!(!content.contains("anchor-page-number"), "{content}"); } /// Changing `moveWithText` on an object with `h_align` set preserves its horizontal From 4216089a23c6cca6fb2fd48c1349cf4500bafce2 Mon Sep 17 00:00:00 2001 From: FredApps Date: Fri, 13 Nov 2026 20:21:59 +0100 Subject: [PATCH 4/5] Tests: restore the ODT frame test #6537 changed; #6536 placed moved body objects by their paragraph again --- crates/engine/src/tests.rs | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index cc9dd55b0c..7f4308a813 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -5541,9 +5541,8 @@ fn arrange_bounds_resizes_and_moves_objects() { assert_eq!(box_area(&mut s).rect, n); } -/// A frame anchored to a page stays fixed on a page when moved in WordCraft, now the page it was -/// dropped on, so the save doesn't put the file's page number back. Set to move with text, it is -/// placed by its paragraph from then on and the page number goes. Only resized, it stays on its page. +/// A frame anchored to a page and moved in WordCraft is placed by its paragraph from then on: the +/// file's page number goes, or the save would put it back. Only resized, it stays on that page. #[test] fn an_odt_frame_moved_from_its_page_loses_the_page_number() { let ns = r#"xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0" xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0""#; @@ -5567,18 +5566,10 @@ fn an_odt_frame_moved_from_its_page_loses_the_page_number() { select_box(&mut s); run(&mut s, "arrange.bounds", json!({"width": 150, "height": 80})); assert!(frame_tag(&s).contains(r#"text:anchor-page-number="3""#), "resized: {}", frame_tag(&s)); - // Fixed on the page, moved to another: it is fixed on that page, with its number. let mut s = Session::new(crate::io::open_bytes("t.odt", &odt).unwrap()); select_box(&mut s); run(&mut s, "arrange.bounds", json!({"page": 0, "x": 200, "y": 300})); let tag = frame_tag(&s); - assert!(tag.contains(r#"text:anchor-type="page""#) && tag.contains(r#"text:anchor-page-number="1""#), "moved: {tag}"); - // Set to move with text, then moved: placed by its paragraph, and the file's page number is gone. - let mut s = Session::new(crate::io::open_bytes("t.odt", &odt).unwrap()); - select_box(&mut s); - run(&mut s, "arrange.moveWithText", json!({"value": true})); - run(&mut s, "arrange.bounds", json!({"page": 0, "x": 200, "y": 300})); - let tag = frame_tag(&s); assert!(!tag.contains("anchor-page-number") && !tag.contains(r#"text:anchor-type="page""#), "moved: {tag}"); // Moved, then placed by the margins: the file's page number is gone, not only hidden. run(&mut s, "arrange.position", json!({"preset": "topLeft"})); From bcacf7466fc846b9d30375293c22df9fb3c3bd97 Mon Sep 17 00:00:00 2001 From: batistalucasv Date: Fri, 13 Nov 2026 16:35:00 -0300 Subject: [PATCH 5/5] repo: restore LF line endings in the files #6537 rewrote #6537 declared a test change but committed 50 text files with CRLF, which `cargo fmt --check` does not catch. Restore the bytes from c37baf76: no text file carries CRLF, and the tree is identical to that commit again. Refs #6537, #6539. --- crates/engine/src/doc_state.rs | 364 +- crates/engine/src/file_access.rs | 1068 +- crates/engine/src/ime.rs | 234 +- crates/engine/src/io.rs | 1756 +- crates/engine/src/io/safe_save.rs | 1042 +- crates/engine/src/io_ext.rs | 180 +- crates/engine/src/keymap.rs | 550 +- crates/engine/src/lib.rs | 424 +- crates/engine/src/locale.rs | 30 +- crates/engine/src/math_gallery.rs | 1182 +- crates/engine/src/packs.rs | 2326 +- crates/engine/src/packs/store.rs | 208 +- crates/engine/src/prefs.rs | 284 +- crates/engine/src/print.rs | 850 +- crates/engine/src/recovery.rs | 458 +- crates/engine/src/recovery/desktop.rs | 1442 +- crates/engine/src/recovery/desktop/tests.rs | 1242 +- crates/engine/src/ribbon.rs | 1012 +- crates/engine/src/sample.rs | 448 +- crates/engine/src/session.rs | 518 +- crates/engine/src/session/documents.rs | 1378 +- crates/engine/src/session/history.rs | 526 +- crates/engine/src/session/ime.rs | 170 +- crates/engine/src/session/layout.rs | 436 +- crates/engine/src/session/lifecycle.rs | 362 +- crates/engine/src/session/query.rs | 604 +- crates/engine/src/session/run.rs | 962 +- crates/engine/src/session/snapshot.rs | 604 +- crates/engine/src/session/views.rs | 340 +- crates/engine/src/snapshot.rs | 160 +- crates/engine/src/speech.rs | 2164 +- crates/engine/src/speech/ssip.rs | 1854 +- crates/engine/src/speech/tests.rs | 1000 +- crates/engine/src/strict.rs | 466 +- crates/engine/src/tests.rs | 26990 +++++++++--------- crates/engine/src/tests/bidi.rs | 650 +- crates/engine/src/tests/czech_test_dict.rs | 118 +- crates/engine/src/tests/documents.rs | 820 +- crates/engine/src/tests/find_apostrophes.rs | 96 +- crates/engine/src/tests/fit_one_page.rs | 2300 +- crates/engine/src/tests/font_sizes.rs | 460 +- crates/engine/src/tests/fonts.rs | 1022 +- crates/engine/src/tests/ime_noop.rs | 244 +- crates/engine/src/tests/para_tools.rs | 968 +- crates/engine/src/tests/revision_checks.rs | 948 +- crates/engine/src/tests/typing.rs | 8076 +++--- crates/engine/src/tests/wordart.rs | 404 +- crates/engine/src/view.rs | 442 +- rustfmt.toml | 4 +- sonar-project.properties | 18 +- 50 files changed, 35102 insertions(+), 35102 deletions(-) diff --git a/crates/engine/src/doc_state.rs b/crates/engine/src/doc_state.rs index 5fd9436dcc..c4543bc2d3 100644 --- a/crates/engine/src/doc_state.rs +++ b/crates/engine/src/doc_state.rs @@ -1,182 +1,182 @@ -use serde::{Deserialize, Serialize}; -use wordcraft_doc::props::CharProps; -use wordcraft_doc::{Document, Path, Pos, StoryRef, Table}; - -/// A selection: anchor (where it started) and focus (where the caret is). -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] -pub struct Selection { - pub anchor: Pos, - pub focus: Pos, -} - -impl Selection { - pub fn caret(p: Pos) -> Self { - Selection { anchor: p.clone(), focus: p } - } - pub fn is_collapsed(&self) -> bool { - self.anchor == self.focus - } - /// (start, end) in document order. - pub fn ordered(&self) -> (Pos, Pos) { - if self.anchor <= self.focus { (self.anchor.clone(), self.focus.clone()) } else { (self.focus.clone(), self.anchor.clone()) } - } -} - -/// The word being typed, left without a spelling squiggle until the caret leaves it (#1552). -#[derive(Clone, Debug, PartialEq, Eq)] -pub(super) struct TypedWord { - /// Where the caret was when the word was typed. - pub(super) at: Pos, - /// The word's byte range in its paragraph. - pub(super) start: usize, - pub(super) end: usize, -} - -/// A column (block) selection: the same x range on every line between two corners. -#[derive(Clone, Debug, PartialEq)] -pub struct ColumnBlock { - /// The stream selection the block was made with (its corners). The block is current only - /// while `Session::sel` still equals it, so any ordinary caret move or selection drops it. - pub sel: Selection, - /// Page x of the anchor and focus corners (points); the block spans the range between. - pub anchor_x: f32, - pub focus_x: f32, - /// One `(start, end)` per line, in document order, each inside one paragraph. - pub segments: Vec<(Pos, Pos)>, - /// Set when the block is whole table cells (Select Cell, Row, Column or Table, or the mouse in - /// a table's selection areas): one segment per paragraph of the cells. - pub cells: Option, -} - -/// Whole table cells selected: rows `rows.0..=rows.1` and every cell of them that reaches into -/// grid columns `cols.0..=cols.1`. -#[derive(Clone, Debug, PartialEq)] -pub struct CellRange { - pub story: StoryRef, - pub table: Path, - pub rows: (usize, usize), - pub cols: (usize, usize), - /// The cells are one run of the text (cells of one row, or whole rows), so the stream - /// selection between the corners holds just them and commands act on that as usual. - /// Otherwise (part of a column) text edits and formatting go cell by cell. - pub stream: bool, -} - -impl CellRange { - /// The selected cells of table `t` (row, cell index), in document order. - pub fn cells(&self, t: &Table) -> Vec<(usize, usize)> { - let ((r0, r1), (g0, g1)) = (self.rows, self.cols); - let mut out = Vec::new(); - for (r, row) in t.rows.iter().enumerate().take(r1.saturating_add(1)).skip(r0) { - let mut g = row.first_grid(); - for (c, cell) in row.cells.iter().enumerate() { - let end = g.saturating_add(cell.span()); - if g <= g1 && end > g0 { - out.push((r, c)); - } - g = end; - } - } - out - } -} - -/// A selection Word's table rule widened to whole cells or rows (`cmd::table::extend_selection`), -/// and where it really runs. -#[derive(Clone, Debug, PartialEq)] -pub(crate) struct Widened { - /// The selection it became; this is current while `Session::sel` still equals it. - pub sel: Selection, - /// Where the selection was really extended from and to: extending it again goes on from here. - pub ends: Selection, -} - -/// Find/replace state. -#[derive(Clone, Debug, Default, Serialize, Deserialize)] -#[serde(default, rename_all = "camelCase")] -pub struct FindState { - pub query: String, - pub replace: String, - pub match_case: bool, - pub whole_word: bool, - pub regex: bool, - /// Word's "Use wildcards" (#543). - pub wildcards: bool, - /// Take Find what literally: no `^` codes (`special: false`). - pub literal: bool, - pub match_prefix: bool, - pub match_suffix: bool, - pub ignore_punct: bool, - pub ignore_space: bool, - /// Matches from the last search: (start, end). - #[serde(skip)] - pub results: Vec<(Pos, Pos)>, - pub current: usize, - /// Reading Highlight: mark every match of `query` in the body. - pub highlight: bool, - /// Highlighted matches, for the document revision they were found in. - #[serde(skip)] - pub highlights: Option<(u64, Vec<(Pos, Pos)>)>, -} - -#[derive(Clone)] -pub(super) struct Undo { - pub(super) label: String, - pub(super) doc: Document, - pub(super) sel: Selection, - /// Formatting waiting at the caret (Bold with nothing selected), so Undo takes it back too. - pub(super) pending: Option, - /// The column block or whole cells the selection was (undoing brings the selection back as - /// it was). - pub(super) column: Option, -} - -/// The number of edit locations Go Back cycles through (Word: four). -pub(super) const EDIT_MARKS: usize = 4; - -/// The equation being edited. -#[derive(Clone, Debug, PartialEq)] -pub struct MathEdit { - /// The equation object (its U+FFFC). This moves as the document is edited, so it is not an - /// IME owner; [`Self::session`] is. - pub at: Pos, - /// The caret inside it. - pub pos: wordcraft_doc::math_edit::MathPos, - /// A stable token for this editing session, unique across documents and re-entry. - pub session: u64, -} - -/// Next stable token for an equation editing session, or `None` when the counter is exhausted. -/// -/// Exhaustion fails closed: a repeated token could let a cached IME context outlive the -/// equation it described, so the caller refuses to start editing instead. -pub fn next_math_session() -> Option { - static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); - NEXT.try_update(std::sync::atomic::Ordering::Relaxed, std::sync::atomic::Ordering::Relaxed, |n| n.checked_add(1)).ok() -} - -/// The citation style a document records (References › Style), APA when it records none or -/// one we don't know. -/// The byte range of the word the caret at `off` ends or sits in, counting apostrophes and -/// hyphens as the spelling check does; `None` unless a letter or digit comes before the caret. -pub(super) fn typed_range(text: &str, off: usize) -> Option<(usize, usize)> { - let in_word = |c: char| wordcraft_doc::bidi::is_word_char(c) || matches!(c, '’' | '-'); - let before = text.get(..off)?; - let start = before.char_indices().rev().take_while(|&(_, c)| in_word(c)).last().map(|(i, _)| i)?; - let end = off.saturating_add(text.get(off..)?.chars().take_while(|&c| in_word(c)).map(char::len_utf8).sum::()); - text.get(start..off)?.chars().any(char::is_alphanumeric).then_some((start, end)) -} - -/// The name of `doc`'s bibliography style, APA when it names none we know. -pub(super) fn doc_bib_style(doc: &Document) -> String { - doc.settings.bib_style.as_deref().and_then(crate::cmd::citations::find_style).map_or("APA", |st| st.name).to_string() -} - -/// Most pending front-end requests a session keeps. -pub const MAX_UI_REQUESTS: usize = 64; - -/// Maximum undo depth. -pub(super) const MAX_UNDO: usize = 500; - -/// How many steps the Undo button's list shows (#701); `edit.undoTo` reaches no further. -pub const UNDO_LIST_MAX: usize = 100; +use serde::{Deserialize, Serialize}; +use wordcraft_doc::props::CharProps; +use wordcraft_doc::{Document, Path, Pos, StoryRef, Table}; + +/// A selection: anchor (where it started) and focus (where the caret is). +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct Selection { + pub anchor: Pos, + pub focus: Pos, +} + +impl Selection { + pub fn caret(p: Pos) -> Self { + Selection { anchor: p.clone(), focus: p } + } + pub fn is_collapsed(&self) -> bool { + self.anchor == self.focus + } + /// (start, end) in document order. + pub fn ordered(&self) -> (Pos, Pos) { + if self.anchor <= self.focus { (self.anchor.clone(), self.focus.clone()) } else { (self.focus.clone(), self.anchor.clone()) } + } +} + +/// The word being typed, left without a spelling squiggle until the caret leaves it (#1552). +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) struct TypedWord { + /// Where the caret was when the word was typed. + pub(super) at: Pos, + /// The word's byte range in its paragraph. + pub(super) start: usize, + pub(super) end: usize, +} + +/// A column (block) selection: the same x range on every line between two corners. +#[derive(Clone, Debug, PartialEq)] +pub struct ColumnBlock { + /// The stream selection the block was made with (its corners). The block is current only + /// while `Session::sel` still equals it, so any ordinary caret move or selection drops it. + pub sel: Selection, + /// Page x of the anchor and focus corners (points); the block spans the range between. + pub anchor_x: f32, + pub focus_x: f32, + /// One `(start, end)` per line, in document order, each inside one paragraph. + pub segments: Vec<(Pos, Pos)>, + /// Set when the block is whole table cells (Select Cell, Row, Column or Table, or the mouse in + /// a table's selection areas): one segment per paragraph of the cells. + pub cells: Option, +} + +/// Whole table cells selected: rows `rows.0..=rows.1` and every cell of them that reaches into +/// grid columns `cols.0..=cols.1`. +#[derive(Clone, Debug, PartialEq)] +pub struct CellRange { + pub story: StoryRef, + pub table: Path, + pub rows: (usize, usize), + pub cols: (usize, usize), + /// The cells are one run of the text (cells of one row, or whole rows), so the stream + /// selection between the corners holds just them and commands act on that as usual. + /// Otherwise (part of a column) text edits and formatting go cell by cell. + pub stream: bool, +} + +impl CellRange { + /// The selected cells of table `t` (row, cell index), in document order. + pub fn cells(&self, t: &Table) -> Vec<(usize, usize)> { + let ((r0, r1), (g0, g1)) = (self.rows, self.cols); + let mut out = Vec::new(); + for (r, row) in t.rows.iter().enumerate().take(r1.saturating_add(1)).skip(r0) { + let mut g = row.first_grid(); + for (c, cell) in row.cells.iter().enumerate() { + let end = g.saturating_add(cell.span()); + if g <= g1 && end > g0 { + out.push((r, c)); + } + g = end; + } + } + out + } +} + +/// A selection Word's table rule widened to whole cells or rows (`cmd::table::extend_selection`), +/// and where it really runs. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct Widened { + /// The selection it became; this is current while `Session::sel` still equals it. + pub sel: Selection, + /// Where the selection was really extended from and to: extending it again goes on from here. + pub ends: Selection, +} + +/// Find/replace state. +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct FindState { + pub query: String, + pub replace: String, + pub match_case: bool, + pub whole_word: bool, + pub regex: bool, + /// Word's "Use wildcards" (#543). + pub wildcards: bool, + /// Take Find what literally: no `^` codes (`special: false`). + pub literal: bool, + pub match_prefix: bool, + pub match_suffix: bool, + pub ignore_punct: bool, + pub ignore_space: bool, + /// Matches from the last search: (start, end). + #[serde(skip)] + pub results: Vec<(Pos, Pos)>, + pub current: usize, + /// Reading Highlight: mark every match of `query` in the body. + pub highlight: bool, + /// Highlighted matches, for the document revision they were found in. + #[serde(skip)] + pub highlights: Option<(u64, Vec<(Pos, Pos)>)>, +} + +#[derive(Clone)] +pub(super) struct Undo { + pub(super) label: String, + pub(super) doc: Document, + pub(super) sel: Selection, + /// Formatting waiting at the caret (Bold with nothing selected), so Undo takes it back too. + pub(super) pending: Option, + /// The column block or whole cells the selection was (undoing brings the selection back as + /// it was). + pub(super) column: Option, +} + +/// The number of edit locations Go Back cycles through (Word: four). +pub(super) const EDIT_MARKS: usize = 4; + +/// The equation being edited. +#[derive(Clone, Debug, PartialEq)] +pub struct MathEdit { + /// The equation object (its U+FFFC). This moves as the document is edited, so it is not an + /// IME owner; [`Self::session`] is. + pub at: Pos, + /// The caret inside it. + pub pos: wordcraft_doc::math_edit::MathPos, + /// A stable token for this editing session, unique across documents and re-entry. + pub session: u64, +} + +/// Next stable token for an equation editing session, or `None` when the counter is exhausted. +/// +/// Exhaustion fails closed: a repeated token could let a cached IME context outlive the +/// equation it described, so the caller refuses to start editing instead. +pub fn next_math_session() -> Option { + static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); + NEXT.try_update(std::sync::atomic::Ordering::Relaxed, std::sync::atomic::Ordering::Relaxed, |n| n.checked_add(1)).ok() +} + +/// The citation style a document records (References › Style), APA when it records none or +/// one we don't know. +/// The byte range of the word the caret at `off` ends or sits in, counting apostrophes and +/// hyphens as the spelling check does; `None` unless a letter or digit comes before the caret. +pub(super) fn typed_range(text: &str, off: usize) -> Option<(usize, usize)> { + let in_word = |c: char| wordcraft_doc::bidi::is_word_char(c) || matches!(c, '’' | '-'); + let before = text.get(..off)?; + let start = before.char_indices().rev().take_while(|&(_, c)| in_word(c)).last().map(|(i, _)| i)?; + let end = off.saturating_add(text.get(off..)?.chars().take_while(|&c| in_word(c)).map(char::len_utf8).sum::()); + text.get(start..off)?.chars().any(char::is_alphanumeric).then_some((start, end)) +} + +/// The name of `doc`'s bibliography style, APA when it names none we know. +pub(super) fn doc_bib_style(doc: &Document) -> String { + doc.settings.bib_style.as_deref().and_then(crate::cmd::citations::find_style).map_or("APA", |st| st.name).to_string() +} + +/// Most pending front-end requests a session keeps. +pub const MAX_UI_REQUESTS: usize = 64; + +/// Maximum undo depth. +pub(super) const MAX_UNDO: usize = 500; + +/// How many steps the Undo button's list shows (#701); `edit.undoTo` reaches no further. +pub const UNDO_LIST_MAX: usize = 100; diff --git a/crates/engine/src/file_access.rs b/crates/engine/src/file_access.rs index ef6479916e..c8e08ffe04 100644 --- a/crates/engine/src/file_access.rs +++ b/crates/engine/src/file_access.rs @@ -1,534 +1,534 @@ -//! The folders automation may read and write (#769). -//! -//! `wordcraft-cli mcp --automation-read-root --automation-write-root ` confines the -//! files an agent's commands reach. [`AutomationRoots`] holds the two folders. While they are in force, every path a command -//! reads goes through [`check_read`] and every path it writes through [`check_write`]: -//! -//! - The path is made absolute against the working directory, as the file system reads it. Its -//! links are followed: for a file that doesn't exist yet, the links of its nearest existing -//! folder, the rest of the path being plain names (`..` below a missing folder is refused, and so -//! is a link that leads nowhere). -//! - The result must lie inside the root, compared by whole components (`/work2` isn't inside -//! `/work`), without regard to case on Windows. -//! - Writing replaces or removes a directory entry, so for write access the entry itself (its -//! folder's links followed, its own name as given) must lie inside the root as well: a link -//! outside the root that leads into it is not a way to write outside (#3735). -//! - Read and write access are separate: a root given alone grants only its own kind of access. -//! -//! Roots are in force on a thread inside [`confine`], and in the whole process after -//! [`confine_process`] (`wordcraft-cli mcp`), except inside [`unconfined`]. Without roots -//! nothing is checked. -//! -//! A path is checked, then opened: a process that can change the folders in between (swapping in -//! a link) can still win that race. The roots keep an agent to the files it was given; they don't -//! guard against other programs on the computer. - -use std::cell::RefCell; -use std::ffi::OsString; -use std::path::{Component, Path, PathBuf}; -use std::sync::{Arc, OnceLock}; - -/// Read access or write access. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum Access { - Read, - Write, -} - -/// The folders automation may read and write; a root left out grants no access of its kind. -#[derive(Debug)] -pub struct AutomationRoots { - read: Option, - write: Option, -} - -#[derive(Debug)] -struct Root { - /// The folder made absolute, links followed: what paths are compared with. - resolved: PathBuf, - /// The same without Windows' `\\?\` prefix, for people. - shown: PathBuf, -} - -impl Root { - /// The folder at `path`, which must exist. - fn open(path: &Path, access: Access) -> Result { - let kind = match access { - Access::Read => "read", - Access::Write => "write", - }; - let resolved = std::fs::canonicalize(path) - .and_then(|p| if p.is_dir() { Ok(p) } else { Err(std::io::ErrorKind::NotADirectory.into()) }) - .map_err(|e| format!("cannot open the automation {kind} root `{}`: {e}", path.display()))?; - Ok(Self { shown: plain(resolved.clone()), resolved }) - } -} - -impl AutomationRoots { - /// The roots `read` and `write`, which must be existing folders; `None` when neither is given - /// (nothing is confined then). - pub fn new(read: Option<&Path>, write: Option<&Path>) -> Result>, String> { - if read.is_none() && write.is_none() { - return Ok(None); - } - let read = read.map(|p| Root::open(p, Access::Read)).transpose()?; - let write = write.map(|p| Root::open(p, Access::Write)).transpose()?; - Ok(Some(Arc::new(Self { read, write }))) - } - - /// The read root, absolute with its links followed. - pub fn read_root(&self) -> Option<&Path> { - self.read.as_ref().map(|r| r.shown.as_path()) - } - - /// The write root, absolute with its links followed. - pub fn write_root(&self) -> Option<&Path> { - self.write.as_ref().map(|r| r.shown.as_path()) - } - - /// May the file or folder at `path` be read (or written)? `Err` says why not. - pub fn check(&self, path: &str, access: Access) -> Result<(), String> { - let root = match access { - Access::Read => self.read.as_ref(), - Access::Write => self.write.as_ref(), - }; - let Some(Root { resolved: folder, shown }) = root else { - return match access { - Access::Read => Err(format!("automation filesystem access is not granted: read authority is absent: {path}")), - Access::Write => Err(format!("automation filesystem access is not granted: write authority is absent: {path}")), - }; - }; - // Writing replaces or removes the directory entry `path` names (a safe save renames its - // temporary file over it, a rename removes it): that entry must be inside the root too, - // not only what a link there leads to (#3735). - let entry_inside = || match access { - Access::Read => Ok(true), - Access::Write => resolve_entry(path).map(|entry| inside(&entry, folder)), - }; - if inside(&resolve(path)?, folder) && entry_inside()? { - return Ok(()); - } - let root = shown.display(); - match access { - Access::Read => Err(format!("automation path rejected: outside the read root {root}: {path}")), - Access::Write => Err(format!("automation path rejected: outside the write root {root}: {path}")), - } - } -} - -/// Where the roots are in force on a thread ([`confine`], [`unconfined`]). -#[derive(Clone, Debug)] -enum Scope { - Confined(Arc), - Unconfined, -} - -thread_local! { - /// This thread's scope; `None`: the process's roots ([`confine_process`]). - static SCOPE: RefCell> = const { RefCell::new(None) }; -} - -/// The roots in force in every thread outside [`confine`] and [`unconfined`]. -static PROCESS: OnceLock> = OnceLock::new(); - -/// Put `roots` in force in the whole process, for good. `false` when roots were in force already -/// (they stay as they were). -pub fn confine_process(roots: Arc) -> bool { - PROCESS.set(roots).is_ok() -} - -/// Puts the scope it replaced back when dropped (also when `f` panics). -struct Restore(Option); - -impl Drop for Restore { - fn drop(&mut self) { - let previous = self.0.take(); - SCOPE.with(|s| *s.borrow_mut() = previous); - } -} - -fn with_scope(scope: Option, f: impl FnOnce() -> T) -> T { - let _restore = Restore(SCOPE.with(|s| s.replace(scope))); - f() -} - -/// Run `f` with `roots` in force on this thread (`None`: as things stand). -pub fn confine(roots: Option<&Arc>, f: impl FnOnce() -> T) -> T { - match roots { - Some(r) => with_scope(Some(Scope::Confined(r.clone())), f), - None => f(), - } -} - -/// Run `f` with no roots in force on this thread: what the person at the keyboard does, and the -/// app's own folders. -pub fn unconfined(f: impl FnOnce() -> T) -> T { - with_scope(Some(Scope::Unconfined), f) -} - -/// The roots in force on this thread, if any. -pub fn current() -> Option> { - match SCOPE.with(|s| s.borrow().clone()) { - Some(Scope::Confined(r)) => Some(r), - Some(Scope::Unconfined) => None, - None => PROCESS.get().cloned(), - } -} - -/// `path` without Windows' `\\?\` prefix (which `canonicalize` adds), for people to read. -fn plain(path: PathBuf) -> PathBuf { - if !cfg!(windows) { - return path; - } - let text = path.to_string_lossy(); - if let Some(rest) = text.strip_prefix(r"\\?\UNC\") { - return PathBuf::from(format!(r"\\{rest}")); - } - match text.strip_prefix(r"\\?\") { - Some(rest) => PathBuf::from(rest), - None => path, - } -} - -/// May `path` be read here? Always, without roots in force. -pub fn check_read(path: &str) -> Result<(), String> { - current().map_or(Ok(()), |r| r.check(path, Access::Read)) -} - -/// May `path` be written here? Always, without roots in force. -pub fn check_write(path: &str) -> Result<(), String> { - current().map_or(Ok(()), |r| r.check(path, Access::Write)) -} - -/// `path` made absolute with its links followed; for a path that doesn't exist (yet), its nearest -/// existing folder's links followed and the rest appended. -fn resolve(path: &str) -> Result { - if path.is_empty() { - return Err("automation path rejected: path is empty".into()); - } - // Checked as given: making `…\NUL` absolute gives the device path `\\.\NUL` on Windows. - if cfg!(windows) && Path::new(path).components().any(|c| matches!(c, Component::Normal(n) if is_device_name(&n.to_string_lossy()))) { - return Err(format!("automation path rejected: reserved device names are not allowed: {path}")); - } - let absolute = std::path::absolute(path).map_err(|e| format!("automation path rejected: cannot resolve {path}: {e}"))?; - resolve_absolute(path, &absolute) -} - -/// The directory entry `path` names: its folder resolved as [`resolve`] resolves it, then its own -/// name as given, so a link there is the entry itself, not what it leads to. That entry is what -/// replacing or removing the file changes. -fn resolve_entry(path: &str) -> Result { - let absolute = std::path::absolute(path).map_err(|e| format!("automation path rejected: cannot resolve {path}: {e}"))?; - let (Some(folder), Some(name)) = (absolute.parent(), absolute.file_name()) else { - return Err(format!("automation path rejected: not a file name: {path}")); - }; - let mut entry = resolve_absolute(path, folder)?; - entry.push(name); - Ok(entry) -} - -/// [`resolve`] for `absolute`, `path` made absolute (`path` as given, for messages). -fn resolve_absolute(path: &str, absolute: &Path) -> Result { - // The names below the nearest existing folder, last first. - let mut missing: Vec = vec![]; - let mut at = absolute; - loop { - match std::fs::canonicalize(at) { - Ok(mut found) => { - found.extend(missing.iter().rev()); - return Ok(found); - } - Err(e) => match std::fs::symlink_metadata(at) { - Ok(m) if m.file_type().is_symlink() => return Err(format!("automation path rejected: a link that leads nowhere: {path}")), - // There, yet its real path can't be had (no permission): nothing to compare. - Ok(_) => return Err(format!("automation path rejected: cannot resolve {path}: {e}")), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} - Err(e) => return Err(format!("automation path rejected: cannot resolve {path}: {e}")), - }, - } - let parent = at.parent(); - match (at.components().next_back(), parent) { - (Some(Component::Normal(name)), Some(parent)) => { - missing.push(name.to_os_string()); - at = parent; - } - (Some(Component::ParentDir), _) => { - return Err(format!("automation path rejected: parent traversal below a folder that doesn't exist: {path}")); - } - // Down to the root (or drive) and it doesn't exist either. - _ => { - let e = std::io::Error::from(std::io::ErrorKind::NotFound); - return Err(format!("automation path rejected: cannot resolve {path}: {e}")); - } - } - } -} - -/// Is `path` (resolved) `root` or inside it, component by component (without regard to case on -/// Windows, whose file systems ignore it)? -fn inside(path: &Path, root: &Path) -> bool { - let mut path = path.components(); - root.components().all(|r| path.next().is_some_and(|p| same_component(p, r))) -} - -fn same_component(a: Component, b: Component) -> bool { - if a == b { - return true; - } - if !cfg!(windows) { - return false; - } - match (a.as_os_str().to_str(), b.as_os_str().to_str()) { - (Some(a), Some(b)) => a.to_lowercase() == b.to_lowercase(), - _ => false, - } -} - -/// A Windows device name (`CON`, `NUL`, `COM1`… also with an extension), which names no file. -fn is_device_name(component: &str) -> bool { - let stem = component.split('.').next().unwrap_or(component).trim_end_matches(' ').to_ascii_uppercase(); - let numbered = |prefix: &str| stem.strip_prefix(prefix).is_some_and(|n| matches!(n, "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9")); - matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL" | "CLOCK$" | "CONIN$" | "CONOUT$") || numbered("COM") || numbered("LPT") -} - -#[cfg(test)] -mod tests { - use super::*; - - /// A fresh folder holding `in` (read root), `out` (write root) and `outside`. - fn folders(name: &str) -> (PathBuf, Arc) { - let base = std::env::temp_dir().join(format!("wc-file-access-{name}-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&base); - for d in ["in/sub", "out", "outside", "in2"] { - std::fs::create_dir_all(base.join(d)).unwrap(); - } - std::fs::write(base.join("in/a.docx"), "x").unwrap(); - std::fs::write(base.join("outside/secret.docx"), "x").unwrap(); - let roots = AutomationRoots::new(Some(&base.join("in")), Some(&base.join("out"))).unwrap().unwrap(); - (base, roots) - } - - fn s(p: PathBuf) -> String { - p.to_string_lossy().into_owned() - } - - #[test] - fn paths_inside_the_roots_pass_and_others_dont() { - let (base, roots) = folders("inside"); - assert_eq!(roots.check(&s(base.join("in/a.docx")), Access::Read), Ok(())); - assert_eq!(roots.check(&s(base.join("in")), Access::Read), Ok(()), "the root itself"); - assert_eq!(roots.check(&s(base.join("in/sub/new.docx")), Access::Read), Ok(()), "missing files are checked by their folder"); - assert_eq!(roots.check(&s(base.join("out/new/deeper/x.png")), Access::Write), Ok(()), "and by their nearest folder"); - let e = roots.check(&s(base.join("outside/secret.docx")), Access::Read).unwrap_err(); - assert!(e.starts_with("automation path rejected: outside the read root"), "{e}"); - // Read and write access are separate. - let e = roots.check(&s(base.join("in/a.docx")), Access::Write).unwrap_err(); - assert!(e.contains("outside the write root"), "{e}"); - assert!(roots.check(&s(base.join("out/x.png")), Access::Read).unwrap_err().contains("outside the read root")); - let _ = std::fs::remove_dir_all(base); - } - - #[test] - fn a_sibling_with_the_same_prefix_is_outside() { - let (base, roots) = folders("prefix"); - std::fs::write(base.join("in2/b.docx"), "x").unwrap(); - assert!(roots.check(&s(base.join("in2/b.docx")), Access::Read).is_err(), "in2 is not inside in"); - assert!(roots.check(&format!("{}2/c.docx", s(base.join("in"))), Access::Read).is_err()); - let _ = std::fs::remove_dir_all(base); - } - - #[test] - fn parent_traversal_is_resolved_or_refused() { - let (base, roots) = folders("dotdot"); - let sep = std::path::MAIN_SEPARATOR; - let up = format!("{}{sep}..{sep}outside{sep}secret.docx", s(base.join("in"))); - assert!(roots.check(&up, Access::Read).unwrap_err().contains("outside the read root"), "`..` out of the root"); - let back = format!("{}{sep}..{sep}a.docx", s(base.join("in/sub"))); - assert_eq!(roots.check(&back, Access::Read), Ok(()), "`..` that stays inside"); - let missing = format!("{}{sep}nope{sep}..{sep}..{sep}outside{sep}x.docx", s(base.join("out"))); - let e = roots.check(&missing, Access::Write).unwrap_err(); - // Windows resolves `..` by name first (so does opening the file there): then it's outside. - assert!(e.contains("parent traversal below a folder that doesn't exist") || e.contains("outside the write root"), "{e}"); - let _ = std::fs::remove_dir_all(base); - } - - #[test] - fn relative_paths_resolve_against_the_working_directory() { - let (base, roots) = folders("relative"); - // The tests' working directory is the crate's folder: not inside the roots. - assert!(roots.check("a.docx", Access::Read).unwrap_err().contains("outside the read root")); - assert!(roots.check("", Access::Read).unwrap_err().contains("path is empty")); - let _ = std::fs::remove_dir_all(base); - } - - #[test] - fn a_missing_root_is_an_error_and_none_is_no_confinement() { - let (base, _) = folders("missing"); - let e = AutomationRoots::new(Some(&base.join("nope")), None).unwrap_err(); - assert!(e.starts_with("cannot open the automation read root"), "{e}"); - let e = AutomationRoots::new(None, Some(&base.join("in/a.docx"))).unwrap_err(); - assert!( - e.starts_with("cannot open the automation write root") - && e.ends_with(&std::io::Error::from(std::io::ErrorKind::NotADirectory).to_string()), - "{e}" - ); - assert!(AutomationRoots::new(Some(Path::new("")), None).unwrap_err().starts_with("cannot open the automation read root ``")); - assert!(AutomationRoots::new(None, None).unwrap().is_none()); - // One root alone grants only its own access. - let read_only = AutomationRoots::new(Some(&base.join("in")), None).unwrap().unwrap(); - let e = read_only.check(&s(base.join("in/x.docx")), Access::Write).unwrap_err(); - assert!(e.contains("write authority is absent"), "{e}"); - let _ = std::fs::remove_dir_all(base); - } - - #[test] - fn links_are_followed_before_comparing() { - let (base, roots) = folders("links"); - #[cfg(unix)] - let made = |target: &Path, link: &Path| std::os::unix::fs::symlink(target, link).is_ok(); - #[cfg(windows)] - let made = |target: &Path, link: &Path| { - let made = - if target.is_dir() { std::os::windows::fs::symlink_dir(target, link) } else { std::os::windows::fs::symlink_file(target, link) }; - made.is_ok() - }; - #[cfg(not(any(unix, windows)))] - let made = |_: &Path, _: &Path| false; - // Windows needs Developer Mode or elevation to make links: nothing to test without them. - if !made(&base.join("outside/secret.docx"), &base.join("in/leak.docx")) { - eprintln!("skipped: symbolic links can't be made here"); - let _ = std::fs::remove_dir_all(base); - return; - } - assert!(roots.check(&s(base.join("in/leak.docx")), Access::Read).unwrap_err().contains("outside the read root"), "a file link out"); - assert!(made(&base.join("outside"), &base.join("out/away"))); - let e = roots.check(&s(base.join("out/away/new.png")), Access::Write).unwrap_err(); - assert!(e.contains("outside the write root"), "a folder link out: {e}"); - assert!(made(&base.join("outside/gone.docx"), &base.join("out/dangling.png"))); - let e = roots.check(&s(base.join("out/dangling.png")), Access::Write).unwrap_err(); - assert!(e.contains("a link that leads nowhere"), "writing would create the file outside: {e}"); - // A link to a folder inside the root stays inside. - assert!(made(&base.join("in/sub"), &base.join("in/alias"))); - assert_eq!(roots.check(&s(base.join("in/alias/x.docx")), Access::Read), Ok(())); - // A link outside the write root that leads into it: writing would replace the link, - // outside (#3735). One inside that leads to a file inside is fine. - std::fs::write(base.join("out/doc.docx"), "x").unwrap(); - assert!(made(&base.join("out/doc.docx"), &base.join("outside/alias.docx"))); - let e = roots.check(&s(base.join("outside/alias.docx")), Access::Write).unwrap_err(); - assert!(e.contains("outside the write root"), "the link itself is outside: {e}"); - assert!(made(&base.join("out/doc.docx"), &base.join("out/alias.docx"))); - assert_eq!(roots.check(&s(base.join("out/alias.docx")), Access::Write), Ok(())); - // A folder link outside that leads into the root: the entry is inside, as the system opens it. - assert!(made(&base.join("out"), &base.join("outside/to-out"))); - assert_eq!(roots.check(&s(base.join("outside/to-out/new.docx")), Access::Write), Ok(())); - // A root given through a link is the folder it leads to. - assert!(made(&base.join("in"), &base.join("in-link"))); - let linked = AutomationRoots::new(Some(&base.join("in-link")), None).unwrap().unwrap(); - assert_eq!(linked.check(&s(base.join("in/a.docx")), Access::Read), Ok(())); - let _ = std::fs::remove_dir_all(base); - } - - #[cfg(windows)] - #[test] - fn windows_ignores_case_and_refuses_devices() { - let (base, roots) = folders("case"); - let upper = s(base.join("IN/A.SVG")); - assert_eq!(roots.check(&upper, Access::Read), Ok(()), "{upper}"); - assert_eq!(roots.check(&s(base.join("In/Sub/New.docx")), Access::Read), Ok(())); - let e = roots.check(&s(base.join("out/NUL")), Access::Write).unwrap_err(); - assert!(e.contains("reserved device names"), "{e}"); - assert!(roots.check(&s(base.join("out/com1.txt")), Access::Write).is_err()); - assert_eq!(roots.check(&s(base.join("out/console.txt")), Access::Write), Ok(())); - let _ = std::fs::remove_dir_all(base); - } - - #[test] - fn scopes_nest_and_stay_on_their_thread() { - let (base, roots) = folders("scopes"); - let outside = s(base.join("outside/secret.docx")); - assert_eq!(check_read(&outside), Ok(()), "nothing is confined by default"); - confine(Some(&roots), || { - assert!(check_read(&outside).is_err()); - assert_eq!(unconfined(|| check_read(&outside)), Ok(())); - assert!(check_read(&outside).is_err(), "restored after unconfined"); - let o = outside.clone(); - let there = std::thread::spawn(move || check_read(&o).is_ok()).join().unwrap(); - assert!(there, "another thread is not confined by this thread's scope"); - }); - assert_eq!(check_read(&outside), Ok(()), "restored after confine"); - let r = std::panic::catch_unwind(|| confine(Some(&roots), || panic!("boom"))); - assert!(r.is_err()); - assert_eq!(check_read(&outside), Ok(()), "restored after a panic"); - assert_eq!(confine(None, || check_read(&outside)), Ok(())); - let _ = std::fs::remove_dir_all(base); - } - - #[test] - fn device_names() { - for d in ["CON", "nul", "Com1", "LPT9.txt", "aux.docx", "NUL "] { - assert!(is_device_name(d), "{d}"); - } - for f in ["console", "com10", "COM0", "lpt", "a.docx", "nul-x"] { - assert!(!is_device_name(f), "{f}"); - } - } - - /// Every file a command reads or writes by path goes through `io` (`open_path_with`, - /// `save_path_with`, `read_file`, `write_file`), which check the roots. Engine code that - /// reaches for `std::fs` itself would bypass them, so none may: every file under `src/` - /// (commands, `print.rs`, `session.rs`…) except `io.rs` with its `io/` helpers (`safe_save`, - /// reached only after `io` checked the roots), this module, and the test files. - /// The host-enabled recovery store owns its profile files; commands address known snapshot - /// IDs, never paths. Only that storage module is exempt, not recovery commands in general. - #[test] - fn commands_do_not_touch_the_file_system_themselves() { - fn walk(dir: &Path, files: &mut Vec) { - for entry in std::fs::read_dir(dir).unwrap() { - let path = entry.unwrap().path(); - if path.is_dir() { - walk(&path, files); - } else if path.extension().is_some_and(|e| e == "rs") { - files.push(path); - } - } - } - let src = Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); - let mut files = vec![]; - walk(&src, &mut files); - assert!(files.iter().any(|f| f.ends_with("print.rs")) && files.iter().any(|f| f.ends_with("cmd/file.rs")), "{files:?}"); - let mut offenders = vec![]; - for path in files { - let name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); - // `io` and this module are the checked gateway; `tests*.rs` files and `tests/` - // directories are test-only modules. - if path.parent() == Some(src.as_path()) && (name == "io.rs" || name == "file_access.rs") - || name.starts_with("tests") - || path.strip_prefix(&src).is_ok_and(|rel| rel.components().any(|c| c.as_os_str() == "tests")) - || path.starts_with(src.join("io")) - || path == src.join("recovery/desktop.rs") - { - continue; - } - let text = std::fs::read_to_string(&path).unwrap(); - // Inline test modules (which build their own fixtures) come last in each file. A - // `#[cfg(test)]` on a `use` line, a `mod tests;` declaration or another item doesn't end - // the code. - let end = text - .match_indices("#[cfg(test)]") - .find(|&(i, a)| { - let first = text[i + a.len()..].trim_start().lines().next().unwrap_or(""); - first.starts_with("mod ") && first.trim_end().ends_with('{') - }) - .map_or(text.len(), |(i, _)| i); - let code = &text[..end]; - for (n, line) in code.lines().enumerate() { - let line = line.trim_start(); - let touches = ["std::fs", "File::open", "File::create", "OpenOptions"].iter().any(|w| line.contains(w)); - if !line.starts_with("//") && touches { - offenders.push(format!("{}:{}: {line}", path.display(), n + 1)); - } - } - } - assert!(offenders.is_empty(), "use crate::io::{{read_file, write_file, open_path_with, save_path_with}}: {offenders:#?}"); - } -} +//! The folders automation may read and write (#769). +//! +//! `wordcraft-cli mcp --automation-read-root --automation-write-root ` confines the +//! files an agent's commands reach. [`AutomationRoots`] holds the two folders. While they are in force, every path a command +//! reads goes through [`check_read`] and every path it writes through [`check_write`]: +//! +//! - The path is made absolute against the working directory, as the file system reads it. Its +//! links are followed: for a file that doesn't exist yet, the links of its nearest existing +//! folder, the rest of the path being plain names (`..` below a missing folder is refused, and so +//! is a link that leads nowhere). +//! - The result must lie inside the root, compared by whole components (`/work2` isn't inside +//! `/work`), without regard to case on Windows. +//! - Writing replaces or removes a directory entry, so for write access the entry itself (its +//! folder's links followed, its own name as given) must lie inside the root as well: a link +//! outside the root that leads into it is not a way to write outside (#3735). +//! - Read and write access are separate: a root given alone grants only its own kind of access. +//! +//! Roots are in force on a thread inside [`confine`], and in the whole process after +//! [`confine_process`] (`wordcraft-cli mcp`), except inside [`unconfined`]. Without roots +//! nothing is checked. +//! +//! A path is checked, then opened: a process that can change the folders in between (swapping in +//! a link) can still win that race. The roots keep an agent to the files it was given; they don't +//! guard against other programs on the computer. + +use std::cell::RefCell; +use std::ffi::OsString; +use std::path::{Component, Path, PathBuf}; +use std::sync::{Arc, OnceLock}; + +/// Read access or write access. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Access { + Read, + Write, +} + +/// The folders automation may read and write; a root left out grants no access of its kind. +#[derive(Debug)] +pub struct AutomationRoots { + read: Option, + write: Option, +} + +#[derive(Debug)] +struct Root { + /// The folder made absolute, links followed: what paths are compared with. + resolved: PathBuf, + /// The same without Windows' `\\?\` prefix, for people. + shown: PathBuf, +} + +impl Root { + /// The folder at `path`, which must exist. + fn open(path: &Path, access: Access) -> Result { + let kind = match access { + Access::Read => "read", + Access::Write => "write", + }; + let resolved = std::fs::canonicalize(path) + .and_then(|p| if p.is_dir() { Ok(p) } else { Err(std::io::ErrorKind::NotADirectory.into()) }) + .map_err(|e| format!("cannot open the automation {kind} root `{}`: {e}", path.display()))?; + Ok(Self { shown: plain(resolved.clone()), resolved }) + } +} + +impl AutomationRoots { + /// The roots `read` and `write`, which must be existing folders; `None` when neither is given + /// (nothing is confined then). + pub fn new(read: Option<&Path>, write: Option<&Path>) -> Result>, String> { + if read.is_none() && write.is_none() { + return Ok(None); + } + let read = read.map(|p| Root::open(p, Access::Read)).transpose()?; + let write = write.map(|p| Root::open(p, Access::Write)).transpose()?; + Ok(Some(Arc::new(Self { read, write }))) + } + + /// The read root, absolute with its links followed. + pub fn read_root(&self) -> Option<&Path> { + self.read.as_ref().map(|r| r.shown.as_path()) + } + + /// The write root, absolute with its links followed. + pub fn write_root(&self) -> Option<&Path> { + self.write.as_ref().map(|r| r.shown.as_path()) + } + + /// May the file or folder at `path` be read (or written)? `Err` says why not. + pub fn check(&self, path: &str, access: Access) -> Result<(), String> { + let root = match access { + Access::Read => self.read.as_ref(), + Access::Write => self.write.as_ref(), + }; + let Some(Root { resolved: folder, shown }) = root else { + return match access { + Access::Read => Err(format!("automation filesystem access is not granted: read authority is absent: {path}")), + Access::Write => Err(format!("automation filesystem access is not granted: write authority is absent: {path}")), + }; + }; + // Writing replaces or removes the directory entry `path` names (a safe save renames its + // temporary file over it, a rename removes it): that entry must be inside the root too, + // not only what a link there leads to (#3735). + let entry_inside = || match access { + Access::Read => Ok(true), + Access::Write => resolve_entry(path).map(|entry| inside(&entry, folder)), + }; + if inside(&resolve(path)?, folder) && entry_inside()? { + return Ok(()); + } + let root = shown.display(); + match access { + Access::Read => Err(format!("automation path rejected: outside the read root {root}: {path}")), + Access::Write => Err(format!("automation path rejected: outside the write root {root}: {path}")), + } + } +} + +/// Where the roots are in force on a thread ([`confine`], [`unconfined`]). +#[derive(Clone, Debug)] +enum Scope { + Confined(Arc), + Unconfined, +} + +thread_local! { + /// This thread's scope; `None`: the process's roots ([`confine_process`]). + static SCOPE: RefCell> = const { RefCell::new(None) }; +} + +/// The roots in force in every thread outside [`confine`] and [`unconfined`]. +static PROCESS: OnceLock> = OnceLock::new(); + +/// Put `roots` in force in the whole process, for good. `false` when roots were in force already +/// (they stay as they were). +pub fn confine_process(roots: Arc) -> bool { + PROCESS.set(roots).is_ok() +} + +/// Puts the scope it replaced back when dropped (also when `f` panics). +struct Restore(Option); + +impl Drop for Restore { + fn drop(&mut self) { + let previous = self.0.take(); + SCOPE.with(|s| *s.borrow_mut() = previous); + } +} + +fn with_scope(scope: Option, f: impl FnOnce() -> T) -> T { + let _restore = Restore(SCOPE.with(|s| s.replace(scope))); + f() +} + +/// Run `f` with `roots` in force on this thread (`None`: as things stand). +pub fn confine(roots: Option<&Arc>, f: impl FnOnce() -> T) -> T { + match roots { + Some(r) => with_scope(Some(Scope::Confined(r.clone())), f), + None => f(), + } +} + +/// Run `f` with no roots in force on this thread: what the person at the keyboard does, and the +/// app's own folders. +pub fn unconfined(f: impl FnOnce() -> T) -> T { + with_scope(Some(Scope::Unconfined), f) +} + +/// The roots in force on this thread, if any. +pub fn current() -> Option> { + match SCOPE.with(|s| s.borrow().clone()) { + Some(Scope::Confined(r)) => Some(r), + Some(Scope::Unconfined) => None, + None => PROCESS.get().cloned(), + } +} + +/// `path` without Windows' `\\?\` prefix (which `canonicalize` adds), for people to read. +fn plain(path: PathBuf) -> PathBuf { + if !cfg!(windows) { + return path; + } + let text = path.to_string_lossy(); + if let Some(rest) = text.strip_prefix(r"\\?\UNC\") { + return PathBuf::from(format!(r"\\{rest}")); + } + match text.strip_prefix(r"\\?\") { + Some(rest) => PathBuf::from(rest), + None => path, + } +} + +/// May `path` be read here? Always, without roots in force. +pub fn check_read(path: &str) -> Result<(), String> { + current().map_or(Ok(()), |r| r.check(path, Access::Read)) +} + +/// May `path` be written here? Always, without roots in force. +pub fn check_write(path: &str) -> Result<(), String> { + current().map_or(Ok(()), |r| r.check(path, Access::Write)) +} + +/// `path` made absolute with its links followed; for a path that doesn't exist (yet), its nearest +/// existing folder's links followed and the rest appended. +fn resolve(path: &str) -> Result { + if path.is_empty() { + return Err("automation path rejected: path is empty".into()); + } + // Checked as given: making `…\NUL` absolute gives the device path `\\.\NUL` on Windows. + if cfg!(windows) && Path::new(path).components().any(|c| matches!(c, Component::Normal(n) if is_device_name(&n.to_string_lossy()))) { + return Err(format!("automation path rejected: reserved device names are not allowed: {path}")); + } + let absolute = std::path::absolute(path).map_err(|e| format!("automation path rejected: cannot resolve {path}: {e}"))?; + resolve_absolute(path, &absolute) +} + +/// The directory entry `path` names: its folder resolved as [`resolve`] resolves it, then its own +/// name as given, so a link there is the entry itself, not what it leads to. That entry is what +/// replacing or removing the file changes. +fn resolve_entry(path: &str) -> Result { + let absolute = std::path::absolute(path).map_err(|e| format!("automation path rejected: cannot resolve {path}: {e}"))?; + let (Some(folder), Some(name)) = (absolute.parent(), absolute.file_name()) else { + return Err(format!("automation path rejected: not a file name: {path}")); + }; + let mut entry = resolve_absolute(path, folder)?; + entry.push(name); + Ok(entry) +} + +/// [`resolve`] for `absolute`, `path` made absolute (`path` as given, for messages). +fn resolve_absolute(path: &str, absolute: &Path) -> Result { + // The names below the nearest existing folder, last first. + let mut missing: Vec = vec![]; + let mut at = absolute; + loop { + match std::fs::canonicalize(at) { + Ok(mut found) => { + found.extend(missing.iter().rev()); + return Ok(found); + } + Err(e) => match std::fs::symlink_metadata(at) { + Ok(m) if m.file_type().is_symlink() => return Err(format!("automation path rejected: a link that leads nowhere: {path}")), + // There, yet its real path can't be had (no permission): nothing to compare. + Ok(_) => return Err(format!("automation path rejected: cannot resolve {path}: {e}")), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(format!("automation path rejected: cannot resolve {path}: {e}")), + }, + } + let parent = at.parent(); + match (at.components().next_back(), parent) { + (Some(Component::Normal(name)), Some(parent)) => { + missing.push(name.to_os_string()); + at = parent; + } + (Some(Component::ParentDir), _) => { + return Err(format!("automation path rejected: parent traversal below a folder that doesn't exist: {path}")); + } + // Down to the root (or drive) and it doesn't exist either. + _ => { + let e = std::io::Error::from(std::io::ErrorKind::NotFound); + return Err(format!("automation path rejected: cannot resolve {path}: {e}")); + } + } + } +} + +/// Is `path` (resolved) `root` or inside it, component by component (without regard to case on +/// Windows, whose file systems ignore it)? +fn inside(path: &Path, root: &Path) -> bool { + let mut path = path.components(); + root.components().all(|r| path.next().is_some_and(|p| same_component(p, r))) +} + +fn same_component(a: Component, b: Component) -> bool { + if a == b { + return true; + } + if !cfg!(windows) { + return false; + } + match (a.as_os_str().to_str(), b.as_os_str().to_str()) { + (Some(a), Some(b)) => a.to_lowercase() == b.to_lowercase(), + _ => false, + } +} + +/// A Windows device name (`CON`, `NUL`, `COM1`… also with an extension), which names no file. +fn is_device_name(component: &str) -> bool { + let stem = component.split('.').next().unwrap_or(component).trim_end_matches(' ').to_ascii_uppercase(); + let numbered = |prefix: &str| stem.strip_prefix(prefix).is_some_and(|n| matches!(n, "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9")); + matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL" | "CLOCK$" | "CONIN$" | "CONOUT$") || numbered("COM") || numbered("LPT") +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A fresh folder holding `in` (read root), `out` (write root) and `outside`. + fn folders(name: &str) -> (PathBuf, Arc) { + let base = std::env::temp_dir().join(format!("wc-file-access-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + for d in ["in/sub", "out", "outside", "in2"] { + std::fs::create_dir_all(base.join(d)).unwrap(); + } + std::fs::write(base.join("in/a.docx"), "x").unwrap(); + std::fs::write(base.join("outside/secret.docx"), "x").unwrap(); + let roots = AutomationRoots::new(Some(&base.join("in")), Some(&base.join("out"))).unwrap().unwrap(); + (base, roots) + } + + fn s(p: PathBuf) -> String { + p.to_string_lossy().into_owned() + } + + #[test] + fn paths_inside_the_roots_pass_and_others_dont() { + let (base, roots) = folders("inside"); + assert_eq!(roots.check(&s(base.join("in/a.docx")), Access::Read), Ok(())); + assert_eq!(roots.check(&s(base.join("in")), Access::Read), Ok(()), "the root itself"); + assert_eq!(roots.check(&s(base.join("in/sub/new.docx")), Access::Read), Ok(()), "missing files are checked by their folder"); + assert_eq!(roots.check(&s(base.join("out/new/deeper/x.png")), Access::Write), Ok(()), "and by their nearest folder"); + let e = roots.check(&s(base.join("outside/secret.docx")), Access::Read).unwrap_err(); + assert!(e.starts_with("automation path rejected: outside the read root"), "{e}"); + // Read and write access are separate. + let e = roots.check(&s(base.join("in/a.docx")), Access::Write).unwrap_err(); + assert!(e.contains("outside the write root"), "{e}"); + assert!(roots.check(&s(base.join("out/x.png")), Access::Read).unwrap_err().contains("outside the read root")); + let _ = std::fs::remove_dir_all(base); + } + + #[test] + fn a_sibling_with_the_same_prefix_is_outside() { + let (base, roots) = folders("prefix"); + std::fs::write(base.join("in2/b.docx"), "x").unwrap(); + assert!(roots.check(&s(base.join("in2/b.docx")), Access::Read).is_err(), "in2 is not inside in"); + assert!(roots.check(&format!("{}2/c.docx", s(base.join("in"))), Access::Read).is_err()); + let _ = std::fs::remove_dir_all(base); + } + + #[test] + fn parent_traversal_is_resolved_or_refused() { + let (base, roots) = folders("dotdot"); + let sep = std::path::MAIN_SEPARATOR; + let up = format!("{}{sep}..{sep}outside{sep}secret.docx", s(base.join("in"))); + assert!(roots.check(&up, Access::Read).unwrap_err().contains("outside the read root"), "`..` out of the root"); + let back = format!("{}{sep}..{sep}a.docx", s(base.join("in/sub"))); + assert_eq!(roots.check(&back, Access::Read), Ok(()), "`..` that stays inside"); + let missing = format!("{}{sep}nope{sep}..{sep}..{sep}outside{sep}x.docx", s(base.join("out"))); + let e = roots.check(&missing, Access::Write).unwrap_err(); + // Windows resolves `..` by name first (so does opening the file there): then it's outside. + assert!(e.contains("parent traversal below a folder that doesn't exist") || e.contains("outside the write root"), "{e}"); + let _ = std::fs::remove_dir_all(base); + } + + #[test] + fn relative_paths_resolve_against_the_working_directory() { + let (base, roots) = folders("relative"); + // The tests' working directory is the crate's folder: not inside the roots. + assert!(roots.check("a.docx", Access::Read).unwrap_err().contains("outside the read root")); + assert!(roots.check("", Access::Read).unwrap_err().contains("path is empty")); + let _ = std::fs::remove_dir_all(base); + } + + #[test] + fn a_missing_root_is_an_error_and_none_is_no_confinement() { + let (base, _) = folders("missing"); + let e = AutomationRoots::new(Some(&base.join("nope")), None).unwrap_err(); + assert!(e.starts_with("cannot open the automation read root"), "{e}"); + let e = AutomationRoots::new(None, Some(&base.join("in/a.docx"))).unwrap_err(); + assert!( + e.starts_with("cannot open the automation write root") + && e.ends_with(&std::io::Error::from(std::io::ErrorKind::NotADirectory).to_string()), + "{e}" + ); + assert!(AutomationRoots::new(Some(Path::new("")), None).unwrap_err().starts_with("cannot open the automation read root ``")); + assert!(AutomationRoots::new(None, None).unwrap().is_none()); + // One root alone grants only its own access. + let read_only = AutomationRoots::new(Some(&base.join("in")), None).unwrap().unwrap(); + let e = read_only.check(&s(base.join("in/x.docx")), Access::Write).unwrap_err(); + assert!(e.contains("write authority is absent"), "{e}"); + let _ = std::fs::remove_dir_all(base); + } + + #[test] + fn links_are_followed_before_comparing() { + let (base, roots) = folders("links"); + #[cfg(unix)] + let made = |target: &Path, link: &Path| std::os::unix::fs::symlink(target, link).is_ok(); + #[cfg(windows)] + let made = |target: &Path, link: &Path| { + let made = + if target.is_dir() { std::os::windows::fs::symlink_dir(target, link) } else { std::os::windows::fs::symlink_file(target, link) }; + made.is_ok() + }; + #[cfg(not(any(unix, windows)))] + let made = |_: &Path, _: &Path| false; + // Windows needs Developer Mode or elevation to make links: nothing to test without them. + if !made(&base.join("outside/secret.docx"), &base.join("in/leak.docx")) { + eprintln!("skipped: symbolic links can't be made here"); + let _ = std::fs::remove_dir_all(base); + return; + } + assert!(roots.check(&s(base.join("in/leak.docx")), Access::Read).unwrap_err().contains("outside the read root"), "a file link out"); + assert!(made(&base.join("outside"), &base.join("out/away"))); + let e = roots.check(&s(base.join("out/away/new.png")), Access::Write).unwrap_err(); + assert!(e.contains("outside the write root"), "a folder link out: {e}"); + assert!(made(&base.join("outside/gone.docx"), &base.join("out/dangling.png"))); + let e = roots.check(&s(base.join("out/dangling.png")), Access::Write).unwrap_err(); + assert!(e.contains("a link that leads nowhere"), "writing would create the file outside: {e}"); + // A link to a folder inside the root stays inside. + assert!(made(&base.join("in/sub"), &base.join("in/alias"))); + assert_eq!(roots.check(&s(base.join("in/alias/x.docx")), Access::Read), Ok(())); + // A link outside the write root that leads into it: writing would replace the link, + // outside (#3735). One inside that leads to a file inside is fine. + std::fs::write(base.join("out/doc.docx"), "x").unwrap(); + assert!(made(&base.join("out/doc.docx"), &base.join("outside/alias.docx"))); + let e = roots.check(&s(base.join("outside/alias.docx")), Access::Write).unwrap_err(); + assert!(e.contains("outside the write root"), "the link itself is outside: {e}"); + assert!(made(&base.join("out/doc.docx"), &base.join("out/alias.docx"))); + assert_eq!(roots.check(&s(base.join("out/alias.docx")), Access::Write), Ok(())); + // A folder link outside that leads into the root: the entry is inside, as the system opens it. + assert!(made(&base.join("out"), &base.join("outside/to-out"))); + assert_eq!(roots.check(&s(base.join("outside/to-out/new.docx")), Access::Write), Ok(())); + // A root given through a link is the folder it leads to. + assert!(made(&base.join("in"), &base.join("in-link"))); + let linked = AutomationRoots::new(Some(&base.join("in-link")), None).unwrap().unwrap(); + assert_eq!(linked.check(&s(base.join("in/a.docx")), Access::Read), Ok(())); + let _ = std::fs::remove_dir_all(base); + } + + #[cfg(windows)] + #[test] + fn windows_ignores_case_and_refuses_devices() { + let (base, roots) = folders("case"); + let upper = s(base.join("IN/A.SVG")); + assert_eq!(roots.check(&upper, Access::Read), Ok(()), "{upper}"); + assert_eq!(roots.check(&s(base.join("In/Sub/New.docx")), Access::Read), Ok(())); + let e = roots.check(&s(base.join("out/NUL")), Access::Write).unwrap_err(); + assert!(e.contains("reserved device names"), "{e}"); + assert!(roots.check(&s(base.join("out/com1.txt")), Access::Write).is_err()); + assert_eq!(roots.check(&s(base.join("out/console.txt")), Access::Write), Ok(())); + let _ = std::fs::remove_dir_all(base); + } + + #[test] + fn scopes_nest_and_stay_on_their_thread() { + let (base, roots) = folders("scopes"); + let outside = s(base.join("outside/secret.docx")); + assert_eq!(check_read(&outside), Ok(()), "nothing is confined by default"); + confine(Some(&roots), || { + assert!(check_read(&outside).is_err()); + assert_eq!(unconfined(|| check_read(&outside)), Ok(())); + assert!(check_read(&outside).is_err(), "restored after unconfined"); + let o = outside.clone(); + let there = std::thread::spawn(move || check_read(&o).is_ok()).join().unwrap(); + assert!(there, "another thread is not confined by this thread's scope"); + }); + assert_eq!(check_read(&outside), Ok(()), "restored after confine"); + let r = std::panic::catch_unwind(|| confine(Some(&roots), || panic!("boom"))); + assert!(r.is_err()); + assert_eq!(check_read(&outside), Ok(()), "restored after a panic"); + assert_eq!(confine(None, || check_read(&outside)), Ok(())); + let _ = std::fs::remove_dir_all(base); + } + + #[test] + fn device_names() { + for d in ["CON", "nul", "Com1", "LPT9.txt", "aux.docx", "NUL "] { + assert!(is_device_name(d), "{d}"); + } + for f in ["console", "com10", "COM0", "lpt", "a.docx", "nul-x"] { + assert!(!is_device_name(f), "{f}"); + } + } + + /// Every file a command reads or writes by path goes through `io` (`open_path_with`, + /// `save_path_with`, `read_file`, `write_file`), which check the roots. Engine code that + /// reaches for `std::fs` itself would bypass them, so none may: every file under `src/` + /// (commands, `print.rs`, `session.rs`…) except `io.rs` with its `io/` helpers (`safe_save`, + /// reached only after `io` checked the roots), this module, and the test files. + /// The host-enabled recovery store owns its profile files; commands address known snapshot + /// IDs, never paths. Only that storage module is exempt, not recovery commands in general. + #[test] + fn commands_do_not_touch_the_file_system_themselves() { + fn walk(dir: &Path, files: &mut Vec) { + for entry in std::fs::read_dir(dir).unwrap() { + let path = entry.unwrap().path(); + if path.is_dir() { + walk(&path, files); + } else if path.extension().is_some_and(|e| e == "rs") { + files.push(path); + } + } + } + let src = Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + let mut files = vec![]; + walk(&src, &mut files); + assert!(files.iter().any(|f| f.ends_with("print.rs")) && files.iter().any(|f| f.ends_with("cmd/file.rs")), "{files:?}"); + let mut offenders = vec![]; + for path in files { + let name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); + // `io` and this module are the checked gateway; `tests*.rs` files and `tests/` + // directories are test-only modules. + if path.parent() == Some(src.as_path()) && (name == "io.rs" || name == "file_access.rs") + || name.starts_with("tests") + || path.strip_prefix(&src).is_ok_and(|rel| rel.components().any(|c| c.as_os_str() == "tests")) + || path.starts_with(src.join("io")) + || path == src.join("recovery/desktop.rs") + { + continue; + } + let text = std::fs::read_to_string(&path).unwrap(); + // Inline test modules (which build their own fixtures) come last in each file. A + // `#[cfg(test)]` on a `use` line, a `mod tests;` declaration or another item doesn't end + // the code. + let end = text + .match_indices("#[cfg(test)]") + .find(|&(i, a)| { + let first = text[i + a.len()..].trim_start().lines().next().unwrap_or(""); + first.starts_with("mod ") && first.trim_end().ends_with('{') + }) + .map_or(text.len(), |(i, _)| i); + let code = &text[..end]; + for (n, line) in code.lines().enumerate() { + let line = line.trim_start(); + let touches = ["std::fs", "File::open", "File::create", "OpenOptions"].iter().any(|w| line.contains(w)); + if !line.starts_with("//") && touches { + offenders.push(format!("{}:{}: {line}", path.display(), n + 1)); + } + } + } + assert!(offenders.is_empty(), "use crate::io::{{read_file, write_file, open_path_with, save_path_with}}: {offenders:#?}"); + } +} diff --git a/crates/engine/src/ime.rs b/crates/engine/src/ime.rs index b1a47980e9..f5f86c7885 100644 --- a/crates/engine/src/ime.rs +++ b/crates/engine/src/ime.rs @@ -1,117 +1,117 @@ -use crate::doc_state::Selection; -#[cfg(test)] -use crate::session::*; -#[cfg(test)] -use wordcraft_doc::{Document, Pos}; - -/// The caret identity used for the process-monotonic IME state revision. -#[derive(Clone, Debug, PartialEq, Eq)] -pub(super) enum ImeCaretState { - Body(Selection), - Equation { session: u64, pos: wordcraft_doc::math_edit::MathPos }, -} - -/// One checked allocation from a process-monotonic IME revision counter. -fn allocate_ime_state_revision(counter: &std::sync::atomic::AtomicU64) -> Option { - counter.try_update(std::sync::atomic::Ordering::Relaxed, std::sync::atomic::Ordering::Relaxed, |n| n.checked_add(1)).ok() -} - -static IME_STATE_REVISION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); - -/// Allocates the next process-monotonic IME state revision, returning `None` when the counter is -/// exhausted. -pub(super) fn next_ime_state_revision() -> Option { - allocate_ime_state_revision(&IME_STATE_REVISION) -} - -#[cfg(test)] -mod ime_revision_tests { - use super::*; - use serde_json::json; - - /// Checked allocations stop at `u64::MAX` and report exhaustion instead of wrapping. - #[test] - fn checked_revision_allocation_refuses_to_wrap() { - let counter = std::sync::atomic::AtomicU64::new(u64::MAX - 1); - assert_eq!(allocate_ime_state_revision(&counter), Some(u64::MAX - 1)); - assert_eq!(allocate_ime_state_revision(&counter), None, "exhaustion fails closed"); - assert_eq!(counter.load(std::sync::atomic::Ordering::Relaxed), u64::MAX); - } - - /// An unchanged caret reuses its revision, while a direct selection assignment is observed and - /// advances the revision. - #[test] - fn selection_and_caret_motion_advance_the_unavailable_revision() { - let mut s = Session::new(Document::from_text("hello")); - let first = s.ime_state_revision().expect("a revision"); - assert_eq!(s.ime_state_revision(), Some(first), "an unchanged caret reuses its revision"); - s.sel = Selection::caret(Pos::body(0, 2)); - let moved = s.ime_state_revision().expect("a revision"); - assert!(moved > first, "a direct selection assignment is caught"); - } - - /// A selection-only command advances the revision on the next read, while a committed state - /// change advances it immediately. - #[test] - fn successful_state_changes_allocate_but_selection_only_commands_do_not() { - let mut s = Session::new(Document::from_text("hello")); - let first = s.ime_state_revision().expect("a revision"); - s.run("select.all", &json!({})).unwrap(); - // `run` does not allocate for selection-only work; the next stamp sees the new caret. - assert!(s.ime_state_revision() > Some(first)); - let selected = s.ime_state_revision().expect("a revision"); - s.run("para.alignCenter", &json!({})).unwrap(); - assert!(s.ime_state_revision() > Some(selected), "a committed state change advances"); - } - - /// After the revision allocator is exhausted, a real equation edit still reports its classified - /// change but cannot mint a revision. - #[test] - fn exhausted_allocator_keeps_a_real_equation_commit_uncertifiable() { - let mut s = Session::new(Document::new()); - s.run("insert.equation", &json!({})).unwrap(); - s.exhaust_ime_state_revision(); - assert_eq!(s.ime_state_revision(), None, "exhaustion fails closed"); - s.run_ime("equation.type", &json!({"text": "x"})).unwrap(); - let outcome = s.ime_edit_outcome(); - assert!(outcome.classified && outcome.content_changed && outcome.state_changed, "{outcome:?}"); - assert_eq!(s.ime_state_revision(), None, "a real producer command cannot mint a revision"); - } - - /// The application-change signal is consumed once; commands run through `run_ime` do not set it, - /// while a normal command does. - #[test] - fn application_origin_signal_is_taken_once_and_ime_commands_do_not_set_it() { - let mut s = Session::new(Document::from_text("hello")); - s.run("para.alignCenter", &json!({})).unwrap(); - assert!(s.take_pending_ime_application_change(), "a normal command signals"); - assert!(!s.take_pending_ime_application_change(), "taking clears it"); - s.run_ime("text.insert", &json!({"text": "x"})).unwrap(); - assert!(!s.take_pending_ime_application_change(), "an IME transaction is not an application change"); - s.run("text.insert", &json!({"text": "y"})).unwrap(); - assert!(s.take_pending_ime_application_change()); - } - - /// Unknown and refused IME commands restore the normal origin without signalling an application - /// change, and a nested command inside a macro inherits the IME origin. - #[test] - fn ime_origin_is_restored_and_nested_commands_inherit_it() { - let mut s = Session::new(Document::from_text("hello")); - s.run("tools.macros", &json!({"define": {"name": "centre", "steps": [{"command": "para.alignCenter", "params": {}}]}})).unwrap(); - s.take_pending_ime_application_change(); - - // Unknown and refused commands restore the normal application origin; the failed run - // itself leaves no application-change signal. - assert!(s.run_ime("no.such.command", &json!({})).is_err()); - assert!(!s.take_pending_ime_application_change(), "an unknown IME command is not an application change"); - assert!(s.run_ime("equation.backspace", &json!({})).is_err()); - assert!(!s.take_pending_ime_application_change(), "a refused IME command is not an application change"); - - // A nested `run` inherits the IME origin from `run_ime`; the same macro run normally is - // an application change. - s.run_ime("tools.macros", &json!({"run": "centre"})).unwrap(); - assert!(!s.take_pending_ime_application_change(), "the nested command inherits the IME origin"); - s.run("tools.macros", &json!({"run": "centre"})).unwrap(); - assert!(s.take_pending_ime_application_change(), "a normal macro run is application-origin"); - } -} +use crate::doc_state::Selection; +#[cfg(test)] +use crate::session::*; +#[cfg(test)] +use wordcraft_doc::{Document, Pos}; + +/// The caret identity used for the process-monotonic IME state revision. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) enum ImeCaretState { + Body(Selection), + Equation { session: u64, pos: wordcraft_doc::math_edit::MathPos }, +} + +/// One checked allocation from a process-monotonic IME revision counter. +fn allocate_ime_state_revision(counter: &std::sync::atomic::AtomicU64) -> Option { + counter.try_update(std::sync::atomic::Ordering::Relaxed, std::sync::atomic::Ordering::Relaxed, |n| n.checked_add(1)).ok() +} + +static IME_STATE_REVISION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); + +/// Allocates the next process-monotonic IME state revision, returning `None` when the counter is +/// exhausted. +pub(super) fn next_ime_state_revision() -> Option { + allocate_ime_state_revision(&IME_STATE_REVISION) +} + +#[cfg(test)] +mod ime_revision_tests { + use super::*; + use serde_json::json; + + /// Checked allocations stop at `u64::MAX` and report exhaustion instead of wrapping. + #[test] + fn checked_revision_allocation_refuses_to_wrap() { + let counter = std::sync::atomic::AtomicU64::new(u64::MAX - 1); + assert_eq!(allocate_ime_state_revision(&counter), Some(u64::MAX - 1)); + assert_eq!(allocate_ime_state_revision(&counter), None, "exhaustion fails closed"); + assert_eq!(counter.load(std::sync::atomic::Ordering::Relaxed), u64::MAX); + } + + /// An unchanged caret reuses its revision, while a direct selection assignment is observed and + /// advances the revision. + #[test] + fn selection_and_caret_motion_advance_the_unavailable_revision() { + let mut s = Session::new(Document::from_text("hello")); + let first = s.ime_state_revision().expect("a revision"); + assert_eq!(s.ime_state_revision(), Some(first), "an unchanged caret reuses its revision"); + s.sel = Selection::caret(Pos::body(0, 2)); + let moved = s.ime_state_revision().expect("a revision"); + assert!(moved > first, "a direct selection assignment is caught"); + } + + /// A selection-only command advances the revision on the next read, while a committed state + /// change advances it immediately. + #[test] + fn successful_state_changes_allocate_but_selection_only_commands_do_not() { + let mut s = Session::new(Document::from_text("hello")); + let first = s.ime_state_revision().expect("a revision"); + s.run("select.all", &json!({})).unwrap(); + // `run` does not allocate for selection-only work; the next stamp sees the new caret. + assert!(s.ime_state_revision() > Some(first)); + let selected = s.ime_state_revision().expect("a revision"); + s.run("para.alignCenter", &json!({})).unwrap(); + assert!(s.ime_state_revision() > Some(selected), "a committed state change advances"); + } + + /// After the revision allocator is exhausted, a real equation edit still reports its classified + /// change but cannot mint a revision. + #[test] + fn exhausted_allocator_keeps_a_real_equation_commit_uncertifiable() { + let mut s = Session::new(Document::new()); + s.run("insert.equation", &json!({})).unwrap(); + s.exhaust_ime_state_revision(); + assert_eq!(s.ime_state_revision(), None, "exhaustion fails closed"); + s.run_ime("equation.type", &json!({"text": "x"})).unwrap(); + let outcome = s.ime_edit_outcome(); + assert!(outcome.classified && outcome.content_changed && outcome.state_changed, "{outcome:?}"); + assert_eq!(s.ime_state_revision(), None, "a real producer command cannot mint a revision"); + } + + /// The application-change signal is consumed once; commands run through `run_ime` do not set it, + /// while a normal command does. + #[test] + fn application_origin_signal_is_taken_once_and_ime_commands_do_not_set_it() { + let mut s = Session::new(Document::from_text("hello")); + s.run("para.alignCenter", &json!({})).unwrap(); + assert!(s.take_pending_ime_application_change(), "a normal command signals"); + assert!(!s.take_pending_ime_application_change(), "taking clears it"); + s.run_ime("text.insert", &json!({"text": "x"})).unwrap(); + assert!(!s.take_pending_ime_application_change(), "an IME transaction is not an application change"); + s.run("text.insert", &json!({"text": "y"})).unwrap(); + assert!(s.take_pending_ime_application_change()); + } + + /// Unknown and refused IME commands restore the normal origin without signalling an application + /// change, and a nested command inside a macro inherits the IME origin. + #[test] + fn ime_origin_is_restored_and_nested_commands_inherit_it() { + let mut s = Session::new(Document::from_text("hello")); + s.run("tools.macros", &json!({"define": {"name": "centre", "steps": [{"command": "para.alignCenter", "params": {}}]}})).unwrap(); + s.take_pending_ime_application_change(); + + // Unknown and refused commands restore the normal application origin; the failed run + // itself leaves no application-change signal. + assert!(s.run_ime("no.such.command", &json!({})).is_err()); + assert!(!s.take_pending_ime_application_change(), "an unknown IME command is not an application change"); + assert!(s.run_ime("equation.backspace", &json!({})).is_err()); + assert!(!s.take_pending_ime_application_change(), "a refused IME command is not an application change"); + + // A nested `run` inherits the IME origin from `run_ime`; the same macro run normally is + // an application change. + s.run_ime("tools.macros", &json!({"run": "centre"})).unwrap(); + assert!(!s.take_pending_ime_application_change(), "the nested command inherits the IME origin"); + s.run("tools.macros", &json!({"run": "centre"})).unwrap(); + assert!(s.take_pending_ime_application_change(), "a normal macro run is application-origin"); + } +} diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index c77152c2ec..b758ec3e77 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -1,878 +1,878 @@ -//! File format dispatch by extension. Format crates plug in here. - -use wordcraft_doc::Document; - -#[cfg(not(target_arch = "wasm32"))] -mod safe_save; - -/// Formats WordCraft opens. -pub const OPEN_EXTS: &[&str] = &[ - "docx", - "docm", - "dotx", - "dotm", - "doc", - "dot", - "txt", - "md", - "markdown", - "html", - "htm", - "mht", - "mhtml", - "rtf", - "odt", - "ott", - "fodt", - "tex", - "latex", - "ltx", - "xml", - "wcraft.json", - "json", -]; -/// Formats WordCraft saves (Save As). -pub const SAVE_EXTS: &[&str] = - &["docx", "docm", "dotx", "dotm", "pdf", "txt", "md", "html", "rtf", "odt", "ott", "fodt", "tex", "png", "json", "xml"]; - -/// Magic number for ZIP package signatures (used by .docx/.docm/.dotx/.dotm). -const ZIP_SIG: &[u8] = b"PK\x03\x04"; -/// Magic number for OLE compound document signatures (used by .doc/.dot). -const OLE_SIG: &[u8] = &[0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1]; - -/// The extension of a file name or path, lower-case; empty when the file name has none (a dot -/// in a folder name doesn't count). -fn ext_of(name: &str) -> String { - let file = name.rsplit(['/', '\\']).next().unwrap_or(name); - file.rsplit_once('.').map(|(_, ext)| ext.to_ascii_lowercase()).unwrap_or_default() -} - -/// Is `ext` one of Word's package formats (.docx/.docm/.dotx/.dotm), which save without loss? -pub fn is_word_package(ext: &str) -> bool { - wordcraft_docx::Flavor::from_ext(ext).is_some() -} - -/// How a copy in another format is written. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub struct ExportOptions { - /// Print Markup: PDFs show tracked changes marked up and, with `comments`, each page scaled - /// down beside a markup area with the comments in balloons (page images get the area too); - /// HTML, Markdown and text copies end with the comments. - pub markup: bool, - /// With `markup`, show the comments (Track Changes Options › Comments). - pub comments: bool, - /// An `.xml` copy is a Word 2003 XML Document (WordprocessingML 2003) instead of a Word XML - /// Document (Flat OPC): `format: "wordml2003"` on the save commands. - pub word2003: bool, - /// How digits look in PDFs and page images (the Numeral setting, `System` resolved). - pub numeral: wordcraft_layout::NumeralMode, - /// A PDF or page image is written only if nothing in it is silently missing (a character no - /// face can draw, a picture that is missing or can't be decoded); else the save fails with - /// `strict: …` (#6005). Ignored for other formats. - pub strict: bool, -} - -/// A document password: wiped from memory when dropped, never printed (`Debug` shows `***`). -#[derive(Clone, Default, PartialEq, Eq)] -pub struct Password(zeroize::Zeroizing); - -impl Password { - pub fn new(s: &str) -> Password { - Password(zeroize::Zeroizing::new(s.to_string())) - } - pub fn as_str(&self) -> &str { - &self.0 - } - /// The text, for a password box to edit in place. - pub fn as_mut_string(&mut self) -> &mut String { - &mut self.0 - } - pub fn is_empty(&self) -> bool { - self.0.is_empty() - } -} - -impl std::fmt::Debug for Password { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str("Password(***)") - } -} - -/// Does this open error mean the file is password-protected and no password was given? -pub fn needs_password(err: &str) -> bool { - err.contains(wordcraft_docx::PASSWORD_REQUIRED) -} - -/// Does this open error mean the password given was wrong? -pub fn wrong_password(err: &str) -> bool { - err.contains(wordcraft_docx::WRONG_PASSWORD) -} - -/// Is the file at `path` empty, zero bytes long? File Explorer's New › Microsoft Word Document -/// makes one, and `file.open` opens it as a new blank document (#1384). False for a file that -/// can't be read: opening it says why. -#[cfg(not(target_arch = "wasm32"))] -pub fn is_empty_file(path: &std::path::Path) -> bool { - crate::file_access::check_read(&path.to_string_lossy()).is_ok() && std::fs::metadata(path).is_ok_and(|m| m.is_file() && m.len() == 0) -} - -/// [`is_empty_file`]: on the web files come through the browser, never by path. -#[cfg(target_arch = "wasm32")] -pub fn is_empty_file(_path: &std::path::Path) -> bool { - false -} - -/// The canonical form of `path` (symbolic links and `.`/`..` resolved), for telling whether two -/// spellings name one file; `None` when it can't be resolved or is outside the readable roots. -#[cfg(not(target_arch = "wasm32"))] -pub(crate) fn canonical(path: &std::path::Path) -> Option { - crate::file_access::check_read(&path.to_string_lossy()).ok()?; - std::fs::canonicalize(path).ok() -} - -/// [`canonical`]: on the web files come through the browser, never by path. -#[cfg(target_arch = "wasm32")] -pub(crate) fn canonical(_path: &std::path::Path) -> Option { - None -} - -/// Can files with this extension be saved with a password (Word's packages, ODT)? -pub fn can_encrypt(name: &str) -> bool { - let ext = ext_of(name); - is_word_package(&ext) || ext == "odt" || ext == "ott" -} - -/// Open a password-protected ODT package (ODF 1.3 Part 2 §3.4). The errors for a missing or -/// wrong password are the `.docx` reader's texts, so [`needs_password`] and [`wrong_password`] -/// recognise them. -fn open_encrypted_odt(bytes: &[u8], password: Option<&str>, page: &wordcraft_doc::SectionProps) -> Result { - let pw = password.ok_or_else(|| format!("ODT: {}", wordcraft_docx::PASSWORD_REQUIRED))?; - let plain = wordcraft_formats::odt::decrypt(bytes, pw).map_err(|e| match e { - wordcraft_formats::odt::CryptError::WrongPassword => format!("ODT: {}", wordcraft_docx::WRONG_PASSWORD), - other => other.to_string(), - })?; - match crate::io_ext::open("odt", &plain, page) { - Some(r) => r, - None => Err("ODT: no reader".into()), - } -} - -/// Finish a document just read from a file: never leave it empty, and bring its unlocked -/// `DATE`/`TIME` fields up to now the way Word does on open (see -/// [`crate::cmd::references::refresh_date_fields`]). -fn opened(doc: &mut Document) { - doc.ensure_nonempty(); - crate::cmd::references::refresh_date_fields(doc); -} - -/// Parse a document from bytes; `name` gives the format by extension. -pub fn open_bytes(name: &str, bytes: &[u8]) -> Result { - open_bytes_with(name, bytes, None).map(|(doc, _)| doc) -} - -/// Open a Word 97-2003 binary file, decrypting it with `password` when it is password-protected -/// (XOR, RC4, RC4 CryptoAPI). The errors for a missing or wrong password are the same texts the -/// `.docx` reader gives, so [`needs_password`] and [`wrong_password`] recognise them. -fn open_binary_doc(bytes: &[u8], password: Option<&str>) -> Result<(Document, bool), String> { - use wordcraft_docbin::DocbinError; - let encrypted = wordcraft_docbin::is_encrypted(bytes); - match wordcraft_docbin::read_with_password(bytes, password) { - Ok(doc) => Ok((doc, encrypted)), - Err(DocbinError::PasswordRequired) => Err(wordcraft_docx::PASSWORD_REQUIRED.to_string()), - Err(DocbinError::WrongPassword) => Err(wordcraft_docx::WRONG_PASSWORD.to_string()), - Err(e) => Err(e.to_string()), - } -} - -/// [`open_bytes`] for a file that may be password-protected: a Word document encrypted with a -/// password ([MS-OFFCRYPTO] agile or Standard encryption, under any Word extension, or the -/// Word 97-2003 schemes) is decrypted with -/// `password` first. Also says -/// whether the file was encrypted. Without the password needed the error satisfies -/// [`needs_password`]; with a wrong one, [`wrong_password`]. -pub fn open_bytes_with(name: &str, bytes: &[u8], password: Option<&str>) -> Result<(Document, bool), String> { - open_bytes_in(name, bytes, password, &wordcraft_geom::Region::default()) -} - -/// The page a file that gives no page size (plain text, Markdown, HTML) opens on: `region`'s -/// paper, as a new blank document (#838, #1381), with the default margins. -pub fn region_page(region: &wordcraft_geom::Region) -> wordcraft_doc::SectionProps { - let (_, w, h) = region.paper(); - wordcraft_doc::SectionProps { page_w: w, page_h: h, ..Default::default() } -} - -/// [`open_bytes_with`] in `region`: a file that gives no page size opens on the region's paper -/// (see [`region_page`]); a document with its own page size keeps it. -pub fn open_bytes_in(name: &str, bytes: &[u8], password: Option<&str>, region: &wordcraft_geom::Region) -> Result<(Document, bool), String> { - let page = region_page(region); - let (doc, encrypted) = read_bytes(name, bytes, password, &page)?; - load_embedded_fonts(&doc); - Ok((doc, encrypted)) -} - -/// Load the fonts `doc` embeds (#1171) for this run, in memory only: text set in a font that isn't -/// installed then lays out and exports with it instead of a fallback. Faces already available -/// stay as they are. -fn load_embedded_fonts(doc: &Document) { - let files: Vec<(Vec, String)> = wordcraft_docx::embedded_fonts(doc) - .into_iter() - .filter_map(|f| wordcraft_fonts::decode_font_file(&f.data).ok().map(|data| (data, f.name))) - .collect(); - if !files.is_empty() { - wordcraft_fonts::FontDb::global().add_document_fonts(files); - } -} - -fn read_bytes(name: &str, bytes: &[u8], password: Option<&str>, page: &wordcraft_doc::SectionProps) -> Result<(Document, bool), String> { - let ext = ext_of(name); - if matches!(ext.as_str(), "docx" | "docm" | "dotx" | "dotm" | "doc" | "dot") && wordcraft_docx::is_encrypted(bytes) { - let mut doc = read_docx(bytes, password).map_err(|e| e.to_string())?; - opened(&mut doc); - return Ok((doc, true)); - } - if (ext == "odt" || ext == "ott") && wordcraft_formats::odt::is_encrypted(bytes) { - let mut doc = open_encrypted_odt(bytes, password, page)?; - opened(&mut doc); - return Ok((doc, true)); - } - if ext.is_empty() || signature_contradicts(&ext, bytes) { - return open_by_content(name, &ext, bytes, password, page); - } - let mut doc = match ext.as_str() { - "doc" | "dot" => { - let (mut doc, encrypted) = open_binary_doc(bytes, password)?; - opened(&mut doc); - return Ok((doc, encrypted)); - } - // Plain text goes through the formats importer: BOMs, unmarked UTF-16, Windows-1252. - "txt" | "text" => wordcraft_formats::txt::import_on(bytes, page), - "json" => { - let mut d = serde_json::from_slice::(bytes).map_err(|e| format!("{name}: {e}"))?; - d.normalize_runs(); - d - } - other => match crate::io_ext::open(other, bytes, page) { - Some(r) => r?, - None => return open_by_content(name, other, bytes, password, page), - }, - }; - opened(&mut doc); - Ok((doc, false)) -} - -/// Read a Word package (decrypted with `password` if it is encrypted), bringing in its -/// alternative format chunks (`w:altChunk`) as Word does when it opens one: see [`import_chunk`]. -pub(crate) fn read_docx(bytes: &[u8], password: Option<&str>) -> Result { - wordcraft_docx::read_with(bytes, &wordcraft_docx::ReadOptions { password, chunks: Some(&import_chunk) }) -} - -/// An alternative format chunk in a format the docx reader leaves to the format importers, as a -/// document: an MHT web archive, HTML, RTF or plain text, told by its content type, its part's -/// extension or what it holds. Its pictures fit the text width, as when the file is opened. -fn import_chunk(c: &wordcraft_docx::AltChunk) -> Option { - use wordcraft_formats::{html, mht, rtf, txt}; - let full = c.content_type.to_ascii_lowercase(); - let ct = full.split(';').next().unwrap_or("").trim(); - let charset = full.split(';').skip(1).find_map(|p| p.trim().strip_prefix("charset=")).map(|v| v.trim_matches('"')); - let ext = ext_of(c.part); - let b = c.bytes; - let mut doc = if matches!(ct, "message/rfc822" | "application/x-mimearchive") - || ct.starts_with("multipart/") - || matches!(ext.as_str(), "mht" | "mhtml") - || mht::is_mht(b) - { - mht::import(b) - } else if ct.contains("rtf") || ext == "rtf" || b.starts_with(b"{\\rtf") { - rtf::import(b).ok()? - } else if ct.contains("html") || matches!(ext.as_str(), "html" | "htm" | "xhtml") { - html::import_page(&html::decode_as(b, charset), &|_| None) - } else if ct == "text/plain" || ext == "txt" { - txt::import(b) - } else { - return None; - }; - // shortcut: pictures fit the chunk's default page, not the section it lands in; fit them there if wide pictures in chunks overflow narrow sections. - fit_pictures(&mut doc); - Some(doc) -} - -/// The narrowest text width of the document's sections, points: what a picture must fit. -pub(crate) fn text_width(doc: &Document) -> f32 { - doc.sections().iter().map(|(_, sp)| sp.text_width()).fold(f32::INFINITY, f32::min).clamp(36.0, 4000.0) -} - -/// Shrink inline pictures wider than `max_w` to it, keeping their proportions. Layout keeps a -/// picture's size (as Word does), so formats whose pictures come at their pixel size (HTML, -/// Markdown, ODT, RTF) are fitted when they are read, as Insert › Pictures does. -pub(crate) fn fit_block_pictures(b: &mut wordcraft_doc::Block, max_w: f32) { - crate::cmd::paste::each_para(b, 0, &mut |p| { - let mut changed = false; - for o in &mut p.objects { - if let wordcraft_doc::InlineObject::Image { w, h, float, .. } = o - && float.wrap == wordcraft_doc::para::Wrap::Inline - && w.is_finite() - && *w > max_w - { - let k = max_w / *w; - *w = max_w; - *h = (*h * k).max(1.0); - changed = true; - } - } - if changed { - p.touch(); - } - }); -} - -/// [`fit_block_pictures`] for a whole document read from a file, against its own text width. -pub(crate) fn fit_pictures(doc: &mut Document) { - let max_w = text_width(doc); - for b in &mut doc.body { - fit_block_pictures(std::sync::Arc::make_mut(b), max_w); - } -} - -/// Does the file hold a different document format than its Word or RTF extension says? An RTF -/// file saved as `.doc` (as accounting systems and report generators often save their -/// documents), a `.docx` package renamed `.doc`, a Word 97-2003 file renamed `.docx`, a Word XML -/// Document (2003 or Flat OPC) or a web page (HTML or MHT) saved as `.doc` (#2853): Word opens -/// each by what it holds, so they go through [`open_by_content`] instead of failing. -fn signature_contradicts(ext: &str, bytes: &[u8]) -> bool { - let expected = match ext { - "docx" | "docm" | "dotx" | "dotm" => "zip", - "doc" | "dot" => "ole", - "rtf" => "rtf", - _ => return false, - }; - let held = if bytes.starts_with(ZIP_SIG) { - "zip" - } else if bytes.starts_with(OLE_SIG) { - "ole" - } else if bytes.starts_with(b"{\\rtf") { - "rtf" - } else if is_word_xml(bytes) { - "xml" - } else if looks_like_web_page(bytes) { - "html" - } else if wordcraft_formats::mht::is_mht(bytes) { - "mht" - } else { - return false; - }; - held != expected -} - -/// The bytes after a UTF-8 byte-order mark and leading white space. -fn after_bom(bytes: &[u8]) -> &[u8] { - let b = bytes.strip_prefix(&[0xEF, 0xBB, 0xBF][..]).unwrap_or(bytes); - let start = b.iter().position(|c| !c.is_ascii_whitespace()).unwrap_or(b.len()); - b.get(start..).unwrap_or_default() -} - -/// Is this a Word XML Document: Word 2003 XML (`w:wordDocument`) or Flat OPC (`pkg:package`)? -/// Only text that starts with markup is parsed (UTF-16 has its byte-order mark). -fn is_word_xml(bytes: &[u8]) -> bool { - let utf16 = bytes.starts_with(&[0xFF, 0xFE]) || bytes.starts_with(&[0xFE, 0xFF]); - (utf16 || after_bom(bytes).starts_with(b"<")) && !matches!(wordcraft_docx::sniff_xml(bytes), wordcraft_docx::XmlKind::Other) -} - -/// Does the file start like a web page (`` or ``, after an XML -/// declaration and comments)? Word's Save as Web Page and many report generators write HTML -/// under a `.doc` name, and Word opens it as a web page. -fn looks_like_web_page(bytes: &[u8]) -> bool { - let head = wordcraft_formats::txt::decode(bytes.get(..bytes.len().min(4096)).unwrap_or(bytes)).to_ascii_lowercase(); - let mut t = head.trim_start_matches(['\u{FEFF}', ' ', '\t', '\r', '\n']); - for _ in 0..64 { - let skip = if t.starts_with("").map(|i| i + 3) - } else if t.starts_with("").map(|i| i + 2) - } else { - break; - }; - let Some(rest) = skip.and_then(|i| t.get(i..)) else { return false }; - t = rest.trim_start(); - } - // The tag name ends there: `` is some other XML, not a web page. - let tag = |name: &str| { - t.strip_prefix(name).is_some_and(|rest| rest.is_empty() || rest.starts_with(|c: char| c.is_ascii_whitespace() || c == '>' || c == '/')) - }; - tag(", - page: &wordcraft_doc::SectionProps, -) -> Result<(Document, bool), String> { - let (mut doc, encrypted) = if bytes.starts_with(OLE_SIG) && wordcraft_docx::is_encrypted(bytes) { - (read_docx(bytes, password).map_err(|e| e.to_string())?, true) - } else if bytes.starts_with(ZIP_SIG) { - (read_docx(bytes, None).map_err(|e| format!("{name}: not a document WordCraft can open ({e})"))?, false) - } else if bytes.starts_with(OLE_SIG) { - open_binary_doc(bytes, password) - .map_err(|e| if needs_password(&e) || wrong_password(&e) { e } else { format!("{name}: not a document WordCraft can open ({e})") })? - } else if bytes.starts_with(b"{\\rtf") { - (wordcraft_formats::rtf::import(bytes)?, false) - } else if is_word_xml(bytes) { - (wordcraft_docx::read_xml(bytes).map_err(|e| format!("{name}: {e}"))?, false) - } else if matches!(ext, "doc" | "dot" | "docx" | "docm" | "dotx" | "dotm" | "rtf") && looks_like_web_page(bytes) { - let opened = crate::io_ext::open("html", bytes, page).unwrap_or_else(|| Err(format!("{name}: can't open web pages"))); - (opened?, false) - } else if matches!(ext, "doc" | "dot" | "docx" | "docm" | "dotx" | "dotm" | "rtf") && wordcraft_formats::mht::is_mht(bytes) { - let opened = crate::io_ext::open("mht", bytes, page).unwrap_or_else(|| Err(format!("{name}: can't open web archives"))); - (opened?, false) - } else if wordcraft_formats::txt::is_text(bytes) { - (wordcraft_formats::txt::import_on(bytes, page), false) - } else if ext.is_empty() { - return Err(format!("{name}: not a document or text file WordCraft can open")); - } else { - return Err(format!("{name}: unsupported format `.{ext}` (not a document or text file)")); - }; - opened(&mut doc); - Ok((doc, encrypted)) -} - -/// Serialise a document; `name` gives the format by extension. -pub fn save_bytes(name: &str, doc: &Document) -> Result, String> { - save_bytes_opts(name, doc, &ExportOptions::default()) -} - -/// [`save_bytes`] with export options (Print Markup). -pub fn save_bytes_opts(name: &str, doc: &Document, opts: &ExportOptions) -> Result, String> { - let ext = ext_of(name); - match ext.as_str() { - // List paragraphs get their label and a tab, as in Word's plain-text save. - "txt" | "text" => { - let bytes = wordcraft_formats::txt::export(doc); - Ok(if opts.markup && opts.comments { wordcraft_formats::comments::append("txt", doc, bytes) } else { bytes }) - } - "json" => serde_json::to_vec_pretty(doc).map_err(|e| e.to_string()), - other => match crate::io_ext::save(other, doc, opts) { - Some(r) => r, - None => Err(format!("{name}: can't save as `.{other}`")), - }, - } -} - -/// [`save_bytes`], encrypted with `password` when there is one and the format is a Word package -/// (other formats are copies and are written as they are). -pub fn save_bytes_with(name: &str, doc: &Document, password: Option<&str>, opts: &ExportOptions) -> Result, String> { - let bytes = save_bytes_opts(name, doc, opts)?; - match password { - Some(pw) if matches!(ext_of(name).as_str(), "odt" | "ott") => wordcraft_formats::odt::encrypt(&bytes, pw), - Some(pw) if can_encrypt(name) => wordcraft_docx::encrypt(&bytes, pw).map_err(|e| e.to_string()), - _ => Ok(bytes), - } -} - -pub fn open_path(path: &std::path::Path) -> Result { - open_path_with(path, None).map(|(doc, _)| doc) -} - -/// [`open_path`] for a file that may be password-protected (see [`open_bytes_with`]). -#[cfg(not(target_arch = "wasm32"))] -pub fn open_path_with(path: &std::path::Path, password: Option<&str>) -> Result<(Document, bool), String> { - open_path_in(path, password, &wordcraft_geom::Region::default()) -} - -/// [`open_path_with`] in `region` (see [`open_bytes_in`]). -#[cfg(not(target_arch = "wasm32"))] -pub fn open_path_in(path: &std::path::Path, password: Option<&str>, region: &wordcraft_geom::Region) -> Result<(Document, bool), String> { - let bytes = read_document(path)?; - let name = path.to_string_lossy(); - if matches!(ext_of(&name).as_str(), "html" | "htm" | "xhtml") { - // Pictures referenced by a relative path load from beside the HTML file. - let images = LocalImages::new(path.parent().unwrap_or(std::path::Path::new(""))); - let html = wordcraft_formats::html::decode(&bytes); - let mut doc = wordcraft_formats::html::import_on(&html, &|src| images.load(src), ®ion_page(region)); - fit_pictures(&mut doc); - opened(&mut doc); - return Ok((doc, false)); - } - open_bytes_in(&name, &bytes, password, region) -} - -/// Largest document file opened by path. -#[cfg(not(target_arch = "wasm32"))] -const MAX_DOCUMENT: u64 = 2 << 30; - -/// The bytes of the document at `path`, read as `file.open` reads a path: inside the automation -/// read root, and only a regular file of at most 2 GB. A named pipe would block the open and a -/// device such as `/dev/zero` would never end ([`read_regular_file`]). Public for the app, which -/// opens a file whose name isn't valid Unicode through `file.open`'s `data` (#1440). -#[cfg(not(target_arch = "wasm32"))] -pub fn read_document(path: &std::path::Path) -> Result, String> { - crate::file_access::check_read(&path.to_string_lossy())?; - let meta = std::fs::metadata(path).map_err(|e| format!("{}: {e}", path.display()))?; - if !meta.is_file() { - return Err(format!("{}: not a file", path.display())); - } - if meta.len() > MAX_DOCUMENT { - return Err(format!("{}: file is larger than 2 GB", path.display())); - } - // Checked again on the opened file, which may not be the one just looked at. - match read_regular_file(path, MAX_DOCUMENT) { - Some(bytes) if bytes.len() as u64 <= MAX_DOCUMENT => Ok(bytes), - Some(_) => Err(format!("{}: file is larger than 2 GB", path.display())), - None => Err(format!("{}: could not be read as a file", path.display())), - } -} - -/// The pictures an HTML file names by a path relative to its folder. -/// -/// Only regular files inside the folder (after resolving `..` and symlinks) are read. The folder is -/// the HTML file's own, which `open_path_with` has already checked against the automation read root. URLs -/// (`http:`, `file:`…) and absolute paths are not followed. Each file is read at most once and -/// at most `max_file` bytes of it; every reference, cached or not, counts against a budget for -/// the whole document, so one large picture can't be multiplied by referencing it many times. -#[cfg(not(target_arch = "wasm32"))] -pub(crate) struct LocalImages { - /// The folder, canonical; `None` when it can't be resolved (nothing loads then). - dir: Option, - max_file: u64, - /// Bytes the document may still load. - left: std::cell::Cell, - cache: std::cell::RefCell>)>>, -} - -#[cfg(not(target_arch = "wasm32"))] -impl LocalImages { - /// Largest picture file read. - const MAX_FILE: u64 = 256 << 20; - /// Picture bytes one document may load, counting every reference. - const BUDGET: u64 = 1 << 30; - /// Files whose bytes are kept for further references. - const CACHE: usize = 64; - - pub(crate) fn new(dir: &std::path::Path) -> LocalImages { - LocalImages::with_limits(dir, LocalImages::MAX_FILE, LocalImages::BUDGET) - } - - pub(crate) fn with_limits(dir: &std::path::Path, max_file: u64, budget: u64) -> LocalImages { - let dir = if dir.as_os_str().is_empty() { std::path::Path::new(".") } else { dir }; - LocalImages { dir: std::fs::canonicalize(dir).ok(), max_file, left: std::cell::Cell::new(budget), cache: Default::default() } - } - - /// Bytes of the file `src` names, or `None` when it isn't a regular file inside the folder, - /// is too large, or the budget is spent. - pub(crate) fn load(&self, src: &str) -> Option>> { - let path = self.resolve(src)?; - let cached = self.cache.borrow().iter().find(|(p, _)| *p == path).map(|(_, d)| d.clone()); - if let Some(data) = cached { - self.charge(data.len())?; - return Some(data); - } - let limit = self.max_file.min(self.left.get()); - let bytes = read_regular_file(&path, limit)?; - // What was read is spent even when it's over the limit. - self.charge(bytes.len())?; - if bytes.len() as u64 > limit { - return None; - } - let data = std::sync::Arc::new(bytes); - let mut cache = self.cache.borrow_mut(); - if cache.len() < LocalImages::CACHE { - cache.push((path, data.clone())); - } - Some(data) - } - - /// Take `n` bytes from the budget; `None` (and the budget emptied) when there aren't enough. - fn charge(&self, n: usize) -> Option<()> { - let left = self.left.get().checked_sub(n as u64); - self.left.set(left.unwrap_or(0)); - left.map(|_| ()) - } - - /// The canonical path `src` names, if it's inside the folder and may be read. - fn resolve(&self, src: &str) -> Option { - let src = src.split(['#', '?']).next().unwrap_or(""); - if src.is_empty() || src.starts_with("//") || src.split_once(':').is_some_and(|(scheme, _)| !scheme.contains('/')) { - return None; - } - let rel = String::from_utf8(wordcraft_formats::model::percent_decode(src)).ok()?; - let rel = std::path::Path::new(&rel); - if rel.components().any(|c| matches!(c, std::path::Component::Prefix(_) | std::path::Component::RootDir)) { - return None; - } - let dir = self.dir.as_ref()?; - // Canonical paths have `..` and symlinks resolved, so this keeps both inside the folder. - let path = std::fs::canonicalize(dir.join(rel)).ok()?; - // The folder is the one the HTML path names, which may be a link from outside the - // automation read root; the picture itself must be inside it (#3736). - crate::file_access::check_read(&path.to_string_lossy()).ok()?; - path.starts_with(dir).then_some(path) - } -} - -/// Up to `limit + 1` bytes of the regular file at `path` (more than `limit` means too large), or -/// `None` for anything else. A named pipe blocks when opened and a device reports a length of 0 -/// and may never end, so the path is checked before opening, the opened handle is checked again, -/// and the read is bounded whatever length the file reports. Public for the app (a copied picture -/// file, Paste Special). -#[cfg(not(target_arch = "wasm32"))] -pub fn read_regular_file(path: &std::path::Path, limit: u64) -> Option> { - let (file, meta) = open_regular_file(path).ok()?; - read_bounded(file, &meta, limit).ok() -} - -#[cfg(target_arch = "wasm32")] -pub fn read_regular_file(_path: &std::path::Path, _limit: u64) -> Option> { - None -} - -/// The bytes of the regular file at `path` when it holds at most `limit` of them; `None` for -/// anything else. A file whose opened handle reports more than `limit` bytes is refused before -/// anything is read; the read stays bounded for a file that grows after the check. Public for the -/// app (a copied picture file, Paste Special). -#[cfg(not(target_arch = "wasm32"))] -pub fn read_regular_file_within(path: &std::path::Path, limit: u64) -> Option> { - let (file, meta) = open_regular_file(path).ok()?; - if meta.len() > limit { - return None; - } - read_bounded(file, &meta, limit).ok().filter(|b| b.len() as u64 <= limit) -} - -#[cfg(target_arch = "wasm32")] -pub fn read_regular_file_within(_path: &std::path::Path, _limit: u64) -> Option> { - None -} - -/// The regular file at `path`, opened once, with the opened handle's metadata. The path is -/// checked before opening (a named pipe blocks in `open` until a writer appears) and the handle -/// after (the path may have been swapped in between); anything but a regular file is "not a file". -#[cfg(not(target_arch = "wasm32"))] -fn open_regular_file(path: &std::path::Path) -> Result<(std::fs::File, std::fs::Metadata), String> { - let meta = std::fs::metadata(path).map_err(|e| format!("{}: {e}", path.display()))?; - if !meta.is_file() { - return Err(format!("{}: not a file", path.display())); - } - let mut open = std::fs::OpenOptions::new(); - open.read(true); - // A path swapped for a named pipe since the check above would block `open`; non-blocking - // opens it at once (no effect on a regular file) and the handle check below refuses it. - #[cfg(unix)] - std::os::unix::fs::OpenOptionsExt::custom_flags(&mut open, libc::O_NONBLOCK); - let file = open.open(path).map_err(|e| format!("{}: {e}", path.display()))?; - let meta = file.metadata().map_err(|e| format!("{}: {e}", path.display()))?; - if !meta.is_file() { - return Err(format!("{}: not a file", path.display())); - } - Ok((file, meta)) -} - -/// Up to `limit + 1` bytes of `file` (more than `limit` means too large), however long it says it is. -#[cfg(not(target_arch = "wasm32"))] -fn read_bounded(file: std::fs::File, meta: &std::fs::Metadata, limit: u64) -> std::io::Result> { - use std::io::Read; - let mut out = Vec::with_capacity(usize::try_from(meta.len().min(limit)).unwrap_or(0)); - file.take(limit.saturating_add(1)).read_to_end(&mut out)?; - Ok(out) -} - -/// Largest file [`read_file`] reads whole — the same 2 GB ceiling [`open_path_with`] gives a -/// document. -pub const MAX_READ_FILE: u64 = 2 << 30; - -/// The bytes of the file at `path`, for commands that take a path (pictures, mail-merge data). -/// Every such read goes through here so the automation read root applies ([`crate::file_access`]). -/// Only a regular file is read, and at most [`MAX_READ_FILE`] bytes of it: a named pipe blocks -/// when opened and a device like `/dev/zero` never reaches end of file. -#[cfg(not(target_arch = "wasm32"))] -pub fn read_file(path: &str) -> Result, String> { - read_file_capped(path, MAX_READ_FILE, "file") -} - -#[cfg(target_arch = "wasm32")] -pub fn read_file(path: &str) -> Result, String> { - Err(format!("{path}: files are opened through the browser on the web")) -} - -/// [`read_file`] for a file that must be a regular file of at most `max` bytes, checked before -/// anything is read (a font named by the user, which could be any size). The file is opened once -/// and the read stops past `max`, so a file swapped or grown after the check is refused too (#3666). -#[cfg(not(target_arch = "wasm32"))] -pub fn read_file_capped(path: &str, max: u64, what: &str) -> Result, String> { - crate::file_access::check_read(path)?; - let too_large = || format!("{path}: the {what} is larger than {} MB", max >> 20); - let (file, meta) = open_regular_file(std::path::Path::new(path))?; - if meta.len() > max { - return Err(too_large()); - } - let bytes = read_bounded(file, &meta, max).map_err(|e| format!("{path}: {e}"))?; - if bytes.len() as u64 > max { - return Err(too_large()); - } - Ok(bytes) -} - -/// The first `max` bytes of the file at `path` (fewer when it is shorter), inside the automation -/// read root when there is one: enough to tell a file's format by its start. Only a regular file -/// is opened: a named pipe would block the open until a writer appeared (#3666). -#[cfg(not(target_arch = "wasm32"))] -pub fn read_file_head(path: &str, max: u64) -> Result, String> { - use std::io::Read; - crate::file_access::check_read(path)?; - let (file, _) = open_regular_file(std::path::Path::new(path))?; - let mut head = Vec::new(); - file.take(max).read_to_end(&mut head).map_err(|e| format!("{path}: {e}"))?; - Ok(head) -} - -/// An entry of [`list_dir`]: a file or folder (links followed). -#[cfg(not(target_arch = "wasm32"))] -#[derive(Clone, Debug)] -pub struct DirEntry { - pub name: String, - pub path: std::path::PathBuf, - pub dir: bool, - /// The file's size in bytes (0 for a folder). - pub len: u64, - /// When it last changed, in seconds since the Unix epoch, if the system says. - pub modified: Option, -} - -/// The files and folders in the folder at `path`, inside the automation read root (entries -/// leading outside it, and ones that can't be read, are left out); at most `max` entries are -/// looked at, and `true` says the listing was cut short there. -#[cfg(not(target_arch = "wasm32"))] -pub fn list_dir(path: &std::path::Path, max: usize) -> Result<(Vec, bool), String> { - crate::file_access::check_read(&path.to_string_lossy())?; - let read = std::fs::read_dir(path).map_err(|e| format!("{}: {e}", path.display()))?; - let mut out = Vec::new(); - for (seen, entry) in read.enumerate() { - if seen >= max { - return Ok((out, true)); - } - let Ok(entry) = entry else { continue }; - let path = entry.path(); - if crate::file_access::check_read(&path.to_string_lossy()).is_err() { - continue; - } - let Ok(meta) = std::fs::metadata(&path) else { continue }; - if !meta.is_dir() && !meta.is_file() { - continue; - } - let modified = meta.modified().ok().and_then(|m| m.duration_since(std::time::UNIX_EPOCH).ok()).map(|d| d.as_secs()); - let name = entry.file_name().to_string_lossy().into_owned(); - out.push(DirEntry { name, path, dir: meta.is_dir(), len: if meta.is_file() { meta.len() } else { 0 }, modified }); - } - Ok((out, false)) -} - -/// The title in the core properties of the Word package at `path` (a file of at most `max_file` -/// bytes; at most `max_part` bytes of the part are read), inside the automation read root. -#[cfg(not(target_arch = "wasm32"))] -pub fn package_title(path: &std::path::Path, max_file: u64, max_part: u64) -> Option { - crate::file_access::check_read(&path.to_string_lossy()).ok()?; - let (file, meta) = open_regular_file(path).ok()?; - if meta.len() > max_file { - return None; - } - wordcraft_docx::package_title(std::io::BufReader::new(file), max_part) -} - -/// Rename without replacing another file, respecting both automation access roots. -#[cfg(not(target_arch = "wasm32"))] -pub fn rename_file(source: &std::path::Path, destination: &std::path::Path) -> Result<(), String> { - crate::file_access::check_read(&source.to_string_lossy())?; - crate::file_access::check_write(&source.to_string_lossy())?; - crate::file_access::check_write(&destination.to_string_lossy())?; - // Creating a sibling hard link refuses collisions atomically and keeps the original bytes. - if let Err(e) = std::fs::hard_link(source, destination) { - if e.kind() == std::io::ErrorKind::AlreadyExists && only_case_differs(source, destination) { - return std::fs::rename(source, destination).map_err(|e| format!("Could not rename document: {e}")); - } - return Err(format!("Could not rename document: {e}")); - } - if let Err(e) = std::fs::remove_file(source) { - let _ = std::fs::remove_file(destination); - return Err(format!("Could not rename document: {e}")); - } - Ok(()) -} - -/// Whether `destination` names `source` in the same directory spelled with different case, and -/// the directory holds no entry spelled exactly as `destination`. -#[cfg(not(target_arch = "wasm32"))] -fn only_case_differs(source: &std::path::Path, destination: &std::path::Path) -> bool { - let (Some(dir), Some(from), Some(to)) = (source.parent(), source.file_name(), destination.file_name()) else { - return false; - }; - if destination.parent() != Some(dir) || from == to || from.to_string_lossy().to_lowercase() != to.to_string_lossy().to_lowercase() { - return false; - } - let dir = if dir.as_os_str().is_empty() { std::path::Path::new(".") } else { dir }; - std::fs::read_dir(dir).is_ok_and(|entries| entries.filter_map(Result::ok).all(|entry| entry.file_name() != to)) -} - -/// Write `bytes` to the file at `path`, inside the automation write root when there is one. -#[cfg(not(target_arch = "wasm32"))] -pub fn write_file(path: &str, bytes: &[u8]) -> Result<(), String> { - crate::file_access::check_write(path)?; - // Like a document save: the old file is replaced only once the new bytes are fully written. - safe_save::write(std::path::Path::new(path), bytes).map_err(|e| format!("{path}: {e}")) -} - -#[cfg(target_arch = "wasm32")] -pub fn write_file(path: &str, _bytes: &[u8]) -> Result<(), String> { - Err(format!("{path}: files are saved through the browser on the web")) -} - -#[cfg(target_arch = "wasm32")] -pub fn open_path_with(path: &std::path::Path, _password: Option<&str>) -> Result<(Document, bool), String> { - Err(format!("{}: files are opened through the browser on the web", path.display())) -} - -#[cfg(target_arch = "wasm32")] -pub fn open_path_in(path: &std::path::Path, password: Option<&str>, _region: &wordcraft_geom::Region) -> Result<(Document, bool), String> { - open_path_with(path, password) -} - -pub fn save_path(path: &std::path::Path, doc: &Document) -> Result<(), String> { - save_path_with(path, doc, None, &ExportOptions::default()) -} - -/// [`save_path`], encrypted with `password` as [`save_bytes_with`] does. -#[cfg(not(target_arch = "wasm32"))] -pub fn save_path_with(path: &std::path::Path, doc: &Document, password: Option<&str>, opts: &ExportOptions) -> Result<(), String> { - crate::file_access::check_write(&path.to_string_lossy())?; - let bytes = save_bytes_with(&path.to_string_lossy(), doc, password, opts)?; - safe_save::write(path, &bytes).map_err(|e| format!("{}: {e}", path.display())) -} - -/// Saves a document to `path` with optional password and export options on web targets. -#[cfg(target_arch = "wasm32")] -pub fn save_path_with(path: &std::path::Path, _doc: &Document, _password: Option<&str>, _opts: &ExportOptions) -> Result<(), String> { - Err(format!("{}: files are saved through the browser on the web", path.display())) -} - -/// Write `text` to `path` as it is (a Markdown file's own text), as safely as [`save_path_with`]. -#[cfg(not(target_arch = "wasm32"))] -pub fn save_text_path(path: &std::path::Path, text: &str) -> Result<(), String> { - crate::file_access::check_write(&path.to_string_lossy())?; - safe_save::write(path, text.as_bytes()).map_err(|e| format!("{}: {e}", path.display())) -} - -/// Write `text` to `path` as it is (a Markdown file's own text), as safely as [`save_path_with`]. -#[cfg(target_arch = "wasm32")] -pub fn save_text_path(path: &std::path::Path, _text: &str) -> Result<(), String> { - Err(format!("{}: files are saved through the browser on the web", path.display())) -} - -/// Decode text bytes: UTF-8 (with or without BOM), UTF-16 LE/BE (BOM or sniffed), else the -/// legacy encoding they look like — GBK, Big5, Shift_JIS, Windows-1252… (see -/// [`wordcraft_formats::txt::decode`]). -pub fn decode_text(b: &[u8]) -> String { - wordcraft_formats::txt::decode(b) -} +//! File format dispatch by extension. Format crates plug in here. + +use wordcraft_doc::Document; + +#[cfg(not(target_arch = "wasm32"))] +mod safe_save; + +/// Formats WordCraft opens. +pub const OPEN_EXTS: &[&str] = &[ + "docx", + "docm", + "dotx", + "dotm", + "doc", + "dot", + "txt", + "md", + "markdown", + "html", + "htm", + "mht", + "mhtml", + "rtf", + "odt", + "ott", + "fodt", + "tex", + "latex", + "ltx", + "xml", + "wcraft.json", + "json", +]; +/// Formats WordCraft saves (Save As). +pub const SAVE_EXTS: &[&str] = + &["docx", "docm", "dotx", "dotm", "pdf", "txt", "md", "html", "rtf", "odt", "ott", "fodt", "tex", "png", "json", "xml"]; + +/// Magic number for ZIP package signatures (used by .docx/.docm/.dotx/.dotm). +const ZIP_SIG: &[u8] = b"PK\x03\x04"; +/// Magic number for OLE compound document signatures (used by .doc/.dot). +const OLE_SIG: &[u8] = &[0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1]; + +/// The extension of a file name or path, lower-case; empty when the file name has none (a dot +/// in a folder name doesn't count). +fn ext_of(name: &str) -> String { + let file = name.rsplit(['/', '\\']).next().unwrap_or(name); + file.rsplit_once('.').map(|(_, ext)| ext.to_ascii_lowercase()).unwrap_or_default() +} + +/// Is `ext` one of Word's package formats (.docx/.docm/.dotx/.dotm), which save without loss? +pub fn is_word_package(ext: &str) -> bool { + wordcraft_docx::Flavor::from_ext(ext).is_some() +} + +/// How a copy in another format is written. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct ExportOptions { + /// Print Markup: PDFs show tracked changes marked up and, with `comments`, each page scaled + /// down beside a markup area with the comments in balloons (page images get the area too); + /// HTML, Markdown and text copies end with the comments. + pub markup: bool, + /// With `markup`, show the comments (Track Changes Options › Comments). + pub comments: bool, + /// An `.xml` copy is a Word 2003 XML Document (WordprocessingML 2003) instead of a Word XML + /// Document (Flat OPC): `format: "wordml2003"` on the save commands. + pub word2003: bool, + /// How digits look in PDFs and page images (the Numeral setting, `System` resolved). + pub numeral: wordcraft_layout::NumeralMode, + /// A PDF or page image is written only if nothing in it is silently missing (a character no + /// face can draw, a picture that is missing or can't be decoded); else the save fails with + /// `strict: …` (#6005). Ignored for other formats. + pub strict: bool, +} + +/// A document password: wiped from memory when dropped, never printed (`Debug` shows `***`). +#[derive(Clone, Default, PartialEq, Eq)] +pub struct Password(zeroize::Zeroizing); + +impl Password { + pub fn new(s: &str) -> Password { + Password(zeroize::Zeroizing::new(s.to_string())) + } + pub fn as_str(&self) -> &str { + &self.0 + } + /// The text, for a password box to edit in place. + pub fn as_mut_string(&mut self) -> &mut String { + &mut self.0 + } + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } +} + +impl std::fmt::Debug for Password { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("Password(***)") + } +} + +/// Does this open error mean the file is password-protected and no password was given? +pub fn needs_password(err: &str) -> bool { + err.contains(wordcraft_docx::PASSWORD_REQUIRED) +} + +/// Does this open error mean the password given was wrong? +pub fn wrong_password(err: &str) -> bool { + err.contains(wordcraft_docx::WRONG_PASSWORD) +} + +/// Is the file at `path` empty, zero bytes long? File Explorer's New › Microsoft Word Document +/// makes one, and `file.open` opens it as a new blank document (#1384). False for a file that +/// can't be read: opening it says why. +#[cfg(not(target_arch = "wasm32"))] +pub fn is_empty_file(path: &std::path::Path) -> bool { + crate::file_access::check_read(&path.to_string_lossy()).is_ok() && std::fs::metadata(path).is_ok_and(|m| m.is_file() && m.len() == 0) +} + +/// [`is_empty_file`]: on the web files come through the browser, never by path. +#[cfg(target_arch = "wasm32")] +pub fn is_empty_file(_path: &std::path::Path) -> bool { + false +} + +/// The canonical form of `path` (symbolic links and `.`/`..` resolved), for telling whether two +/// spellings name one file; `None` when it can't be resolved or is outside the readable roots. +#[cfg(not(target_arch = "wasm32"))] +pub(crate) fn canonical(path: &std::path::Path) -> Option { + crate::file_access::check_read(&path.to_string_lossy()).ok()?; + std::fs::canonicalize(path).ok() +} + +/// [`canonical`]: on the web files come through the browser, never by path. +#[cfg(target_arch = "wasm32")] +pub(crate) fn canonical(_path: &std::path::Path) -> Option { + None +} + +/// Can files with this extension be saved with a password (Word's packages, ODT)? +pub fn can_encrypt(name: &str) -> bool { + let ext = ext_of(name); + is_word_package(&ext) || ext == "odt" || ext == "ott" +} + +/// Open a password-protected ODT package (ODF 1.3 Part 2 §3.4). The errors for a missing or +/// wrong password are the `.docx` reader's texts, so [`needs_password`] and [`wrong_password`] +/// recognise them. +fn open_encrypted_odt(bytes: &[u8], password: Option<&str>, page: &wordcraft_doc::SectionProps) -> Result { + let pw = password.ok_or_else(|| format!("ODT: {}", wordcraft_docx::PASSWORD_REQUIRED))?; + let plain = wordcraft_formats::odt::decrypt(bytes, pw).map_err(|e| match e { + wordcraft_formats::odt::CryptError::WrongPassword => format!("ODT: {}", wordcraft_docx::WRONG_PASSWORD), + other => other.to_string(), + })?; + match crate::io_ext::open("odt", &plain, page) { + Some(r) => r, + None => Err("ODT: no reader".into()), + } +} + +/// Finish a document just read from a file: never leave it empty, and bring its unlocked +/// `DATE`/`TIME` fields up to now the way Word does on open (see +/// [`crate::cmd::references::refresh_date_fields`]). +fn opened(doc: &mut Document) { + doc.ensure_nonempty(); + crate::cmd::references::refresh_date_fields(doc); +} + +/// Parse a document from bytes; `name` gives the format by extension. +pub fn open_bytes(name: &str, bytes: &[u8]) -> Result { + open_bytes_with(name, bytes, None).map(|(doc, _)| doc) +} + +/// Open a Word 97-2003 binary file, decrypting it with `password` when it is password-protected +/// (XOR, RC4, RC4 CryptoAPI). The errors for a missing or wrong password are the same texts the +/// `.docx` reader gives, so [`needs_password`] and [`wrong_password`] recognise them. +fn open_binary_doc(bytes: &[u8], password: Option<&str>) -> Result<(Document, bool), String> { + use wordcraft_docbin::DocbinError; + let encrypted = wordcraft_docbin::is_encrypted(bytes); + match wordcraft_docbin::read_with_password(bytes, password) { + Ok(doc) => Ok((doc, encrypted)), + Err(DocbinError::PasswordRequired) => Err(wordcraft_docx::PASSWORD_REQUIRED.to_string()), + Err(DocbinError::WrongPassword) => Err(wordcraft_docx::WRONG_PASSWORD.to_string()), + Err(e) => Err(e.to_string()), + } +} + +/// [`open_bytes`] for a file that may be password-protected: a Word document encrypted with a +/// password ([MS-OFFCRYPTO] agile or Standard encryption, under any Word extension, or the +/// Word 97-2003 schemes) is decrypted with +/// `password` first. Also says +/// whether the file was encrypted. Without the password needed the error satisfies +/// [`needs_password`]; with a wrong one, [`wrong_password`]. +pub fn open_bytes_with(name: &str, bytes: &[u8], password: Option<&str>) -> Result<(Document, bool), String> { + open_bytes_in(name, bytes, password, &wordcraft_geom::Region::default()) +} + +/// The page a file that gives no page size (plain text, Markdown, HTML) opens on: `region`'s +/// paper, as a new blank document (#838, #1381), with the default margins. +pub fn region_page(region: &wordcraft_geom::Region) -> wordcraft_doc::SectionProps { + let (_, w, h) = region.paper(); + wordcraft_doc::SectionProps { page_w: w, page_h: h, ..Default::default() } +} + +/// [`open_bytes_with`] in `region`: a file that gives no page size opens on the region's paper +/// (see [`region_page`]); a document with its own page size keeps it. +pub fn open_bytes_in(name: &str, bytes: &[u8], password: Option<&str>, region: &wordcraft_geom::Region) -> Result<(Document, bool), String> { + let page = region_page(region); + let (doc, encrypted) = read_bytes(name, bytes, password, &page)?; + load_embedded_fonts(&doc); + Ok((doc, encrypted)) +} + +/// Load the fonts `doc` embeds (#1171) for this run, in memory only: text set in a font that isn't +/// installed then lays out and exports with it instead of a fallback. Faces already available +/// stay as they are. +fn load_embedded_fonts(doc: &Document) { + let files: Vec<(Vec, String)> = wordcraft_docx::embedded_fonts(doc) + .into_iter() + .filter_map(|f| wordcraft_fonts::decode_font_file(&f.data).ok().map(|data| (data, f.name))) + .collect(); + if !files.is_empty() { + wordcraft_fonts::FontDb::global().add_document_fonts(files); + } +} + +fn read_bytes(name: &str, bytes: &[u8], password: Option<&str>, page: &wordcraft_doc::SectionProps) -> Result<(Document, bool), String> { + let ext = ext_of(name); + if matches!(ext.as_str(), "docx" | "docm" | "dotx" | "dotm" | "doc" | "dot") && wordcraft_docx::is_encrypted(bytes) { + let mut doc = read_docx(bytes, password).map_err(|e| e.to_string())?; + opened(&mut doc); + return Ok((doc, true)); + } + if (ext == "odt" || ext == "ott") && wordcraft_formats::odt::is_encrypted(bytes) { + let mut doc = open_encrypted_odt(bytes, password, page)?; + opened(&mut doc); + return Ok((doc, true)); + } + if ext.is_empty() || signature_contradicts(&ext, bytes) { + return open_by_content(name, &ext, bytes, password, page); + } + let mut doc = match ext.as_str() { + "doc" | "dot" => { + let (mut doc, encrypted) = open_binary_doc(bytes, password)?; + opened(&mut doc); + return Ok((doc, encrypted)); + } + // Plain text goes through the formats importer: BOMs, unmarked UTF-16, Windows-1252. + "txt" | "text" => wordcraft_formats::txt::import_on(bytes, page), + "json" => { + let mut d = serde_json::from_slice::(bytes).map_err(|e| format!("{name}: {e}"))?; + d.normalize_runs(); + d + } + other => match crate::io_ext::open(other, bytes, page) { + Some(r) => r?, + None => return open_by_content(name, other, bytes, password, page), + }, + }; + opened(&mut doc); + Ok((doc, false)) +} + +/// Read a Word package (decrypted with `password` if it is encrypted), bringing in its +/// alternative format chunks (`w:altChunk`) as Word does when it opens one: see [`import_chunk`]. +pub(crate) fn read_docx(bytes: &[u8], password: Option<&str>) -> Result { + wordcraft_docx::read_with(bytes, &wordcraft_docx::ReadOptions { password, chunks: Some(&import_chunk) }) +} + +/// An alternative format chunk in a format the docx reader leaves to the format importers, as a +/// document: an MHT web archive, HTML, RTF or plain text, told by its content type, its part's +/// extension or what it holds. Its pictures fit the text width, as when the file is opened. +fn import_chunk(c: &wordcraft_docx::AltChunk) -> Option { + use wordcraft_formats::{html, mht, rtf, txt}; + let full = c.content_type.to_ascii_lowercase(); + let ct = full.split(';').next().unwrap_or("").trim(); + let charset = full.split(';').skip(1).find_map(|p| p.trim().strip_prefix("charset=")).map(|v| v.trim_matches('"')); + let ext = ext_of(c.part); + let b = c.bytes; + let mut doc = if matches!(ct, "message/rfc822" | "application/x-mimearchive") + || ct.starts_with("multipart/") + || matches!(ext.as_str(), "mht" | "mhtml") + || mht::is_mht(b) + { + mht::import(b) + } else if ct.contains("rtf") || ext == "rtf" || b.starts_with(b"{\\rtf") { + rtf::import(b).ok()? + } else if ct.contains("html") || matches!(ext.as_str(), "html" | "htm" | "xhtml") { + html::import_page(&html::decode_as(b, charset), &|_| None) + } else if ct == "text/plain" || ext == "txt" { + txt::import(b) + } else { + return None; + }; + // shortcut: pictures fit the chunk's default page, not the section it lands in; fit them there if wide pictures in chunks overflow narrow sections. + fit_pictures(&mut doc); + Some(doc) +} + +/// The narrowest text width of the document's sections, points: what a picture must fit. +pub(crate) fn text_width(doc: &Document) -> f32 { + doc.sections().iter().map(|(_, sp)| sp.text_width()).fold(f32::INFINITY, f32::min).clamp(36.0, 4000.0) +} + +/// Shrink inline pictures wider than `max_w` to it, keeping their proportions. Layout keeps a +/// picture's size (as Word does), so formats whose pictures come at their pixel size (HTML, +/// Markdown, ODT, RTF) are fitted when they are read, as Insert › Pictures does. +pub(crate) fn fit_block_pictures(b: &mut wordcraft_doc::Block, max_w: f32) { + crate::cmd::paste::each_para(b, 0, &mut |p| { + let mut changed = false; + for o in &mut p.objects { + if let wordcraft_doc::InlineObject::Image { w, h, float, .. } = o + && float.wrap == wordcraft_doc::para::Wrap::Inline + && w.is_finite() + && *w > max_w + { + let k = max_w / *w; + *w = max_w; + *h = (*h * k).max(1.0); + changed = true; + } + } + if changed { + p.touch(); + } + }); +} + +/// [`fit_block_pictures`] for a whole document read from a file, against its own text width. +pub(crate) fn fit_pictures(doc: &mut Document) { + let max_w = text_width(doc); + for b in &mut doc.body { + fit_block_pictures(std::sync::Arc::make_mut(b), max_w); + } +} + +/// Does the file hold a different document format than its Word or RTF extension says? An RTF +/// file saved as `.doc` (as accounting systems and report generators often save their +/// documents), a `.docx` package renamed `.doc`, a Word 97-2003 file renamed `.docx`, a Word XML +/// Document (2003 or Flat OPC) or a web page (HTML or MHT) saved as `.doc` (#2853): Word opens +/// each by what it holds, so they go through [`open_by_content`] instead of failing. +fn signature_contradicts(ext: &str, bytes: &[u8]) -> bool { + let expected = match ext { + "docx" | "docm" | "dotx" | "dotm" => "zip", + "doc" | "dot" => "ole", + "rtf" => "rtf", + _ => return false, + }; + let held = if bytes.starts_with(ZIP_SIG) { + "zip" + } else if bytes.starts_with(OLE_SIG) { + "ole" + } else if bytes.starts_with(b"{\\rtf") { + "rtf" + } else if is_word_xml(bytes) { + "xml" + } else if looks_like_web_page(bytes) { + "html" + } else if wordcraft_formats::mht::is_mht(bytes) { + "mht" + } else { + return false; + }; + held != expected +} + +/// The bytes after a UTF-8 byte-order mark and leading white space. +fn after_bom(bytes: &[u8]) -> &[u8] { + let b = bytes.strip_prefix(&[0xEF, 0xBB, 0xBF][..]).unwrap_or(bytes); + let start = b.iter().position(|c| !c.is_ascii_whitespace()).unwrap_or(b.len()); + b.get(start..).unwrap_or_default() +} + +/// Is this a Word XML Document: Word 2003 XML (`w:wordDocument`) or Flat OPC (`pkg:package`)? +/// Only text that starts with markup is parsed (UTF-16 has its byte-order mark). +fn is_word_xml(bytes: &[u8]) -> bool { + let utf16 = bytes.starts_with(&[0xFF, 0xFE]) || bytes.starts_with(&[0xFE, 0xFF]); + (utf16 || after_bom(bytes).starts_with(b"<")) && !matches!(wordcraft_docx::sniff_xml(bytes), wordcraft_docx::XmlKind::Other) +} + +/// Does the file start like a web page (`` or ``, after an XML +/// declaration and comments)? Word's Save as Web Page and many report generators write HTML +/// under a `.doc` name, and Word opens it as a web page. +fn looks_like_web_page(bytes: &[u8]) -> bool { + let head = wordcraft_formats::txt::decode(bytes.get(..bytes.len().min(4096)).unwrap_or(bytes)).to_ascii_lowercase(); + let mut t = head.trim_start_matches(['\u{FEFF}', ' ', '\t', '\r', '\n']); + for _ in 0..64 { + let skip = if t.starts_with("").map(|i| i + 3) + } else if t.starts_with("").map(|i| i + 2) + } else { + break; + }; + let Some(rest) = skip.and_then(|i| t.get(i..)) else { return false }; + t = rest.trim_start(); + } + // The tag name ends there: `` is some other XML, not a web page. + let tag = |name: &str| { + t.strip_prefix(name).is_some_and(|rest| rest.is_empty() || rest.starts_with(|c: char| c.is_ascii_whitespace() || c == '>' || c == '/')) + }; + tag(", + page: &wordcraft_doc::SectionProps, +) -> Result<(Document, bool), String> { + let (mut doc, encrypted) = if bytes.starts_with(OLE_SIG) && wordcraft_docx::is_encrypted(bytes) { + (read_docx(bytes, password).map_err(|e| e.to_string())?, true) + } else if bytes.starts_with(ZIP_SIG) { + (read_docx(bytes, None).map_err(|e| format!("{name}: not a document WordCraft can open ({e})"))?, false) + } else if bytes.starts_with(OLE_SIG) { + open_binary_doc(bytes, password) + .map_err(|e| if needs_password(&e) || wrong_password(&e) { e } else { format!("{name}: not a document WordCraft can open ({e})") })? + } else if bytes.starts_with(b"{\\rtf") { + (wordcraft_formats::rtf::import(bytes)?, false) + } else if is_word_xml(bytes) { + (wordcraft_docx::read_xml(bytes).map_err(|e| format!("{name}: {e}"))?, false) + } else if matches!(ext, "doc" | "dot" | "docx" | "docm" | "dotx" | "dotm" | "rtf") && looks_like_web_page(bytes) { + let opened = crate::io_ext::open("html", bytes, page).unwrap_or_else(|| Err(format!("{name}: can't open web pages"))); + (opened?, false) + } else if matches!(ext, "doc" | "dot" | "docx" | "docm" | "dotx" | "dotm" | "rtf") && wordcraft_formats::mht::is_mht(bytes) { + let opened = crate::io_ext::open("mht", bytes, page).unwrap_or_else(|| Err(format!("{name}: can't open web archives"))); + (opened?, false) + } else if wordcraft_formats::txt::is_text(bytes) { + (wordcraft_formats::txt::import_on(bytes, page), false) + } else if ext.is_empty() { + return Err(format!("{name}: not a document or text file WordCraft can open")); + } else { + return Err(format!("{name}: unsupported format `.{ext}` (not a document or text file)")); + }; + opened(&mut doc); + Ok((doc, encrypted)) +} + +/// Serialise a document; `name` gives the format by extension. +pub fn save_bytes(name: &str, doc: &Document) -> Result, String> { + save_bytes_opts(name, doc, &ExportOptions::default()) +} + +/// [`save_bytes`] with export options (Print Markup). +pub fn save_bytes_opts(name: &str, doc: &Document, opts: &ExportOptions) -> Result, String> { + let ext = ext_of(name); + match ext.as_str() { + // List paragraphs get their label and a tab, as in Word's plain-text save. + "txt" | "text" => { + let bytes = wordcraft_formats::txt::export(doc); + Ok(if opts.markup && opts.comments { wordcraft_formats::comments::append("txt", doc, bytes) } else { bytes }) + } + "json" => serde_json::to_vec_pretty(doc).map_err(|e| e.to_string()), + other => match crate::io_ext::save(other, doc, opts) { + Some(r) => r, + None => Err(format!("{name}: can't save as `.{other}`")), + }, + } +} + +/// [`save_bytes`], encrypted with `password` when there is one and the format is a Word package +/// (other formats are copies and are written as they are). +pub fn save_bytes_with(name: &str, doc: &Document, password: Option<&str>, opts: &ExportOptions) -> Result, String> { + let bytes = save_bytes_opts(name, doc, opts)?; + match password { + Some(pw) if matches!(ext_of(name).as_str(), "odt" | "ott") => wordcraft_formats::odt::encrypt(&bytes, pw), + Some(pw) if can_encrypt(name) => wordcraft_docx::encrypt(&bytes, pw).map_err(|e| e.to_string()), + _ => Ok(bytes), + } +} + +pub fn open_path(path: &std::path::Path) -> Result { + open_path_with(path, None).map(|(doc, _)| doc) +} + +/// [`open_path`] for a file that may be password-protected (see [`open_bytes_with`]). +#[cfg(not(target_arch = "wasm32"))] +pub fn open_path_with(path: &std::path::Path, password: Option<&str>) -> Result<(Document, bool), String> { + open_path_in(path, password, &wordcraft_geom::Region::default()) +} + +/// [`open_path_with`] in `region` (see [`open_bytes_in`]). +#[cfg(not(target_arch = "wasm32"))] +pub fn open_path_in(path: &std::path::Path, password: Option<&str>, region: &wordcraft_geom::Region) -> Result<(Document, bool), String> { + let bytes = read_document(path)?; + let name = path.to_string_lossy(); + if matches!(ext_of(&name).as_str(), "html" | "htm" | "xhtml") { + // Pictures referenced by a relative path load from beside the HTML file. + let images = LocalImages::new(path.parent().unwrap_or(std::path::Path::new(""))); + let html = wordcraft_formats::html::decode(&bytes); + let mut doc = wordcraft_formats::html::import_on(&html, &|src| images.load(src), ®ion_page(region)); + fit_pictures(&mut doc); + opened(&mut doc); + return Ok((doc, false)); + } + open_bytes_in(&name, &bytes, password, region) +} + +/// Largest document file opened by path. +#[cfg(not(target_arch = "wasm32"))] +const MAX_DOCUMENT: u64 = 2 << 30; + +/// The bytes of the document at `path`, read as `file.open` reads a path: inside the automation +/// read root, and only a regular file of at most 2 GB. A named pipe would block the open and a +/// device such as `/dev/zero` would never end ([`read_regular_file`]). Public for the app, which +/// opens a file whose name isn't valid Unicode through `file.open`'s `data` (#1440). +#[cfg(not(target_arch = "wasm32"))] +pub fn read_document(path: &std::path::Path) -> Result, String> { + crate::file_access::check_read(&path.to_string_lossy())?; + let meta = std::fs::metadata(path).map_err(|e| format!("{}: {e}", path.display()))?; + if !meta.is_file() { + return Err(format!("{}: not a file", path.display())); + } + if meta.len() > MAX_DOCUMENT { + return Err(format!("{}: file is larger than 2 GB", path.display())); + } + // Checked again on the opened file, which may not be the one just looked at. + match read_regular_file(path, MAX_DOCUMENT) { + Some(bytes) if bytes.len() as u64 <= MAX_DOCUMENT => Ok(bytes), + Some(_) => Err(format!("{}: file is larger than 2 GB", path.display())), + None => Err(format!("{}: could not be read as a file", path.display())), + } +} + +/// The pictures an HTML file names by a path relative to its folder. +/// +/// Only regular files inside the folder (after resolving `..` and symlinks) are read. The folder is +/// the HTML file's own, which `open_path_with` has already checked against the automation read root. URLs +/// (`http:`, `file:`…) and absolute paths are not followed. Each file is read at most once and +/// at most `max_file` bytes of it; every reference, cached or not, counts against a budget for +/// the whole document, so one large picture can't be multiplied by referencing it many times. +#[cfg(not(target_arch = "wasm32"))] +pub(crate) struct LocalImages { + /// The folder, canonical; `None` when it can't be resolved (nothing loads then). + dir: Option, + max_file: u64, + /// Bytes the document may still load. + left: std::cell::Cell, + cache: std::cell::RefCell>)>>, +} + +#[cfg(not(target_arch = "wasm32"))] +impl LocalImages { + /// Largest picture file read. + const MAX_FILE: u64 = 256 << 20; + /// Picture bytes one document may load, counting every reference. + const BUDGET: u64 = 1 << 30; + /// Files whose bytes are kept for further references. + const CACHE: usize = 64; + + pub(crate) fn new(dir: &std::path::Path) -> LocalImages { + LocalImages::with_limits(dir, LocalImages::MAX_FILE, LocalImages::BUDGET) + } + + pub(crate) fn with_limits(dir: &std::path::Path, max_file: u64, budget: u64) -> LocalImages { + let dir = if dir.as_os_str().is_empty() { std::path::Path::new(".") } else { dir }; + LocalImages { dir: std::fs::canonicalize(dir).ok(), max_file, left: std::cell::Cell::new(budget), cache: Default::default() } + } + + /// Bytes of the file `src` names, or `None` when it isn't a regular file inside the folder, + /// is too large, or the budget is spent. + pub(crate) fn load(&self, src: &str) -> Option>> { + let path = self.resolve(src)?; + let cached = self.cache.borrow().iter().find(|(p, _)| *p == path).map(|(_, d)| d.clone()); + if let Some(data) = cached { + self.charge(data.len())?; + return Some(data); + } + let limit = self.max_file.min(self.left.get()); + let bytes = read_regular_file(&path, limit)?; + // What was read is spent even when it's over the limit. + self.charge(bytes.len())?; + if bytes.len() as u64 > limit { + return None; + } + let data = std::sync::Arc::new(bytes); + let mut cache = self.cache.borrow_mut(); + if cache.len() < LocalImages::CACHE { + cache.push((path, data.clone())); + } + Some(data) + } + + /// Take `n` bytes from the budget; `None` (and the budget emptied) when there aren't enough. + fn charge(&self, n: usize) -> Option<()> { + let left = self.left.get().checked_sub(n as u64); + self.left.set(left.unwrap_or(0)); + left.map(|_| ()) + } + + /// The canonical path `src` names, if it's inside the folder and may be read. + fn resolve(&self, src: &str) -> Option { + let src = src.split(['#', '?']).next().unwrap_or(""); + if src.is_empty() || src.starts_with("//") || src.split_once(':').is_some_and(|(scheme, _)| !scheme.contains('/')) { + return None; + } + let rel = String::from_utf8(wordcraft_formats::model::percent_decode(src)).ok()?; + let rel = std::path::Path::new(&rel); + if rel.components().any(|c| matches!(c, std::path::Component::Prefix(_) | std::path::Component::RootDir)) { + return None; + } + let dir = self.dir.as_ref()?; + // Canonical paths have `..` and symlinks resolved, so this keeps both inside the folder. + let path = std::fs::canonicalize(dir.join(rel)).ok()?; + // The folder is the one the HTML path names, which may be a link from outside the + // automation read root; the picture itself must be inside it (#3736). + crate::file_access::check_read(&path.to_string_lossy()).ok()?; + path.starts_with(dir).then_some(path) + } +} + +/// Up to `limit + 1` bytes of the regular file at `path` (more than `limit` means too large), or +/// `None` for anything else. A named pipe blocks when opened and a device reports a length of 0 +/// and may never end, so the path is checked before opening, the opened handle is checked again, +/// and the read is bounded whatever length the file reports. Public for the app (a copied picture +/// file, Paste Special). +#[cfg(not(target_arch = "wasm32"))] +pub fn read_regular_file(path: &std::path::Path, limit: u64) -> Option> { + let (file, meta) = open_regular_file(path).ok()?; + read_bounded(file, &meta, limit).ok() +} + +#[cfg(target_arch = "wasm32")] +pub fn read_regular_file(_path: &std::path::Path, _limit: u64) -> Option> { + None +} + +/// The bytes of the regular file at `path` when it holds at most `limit` of them; `None` for +/// anything else. A file whose opened handle reports more than `limit` bytes is refused before +/// anything is read; the read stays bounded for a file that grows after the check. Public for the +/// app (a copied picture file, Paste Special). +#[cfg(not(target_arch = "wasm32"))] +pub fn read_regular_file_within(path: &std::path::Path, limit: u64) -> Option> { + let (file, meta) = open_regular_file(path).ok()?; + if meta.len() > limit { + return None; + } + read_bounded(file, &meta, limit).ok().filter(|b| b.len() as u64 <= limit) +} + +#[cfg(target_arch = "wasm32")] +pub fn read_regular_file_within(_path: &std::path::Path, _limit: u64) -> Option> { + None +} + +/// The regular file at `path`, opened once, with the opened handle's metadata. The path is +/// checked before opening (a named pipe blocks in `open` until a writer appears) and the handle +/// after (the path may have been swapped in between); anything but a regular file is "not a file". +#[cfg(not(target_arch = "wasm32"))] +fn open_regular_file(path: &std::path::Path) -> Result<(std::fs::File, std::fs::Metadata), String> { + let meta = std::fs::metadata(path).map_err(|e| format!("{}: {e}", path.display()))?; + if !meta.is_file() { + return Err(format!("{}: not a file", path.display())); + } + let mut open = std::fs::OpenOptions::new(); + open.read(true); + // A path swapped for a named pipe since the check above would block `open`; non-blocking + // opens it at once (no effect on a regular file) and the handle check below refuses it. + #[cfg(unix)] + std::os::unix::fs::OpenOptionsExt::custom_flags(&mut open, libc::O_NONBLOCK); + let file = open.open(path).map_err(|e| format!("{}: {e}", path.display()))?; + let meta = file.metadata().map_err(|e| format!("{}: {e}", path.display()))?; + if !meta.is_file() { + return Err(format!("{}: not a file", path.display())); + } + Ok((file, meta)) +} + +/// Up to `limit + 1` bytes of `file` (more than `limit` means too large), however long it says it is. +#[cfg(not(target_arch = "wasm32"))] +fn read_bounded(file: std::fs::File, meta: &std::fs::Metadata, limit: u64) -> std::io::Result> { + use std::io::Read; + let mut out = Vec::with_capacity(usize::try_from(meta.len().min(limit)).unwrap_or(0)); + file.take(limit.saturating_add(1)).read_to_end(&mut out)?; + Ok(out) +} + +/// Largest file [`read_file`] reads whole — the same 2 GB ceiling [`open_path_with`] gives a +/// document. +pub const MAX_READ_FILE: u64 = 2 << 30; + +/// The bytes of the file at `path`, for commands that take a path (pictures, mail-merge data). +/// Every such read goes through here so the automation read root applies ([`crate::file_access`]). +/// Only a regular file is read, and at most [`MAX_READ_FILE`] bytes of it: a named pipe blocks +/// when opened and a device like `/dev/zero` never reaches end of file. +#[cfg(not(target_arch = "wasm32"))] +pub fn read_file(path: &str) -> Result, String> { + read_file_capped(path, MAX_READ_FILE, "file") +} + +#[cfg(target_arch = "wasm32")] +pub fn read_file(path: &str) -> Result, String> { + Err(format!("{path}: files are opened through the browser on the web")) +} + +/// [`read_file`] for a file that must be a regular file of at most `max` bytes, checked before +/// anything is read (a font named by the user, which could be any size). The file is opened once +/// and the read stops past `max`, so a file swapped or grown after the check is refused too (#3666). +#[cfg(not(target_arch = "wasm32"))] +pub fn read_file_capped(path: &str, max: u64, what: &str) -> Result, String> { + crate::file_access::check_read(path)?; + let too_large = || format!("{path}: the {what} is larger than {} MB", max >> 20); + let (file, meta) = open_regular_file(std::path::Path::new(path))?; + if meta.len() > max { + return Err(too_large()); + } + let bytes = read_bounded(file, &meta, max).map_err(|e| format!("{path}: {e}"))?; + if bytes.len() as u64 > max { + return Err(too_large()); + } + Ok(bytes) +} + +/// The first `max` bytes of the file at `path` (fewer when it is shorter), inside the automation +/// read root when there is one: enough to tell a file's format by its start. Only a regular file +/// is opened: a named pipe would block the open until a writer appeared (#3666). +#[cfg(not(target_arch = "wasm32"))] +pub fn read_file_head(path: &str, max: u64) -> Result, String> { + use std::io::Read; + crate::file_access::check_read(path)?; + let (file, _) = open_regular_file(std::path::Path::new(path))?; + let mut head = Vec::new(); + file.take(max).read_to_end(&mut head).map_err(|e| format!("{path}: {e}"))?; + Ok(head) +} + +/// An entry of [`list_dir`]: a file or folder (links followed). +#[cfg(not(target_arch = "wasm32"))] +#[derive(Clone, Debug)] +pub struct DirEntry { + pub name: String, + pub path: std::path::PathBuf, + pub dir: bool, + /// The file's size in bytes (0 for a folder). + pub len: u64, + /// When it last changed, in seconds since the Unix epoch, if the system says. + pub modified: Option, +} + +/// The files and folders in the folder at `path`, inside the automation read root (entries +/// leading outside it, and ones that can't be read, are left out); at most `max` entries are +/// looked at, and `true` says the listing was cut short there. +#[cfg(not(target_arch = "wasm32"))] +pub fn list_dir(path: &std::path::Path, max: usize) -> Result<(Vec, bool), String> { + crate::file_access::check_read(&path.to_string_lossy())?; + let read = std::fs::read_dir(path).map_err(|e| format!("{}: {e}", path.display()))?; + let mut out = Vec::new(); + for (seen, entry) in read.enumerate() { + if seen >= max { + return Ok((out, true)); + } + let Ok(entry) = entry else { continue }; + let path = entry.path(); + if crate::file_access::check_read(&path.to_string_lossy()).is_err() { + continue; + } + let Ok(meta) = std::fs::metadata(&path) else { continue }; + if !meta.is_dir() && !meta.is_file() { + continue; + } + let modified = meta.modified().ok().and_then(|m| m.duration_since(std::time::UNIX_EPOCH).ok()).map(|d| d.as_secs()); + let name = entry.file_name().to_string_lossy().into_owned(); + out.push(DirEntry { name, path, dir: meta.is_dir(), len: if meta.is_file() { meta.len() } else { 0 }, modified }); + } + Ok((out, false)) +} + +/// The title in the core properties of the Word package at `path` (a file of at most `max_file` +/// bytes; at most `max_part` bytes of the part are read), inside the automation read root. +#[cfg(not(target_arch = "wasm32"))] +pub fn package_title(path: &std::path::Path, max_file: u64, max_part: u64) -> Option { + crate::file_access::check_read(&path.to_string_lossy()).ok()?; + let (file, meta) = open_regular_file(path).ok()?; + if meta.len() > max_file { + return None; + } + wordcraft_docx::package_title(std::io::BufReader::new(file), max_part) +} + +/// Rename without replacing another file, respecting both automation access roots. +#[cfg(not(target_arch = "wasm32"))] +pub fn rename_file(source: &std::path::Path, destination: &std::path::Path) -> Result<(), String> { + crate::file_access::check_read(&source.to_string_lossy())?; + crate::file_access::check_write(&source.to_string_lossy())?; + crate::file_access::check_write(&destination.to_string_lossy())?; + // Creating a sibling hard link refuses collisions atomically and keeps the original bytes. + if let Err(e) = std::fs::hard_link(source, destination) { + if e.kind() == std::io::ErrorKind::AlreadyExists && only_case_differs(source, destination) { + return std::fs::rename(source, destination).map_err(|e| format!("Could not rename document: {e}")); + } + return Err(format!("Could not rename document: {e}")); + } + if let Err(e) = std::fs::remove_file(source) { + let _ = std::fs::remove_file(destination); + return Err(format!("Could not rename document: {e}")); + } + Ok(()) +} + +/// Whether `destination` names `source` in the same directory spelled with different case, and +/// the directory holds no entry spelled exactly as `destination`. +#[cfg(not(target_arch = "wasm32"))] +fn only_case_differs(source: &std::path::Path, destination: &std::path::Path) -> bool { + let (Some(dir), Some(from), Some(to)) = (source.parent(), source.file_name(), destination.file_name()) else { + return false; + }; + if destination.parent() != Some(dir) || from == to || from.to_string_lossy().to_lowercase() != to.to_string_lossy().to_lowercase() { + return false; + } + let dir = if dir.as_os_str().is_empty() { std::path::Path::new(".") } else { dir }; + std::fs::read_dir(dir).is_ok_and(|entries| entries.filter_map(Result::ok).all(|entry| entry.file_name() != to)) +} + +/// Write `bytes` to the file at `path`, inside the automation write root when there is one. +#[cfg(not(target_arch = "wasm32"))] +pub fn write_file(path: &str, bytes: &[u8]) -> Result<(), String> { + crate::file_access::check_write(path)?; + // Like a document save: the old file is replaced only once the new bytes are fully written. + safe_save::write(std::path::Path::new(path), bytes).map_err(|e| format!("{path}: {e}")) +} + +#[cfg(target_arch = "wasm32")] +pub fn write_file(path: &str, _bytes: &[u8]) -> Result<(), String> { + Err(format!("{path}: files are saved through the browser on the web")) +} + +#[cfg(target_arch = "wasm32")] +pub fn open_path_with(path: &std::path::Path, _password: Option<&str>) -> Result<(Document, bool), String> { + Err(format!("{}: files are opened through the browser on the web", path.display())) +} + +#[cfg(target_arch = "wasm32")] +pub fn open_path_in(path: &std::path::Path, password: Option<&str>, _region: &wordcraft_geom::Region) -> Result<(Document, bool), String> { + open_path_with(path, password) +} + +pub fn save_path(path: &std::path::Path, doc: &Document) -> Result<(), String> { + save_path_with(path, doc, None, &ExportOptions::default()) +} + +/// [`save_path`], encrypted with `password` as [`save_bytes_with`] does. +#[cfg(not(target_arch = "wasm32"))] +pub fn save_path_with(path: &std::path::Path, doc: &Document, password: Option<&str>, opts: &ExportOptions) -> Result<(), String> { + crate::file_access::check_write(&path.to_string_lossy())?; + let bytes = save_bytes_with(&path.to_string_lossy(), doc, password, opts)?; + safe_save::write(path, &bytes).map_err(|e| format!("{}: {e}", path.display())) +} + +/// Saves a document to `path` with optional password and export options on web targets. +#[cfg(target_arch = "wasm32")] +pub fn save_path_with(path: &std::path::Path, _doc: &Document, _password: Option<&str>, _opts: &ExportOptions) -> Result<(), String> { + Err(format!("{}: files are saved through the browser on the web", path.display())) +} + +/// Write `text` to `path` as it is (a Markdown file's own text), as safely as [`save_path_with`]. +#[cfg(not(target_arch = "wasm32"))] +pub fn save_text_path(path: &std::path::Path, text: &str) -> Result<(), String> { + crate::file_access::check_write(&path.to_string_lossy())?; + safe_save::write(path, text.as_bytes()).map_err(|e| format!("{}: {e}", path.display())) +} + +/// Write `text` to `path` as it is (a Markdown file's own text), as safely as [`save_path_with`]. +#[cfg(target_arch = "wasm32")] +pub fn save_text_path(path: &std::path::Path, _text: &str) -> Result<(), String> { + Err(format!("{}: files are saved through the browser on the web", path.display())) +} + +/// Decode text bytes: UTF-8 (with or without BOM), UTF-16 LE/BE (BOM or sniffed), else the +/// legacy encoding they look like — GBK, Big5, Shift_JIS, Windows-1252… (see +/// [`wordcraft_formats::txt::decode`]). +pub fn decode_text(b: &[u8]) -> String { + wordcraft_formats::txt::decode(b) +} diff --git a/crates/engine/src/io/safe_save.rs b/crates/engine/src/io/safe_save.rs index 7f79cf0e21..e5c3b161b3 100644 --- a/crates/engine/src/io/safe_save.rs +++ b/crates/engine/src/io/safe_save.rs @@ -1,521 +1,521 @@ -//! Sibling temporary files owned by one save. The destination changes only after preparation -//! succeeds; concurrent saves have separate files and the last successful replacement wins. -//! -//! When the folder refuses the temporary file (macOS privacy protection, #2138, or a read-only -//! folder), an existing destination is never written in place: it is left exactly as it was and -//! the error says to use Save As to another folder or to give WordCraft access to this one. Only -//! a destination that doesn't exist yet, so has no previous version to lose, is created directly; -//! a failed direct write removes it again. -use std::fs::{self, File, OpenOptions}; -use std::io::{self, Write}; -use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicU64, Ordering}; - -/// Writes `bytes` safely to `path`, replacing an existing destination atomically via a -/// temporary file. If the destination's folder refuses the temporary file, a new file -/// is created directly and removed on failure; an existing file is left untouched. -pub(super) fn write(path: &Path, bytes: &[u8]) -> io::Result<()> { - let permissions = match fs::metadata(path) { - Ok(meta) if meta.is_file() => Some(meta.permissions()), - Ok(_) => None, - Err(e) if e.kind() == io::ErrorKind::NotFound => None, - Err(e) => return Err(e), - }; - // A new document gets the usual mode (0666 less the umask); a replaced one keeps its own, - // and its temporary file stays private until that mode is applied. - let private = permissions.is_some(); - let written = replace_with(path, private, |file| { - file.write_all(bytes)?; - if let Some(permissions) = permissions { - keep_permissions(file.set_permissions(permissions))?; - } - // Synchronize the prepared file before replacing the destination. Directory durability - // and network-filesystem/power-loss guarantees are outside this operation's contract. - sync_file(file) - }); - written.map_err(explain) -} - -/// Flushes `file` to stable storage (`sync_all`). On Apple platforms that is a full flush -/// (`F_FULLFSYNC`); where the file system can't do one (SMB shares and virtual file systems -/// refuse it with `ENOTSUP` #6155, `EINVAL`, or `ENOTTY`), this falls back to a plain `fsync`, -/// as Apple's own `removefile` and SQLite do. Plain `fsync` flushes to the drive controller but -/// does not guarantee disk cache durability; any other error is returned as it is. -fn sync_file(file: &File) -> io::Result<()> { - #[cfg(target_vendor = "apple")] - let plain = || rustix::fs::fsync(file).map_err(io::Error::from); - #[cfg(not(target_vendor = "apple"))] - let plain = || Ok(()); - or_plain_fsync(file.sync_all(), plain) -} - -/// Returns `full`, or calls `fsync()` when `full` failed only because the file system can't do a full flush. -fn or_plain_fsync(full: io::Result<()>, fsync: impl FnOnce() -> io::Result<()>) -> io::Result<()> { - match full { - Err(e) if full_flush_unsupported(&e) => fsync(), - other => other, - } -} - -/// Whether `e` is a file system refusing a full flush (`F_FULLFSYNC`): `ENOTSUP` (45, SMB), -/// `EINVAL` (22), or `ENOTTY` (25) on Apple platforms. Never elsewhere: there `sync_all` is a plain `fsync`. -fn full_flush_unsupported(e: &io::Error) -> bool { - const EINVAL: i32 = 22; - const ENOTTY: i32 = 25; - const ENOTSUP: i32 = 45; - cfg!(target_vendor = "apple") && matches!(e.raw_os_error(), Some(ENOTSUP | EINVAL | ENOTTY)) -} - -/// A folder that keeps no Unix modes refuses to set one with "Operation not supported": a NAS -/// share mounted by GVFS (`smb://` in the Linux file manager) refuses every mode change (#3003). -/// There is no mode to keep there, so the save goes on; any other failure still stops it. -fn keep_permissions(set: io::Result<()>) -> io::Result<()> { - match set { - Err(e) if not_supported(&e) => Ok(()), - set => set, - } -} - -/// ENOTSUP and EOPNOTSUPP, which std doesn't count as `Unsupported` (only ENOSYS), plus that kind. -fn not_supported(e: &io::Error) -> bool { - const CODES: &[i32] = if cfg!(any(target_os = "linux", target_os = "android")) { - &[95] - } else if cfg!(any(target_os = "macos", target_os = "ios")) { - &[45, 102] - } else if cfg!(target_os = "openbsd") { - &[45, 91] - } else if cfg!(target_os = "netbsd") { - &[45, 86] - } else if cfg!(any(target_os = "freebsd", target_os = "dragonfly")) { - &[45] - } else { - &[] - }; - e.kind() == io::ErrorKind::Unsupported || e.raw_os_error().is_some_and(|code| CODES.contains(&code)) -} - -fn replace_with(path: &Path, private: bool, prepare: impl FnOnce(&mut File) -> io::Result<()>) -> io::Result<()> { - let mut temp = match Temporary::create(path, private) { - Ok(temp) => temp, - // macOS only lets an app write the file picked in the Save panel inside a protected - // folder (Desktop, Documents, Downloads, iCloud Drive), not a sibling temporary file - // (#2138); a read-only folder refuses it too. - Err(e) if e.kind() == io::ErrorKind::PermissionDenied => return create_directly(path, e, prepare), - Err(e) => return Err(e), - }; - prepare(temp.file()?)?; - temp.commit(path) -} - -/// No temporary file may be created beside `path` (`refused` says why). A file that doesn't exist -/// yet is created directly (there is no previous version to lose) and removed again if the save -/// fails. Anything already at `path` is left untouched: overwriting it in place could destroy the -/// only copy, so the error says how to save safely instead. -fn create_directly(path: &Path, refused: io::Error, prepare: impl FnOnce(&mut File) -> io::Result<()>) -> io::Result<()> { - // `create_new` never opens an existing file or follows a link, so it can't touch one. - let mut file = match OpenOptions::new().write(true).create_new(true).open(path) { - Ok(file) => file, - Err(e) if e.kind() == io::ErrorKind::AlreadyExists => { - return Err(guided( - refused, - "This folder doesn't let WordCraft create the temporary file a safe save needs, so the existing file was left unchanged.", - )); - } - Err(e) if e.kind() == io::ErrorKind::PermissionDenied => return Err(guided(e, "WordCraft can't create files in this folder.")), - Err(e) => return Err(e), - }; - let Err(failed) = prepare(&mut file) else { - return Ok(()); - }; - drop(file); - match fs::remove_file(path) { - Ok(()) => Err(failed), - Err(e) => Err(io::Error::new(failed.kind(), format!("{failed}; removing the incomplete new file also failed ({e})"))), - } -} - -/// A save refused for want of folder access, already worded with what to do about it. -#[derive(Debug)] -struct Guided(String); - -impl std::fmt::Display for Guided { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(&self.0) - } -} - -impl std::error::Error for Guided {} - -/// `e`, then `why`, then how to save anyway: Save As to another folder, or access to this one. -fn guided(e: io::Error, why: &str) -> io::Error { - let access = if cfg!(target_os = "macos") { - "allow WordCraft to use this folder in System Settings › Privacy & Security › Files and Folders" - } else { - "get write access to this folder" - }; - io::Error::new(e.kind(), Guided(format!("{e}. {why} Use Save As to save the document in another folder, or {access}, then save again."))) -} - -/// A permission error on macOS usually means its privacy protection keeps WordCraft out of the -/// folder; say where to allow it (errors from [`guided`] already do). -fn explain(e: io::Error) -> io::Error { - let worded = e.get_ref().is_some_and(|inner| inner.is::()); - if cfg!(target_os = "macos") && e.kind() == io::ErrorKind::PermissionDenied && !worded { - return io::Error::new( - e.kind(), - format!( - "{e}. macOS may be blocking WordCraft from this folder: allow it in System Settings › Privacy & Security › Files and Folders, or save somewhere else" - ), - ); - } - e -} - -struct Temporary { - path: PathBuf, - file: Option, - committed: bool, -} - -impl Temporary { - /// `private`: create the file readable by its owner only (0600) rather than with the default - /// mode (0666 less the umask). - fn create(destination: &Path, private: bool) -> io::Result { - static NEXT: AtomicU64 = AtomicU64::new(0); - if destination.file_name().is_none() { - return Err(io::Error::new(io::ErrorKind::InvalidInput, "save destination needs a file name")); - } - // A short name also works beside a destination already at the filename-length limit. - for _ in 0..128 { - let id = NEXT.fetch_add(1, Ordering::Relaxed); - let path = destination.with_file_name(format!(".wordcraft-save-{}-{id}.tmp", std::process::id())); - let mut options = OpenOptions::new(); - options.write(true).create_new(true); - #[cfg(unix)] - if private { - use std::os::unix::fs::OpenOptionsExt; - options.mode(0o600); - } - #[cfg(not(unix))] - let _ = private; - match options.open(&path) { - Ok(file) => return Ok(Self { path, file: Some(file), committed: false }), - Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue, - Err(e) => return Err(e), - } - } - Err(io::Error::new(io::ErrorKind::AlreadyExists, "could not create a save temporary file")) - } - - fn file(&mut self) -> io::Result<&mut File> { - self.file.as_mut().ok_or_else(|| io::Error::other("save temporary file is closed")) - } - - fn commit(mut self, destination: &Path) -> io::Result<()> { - // Close before replacement, including on Windows. - drop(self.file.take()); - fs::rename(&self.path, destination)?; - self.committed = true; - Ok(()) - } -} - -impl Drop for Temporary { - fn drop(&mut self) { - drop(self.file.take()); - if !self.committed { - // If filesystem permissions change, cleanup can fail too. Never remove a neighbour - // or the destination to compensate for a failed save. - let _ = fs::remove_file(&self.path); - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - struct Scratch(PathBuf); - impl Scratch { - fn new() -> Self { - static NEXT: AtomicU64 = AtomicU64::new(0); - let path = - std::env::temp_dir().join(format!("wordcraft-save-owner-test-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed))); - fs::create_dir(&path).unwrap(); - Self(path) - } - fn target(&self) -> PathBuf { - self.0.join("original.docx") - } - } - impl Drop for Scratch { - fn drop(&mut self) { - let _ = fs::remove_dir_all(&self.0); - } - } - - #[test] - fn partial_and_complete_preparation_failures_leave_original_and_no_owned_temp() { - for partial in [true, false] { - let d = Scratch::new(); - let path = d.target(); - fs::write(&path, b"Original bytes.").unwrap(); - let err = replace_with(&path, true, |file| { - file.write_all(if partial { b"Partial" } else { b"Complete replacement bytes." })?; - Err(io::Error::other("injected preparation failure")) - }) - .unwrap_err(); - assert!(err.to_string().contains("injected")); - assert_eq!(fs::read(&path).unwrap(), b"Original bytes."); - assert_eq!(fs::read_dir(&d.0).unwrap().count(), 1); - } - } - - #[test] - fn overlapping_prepared_saves_keep_separate_bytes_and_commit_complete_files() { - let d = Scratch::new(); - let path = d.target(); - fs::write(&path, b"Original bytes.").unwrap(); - let mut first = Temporary::create(&path, true).unwrap(); - let mut second = Temporary::create(&path, true).unwrap(); - assert_ne!(first.path, second.path); - first.file().unwrap().write_all(b"First complete save.").unwrap(); - second.file().unwrap().write_all(b"Second complete save.").unwrap(); - first.file().unwrap().sync_all().unwrap(); - second.file().unwrap().sync_all().unwrap(); - assert_eq!(fs::read(&path).unwrap(), b"Original bytes."); - first.commit(&path).unwrap(); - assert_eq!(fs::read(&path).unwrap(), b"First complete save."); - second.commit(&path).unwrap(); - assert_eq!(fs::read(&path).unwrap(), b"Second complete save."); - assert_eq!(fs::read_dir(&d.0).unwrap().count(), 1); - } - - /// Exports (`write_file`) replace the destination instead of truncating it in place: another - /// hard link to the old file keeps the old bytes, and no temporary file is left behind. - #[test] - fn write_file_replaces_the_destination_instead_of_truncating_it() { - let d = Scratch::new(); - let path = d.target(); - let alias = d.0.join("alias.docx"); - fs::write(&path, b"Original bytes.").unwrap(); - fs::hard_link(&path, &alias).unwrap(); - crate::io::write_file(&path.to_string_lossy(), b"Exported bytes.").unwrap(); - assert_eq!(fs::read(&path).unwrap(), b"Exported bytes."); - assert_eq!(fs::read(&alias).unwrap(), b"Original bytes."); - assert_eq!(fs::read_dir(&d.0).unwrap().count(), 2); - } - - /// #3003: a folder that refuses mode changes (a GVFS-mounted NAS share) still takes the - /// save; a refused mode change for any other reason still stops it and keeps the original. - #[test] - fn a_folder_without_unix_modes_still_takes_the_save() { - let d = Scratch::new(); - let path = d.target(); - fs::write(&path, b"Original bytes.").unwrap(); - replace_with(&path, true, |file| { - file.write_all(b"Saved on the share.")?; - keep_permissions(Err(io::Error::from(io::ErrorKind::Unsupported)))?; - file.sync_all() - }) - .unwrap(); - assert_eq!(fs::read(&path).unwrap(), b"Saved on the share."); - assert_eq!(fs::read_dir(&d.0).unwrap().count(), 1); - - let err = replace_with(&path, true, |file| { - file.write_all(b"Refused.")?; - keep_permissions(Err(io::Error::from(io::ErrorKind::PermissionDenied))) - }) - .unwrap_err(); - assert_eq!(err.kind(), io::ErrorKind::PermissionDenied); - assert_eq!(fs::read(&path).unwrap(), b"Saved on the share."); - assert_eq!(fs::read_dir(&d.0).unwrap().count(), 1); - } - - /// GVFS reports a refused mode change as ENOTSUP, which std leaves uncategorized; other OS - /// errors still stop the save. - #[cfg(any(target_os = "linux", target_os = "macos"))] - #[test] - fn enotsup_from_the_os_is_an_unsupported_mode_change() { - let enotsup = if cfg!(target_os = "linux") { 95 } else { 45 }; - assert!(keep_permissions(Err(io::Error::from_raw_os_error(enotsup))).is_ok()); - let eacces = 13; - assert!(keep_permissions(Err(io::Error::from_raw_os_error(eacces))).is_err()); - } - - /// Makes `dir` read-only while `f` runs. `None` when running as root, where the folder isn't - /// read-only to us. - #[cfg(unix)] - fn in_read_only(dir: &Path, f: impl FnOnce() -> T) -> Option { - use std::os::unix::fs::PermissionsExt; - fs::set_permissions(dir, fs::Permissions::from_mode(0o555)).unwrap(); - let probe = fs::write(dir.join("probe"), b""); - let out = f(); - fs::set_permissions(dir, fs::Permissions::from_mode(0o755)).unwrap(); - probe.is_err().then_some(out) - } - - /// #2138 review: where no sibling temporary file may be created (macOS privacy protection, - /// or a read-only folder), an existing document is left byte for byte as it was, and the - /// error says to use Save As or get access to the folder. - #[cfg(unix)] - #[test] - fn an_existing_file_in_a_folder_refusing_the_temporary_file_is_left_untouched() { - let d = Scratch::new(); - let path = d.target(); - fs::write(&path, b"Original bytes.").unwrap(); - let Some(saved) = in_read_only(&d.0, || write(&path, b"Saved bytes.")) else { return }; - let err = saved.unwrap_err(); - assert_eq!(err.kind(), io::ErrorKind::PermissionDenied); - let message = err.to_string(); - assert!(message.contains("left unchanged") && message.contains("Save As") && message.contains("this folder"), "{message}"); - assert_eq!(message.matches("Privacy & Security").count(), usize::from(cfg!(target_os = "macos")), "{message}"); - assert_eq!(fs::read(&path).unwrap(), b"Original bytes."); - assert_eq!(fs::read_dir(&d.0).unwrap().count(), 1); - } - - /// FredApps' #2138 regression: a save that would fail partway through still leaves the - /// previous document byte-identical when the folder refuses the temporary file. - #[cfg(unix)] - #[test] - fn a_failed_save_in_a_folder_refusing_the_temporary_file_keeps_the_original() { - let d = Scratch::new(); - let path = d.target(); - fs::write(&path, b"Original bytes.").unwrap(); - let Some(result) = in_read_only(&d.0, || { - replace_with(&path, true, |file| { - file.write_all(b"Partial")?; - Err(io::Error::other("injected partial write failure")) - }) - }) else { - return; - }; - assert!(result.is_err()); - assert_eq!(fs::read(&path).unwrap(), b"Original bytes.", "failed save must preserve the previous document"); - assert_eq!(fs::read_dir(&d.0).unwrap().count(), 1); - } - - /// A folder that refuses new files altogether refuses a new document too, with the same - /// guidance, and leaves nothing behind. - #[cfg(unix)] - #[test] - fn a_new_file_in_a_read_only_folder_is_refused_with_guidance() { - let d = Scratch::new(); - let path = d.target(); - let Some(saved) = in_read_only(&d.0, || write(&path, b"New document.")) else { return }; - let message = saved.unwrap_err().to_string(); - assert!(message.contains("Save As"), "{message}"); - assert!(!path.exists()); - } - - /// When only the temporary file is refused (macOS lets the app write the file picked in the - /// Save panel), a file that doesn't exist yet is created directly; nothing is replaced. - #[test] - fn a_new_file_is_created_directly_when_only_the_temporary_file_is_refused() { - let d = Scratch::new(); - let path = d.target(); - let refused = io::Error::from(io::ErrorKind::PermissionDenied); - create_directly(&path, refused, |file| file.write_all(b"New document.")).unwrap(); - assert_eq!(fs::read(&path).unwrap(), b"New document."); - assert_eq!(fs::read_dir(&d.0).unwrap().count(), 1); - } - - #[test] - fn a_failed_direct_save_of_a_new_file_leaves_nothing() { - let d = Scratch::new(); - let path = d.target(); - let refused = io::Error::from(io::ErrorKind::PermissionDenied); - let err = create_directly(&path, refused, |file| { - file.write_all(b"Partial")?; - Err(io::Error::other("injected")) - }) - .unwrap_err(); - assert!(err.to_string().contains("injected")); - assert!(!path.exists()); - } - - /// The direct path never opens an existing file and never runs the save: the original is - /// untouched and the error carries the Save As guidance. - #[test] - fn an_existing_file_is_never_written_directly() { - let d = Scratch::new(); - let path = d.target(); - fs::write(&path, b"Original bytes.").unwrap(); - let refused = io::Error::from(io::ErrorKind::PermissionDenied); - let mut ran = false; - let err = create_directly(&path, refused, |file| { - ran = true; - file.write_all(b"New") - }) - .unwrap_err(); - assert!(!ran); - assert_eq!(err.kind(), io::ErrorKind::PermissionDenied); - assert!(err.to_string().contains("Save As") && err.to_string().contains("left unchanged"), "{err}"); - assert_eq!(fs::read(&path).unwrap(), b"Original bytes."); - } - - #[cfg(unix)] - #[test] - fn new_temporary_files_are_private_until_prepared() { - use std::os::unix::fs::PermissionsExt; - let d = Scratch::new(); - let temp = Temporary::create(&d.target(), true).unwrap(); - assert_eq!(fs::metadata(&temp.path).unwrap().permissions().mode() & 0o777, 0o600); - } - - /// A new document gets the default mode, as `fs::write` would give it — not 0600; a replaced - /// document keeps its own mode. - #[cfg(unix)] - #[test] - fn new_documents_get_the_default_mode_and_replaced_ones_keep_theirs() { - use std::os::unix::fs::PermissionsExt; - let d = Scratch::new(); - let reference = d.0.join("reference.docx"); - fs::write(&reference, b"x").unwrap(); - let default_mode = fs::metadata(&reference).unwrap().permissions().mode() & 0o777; - let path = d.target(); - write(&path, b"New document.").unwrap(); - assert_eq!(fs::metadata(&path).unwrap().permissions().mode() & 0o777, default_mode); - fs::set_permissions(&path, fs::Permissions::from_mode(0o640)).unwrap(); - write(&path, b"Saved again.").unwrap(); - assert_eq!(fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o640); - assert_eq!(fs::read(&path).unwrap(), b"Saved again."); - } - - /// A share that refuses the full flush (`F_FULLFSYNC`, #6155) still saves through a plain fsync - /// on Apple platforms; every other sync error still fails the save. - #[test] - fn a_refused_full_flush_falls_back_to_a_plain_fsync_on_apple_only() { - use std::cell::Cell; - let refused = |errno| Err(io::Error::from_raw_os_error(errno)); - for (errno, name) in [(45, "ENOTSUP, SMB"), (22, "EINVAL"), (25, "ENOTTY")] { - let fsynced = Cell::new(false); - let r = or_plain_fsync(refused(errno), || { - fsynced.set(true); - Ok(()) - }); - let apple = cfg!(target_vendor = "apple"); - assert_eq!(r.is_ok(), apple, "{name}"); - assert_eq!(fsynced.get(), apple, "{name}: the plain fsync runs only on Apple platforms"); - } - // The plain fsync's own failure is reported. - if cfg!(target_vendor = "apple") { - let r = or_plain_fsync(refused(45), || Err(io::Error::from_raw_os_error(5))); - assert_eq!(r.map_err(|e| e.raw_os_error()), Err(Some(5))); - } - // Real errors (EIO, ENOSPC, EACCES) never fall back, and success needs no fallback. - for errno in [5, 28, 13] { - let r = or_plain_fsync(refused(errno), || panic!("no fallback for errno {errno}")); - assert_eq!(r.map_err(|e| e.raw_os_error()), Err(Some(errno))); - } - assert!(or_plain_fsync(Ok(()), || panic!("no fallback after a full flush")).is_ok()); - } - - #[test] - fn sync_file_flushes_a_local_file() { - let d = Scratch::new(); - let path = d.0.join("f.bin"); - let file = OpenOptions::new().write(true).create_new(true).open(&path).unwrap(); - sync_file(&file).unwrap(); - } -} +//! Sibling temporary files owned by one save. The destination changes only after preparation +//! succeeds; concurrent saves have separate files and the last successful replacement wins. +//! +//! When the folder refuses the temporary file (macOS privacy protection, #2138, or a read-only +//! folder), an existing destination is never written in place: it is left exactly as it was and +//! the error says to use Save As to another folder or to give WordCraft access to this one. Only +//! a destination that doesn't exist yet, so has no previous version to lose, is created directly; +//! a failed direct write removes it again. +use std::fs::{self, File, OpenOptions}; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + +/// Writes `bytes` safely to `path`, replacing an existing destination atomically via a +/// temporary file. If the destination's folder refuses the temporary file, a new file +/// is created directly and removed on failure; an existing file is left untouched. +pub(super) fn write(path: &Path, bytes: &[u8]) -> io::Result<()> { + let permissions = match fs::metadata(path) { + Ok(meta) if meta.is_file() => Some(meta.permissions()), + Ok(_) => None, + Err(e) if e.kind() == io::ErrorKind::NotFound => None, + Err(e) => return Err(e), + }; + // A new document gets the usual mode (0666 less the umask); a replaced one keeps its own, + // and its temporary file stays private until that mode is applied. + let private = permissions.is_some(); + let written = replace_with(path, private, |file| { + file.write_all(bytes)?; + if let Some(permissions) = permissions { + keep_permissions(file.set_permissions(permissions))?; + } + // Synchronize the prepared file before replacing the destination. Directory durability + // and network-filesystem/power-loss guarantees are outside this operation's contract. + sync_file(file) + }); + written.map_err(explain) +} + +/// Flushes `file` to stable storage (`sync_all`). On Apple platforms that is a full flush +/// (`F_FULLFSYNC`); where the file system can't do one (SMB shares and virtual file systems +/// refuse it with `ENOTSUP` #6155, `EINVAL`, or `ENOTTY`), this falls back to a plain `fsync`, +/// as Apple's own `removefile` and SQLite do. Plain `fsync` flushes to the drive controller but +/// does not guarantee disk cache durability; any other error is returned as it is. +fn sync_file(file: &File) -> io::Result<()> { + #[cfg(target_vendor = "apple")] + let plain = || rustix::fs::fsync(file).map_err(io::Error::from); + #[cfg(not(target_vendor = "apple"))] + let plain = || Ok(()); + or_plain_fsync(file.sync_all(), plain) +} + +/// Returns `full`, or calls `fsync()` when `full` failed only because the file system can't do a full flush. +fn or_plain_fsync(full: io::Result<()>, fsync: impl FnOnce() -> io::Result<()>) -> io::Result<()> { + match full { + Err(e) if full_flush_unsupported(&e) => fsync(), + other => other, + } +} + +/// Whether `e` is a file system refusing a full flush (`F_FULLFSYNC`): `ENOTSUP` (45, SMB), +/// `EINVAL` (22), or `ENOTTY` (25) on Apple platforms. Never elsewhere: there `sync_all` is a plain `fsync`. +fn full_flush_unsupported(e: &io::Error) -> bool { + const EINVAL: i32 = 22; + const ENOTTY: i32 = 25; + const ENOTSUP: i32 = 45; + cfg!(target_vendor = "apple") && matches!(e.raw_os_error(), Some(ENOTSUP | EINVAL | ENOTTY)) +} + +/// A folder that keeps no Unix modes refuses to set one with "Operation not supported": a NAS +/// share mounted by GVFS (`smb://` in the Linux file manager) refuses every mode change (#3003). +/// There is no mode to keep there, so the save goes on; any other failure still stops it. +fn keep_permissions(set: io::Result<()>) -> io::Result<()> { + match set { + Err(e) if not_supported(&e) => Ok(()), + set => set, + } +} + +/// ENOTSUP and EOPNOTSUPP, which std doesn't count as `Unsupported` (only ENOSYS), plus that kind. +fn not_supported(e: &io::Error) -> bool { + const CODES: &[i32] = if cfg!(any(target_os = "linux", target_os = "android")) { + &[95] + } else if cfg!(any(target_os = "macos", target_os = "ios")) { + &[45, 102] + } else if cfg!(target_os = "openbsd") { + &[45, 91] + } else if cfg!(target_os = "netbsd") { + &[45, 86] + } else if cfg!(any(target_os = "freebsd", target_os = "dragonfly")) { + &[45] + } else { + &[] + }; + e.kind() == io::ErrorKind::Unsupported || e.raw_os_error().is_some_and(|code| CODES.contains(&code)) +} + +fn replace_with(path: &Path, private: bool, prepare: impl FnOnce(&mut File) -> io::Result<()>) -> io::Result<()> { + let mut temp = match Temporary::create(path, private) { + Ok(temp) => temp, + // macOS only lets an app write the file picked in the Save panel inside a protected + // folder (Desktop, Documents, Downloads, iCloud Drive), not a sibling temporary file + // (#2138); a read-only folder refuses it too. + Err(e) if e.kind() == io::ErrorKind::PermissionDenied => return create_directly(path, e, prepare), + Err(e) => return Err(e), + }; + prepare(temp.file()?)?; + temp.commit(path) +} + +/// No temporary file may be created beside `path` (`refused` says why). A file that doesn't exist +/// yet is created directly (there is no previous version to lose) and removed again if the save +/// fails. Anything already at `path` is left untouched: overwriting it in place could destroy the +/// only copy, so the error says how to save safely instead. +fn create_directly(path: &Path, refused: io::Error, prepare: impl FnOnce(&mut File) -> io::Result<()>) -> io::Result<()> { + // `create_new` never opens an existing file or follows a link, so it can't touch one. + let mut file = match OpenOptions::new().write(true).create_new(true).open(path) { + Ok(file) => file, + Err(e) if e.kind() == io::ErrorKind::AlreadyExists => { + return Err(guided( + refused, + "This folder doesn't let WordCraft create the temporary file a safe save needs, so the existing file was left unchanged.", + )); + } + Err(e) if e.kind() == io::ErrorKind::PermissionDenied => return Err(guided(e, "WordCraft can't create files in this folder.")), + Err(e) => return Err(e), + }; + let Err(failed) = prepare(&mut file) else { + return Ok(()); + }; + drop(file); + match fs::remove_file(path) { + Ok(()) => Err(failed), + Err(e) => Err(io::Error::new(failed.kind(), format!("{failed}; removing the incomplete new file also failed ({e})"))), + } +} + +/// A save refused for want of folder access, already worded with what to do about it. +#[derive(Debug)] +struct Guided(String); + +impl std::fmt::Display for Guided { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +impl std::error::Error for Guided {} + +/// `e`, then `why`, then how to save anyway: Save As to another folder, or access to this one. +fn guided(e: io::Error, why: &str) -> io::Error { + let access = if cfg!(target_os = "macos") { + "allow WordCraft to use this folder in System Settings › Privacy & Security › Files and Folders" + } else { + "get write access to this folder" + }; + io::Error::new(e.kind(), Guided(format!("{e}. {why} Use Save As to save the document in another folder, or {access}, then save again."))) +} + +/// A permission error on macOS usually means its privacy protection keeps WordCraft out of the +/// folder; say where to allow it (errors from [`guided`] already do). +fn explain(e: io::Error) -> io::Error { + let worded = e.get_ref().is_some_and(|inner| inner.is::()); + if cfg!(target_os = "macos") && e.kind() == io::ErrorKind::PermissionDenied && !worded { + return io::Error::new( + e.kind(), + format!( + "{e}. macOS may be blocking WordCraft from this folder: allow it in System Settings › Privacy & Security › Files and Folders, or save somewhere else" + ), + ); + } + e +} + +struct Temporary { + path: PathBuf, + file: Option, + committed: bool, +} + +impl Temporary { + /// `private`: create the file readable by its owner only (0600) rather than with the default + /// mode (0666 less the umask). + fn create(destination: &Path, private: bool) -> io::Result { + static NEXT: AtomicU64 = AtomicU64::new(0); + if destination.file_name().is_none() { + return Err(io::Error::new(io::ErrorKind::InvalidInput, "save destination needs a file name")); + } + // A short name also works beside a destination already at the filename-length limit. + for _ in 0..128 { + let id = NEXT.fetch_add(1, Ordering::Relaxed); + let path = destination.with_file_name(format!(".wordcraft-save-{}-{id}.tmp", std::process::id())); + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + if private { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + #[cfg(not(unix))] + let _ = private; + match options.open(&path) { + Ok(file) => return Ok(Self { path, file: Some(file), committed: false }), + Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue, + Err(e) => return Err(e), + } + } + Err(io::Error::new(io::ErrorKind::AlreadyExists, "could not create a save temporary file")) + } + + fn file(&mut self) -> io::Result<&mut File> { + self.file.as_mut().ok_or_else(|| io::Error::other("save temporary file is closed")) + } + + fn commit(mut self, destination: &Path) -> io::Result<()> { + // Close before replacement, including on Windows. + drop(self.file.take()); + fs::rename(&self.path, destination)?; + self.committed = true; + Ok(()) + } +} + +impl Drop for Temporary { + fn drop(&mut self) { + drop(self.file.take()); + if !self.committed { + // If filesystem permissions change, cleanup can fail too. Never remove a neighbour + // or the destination to compensate for a failed save. + let _ = fs::remove_file(&self.path); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + struct Scratch(PathBuf); + impl Scratch { + fn new() -> Self { + static NEXT: AtomicU64 = AtomicU64::new(0); + let path = + std::env::temp_dir().join(format!("wordcraft-save-owner-test-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed))); + fs::create_dir(&path).unwrap(); + Self(path) + } + fn target(&self) -> PathBuf { + self.0.join("original.docx") + } + } + impl Drop for Scratch { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + + #[test] + fn partial_and_complete_preparation_failures_leave_original_and_no_owned_temp() { + for partial in [true, false] { + let d = Scratch::new(); + let path = d.target(); + fs::write(&path, b"Original bytes.").unwrap(); + let err = replace_with(&path, true, |file| { + file.write_all(if partial { b"Partial" } else { b"Complete replacement bytes." })?; + Err(io::Error::other("injected preparation failure")) + }) + .unwrap_err(); + assert!(err.to_string().contains("injected")); + assert_eq!(fs::read(&path).unwrap(), b"Original bytes."); + assert_eq!(fs::read_dir(&d.0).unwrap().count(), 1); + } + } + + #[test] + fn overlapping_prepared_saves_keep_separate_bytes_and_commit_complete_files() { + let d = Scratch::new(); + let path = d.target(); + fs::write(&path, b"Original bytes.").unwrap(); + let mut first = Temporary::create(&path, true).unwrap(); + let mut second = Temporary::create(&path, true).unwrap(); + assert_ne!(first.path, second.path); + first.file().unwrap().write_all(b"First complete save.").unwrap(); + second.file().unwrap().write_all(b"Second complete save.").unwrap(); + first.file().unwrap().sync_all().unwrap(); + second.file().unwrap().sync_all().unwrap(); + assert_eq!(fs::read(&path).unwrap(), b"Original bytes."); + first.commit(&path).unwrap(); + assert_eq!(fs::read(&path).unwrap(), b"First complete save."); + second.commit(&path).unwrap(); + assert_eq!(fs::read(&path).unwrap(), b"Second complete save."); + assert_eq!(fs::read_dir(&d.0).unwrap().count(), 1); + } + + /// Exports (`write_file`) replace the destination instead of truncating it in place: another + /// hard link to the old file keeps the old bytes, and no temporary file is left behind. + #[test] + fn write_file_replaces_the_destination_instead_of_truncating_it() { + let d = Scratch::new(); + let path = d.target(); + let alias = d.0.join("alias.docx"); + fs::write(&path, b"Original bytes.").unwrap(); + fs::hard_link(&path, &alias).unwrap(); + crate::io::write_file(&path.to_string_lossy(), b"Exported bytes.").unwrap(); + assert_eq!(fs::read(&path).unwrap(), b"Exported bytes."); + assert_eq!(fs::read(&alias).unwrap(), b"Original bytes."); + assert_eq!(fs::read_dir(&d.0).unwrap().count(), 2); + } + + /// #3003: a folder that refuses mode changes (a GVFS-mounted NAS share) still takes the + /// save; a refused mode change for any other reason still stops it and keeps the original. + #[test] + fn a_folder_without_unix_modes_still_takes_the_save() { + let d = Scratch::new(); + let path = d.target(); + fs::write(&path, b"Original bytes.").unwrap(); + replace_with(&path, true, |file| { + file.write_all(b"Saved on the share.")?; + keep_permissions(Err(io::Error::from(io::ErrorKind::Unsupported)))?; + file.sync_all() + }) + .unwrap(); + assert_eq!(fs::read(&path).unwrap(), b"Saved on the share."); + assert_eq!(fs::read_dir(&d.0).unwrap().count(), 1); + + let err = replace_with(&path, true, |file| { + file.write_all(b"Refused.")?; + keep_permissions(Err(io::Error::from(io::ErrorKind::PermissionDenied))) + }) + .unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::PermissionDenied); + assert_eq!(fs::read(&path).unwrap(), b"Saved on the share."); + assert_eq!(fs::read_dir(&d.0).unwrap().count(), 1); + } + + /// GVFS reports a refused mode change as ENOTSUP, which std leaves uncategorized; other OS + /// errors still stop the save. + #[cfg(any(target_os = "linux", target_os = "macos"))] + #[test] + fn enotsup_from_the_os_is_an_unsupported_mode_change() { + let enotsup = if cfg!(target_os = "linux") { 95 } else { 45 }; + assert!(keep_permissions(Err(io::Error::from_raw_os_error(enotsup))).is_ok()); + let eacces = 13; + assert!(keep_permissions(Err(io::Error::from_raw_os_error(eacces))).is_err()); + } + + /// Makes `dir` read-only while `f` runs. `None` when running as root, where the folder isn't + /// read-only to us. + #[cfg(unix)] + fn in_read_only(dir: &Path, f: impl FnOnce() -> T) -> Option { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(dir, fs::Permissions::from_mode(0o555)).unwrap(); + let probe = fs::write(dir.join("probe"), b""); + let out = f(); + fs::set_permissions(dir, fs::Permissions::from_mode(0o755)).unwrap(); + probe.is_err().then_some(out) + } + + /// #2138 review: where no sibling temporary file may be created (macOS privacy protection, + /// or a read-only folder), an existing document is left byte for byte as it was, and the + /// error says to use Save As or get access to the folder. + #[cfg(unix)] + #[test] + fn an_existing_file_in_a_folder_refusing_the_temporary_file_is_left_untouched() { + let d = Scratch::new(); + let path = d.target(); + fs::write(&path, b"Original bytes.").unwrap(); + let Some(saved) = in_read_only(&d.0, || write(&path, b"Saved bytes.")) else { return }; + let err = saved.unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::PermissionDenied); + let message = err.to_string(); + assert!(message.contains("left unchanged") && message.contains("Save As") && message.contains("this folder"), "{message}"); + assert_eq!(message.matches("Privacy & Security").count(), usize::from(cfg!(target_os = "macos")), "{message}"); + assert_eq!(fs::read(&path).unwrap(), b"Original bytes."); + assert_eq!(fs::read_dir(&d.0).unwrap().count(), 1); + } + + /// FredApps' #2138 regression: a save that would fail partway through still leaves the + /// previous document byte-identical when the folder refuses the temporary file. + #[cfg(unix)] + #[test] + fn a_failed_save_in_a_folder_refusing_the_temporary_file_keeps_the_original() { + let d = Scratch::new(); + let path = d.target(); + fs::write(&path, b"Original bytes.").unwrap(); + let Some(result) = in_read_only(&d.0, || { + replace_with(&path, true, |file| { + file.write_all(b"Partial")?; + Err(io::Error::other("injected partial write failure")) + }) + }) else { + return; + }; + assert!(result.is_err()); + assert_eq!(fs::read(&path).unwrap(), b"Original bytes.", "failed save must preserve the previous document"); + assert_eq!(fs::read_dir(&d.0).unwrap().count(), 1); + } + + /// A folder that refuses new files altogether refuses a new document too, with the same + /// guidance, and leaves nothing behind. + #[cfg(unix)] + #[test] + fn a_new_file_in_a_read_only_folder_is_refused_with_guidance() { + let d = Scratch::new(); + let path = d.target(); + let Some(saved) = in_read_only(&d.0, || write(&path, b"New document.")) else { return }; + let message = saved.unwrap_err().to_string(); + assert!(message.contains("Save As"), "{message}"); + assert!(!path.exists()); + } + + /// When only the temporary file is refused (macOS lets the app write the file picked in the + /// Save panel), a file that doesn't exist yet is created directly; nothing is replaced. + #[test] + fn a_new_file_is_created_directly_when_only_the_temporary_file_is_refused() { + let d = Scratch::new(); + let path = d.target(); + let refused = io::Error::from(io::ErrorKind::PermissionDenied); + create_directly(&path, refused, |file| file.write_all(b"New document.")).unwrap(); + assert_eq!(fs::read(&path).unwrap(), b"New document."); + assert_eq!(fs::read_dir(&d.0).unwrap().count(), 1); + } + + #[test] + fn a_failed_direct_save_of_a_new_file_leaves_nothing() { + let d = Scratch::new(); + let path = d.target(); + let refused = io::Error::from(io::ErrorKind::PermissionDenied); + let err = create_directly(&path, refused, |file| { + file.write_all(b"Partial")?; + Err(io::Error::other("injected")) + }) + .unwrap_err(); + assert!(err.to_string().contains("injected")); + assert!(!path.exists()); + } + + /// The direct path never opens an existing file and never runs the save: the original is + /// untouched and the error carries the Save As guidance. + #[test] + fn an_existing_file_is_never_written_directly() { + let d = Scratch::new(); + let path = d.target(); + fs::write(&path, b"Original bytes.").unwrap(); + let refused = io::Error::from(io::ErrorKind::PermissionDenied); + let mut ran = false; + let err = create_directly(&path, refused, |file| { + ran = true; + file.write_all(b"New") + }) + .unwrap_err(); + assert!(!ran); + assert_eq!(err.kind(), io::ErrorKind::PermissionDenied); + assert!(err.to_string().contains("Save As") && err.to_string().contains("left unchanged"), "{err}"); + assert_eq!(fs::read(&path).unwrap(), b"Original bytes."); + } + + #[cfg(unix)] + #[test] + fn new_temporary_files_are_private_until_prepared() { + use std::os::unix::fs::PermissionsExt; + let d = Scratch::new(); + let temp = Temporary::create(&d.target(), true).unwrap(); + assert_eq!(fs::metadata(&temp.path).unwrap().permissions().mode() & 0o777, 0o600); + } + + /// A new document gets the default mode, as `fs::write` would give it — not 0600; a replaced + /// document keeps its own mode. + #[cfg(unix)] + #[test] + fn new_documents_get_the_default_mode_and_replaced_ones_keep_theirs() { + use std::os::unix::fs::PermissionsExt; + let d = Scratch::new(); + let reference = d.0.join("reference.docx"); + fs::write(&reference, b"x").unwrap(); + let default_mode = fs::metadata(&reference).unwrap().permissions().mode() & 0o777; + let path = d.target(); + write(&path, b"New document.").unwrap(); + assert_eq!(fs::metadata(&path).unwrap().permissions().mode() & 0o777, default_mode); + fs::set_permissions(&path, fs::Permissions::from_mode(0o640)).unwrap(); + write(&path, b"Saved again.").unwrap(); + assert_eq!(fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o640); + assert_eq!(fs::read(&path).unwrap(), b"Saved again."); + } + + /// A share that refuses the full flush (`F_FULLFSYNC`, #6155) still saves through a plain fsync + /// on Apple platforms; every other sync error still fails the save. + #[test] + fn a_refused_full_flush_falls_back_to_a_plain_fsync_on_apple_only() { + use std::cell::Cell; + let refused = |errno| Err(io::Error::from_raw_os_error(errno)); + for (errno, name) in [(45, "ENOTSUP, SMB"), (22, "EINVAL"), (25, "ENOTTY")] { + let fsynced = Cell::new(false); + let r = or_plain_fsync(refused(errno), || { + fsynced.set(true); + Ok(()) + }); + let apple = cfg!(target_vendor = "apple"); + assert_eq!(r.is_ok(), apple, "{name}"); + assert_eq!(fsynced.get(), apple, "{name}: the plain fsync runs only on Apple platforms"); + } + // The plain fsync's own failure is reported. + if cfg!(target_vendor = "apple") { + let r = or_plain_fsync(refused(45), || Err(io::Error::from_raw_os_error(5))); + assert_eq!(r.map_err(|e| e.raw_os_error()), Err(Some(5))); + } + // Real errors (EIO, ENOSPC, EACCES) never fall back, and success needs no fallback. + for errno in [5, 28, 13] { + let r = or_plain_fsync(refused(errno), || panic!("no fallback for errno {errno}")); + assert_eq!(r.map_err(|e| e.raw_os_error()), Err(Some(errno))); + } + assert!(or_plain_fsync(Ok(()), || panic!("no fallback after a full flush")).is_ok()); + } + + #[test] + fn sync_file_flushes_a_local_file() { + let d = Scratch::new(); + let path = d.0.join("f.bin"); + let file = OpenOptions::new().write(true).create_new(true).open(&path).unwrap(); + sync_file(&file).unwrap(); + } +} diff --git a/crates/engine/src/io_ext.rs b/crates/engine/src/io_ext.rs index 2fde16034e..16c8136f8f 100644 --- a/crates/engine/src/io_ext.rs +++ b/crates/engine/src/io_ext.rs @@ -1,90 +1,90 @@ -//! Format crates registered with the I/O dispatcher. - -use wordcraft_doc::Document; - -/// Open a format other than plain text / JSON; files that give no page size (Markdown, HTML) -/// start on `page`. -pub fn open(ext: &str, bytes: &[u8], page: &wordcraft_doc::SectionProps) -> Option> { - match ext { - "docx" | "docm" | "dotx" | "dotm" => Some(crate::io::read_docx(bytes, None).map_err(|e| e.to_string())), - "doc" | "dot" => Some(wordcraft_docbin::read(bytes).map_err(|e| e.to_string())), - // Word XML Document (Flat OPC) or Word 2003 XML, told apart by the root element. - "xml" => Some(wordcraft_docx::read_xml(bytes).map_err(|e| e.to_string())), - // HTML, Markdown, RTF…: pictures fit the text width (see `io::fit_pictures`). An - // OpenDocument file says how big they are, and one wider than the text is meant to be; - // changing it would take its paragraph off being written as read. Its reader fits those - // that state no size. - other => wordcraft_formats::import_on(other, bytes, page).map(|r| { - r.map(|mut doc| { - if !matches!(other, "odt" | "ott" | "fodt") { - crate::io::fit_pictures(&mut doc); - } - draw_charts(&mut doc); - doc - }) - }), - } -} - -/// Draw the charts a reader left undrawn (ODT charts come as their chart model alone): in the -/// document's theme colours, at their size. -fn draw_charts(doc: &mut Document) { - use wordcraft_doc::para::InlineObject; - /// Groups nest at most this deep here (the readers flatten deeper ones). - const DEPTH: usize = 16; - fn undrawn(o: &InlineObject, depth: usize) -> bool { - match o { - InlineObject::Graphic { graphic, .. } => graphic.chart.is_some() && graphic.items.is_empty(), - InlineObject::Group { children, .. } if depth < DEPTH => children.iter().any(|c| undrawn(&c.obj, depth + 1)), - _ => false, - } - } - fn draw(o: &mut InlineObject, theme: &[wordcraft_doc::Rgb], depth: usize) { - match o { - InlineObject::Group { children, .. } if depth < DEPTH => children.iter_mut().for_each(|c| draw(&mut c.obj, theme, depth + 1)), - o if undrawn(o, depth) => crate::cmd::chart::redraw(o, theme), - _ => {} - } - } - let theme = doc.settings.theme_colors.clone(); - // (Drawing them doesn't change the document: markup kept as read for them still is.) - doc.fill_objects(&|o| undrawn(o, 0), &mut |o| draw(o, &theme, 0)); -} - -/// Save to a format other than plain text / JSON. -pub fn save(ext: &str, doc: &Document, opts: &crate::io::ExportOptions) -> Option, String>> { - // .docx/.docm/.dotx/.dotm: the extension picks the package flavour Word expects. - if let Some(flavor) = wordcraft_docx::Flavor::from_ext(ext) { - return Some(wordcraft_docx::write_as(doc, flavor).map_err(|e| e.to_string())); - } - match ext { - "xml" if opts.word2003 => Some(wordcraft_docx::write_wordml2003(doc).map_err(|e| e.to_string())), - "xml" => Some(wordcraft_docx::write_flat(doc).map_err(|e| e.to_string())), - "png" => Some(render_png(doc, 0, 2.0, opts)), - "pdf" => { - // Output shows the fields Word fills in as it prints (see `output_fields`). - let out = crate::cmd::references::output_fields(doc); - let doc = out.as_ref().unwrap_or(doc); - Some(crate::strict::export_pdf(doc, opts)) - } - other => wordcraft_formats::export_with(other, doc, &wordcraft_formats::ExportOptions { comments: opts.markup && opts.comments }), - } -} - -/// Render page `page` as PNG at `scale` px/pt. With Print Markup's comments the page has its -/// markup area with the comments in balloons (page images always show tracked changes). -pub fn render_png(doc: &Document, page: usize, scale: f32, opts: &crate::io::ExportOptions) -> Result, String> { - let out = crate::cmd::references::output_fields(doc); - let doc = out.as_ref().unwrap_or(doc); - let lo = wordcraft_layout::LayoutOptions { comment_balloons: opts.markup && opts.comments, numeral: opts.numeral, ..Default::default() }; - let l = wordcraft_layout::layout(doc, &mut wordcraft_layout::LayoutCache::new(), &lo); - let p = l.pages.get(page).ok_or_else(|| format!("no page {}", page + 1))?; - if opts.strict { - let report = crate::strict::strict_report(doc, &l, &crate::strict::png_display(), Some(page..=page), wordcraft_layout::StrictOutput::Png); - if !report.passed() { - return Err(report.refusal()); - } - } - let img = wordcraft_render::render_page(doc, p, scale, &Default::default()); - Ok(img.to_png()) -} +//! Format crates registered with the I/O dispatcher. + +use wordcraft_doc::Document; + +/// Open a format other than plain text / JSON; files that give no page size (Markdown, HTML) +/// start on `page`. +pub fn open(ext: &str, bytes: &[u8], page: &wordcraft_doc::SectionProps) -> Option> { + match ext { + "docx" | "docm" | "dotx" | "dotm" => Some(crate::io::read_docx(bytes, None).map_err(|e| e.to_string())), + "doc" | "dot" => Some(wordcraft_docbin::read(bytes).map_err(|e| e.to_string())), + // Word XML Document (Flat OPC) or Word 2003 XML, told apart by the root element. + "xml" => Some(wordcraft_docx::read_xml(bytes).map_err(|e| e.to_string())), + // HTML, Markdown, RTF…: pictures fit the text width (see `io::fit_pictures`). An + // OpenDocument file says how big they are, and one wider than the text is meant to be; + // changing it would take its paragraph off being written as read. Its reader fits those + // that state no size. + other => wordcraft_formats::import_on(other, bytes, page).map(|r| { + r.map(|mut doc| { + if !matches!(other, "odt" | "ott" | "fodt") { + crate::io::fit_pictures(&mut doc); + } + draw_charts(&mut doc); + doc + }) + }), + } +} + +/// Draw the charts a reader left undrawn (ODT charts come as their chart model alone): in the +/// document's theme colours, at their size. +fn draw_charts(doc: &mut Document) { + use wordcraft_doc::para::InlineObject; + /// Groups nest at most this deep here (the readers flatten deeper ones). + const DEPTH: usize = 16; + fn undrawn(o: &InlineObject, depth: usize) -> bool { + match o { + InlineObject::Graphic { graphic, .. } => graphic.chart.is_some() && graphic.items.is_empty(), + InlineObject::Group { children, .. } if depth < DEPTH => children.iter().any(|c| undrawn(&c.obj, depth + 1)), + _ => false, + } + } + fn draw(o: &mut InlineObject, theme: &[wordcraft_doc::Rgb], depth: usize) { + match o { + InlineObject::Group { children, .. } if depth < DEPTH => children.iter_mut().for_each(|c| draw(&mut c.obj, theme, depth + 1)), + o if undrawn(o, depth) => crate::cmd::chart::redraw(o, theme), + _ => {} + } + } + let theme = doc.settings.theme_colors.clone(); + // (Drawing them doesn't change the document: markup kept as read for them still is.) + doc.fill_objects(&|o| undrawn(o, 0), &mut |o| draw(o, &theme, 0)); +} + +/// Save to a format other than plain text / JSON. +pub fn save(ext: &str, doc: &Document, opts: &crate::io::ExportOptions) -> Option, String>> { + // .docx/.docm/.dotx/.dotm: the extension picks the package flavour Word expects. + if let Some(flavor) = wordcraft_docx::Flavor::from_ext(ext) { + return Some(wordcraft_docx::write_as(doc, flavor).map_err(|e| e.to_string())); + } + match ext { + "xml" if opts.word2003 => Some(wordcraft_docx::write_wordml2003(doc).map_err(|e| e.to_string())), + "xml" => Some(wordcraft_docx::write_flat(doc).map_err(|e| e.to_string())), + "png" => Some(render_png(doc, 0, 2.0, opts)), + "pdf" => { + // Output shows the fields Word fills in as it prints (see `output_fields`). + let out = crate::cmd::references::output_fields(doc); + let doc = out.as_ref().unwrap_or(doc); + Some(crate::strict::export_pdf(doc, opts)) + } + other => wordcraft_formats::export_with(other, doc, &wordcraft_formats::ExportOptions { comments: opts.markup && opts.comments }), + } +} + +/// Render page `page` as PNG at `scale` px/pt. With Print Markup's comments the page has its +/// markup area with the comments in balloons (page images always show tracked changes). +pub fn render_png(doc: &Document, page: usize, scale: f32, opts: &crate::io::ExportOptions) -> Result, String> { + let out = crate::cmd::references::output_fields(doc); + let doc = out.as_ref().unwrap_or(doc); + let lo = wordcraft_layout::LayoutOptions { comment_balloons: opts.markup && opts.comments, numeral: opts.numeral, ..Default::default() }; + let l = wordcraft_layout::layout(doc, &mut wordcraft_layout::LayoutCache::new(), &lo); + let p = l.pages.get(page).ok_or_else(|| format!("no page {}", page + 1))?; + if opts.strict { + let report = crate::strict::strict_report(doc, &l, &crate::strict::png_display(), Some(page..=page), wordcraft_layout::StrictOutput::Png); + if !report.passed() { + return Err(report.refusal()); + } + } + let img = wordcraft_render::render_page(doc, p, scale, &Default::default()); + Ok(img.to_png()) +} diff --git a/crates/engine/src/keymap.rs b/crates/engine/src/keymap.rs index 0cd6173b69..54be04f0db 100644 --- a/crates/engine/src/keymap.rs +++ b/crates/engine/src/keymap.rs @@ -1,275 +1,275 @@ -//! Custom keyboard shortcuts (Tools › Customize Keyboard, `tools.customizeKeyboard`): keys the -//! user assigned to commands and built-in keys the user removed. The user's map is consulted -//! before the registry's built-in `shortcut`s, so an assignment overrides a built-in key and a -//! removed built-in key does nothing. -//! -//! Keys are written like the registry's shortcuts (`Mod+Shift+K`, `Alt+F7`, `F2`; `Mod` is Ctrl, -//! or ⌘ on macOS) and compared in one canonical form ([`normalize`]). The map is saved with the -//! front end's preferences; reading a saved map never fails: entries that aren't a valid key or -//! command are dropped. - -use std::collections::{BTreeMap, BTreeSet}; - -use serde::{Deserialize, Serialize}; -use serde_json::Value; - -use crate::{CommandSpec, Registry}; - -/// At most this many saved entries are read (a hand-edited settings file can't balloon memory). -const MAX_ENTRIES: usize = 2000; -/// Longest key text accepted. -const MAX_KEY_LEN: usize = 48; - -/// Named (non-character) keys, in their canonical spelling. -const NAMED: &[&str] = - &["Left", "Right", "Up", "Down", "Home", "End", "PageUp", "PageDown", "Enter", "Tab", "Backspace", "Delete", "Escape", "Space", "Insert"]; - -/// Custom keyboard shortcuts. `assigned` maps a key to the command it runs; `removed` holds -/// built-in keys that were taken away (removed, or reassigned to another command). -#[derive(Clone, Debug, Default, PartialEq, Serialize)] -pub struct KeyMap { - pub assigned: BTreeMap, - pub removed: BTreeSet, -} - -/// A key in canonical form: modifiers in the order `Mod+Ctrl+Alt+Shift+`, then the key (a single -/// character upper-cased, a named key, or `F1`–`F24`). `None` when it isn't a key. -pub fn normalize(key: &str) -> Option { - let key = key.trim(); - if key.is_empty() || key.len() > MAX_KEY_LEN { - return None; - } - // `Mod++` is the plus key with Mod. - let (mods, name) = match key.strip_suffix("++") { - Some(m) => (m, "+"), - None => match key.rsplit_once('+') { - Some((m, n)) => (m, n), - None => ("", key), - }, - }; - let (mut cmd, mut ctrl, mut alt, mut shift) = (false, false, false, false); - for m in mods.split('+').filter(|m| !m.is_empty()) { - match m.to_ascii_lowercase().as_str() { - "mod" => cmd = true, - "ctrl" => ctrl = true, - "alt" => alt = true, - "shift" => shift = true, - _ => return None, - } - } - let name = canonical_name(name)?; - let mut out = String::new(); - for (on, m) in [(cmd, "Mod+"), (ctrl, "Ctrl+"), (alt, "Alt+"), (shift, "Shift+")] { - if on { - out.push_str(m); - } - } - out.push_str(&name); - Some(out) -} - -fn canonical_name(name: &str) -> Option { - let mut chars = name.chars(); - if let (Some(c), None) = (chars.next(), chars.next()) { - return (!c.is_whitespace() && !c.is_control()).then(|| c.to_uppercase().collect()); - } - if let Some(n) = NAMED.iter().find(|n| n.eq_ignore_ascii_case(name)) { - return Some((*n).to_string()); - } - let f = name.strip_prefix('F').or_else(|| name.strip_prefix('f'))?; - let n: u8 = f.parse().ok()?; - (1..=24).contains(&n).then(|| format!("F{n}")) -} - -/// A command's built-in keys, normalized. -fn builtin_keys(spec: &CommandSpec) -> impl Iterator + '_ { - spec.shortcut.split(" / ").filter_map(normalize) -} - -/// The command a key runs without customization (the first command listing it). -fn builtin<'a>(reg: &'a Registry, key: &str) -> Option<&'a CommandSpec> { - reg.all().iter().find(|s| !s.shortcut.is_empty() && builtin_keys(s).any(|k| k == key)) -} - -impl KeyMap { - /// Read a saved map, keeping only well-formed entries (never fails). - pub fn from_value(v: &Value) -> KeyMap { - let mut map = KeyMap::default(); - if let Some(a) = v.get("assigned").and_then(Value::as_object) { - for (k, id) in a.iter().take(MAX_ENTRIES) { - if let (Some(k), Some(id)) = (normalize(k), id.as_str().filter(|id| !id.is_empty() && id.len() <= 128)) { - map.assigned.insert(k, id.to_string()); - } - } - } - if let Some(r) = v.get("removed").and_then(Value::as_array) { - map.removed.extend(r.iter().take(MAX_ENTRIES).filter_map(Value::as_str).filter_map(normalize)); - } - map - } - - /// Whether nothing is customized. - pub fn is_empty(&self) -> bool { - self.assigned.is_empty() && self.removed.is_empty() - } - - /// Drop entries for commands that don't exist and removals of keys that aren't built in. - pub fn retain_known(&mut self, reg: &Registry) { - self.assigned.retain(|_, id| reg.get(id).is_some()); - self.removed.retain(|k| builtin(reg, k).is_some()); - } - - /// The command a key runs: the user's assignment, else the built-in one unless removed. - pub fn resolve<'a>(&self, reg: &'a Registry, key: &str) -> Option<&'a CommandSpec> { - let key = normalize(key)?; - if let Some(spec) = self.assigned.get(&key).and_then(|id| reg.get(id)) { - return Some(spec); - } - if self.removed.contains(&key) { - return None; - } - builtin(reg, &key) - } - - /// The keys that run a command now: its built-in keys still in effect, then the user's. - pub fn keys_for(&self, reg: &Registry, id: &str) -> Vec { - let mut keys: Vec = Vec::new(); - if let Some(spec) = reg.get(id) { - for k in builtin_keys(spec) { - // Uncustomized (the usual case, every frame for tooltips): no registry scan. - if !keys.contains(&k) && (self.is_empty() || self.resolve(reg, &k).is_some_and(|s| s.id == id)) { - keys.push(k); - } - } - } - for (k, owner) in &self.assigned { - if owner == id && !keys.contains(k) { - keys.push(k.clone()); - } - } - keys - } - - /// Assign `key` to command `id`; the key leaves whatever command had it. Returns that command. - pub fn assign(&mut self, reg: &Registry, id: &str, key: &str) -> Result, String> { - if reg.get(id).is_none() { - return Err(format!("unknown command `{id}`")); - } - let key = normalize(key).ok_or_else(|| format!("`{key}` isn't a key (e.g. Mod+Shift+K, Alt+F7, F2)"))?; - let previous = self.resolve(reg, &key).map(|s| s.id.to_string()).filter(|p| p != id); - match builtin(reg, &key) { - // Back to its own built-in key: no custom entry needed. - Some(b) if b.id == id => { - self.assigned.remove(&key); - self.removed.remove(&key); - } - // Taken from a built-in command: it loses the key for good. - Some(_) => { - self.assigned.insert(key.clone(), id.to_string()); - self.removed.insert(key); - } - None => { - self.assigned.insert(key, id.to_string()); - } - } - Ok(previous) - } - - /// Take `key` away from command `id`. Returns whether it had the key. - pub fn remove(&mut self, reg: &Registry, id: &str, key: &str) -> Result { - let key = normalize(key).ok_or_else(|| format!("`{key}` isn't a key"))?; - if self.resolve(reg, &key).is_none_or(|s| s.id != id) { - return Ok(false); - } - self.assigned.remove(&key); - if builtin(reg, &key).is_some() { - self.removed.insert(key); - } - Ok(true) - } - - /// Back to the built-in keys only. - pub fn reset(&mut self) { - *self = KeyMap::default(); - } -} - -impl<'de> Deserialize<'de> for KeyMap { - /// Lenient: any JSON reads (junk entries dropped), so a damaged map never loses the other - /// preferences saved next to it. - fn deserialize>(d: D) -> Result { - Ok(KeyMap::from_value(&Value::deserialize(d)?)) - } -} - -#[cfg(test)] -mod tests { - use serde_json::json; - - use super::*; - - #[test] - fn keys_normalize_to_one_spelling() { - assert_eq!(normalize("mod+shift+alt+v").as_deref(), Some("Mod+Alt+Shift+V")); - assert_eq!(normalize("Mod+=").as_deref(), Some("Mod+=")); - assert_eq!(normalize("Mod++").as_deref(), Some("Mod++")); - assert_eq!(normalize("f12").as_deref(), Some("F12")); - assert_eq!(normalize("Ctrl+pagedown").as_deref(), Some("Ctrl+PageDown")); - for junk in ["", "Mod+", "Hyper+K", "F99", "Mod+Shift+NotAKey", "Mod+\u{7}", &"x".repeat(100)] { - assert_eq!(normalize(junk), None, "{junk:?}"); - } - } - - /// #368: an assignment overrides a built-in key, a removed built-in key runs nothing, and - /// Reset All brings the built-ins back. - #[test] - fn user_keys_override_and_remove_built_ins() { - let reg = crate::cmd::registry(); - let mut m = KeyMap::default(); - assert_eq!(m.resolve(®, "Mod+B").map(|s| s.id), Some("format.bold")); - // Mod+B to Italic: Bold loses it. - assert_eq!(m.assign(®, "format.italic", "mod+b").unwrap().as_deref(), Some("format.bold")); - assert_eq!(m.resolve(®, "Mod+B").map(|s| s.id), Some("format.italic")); - assert!(m.keys_for(®, "format.bold").is_empty()); - assert_eq!(m.keys_for(®, "format.italic"), ["Mod+I", "Mod+B"]); - // Removing it from Italic leaves Mod+B doing nothing (Bold doesn't get it back). - assert!(m.remove(®, "format.italic", "Mod+B").unwrap()); - assert!(m.resolve(®, "Mod+B").is_none()); - // Removing a built-in key disables it; a new key works. - assert!(m.remove(®, "format.underline", "Mod+U").unwrap()); - assert!(m.resolve(®, "Mod+U").is_none()); - assert_eq!(m.assign(®, "format.underline", "Mod+Alt+Shift+U").unwrap(), None); - assert_eq!(m.resolve(®, "Mod+Shift+Alt+U").map(|s| s.id), Some("format.underline")); - assert!(m.assign(®, "no.such.command", "Mod+Q").is_err()); - assert!(m.assign(®, "format.bold", "Hyper+Q").is_err()); - m.reset(); - assert_eq!(m.resolve(®, "Mod+B").map(|s| s.id), Some("format.bold")); - assert_eq!(m.resolve(®, "Mod+U").map(|s| s.id), Some("format.underline")); - - // The command does the same for agents, without opening the dialog. - let mut s = crate::Session::new(wordcraft_doc::Document::new()); - let r = s.run("tools.customizeKeyboard", &json!({"assign": {"command": "format.italic", "key": "Mod+B"}, "key": "Mod+B"})).unwrap(); - assert_eq!((r["replaced"].as_str(), r["assignedTo"].as_str()), (Some("format.bold"), Some("format.italic"))); - assert!(s.ui_requests.is_empty(), "programmatic calls never open dialogs"); - s.run("tools.customizeKeyboard", &json!({})).unwrap(); - assert_eq!(s.ui_requests.last(), Some(&json!({"open": "customizeKeyboard"}))); - } - - #[test] - fn saved_maps_round_trip_and_junk_is_ignored() { - let reg = crate::cmd::registry(); - let mut m = KeyMap::default(); - m.assign(®, "format.italic", "Mod+B").unwrap(); - let back: KeyMap = serde_json::from_str(&serde_json::to_string(&m).unwrap()).unwrap(); - assert_eq!(back, m); - let junk = json!({"assigned": {"Hyper+X": "format.bold", "Mod+Q": 7, "Mod+J": "no.such", "Mod+Alt+K": "format.bold"}, "removed": ["Mod+U", 3, "F99", "Mod+Shift+Q"]}); - let mut j: KeyMap = serde_json::from_value(junk).unwrap(); - j.retain_known(®); - assert_eq!(j.assigned.len(), 1, "{j:?}"); - assert_eq!(j.resolve(®, "Mod+Alt+K").map(|s| s.id), Some("format.bold")); - assert_eq!(j.removed.iter().collect::>(), ["Mod+U"]); - for v in [json!(null), json!(5), json!("x"), json!({"assigned": [1], "removed": {"a": 1}})] { - assert!(serde_json::from_value::(v).unwrap().is_empty()); - } - } -} +//! Custom keyboard shortcuts (Tools › Customize Keyboard, `tools.customizeKeyboard`): keys the +//! user assigned to commands and built-in keys the user removed. The user's map is consulted +//! before the registry's built-in `shortcut`s, so an assignment overrides a built-in key and a +//! removed built-in key does nothing. +//! +//! Keys are written like the registry's shortcuts (`Mod+Shift+K`, `Alt+F7`, `F2`; `Mod` is Ctrl, +//! or ⌘ on macOS) and compared in one canonical form ([`normalize`]). The map is saved with the +//! front end's preferences; reading a saved map never fails: entries that aren't a valid key or +//! command are dropped. + +use std::collections::{BTreeMap, BTreeSet}; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::{CommandSpec, Registry}; + +/// At most this many saved entries are read (a hand-edited settings file can't balloon memory). +const MAX_ENTRIES: usize = 2000; +/// Longest key text accepted. +const MAX_KEY_LEN: usize = 48; + +/// Named (non-character) keys, in their canonical spelling. +const NAMED: &[&str] = + &["Left", "Right", "Up", "Down", "Home", "End", "PageUp", "PageDown", "Enter", "Tab", "Backspace", "Delete", "Escape", "Space", "Insert"]; + +/// Custom keyboard shortcuts. `assigned` maps a key to the command it runs; `removed` holds +/// built-in keys that were taken away (removed, or reassigned to another command). +#[derive(Clone, Debug, Default, PartialEq, Serialize)] +pub struct KeyMap { + pub assigned: BTreeMap, + pub removed: BTreeSet, +} + +/// A key in canonical form: modifiers in the order `Mod+Ctrl+Alt+Shift+`, then the key (a single +/// character upper-cased, a named key, or `F1`–`F24`). `None` when it isn't a key. +pub fn normalize(key: &str) -> Option { + let key = key.trim(); + if key.is_empty() || key.len() > MAX_KEY_LEN { + return None; + } + // `Mod++` is the plus key with Mod. + let (mods, name) = match key.strip_suffix("++") { + Some(m) => (m, "+"), + None => match key.rsplit_once('+') { + Some((m, n)) => (m, n), + None => ("", key), + }, + }; + let (mut cmd, mut ctrl, mut alt, mut shift) = (false, false, false, false); + for m in mods.split('+').filter(|m| !m.is_empty()) { + match m.to_ascii_lowercase().as_str() { + "mod" => cmd = true, + "ctrl" => ctrl = true, + "alt" => alt = true, + "shift" => shift = true, + _ => return None, + } + } + let name = canonical_name(name)?; + let mut out = String::new(); + for (on, m) in [(cmd, "Mod+"), (ctrl, "Ctrl+"), (alt, "Alt+"), (shift, "Shift+")] { + if on { + out.push_str(m); + } + } + out.push_str(&name); + Some(out) +} + +fn canonical_name(name: &str) -> Option { + let mut chars = name.chars(); + if let (Some(c), None) = (chars.next(), chars.next()) { + return (!c.is_whitespace() && !c.is_control()).then(|| c.to_uppercase().collect()); + } + if let Some(n) = NAMED.iter().find(|n| n.eq_ignore_ascii_case(name)) { + return Some((*n).to_string()); + } + let f = name.strip_prefix('F').or_else(|| name.strip_prefix('f'))?; + let n: u8 = f.parse().ok()?; + (1..=24).contains(&n).then(|| format!("F{n}")) +} + +/// A command's built-in keys, normalized. +fn builtin_keys(spec: &CommandSpec) -> impl Iterator + '_ { + spec.shortcut.split(" / ").filter_map(normalize) +} + +/// The command a key runs without customization (the first command listing it). +fn builtin<'a>(reg: &'a Registry, key: &str) -> Option<&'a CommandSpec> { + reg.all().iter().find(|s| !s.shortcut.is_empty() && builtin_keys(s).any(|k| k == key)) +} + +impl KeyMap { + /// Read a saved map, keeping only well-formed entries (never fails). + pub fn from_value(v: &Value) -> KeyMap { + let mut map = KeyMap::default(); + if let Some(a) = v.get("assigned").and_then(Value::as_object) { + for (k, id) in a.iter().take(MAX_ENTRIES) { + if let (Some(k), Some(id)) = (normalize(k), id.as_str().filter(|id| !id.is_empty() && id.len() <= 128)) { + map.assigned.insert(k, id.to_string()); + } + } + } + if let Some(r) = v.get("removed").and_then(Value::as_array) { + map.removed.extend(r.iter().take(MAX_ENTRIES).filter_map(Value::as_str).filter_map(normalize)); + } + map + } + + /// Whether nothing is customized. + pub fn is_empty(&self) -> bool { + self.assigned.is_empty() && self.removed.is_empty() + } + + /// Drop entries for commands that don't exist and removals of keys that aren't built in. + pub fn retain_known(&mut self, reg: &Registry) { + self.assigned.retain(|_, id| reg.get(id).is_some()); + self.removed.retain(|k| builtin(reg, k).is_some()); + } + + /// The command a key runs: the user's assignment, else the built-in one unless removed. + pub fn resolve<'a>(&self, reg: &'a Registry, key: &str) -> Option<&'a CommandSpec> { + let key = normalize(key)?; + if let Some(spec) = self.assigned.get(&key).and_then(|id| reg.get(id)) { + return Some(spec); + } + if self.removed.contains(&key) { + return None; + } + builtin(reg, &key) + } + + /// The keys that run a command now: its built-in keys still in effect, then the user's. + pub fn keys_for(&self, reg: &Registry, id: &str) -> Vec { + let mut keys: Vec = Vec::new(); + if let Some(spec) = reg.get(id) { + for k in builtin_keys(spec) { + // Uncustomized (the usual case, every frame for tooltips): no registry scan. + if !keys.contains(&k) && (self.is_empty() || self.resolve(reg, &k).is_some_and(|s| s.id == id)) { + keys.push(k); + } + } + } + for (k, owner) in &self.assigned { + if owner == id && !keys.contains(k) { + keys.push(k.clone()); + } + } + keys + } + + /// Assign `key` to command `id`; the key leaves whatever command had it. Returns that command. + pub fn assign(&mut self, reg: &Registry, id: &str, key: &str) -> Result, String> { + if reg.get(id).is_none() { + return Err(format!("unknown command `{id}`")); + } + let key = normalize(key).ok_or_else(|| format!("`{key}` isn't a key (e.g. Mod+Shift+K, Alt+F7, F2)"))?; + let previous = self.resolve(reg, &key).map(|s| s.id.to_string()).filter(|p| p != id); + match builtin(reg, &key) { + // Back to its own built-in key: no custom entry needed. + Some(b) if b.id == id => { + self.assigned.remove(&key); + self.removed.remove(&key); + } + // Taken from a built-in command: it loses the key for good. + Some(_) => { + self.assigned.insert(key.clone(), id.to_string()); + self.removed.insert(key); + } + None => { + self.assigned.insert(key, id.to_string()); + } + } + Ok(previous) + } + + /// Take `key` away from command `id`. Returns whether it had the key. + pub fn remove(&mut self, reg: &Registry, id: &str, key: &str) -> Result { + let key = normalize(key).ok_or_else(|| format!("`{key}` isn't a key"))?; + if self.resolve(reg, &key).is_none_or(|s| s.id != id) { + return Ok(false); + } + self.assigned.remove(&key); + if builtin(reg, &key).is_some() { + self.removed.insert(key); + } + Ok(true) + } + + /// Back to the built-in keys only. + pub fn reset(&mut self) { + *self = KeyMap::default(); + } +} + +impl<'de> Deserialize<'de> for KeyMap { + /// Lenient: any JSON reads (junk entries dropped), so a damaged map never loses the other + /// preferences saved next to it. + fn deserialize>(d: D) -> Result { + Ok(KeyMap::from_value(&Value::deserialize(d)?)) + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + #[test] + fn keys_normalize_to_one_spelling() { + assert_eq!(normalize("mod+shift+alt+v").as_deref(), Some("Mod+Alt+Shift+V")); + assert_eq!(normalize("Mod+=").as_deref(), Some("Mod+=")); + assert_eq!(normalize("Mod++").as_deref(), Some("Mod++")); + assert_eq!(normalize("f12").as_deref(), Some("F12")); + assert_eq!(normalize("Ctrl+pagedown").as_deref(), Some("Ctrl+PageDown")); + for junk in ["", "Mod+", "Hyper+K", "F99", "Mod+Shift+NotAKey", "Mod+\u{7}", &"x".repeat(100)] { + assert_eq!(normalize(junk), None, "{junk:?}"); + } + } + + /// #368: an assignment overrides a built-in key, a removed built-in key runs nothing, and + /// Reset All brings the built-ins back. + #[test] + fn user_keys_override_and_remove_built_ins() { + let reg = crate::cmd::registry(); + let mut m = KeyMap::default(); + assert_eq!(m.resolve(®, "Mod+B").map(|s| s.id), Some("format.bold")); + // Mod+B to Italic: Bold loses it. + assert_eq!(m.assign(®, "format.italic", "mod+b").unwrap().as_deref(), Some("format.bold")); + assert_eq!(m.resolve(®, "Mod+B").map(|s| s.id), Some("format.italic")); + assert!(m.keys_for(®, "format.bold").is_empty()); + assert_eq!(m.keys_for(®, "format.italic"), ["Mod+I", "Mod+B"]); + // Removing it from Italic leaves Mod+B doing nothing (Bold doesn't get it back). + assert!(m.remove(®, "format.italic", "Mod+B").unwrap()); + assert!(m.resolve(®, "Mod+B").is_none()); + // Removing a built-in key disables it; a new key works. + assert!(m.remove(®, "format.underline", "Mod+U").unwrap()); + assert!(m.resolve(®, "Mod+U").is_none()); + assert_eq!(m.assign(®, "format.underline", "Mod+Alt+Shift+U").unwrap(), None); + assert_eq!(m.resolve(®, "Mod+Shift+Alt+U").map(|s| s.id), Some("format.underline")); + assert!(m.assign(®, "no.such.command", "Mod+Q").is_err()); + assert!(m.assign(®, "format.bold", "Hyper+Q").is_err()); + m.reset(); + assert_eq!(m.resolve(®, "Mod+B").map(|s| s.id), Some("format.bold")); + assert_eq!(m.resolve(®, "Mod+U").map(|s| s.id), Some("format.underline")); + + // The command does the same for agents, without opening the dialog. + let mut s = crate::Session::new(wordcraft_doc::Document::new()); + let r = s.run("tools.customizeKeyboard", &json!({"assign": {"command": "format.italic", "key": "Mod+B"}, "key": "Mod+B"})).unwrap(); + assert_eq!((r["replaced"].as_str(), r["assignedTo"].as_str()), (Some("format.bold"), Some("format.italic"))); + assert!(s.ui_requests.is_empty(), "programmatic calls never open dialogs"); + s.run("tools.customizeKeyboard", &json!({})).unwrap(); + assert_eq!(s.ui_requests.last(), Some(&json!({"open": "customizeKeyboard"}))); + } + + #[test] + fn saved_maps_round_trip_and_junk_is_ignored() { + let reg = crate::cmd::registry(); + let mut m = KeyMap::default(); + m.assign(®, "format.italic", "Mod+B").unwrap(); + let back: KeyMap = serde_json::from_str(&serde_json::to_string(&m).unwrap()).unwrap(); + assert_eq!(back, m); + let junk = json!({"assigned": {"Hyper+X": "format.bold", "Mod+Q": 7, "Mod+J": "no.such", "Mod+Alt+K": "format.bold"}, "removed": ["Mod+U", 3, "F99", "Mod+Shift+Q"]}); + let mut j: KeyMap = serde_json::from_value(junk).unwrap(); + j.retain_known(®); + assert_eq!(j.assigned.len(), 1, "{j:?}"); + assert_eq!(j.resolve(®, "Mod+Alt+K").map(|s| s.id), Some("format.bold")); + assert_eq!(j.removed.iter().collect::>(), ["Mod+U"]); + for v in [json!(null), json!(5), json!("x"), json!({"assigned": [1], "removed": {"a": 1}})] { + assert!(serde_json::from_value::(v).unwrap().is_empty()); + } + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 875b818c26..e5795e534e 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1,212 +1,212 @@ -//! WordCraft engine: the editing session and its commands. -//! -//! **Everything is a command.** Each user-visible action is a [`CommandSpec`] with a stable id -//! (`format.bold`, `insert.table`), a label, the ribbon/menu location, a default shortcut, a -//! params description and a `run` function. Menus, ribbon, shortcuts, the command palette, the -//! CLI, the JSON control channel and the MCP server all call [`Session::run`]. -#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] - -pub mod catalog; -pub mod cmd; -pub(crate) mod doc_state; -pub mod file_access; -pub(crate) mod ime; -pub mod io; -mod io_ext; -pub mod keymap; -pub(crate) mod locale; -pub mod math_gallery; -pub mod packs; -pub(crate) mod prefs; -pub mod print; -pub mod recovery; -pub mod ribbon; -pub mod sample; -mod session; -pub(crate) mod snapshot; -pub mod speech; -pub mod strict; -pub(crate) mod view; - -use std::collections::HashMap; - -use serde_json::Value; - -pub use io::Password; -pub use keymap::KeyMap; -pub use ribbon::RibbonLayout; -pub use session::{ - CellRange, ColumnBlock, DocInfo, DocState, EditSnapshot, FindState, MathEdit, Prefs, Selection, Session, StylesPaneDock, UNDO_LIST_MAX, - ViewState, WindowView, blank_document, next_math_session, set_system_locale, -}; -/// The outcome a command reports for IME receipt classification. -/// -/// A command that can be called by an input method must classify its committed effects. Only a -/// classified content change without an automatic rewrite may mint an IME receipt; any other -/// committed state change still advances the IME state revision and therefore makes a mixed -/// frame `Other` rather than certifying it. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub struct ImeEditOutcome { - /// The handler explicitly classified the committed effects. - pub classified: bool, - /// IME-visible content changed. Only this gates an IME receipt. - pub content_changed: bool, - /// Any committed document state changed, including style or representation changes. - pub state_changed: bool, - /// An automatic engine rewrite was applied (AutoCorrect, build-up, AutoFormat). - pub transformed: bool, - /// The caret or selection changed. Filled in by [`Session::run`]. - pub selection_changed: bool, -} - -/// The largest text one `equation.type` call inserts, in characters. The engine truncates to this -/// in both linear and built-up modes; the UI refuses an IME commit above it instead of inserting a -/// prefix. -pub const MAX_EQUATION_TYPE_CHARS: usize = 10_000; - -/// The largest number of unit steps one `equation.deleteSurrounding` request may take on either -/// side of the caret. A hostile IME count must not become an unbounded command. -pub const MAX_EQUATION_DELETE_UNITS: usize = 64; - -pub use wordcraft_doc as doc; -pub use wordcraft_geom::Region; -pub use wordcraft_layout as layout; -pub use wordcraft_proof as proof; -pub use wordcraft_render as render; - -#[derive(Debug, thiserror::Error, Clone, PartialEq)] -pub enum CmdError { - #[error("unknown command `{0}`")] - Unknown(String), - #[error("bad parameters: {0}")] - Params(String), - #[error("not available: {0}")] - Disabled(String), - #[error("{0}")] - Failed(String), -} - -impl From for CmdError { - fn from(e: wordcraft_doc::DocError) -> Self { - CmdError::Failed(e.to_string()) - } -} - -pub type CmdResult = Result; - -/// A registered command. -#[derive(Clone)] -pub struct CommandSpec { - pub id: &'static str, - pub label: &'static str, - /// Where it lives in the UI: `Home › Font`, `Insert › Tables`, `File`… - pub location: &'static str, - /// Default shortcut in a portable form (`Mod+B`, `Mod+Shift+>`, `F7`). `Mod` = Ctrl, or ⌘ on macOS. - pub shortcut: &'static str, - /// Parameter description for agents (`{"size": number}`). - pub params: &'static str, - pub mutates: bool, - /// `None` = enabled; `Some(reason)` = disabled. - pub enabled: fn(&Session) -> Option<&'static str>, - pub run: fn(&mut Session, &Value) -> CmdResult, -} - -fn always(_: &Session) -> Option<&'static str> { - None -} - -impl CommandSpec { - pub const fn new(id: &'static str, label: &'static str, location: &'static str, run: fn(&mut Session, &Value) -> CmdResult) -> Self { - CommandSpec { id, label, location, shortcut: "", params: "{}", mutates: true, enabled: always, run } - } - pub const fn key(mut self, s: &'static str) -> Self { - self.shortcut = s; - self - } - pub const fn params(mut self, p: &'static str) -> Self { - self.params = p; - self - } - /// Doesn't change the document (navigation, view, export). - pub const fn pure(mut self) -> Self { - self.mutates = false; - self - } - pub const fn when(mut self, f: fn(&Session) -> Option<&'static str>) -> Self { - self.enabled = f; - self - } -} - -/// All commands by id. -pub struct Registry { - specs: Vec, - by_id: HashMap<&'static str, usize>, -} - -impl Registry { - pub fn new(specs: Vec) -> Self { - let mut by_id = HashMap::new(); - for (i, s) in specs.iter().enumerate() { - by_id.insert(s.id, i); - } - Registry { specs, by_id } - } - pub fn get(&self, id: &str) -> Option<&CommandSpec> { - self.by_id.get(id).and_then(|i| self.specs.get(*i)) - } - pub fn all(&self) -> &[CommandSpec] { - &self.specs - } - /// Command list as JSON (for agents: `commands` / `list_commands`). - pub fn describe(&self) -> Value { - Value::Array( - self.specs - .iter() - .map(|s| serde_json::json!({"id": s.id, "label": s.label, "location": s.location, "shortcut": s.shortcut, "params": s.params, "mutates": s.mutates})) - .collect(), - ) - } - /// The command bound to a shortcut, if any. - pub fn by_shortcut(&self, key: &str) -> Option<&CommandSpec> { - self.specs.iter().find(|s| !s.shortcut.is_empty() && s.shortcut.split(" / ").any(|k| k.eq_ignore_ascii_case(key))) - } -} - -/// Parameter helpers. -pub mod p { - use serde_json::Value; - - use crate::CmdError; - - pub fn str<'a>(v: &'a Value, k: &str) -> Option<&'a str> { - v.get(k).and_then(Value::as_str) - } - pub fn req_str<'a>(v: &'a Value, k: &str) -> Result<&'a str, CmdError> { - str(v, k).ok_or_else(|| CmdError::Params(format!("`{k}` (string) is required"))) - } - pub fn f32(v: &Value, k: &str) -> Option { - v.get(k).and_then(Value::as_f64).map(|x| x as f32).filter(|x| x.is_finite()) - } - pub fn req_f32(v: &Value, k: &str) -> Result { - f32(v, k).ok_or_else(|| CmdError::Params(format!("`{k}` (number) is required"))) - } - pub fn u64(v: &Value, k: &str) -> Option { - v.get(k).and_then(|x| x.as_u64().or_else(|| x.as_f64().filter(|f| *f >= 0.0 && f.is_finite()).map(|f| f as u64))) - } - /// A 1-based page parameter as a 0-based index. A value that cannot fit in - /// `usize` (32-bit and wasm) saturates instead of wrapping to page 1. - pub fn page_index(page: u64) -> usize { - usize::try_from(page.max(1)).unwrap_or(usize::MAX).saturating_sub(1) - } - pub fn bool(v: &Value, k: &str) -> Option { - v.get(k).and_then(Value::as_bool) - } - /// The `value` param, or the first positional-ish string. - pub fn value(v: &Value) -> Option<&Value> { - v.get("value") - } -} - -#[cfg(test)] -mod tests; +//! WordCraft engine: the editing session and its commands. +//! +//! **Everything is a command.** Each user-visible action is a [`CommandSpec`] with a stable id +//! (`format.bold`, `insert.table`), a label, the ribbon/menu location, a default shortcut, a +//! params description and a `run` function. Menus, ribbon, shortcuts, the command palette, the +//! CLI, the JSON control channel and the MCP server all call [`Session::run`]. +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] + +pub mod catalog; +pub mod cmd; +pub(crate) mod doc_state; +pub mod file_access; +pub(crate) mod ime; +pub mod io; +mod io_ext; +pub mod keymap; +pub(crate) mod locale; +pub mod math_gallery; +pub mod packs; +pub(crate) mod prefs; +pub mod print; +pub mod recovery; +pub mod ribbon; +pub mod sample; +mod session; +pub(crate) mod snapshot; +pub mod speech; +pub mod strict; +pub(crate) mod view; + +use std::collections::HashMap; + +use serde_json::Value; + +pub use io::Password; +pub use keymap::KeyMap; +pub use ribbon::RibbonLayout; +pub use session::{ + CellRange, ColumnBlock, DocInfo, DocState, EditSnapshot, FindState, MathEdit, Prefs, Selection, Session, StylesPaneDock, UNDO_LIST_MAX, + ViewState, WindowView, blank_document, next_math_session, set_system_locale, +}; +/// The outcome a command reports for IME receipt classification. +/// +/// A command that can be called by an input method must classify its committed effects. Only a +/// classified content change without an automatic rewrite may mint an IME receipt; any other +/// committed state change still advances the IME state revision and therefore makes a mixed +/// frame `Other` rather than certifying it. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct ImeEditOutcome { + /// The handler explicitly classified the committed effects. + pub classified: bool, + /// IME-visible content changed. Only this gates an IME receipt. + pub content_changed: bool, + /// Any committed document state changed, including style or representation changes. + pub state_changed: bool, + /// An automatic engine rewrite was applied (AutoCorrect, build-up, AutoFormat). + pub transformed: bool, + /// The caret or selection changed. Filled in by [`Session::run`]. + pub selection_changed: bool, +} + +/// The largest text one `equation.type` call inserts, in characters. The engine truncates to this +/// in both linear and built-up modes; the UI refuses an IME commit above it instead of inserting a +/// prefix. +pub const MAX_EQUATION_TYPE_CHARS: usize = 10_000; + +/// The largest number of unit steps one `equation.deleteSurrounding` request may take on either +/// side of the caret. A hostile IME count must not become an unbounded command. +pub const MAX_EQUATION_DELETE_UNITS: usize = 64; + +pub use wordcraft_doc as doc; +pub use wordcraft_geom::Region; +pub use wordcraft_layout as layout; +pub use wordcraft_proof as proof; +pub use wordcraft_render as render; + +#[derive(Debug, thiserror::Error, Clone, PartialEq)] +pub enum CmdError { + #[error("unknown command `{0}`")] + Unknown(String), + #[error("bad parameters: {0}")] + Params(String), + #[error("not available: {0}")] + Disabled(String), + #[error("{0}")] + Failed(String), +} + +impl From for CmdError { + fn from(e: wordcraft_doc::DocError) -> Self { + CmdError::Failed(e.to_string()) + } +} + +pub type CmdResult = Result; + +/// A registered command. +#[derive(Clone)] +pub struct CommandSpec { + pub id: &'static str, + pub label: &'static str, + /// Where it lives in the UI: `Home › Font`, `Insert › Tables`, `File`… + pub location: &'static str, + /// Default shortcut in a portable form (`Mod+B`, `Mod+Shift+>`, `F7`). `Mod` = Ctrl, or ⌘ on macOS. + pub shortcut: &'static str, + /// Parameter description for agents (`{"size": number}`). + pub params: &'static str, + pub mutates: bool, + /// `None` = enabled; `Some(reason)` = disabled. + pub enabled: fn(&Session) -> Option<&'static str>, + pub run: fn(&mut Session, &Value) -> CmdResult, +} + +fn always(_: &Session) -> Option<&'static str> { + None +} + +impl CommandSpec { + pub const fn new(id: &'static str, label: &'static str, location: &'static str, run: fn(&mut Session, &Value) -> CmdResult) -> Self { + CommandSpec { id, label, location, shortcut: "", params: "{}", mutates: true, enabled: always, run } + } + pub const fn key(mut self, s: &'static str) -> Self { + self.shortcut = s; + self + } + pub const fn params(mut self, p: &'static str) -> Self { + self.params = p; + self + } + /// Doesn't change the document (navigation, view, export). + pub const fn pure(mut self) -> Self { + self.mutates = false; + self + } + pub const fn when(mut self, f: fn(&Session) -> Option<&'static str>) -> Self { + self.enabled = f; + self + } +} + +/// All commands by id. +pub struct Registry { + specs: Vec, + by_id: HashMap<&'static str, usize>, +} + +impl Registry { + pub fn new(specs: Vec) -> Self { + let mut by_id = HashMap::new(); + for (i, s) in specs.iter().enumerate() { + by_id.insert(s.id, i); + } + Registry { specs, by_id } + } + pub fn get(&self, id: &str) -> Option<&CommandSpec> { + self.by_id.get(id).and_then(|i| self.specs.get(*i)) + } + pub fn all(&self) -> &[CommandSpec] { + &self.specs + } + /// Command list as JSON (for agents: `commands` / `list_commands`). + pub fn describe(&self) -> Value { + Value::Array( + self.specs + .iter() + .map(|s| serde_json::json!({"id": s.id, "label": s.label, "location": s.location, "shortcut": s.shortcut, "params": s.params, "mutates": s.mutates})) + .collect(), + ) + } + /// The command bound to a shortcut, if any. + pub fn by_shortcut(&self, key: &str) -> Option<&CommandSpec> { + self.specs.iter().find(|s| !s.shortcut.is_empty() && s.shortcut.split(" / ").any(|k| k.eq_ignore_ascii_case(key))) + } +} + +/// Parameter helpers. +pub mod p { + use serde_json::Value; + + use crate::CmdError; + + pub fn str<'a>(v: &'a Value, k: &str) -> Option<&'a str> { + v.get(k).and_then(Value::as_str) + } + pub fn req_str<'a>(v: &'a Value, k: &str) -> Result<&'a str, CmdError> { + str(v, k).ok_or_else(|| CmdError::Params(format!("`{k}` (string) is required"))) + } + pub fn f32(v: &Value, k: &str) -> Option { + v.get(k).and_then(Value::as_f64).map(|x| x as f32).filter(|x| x.is_finite()) + } + pub fn req_f32(v: &Value, k: &str) -> Result { + f32(v, k).ok_or_else(|| CmdError::Params(format!("`{k}` (number) is required"))) + } + pub fn u64(v: &Value, k: &str) -> Option { + v.get(k).and_then(|x| x.as_u64().or_else(|| x.as_f64().filter(|f| *f >= 0.0 && f.is_finite()).map(|f| f as u64))) + } + /// A 1-based page parameter as a 0-based index. A value that cannot fit in + /// `usize` (32-bit and wasm) saturates instead of wrapping to page 1. + pub fn page_index(page: u64) -> usize { + usize::try_from(page.max(1)).unwrap_or(usize::MAX).saturating_sub(1) + } + pub fn bool(v: &Value, k: &str) -> Option { + v.get(k).and_then(Value::as_bool) + } + /// The `value` param, or the first positional-ish string. + pub fn value(v: &Value) -> Option<&Value> { + v.get("value") + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/engine/src/locale.rs b/crates/engine/src/locale.rs index b712648a4e..5d76e2eb87 100644 --- a/crates/engine/src/locale.rs +++ b/crates/engine/src/locale.rs @@ -1,15 +1,15 @@ -use std::sync::OnceLock; - -/// The system locale (BCP 47 tag) the shell found at startup, for Numeral › System. -pub(super) static SYSTEM_LOCALE: OnceLock = OnceLock::new(); - -/// Tell the engine the system locale (the shell calls this once at startup; layout never reads -/// the environment). Headless sessions without it show Western digits for Numeral › System. -pub fn set_system_locale(tag: &str) { - let _ = SYSTEM_LOCALE.set(tag.to_string()); -} - -/// The system locale [`set_system_locale`] recorded, if the shell set one. -pub(crate) fn system_locale() -> Option<&'static str> { - SYSTEM_LOCALE.get().map(String::as_str) -} +use std::sync::OnceLock; + +/// The system locale (BCP 47 tag) the shell found at startup, for Numeral › System. +pub(super) static SYSTEM_LOCALE: OnceLock = OnceLock::new(); + +/// Tell the engine the system locale (the shell calls this once at startup; layout never reads +/// the environment). Headless sessions without it show Western digits for Numeral › System. +pub fn set_system_locale(tag: &str) { + let _ = SYSTEM_LOCALE.set(tag.to_string()); +} + +/// The system locale [`set_system_locale`] recorded, if the shell set one. +pub(crate) fn system_locale() -> Option<&'static str> { + SYSTEM_LOCALE.get().map(String::as_str) +} diff --git a/crates/engine/src/math_gallery.rs b/crates/engine/src/math_gallery.rs index c4375a1fa8..6c101592d7 100644 --- a/crates/engine/src/math_gallery.rs +++ b/crates/engine/src/math_gallery.rs @@ -1,591 +1,591 @@ -//! The Equation tab's galleries: structure templates (fractions, scripts, radicals, integrals, -//! large operators, brackets, functions, accents, limits and logs, operators, matrices), symbol -//! sets and ready-made equations. Every template is linear-format text (`⬚` = empty slot) that -//! [`wordcraft_doc::math::parse_linear`] builds into structures. - -use wordcraft_doc::math::{Arg, LimLoc, parse_linear}; - -/// One template. -#[derive(Clone, Copy, Debug)] -pub struct Template { - pub id: &'static str, - pub label: &'static str, - /// Linear format; `⬚` marks an empty argument. - pub linear: &'static str, - /// Limit placement for n-ary operators (`None`: the operator's default). - pub limits: Option, -} - -const fn t(id: &'static str, label: &'static str, linear: &'static str) -> Template { - Template { id, label, linear, limits: None } -} -const fn stacked(id: &'static str, label: &'static str, linear: &'static str) -> Template { - Template { id, label, linear, limits: Some(LimLoc::UndOvr) } -} -const fn beside(id: &'static str, label: &'static str, linear: &'static str) -> Template { - Template { id, label, linear, limits: Some(LimLoc::SubSup) } -} - -impl Template { - /// The template as nodes. - pub fn nodes(&self) -> Arg { - let mut n = parse_linear(self.linear); - if self.limits.is_some() { - wordcraft_doc::math_edit::set_limits(&mut n, self.limits); - } - n - } -} - -/// A structure gallery (one Equation tab button) with its sections. -pub struct Gallery { - pub id: &'static str, - pub label: &'static str, - pub sections: &'static [(&'static str, &'static [Template])], -} - -pub const STRUCTURES: &[Gallery] = &[ - Gallery { - id: "fraction", - label: "Fraction", - sections: &[ - ( - "Fraction", - &[ - t("frac.stacked", "Stacked Fraction", "⬚/⬚"), - t("frac.skewed", "Skewed Fraction", "⬚⁄⬚"), - t("frac.linear", "Linear Fraction", "⬚∕⬚"), - t("frac.nobar", "Stacked, no bar", "⬚¦⬚"), - ], - ), - ( - "Common Fractions", - &[ - t("frac.dydx", "Differential", "dy/dx"), - t("frac.DyDx", "Change in y over change in x", "Δy/Δx"), - t("frac.partial", "Partial Differential", "∂y/∂x"), - t("frac.delta", "Small change", "δy/δx"), - t("frac.pi2", "Pi Over 2", "π/2"), - t("frac.half", "One half", "1/2"), - ], - ), - ], - }, - Gallery { - id: "script", - label: "Script", - sections: &[ - ( - "Subscripts and Superscripts", - &[ - t("script.sup", "Superscript", "⬚^⬚"), - t("script.sub", "Subscript", "⬚_⬚"), - t("script.subsup", "Subscript-Superscript", "⬚_⬚^⬚"), - t("script.pre", "Left Subscript-Superscript", "_⬚^⬚▒⬚"), - ], - ), - ( - "Common Subscripts and Superscripts", - &[ - t("script.xy2", "Subscript-Superscript", "x_y^2"), - t("script.eiwt", "Exponential", "e^(−iωt)"), - t("script.x2", "Square", "x^2"), - t("script.pre1n", "Left Subscript-Superscript", "_1^n▒Y"), - ], - ), - ], - }, - Gallery { - id: "radical", - label: "Radical", - sections: &[ - ( - "Radicals", - &[ - t("rad.sqrt", "Square Root", "√⬚"), - t("rad.nth", "Radical with Degree", "√(⬚&⬚)"), - t("rad.sqrt2", "Square Root with Degree", "√(2&⬚)"), - t("rad.cbrt", "Cube Root", "√(3&⬚)"), - ], - ), - ("Common Radicals", &[t("rad.quadratic", "Quadratic Root", "(−b±√(b^2−4ac))/2a"), t("rad.hyp", "Hypotenuse", "√(a^2+b^2)")]), - ], - }, - Gallery { - id: "integral", - label: "Integral", - sections: &[ - ( - "Integrals", - &[ - t("int.plain", "Integral", "∫▒⬚"), - beside("int.limits", "Integral with Limits", "∫_⬚^⬚▒⬚"), - stacked("int.stacked", "Integral with Stacked Limits", "∫_⬚^⬚▒⬚"), - t("int.double", "Double Integral", "∬▒⬚"), - beside("int.doubleLimits", "Double Integral with Limits", "∬_⬚^⬚▒⬚"), - stacked("int.doubleStacked", "Double Integral with Stacked Limits", "∬_⬚^⬚▒⬚"), - t("int.triple", "Triple Integral", "∭▒⬚"), - beside("int.tripleLimits", "Triple Integral with Limits", "∭_⬚^⬚▒⬚"), - stacked("int.tripleStacked", "Triple Integral with Stacked Limits", "∭_⬚^⬚▒⬚"), - ], - ), - ( - "Contour Integrals", - &[ - t("int.contour", "Contour Integral", "∮▒⬚"), - beside("int.contourLimits", "Contour Integral with Limits", "∮_⬚^⬚▒⬚"), - stacked("int.contourStacked", "Contour Integral with Stacked Limits", "∮_⬚^⬚▒⬚"), - t("int.surface", "Surface Integral", "∯▒⬚"), - beside("int.surfaceLimits", "Surface Integral with Limits", "∯_⬚^⬚▒⬚"), - stacked("int.surfaceStacked", "Surface Integral with Stacked Limits", "∯_⬚^⬚▒⬚"), - t("int.volume", "Volume Integral", "∰▒⬚"), - beside("int.volumeLimits", "Volume Integral with Limits", "∰_⬚^⬚▒⬚"), - stacked("int.volumeStacked", "Volume Integral with Stacked Limits", "∰_⬚^⬚▒⬚"), - ], - ), - ( - "Differentials", - &[t("int.dx", "Differential x", "dx"), t("int.dy", "Differential y", "dy"), t("int.dtheta", "Differential theta", "dθ")], - ), - ], - }, - Gallery { - id: "largeOperator", - label: "Large\nOperator", - sections: &[ - ( - "Summations", - &[ - t("nary.sum", "Summation", "∑▒⬚"), - stacked("nary.sumLimits", "Summation with Limits", "∑_⬚^⬚▒⬚"), - beside("nary.sumScripts", "Summation with Subscript-Superscript Limits", "∑_⬚^⬚▒⬚"), - stacked("nary.sumLower", "Summation with Lower Limit", "∑_⬚▒⬚"), - beside("nary.sumSub", "Summation with Subscript Limit", "∑_⬚▒⬚"), - ], - ), - ( - "Products and Coproducts", - &[ - t("nary.prod", "Product", "∏▒⬚"), - stacked("nary.prodLimits", "Product with Limits", "∏_⬚^⬚▒⬚"), - beside("nary.prodScripts", "Product with Subscript-Superscript Limits", "∏_⬚^⬚▒⬚"), - stacked("nary.prodLower", "Product with Lower Limit", "∏_⬚▒⬚"), - t("nary.coprod", "Coproduct", "∐▒⬚"), - stacked("nary.coprodLimits", "Coproduct with Limits", "∐_⬚^⬚▒⬚"), - beside("nary.coprodScripts", "Coproduct with Subscript-Superscript Limits", "∐_⬚^⬚▒⬚"), - stacked("nary.coprodLower", "Coproduct with Lower Limit", "∐_⬚▒⬚"), - ], - ), - ( - "Unions and Intersections", - &[ - t("nary.union", "Union", "⋃▒⬚"), - stacked("nary.unionLimits", "Union with Limits", "⋃_⬚^⬚▒⬚"), - beside("nary.unionScripts", "Union with Subscript-Superscript Limits", "⋃_⬚^⬚▒⬚"), - stacked("nary.unionLower", "Union with Lower Limit", "⋃_⬚▒⬚"), - t("nary.intersection", "Intersection", "⋂▒⬚"), - stacked("nary.intersectionLimits", "Intersection with Limits", "⋂_⬚^⬚▒⬚"), - beside("nary.intersectionScripts", "Intersection with Subscript-Superscript Limits", "⋂_⬚^⬚▒⬚"), - stacked("nary.intersectionLower", "Intersection with Lower Limit", "⋂_⬚▒⬚"), - ], - ), - ( - "Other Large Operators", - &[ - t("nary.or", "Logical OR", "⋁▒⬚"), - stacked("nary.orLimits", "Logical OR with Limits", "⋁_⬚^⬚▒⬚"), - t("nary.and", "Logical AND", "⋀▒⬚"), - stacked("nary.andLimits", "Logical AND with Limits", "⋀_⬚^⬚▒⬚"), - t("nary.oplus", "Circled Plus", "⨁▒⬚"), - t("nary.otimes", "Circled Times", "⨂▒⬚"), - t("nary.odot", "Circled Dot", "⨀▒⬚"), - t("nary.uplus", "Union with Plus", "⨄▒⬚"), - t("nary.sqcup", "Square Union", "⨆▒⬚"), - ], - ), - ( - "Common Large Operators", - &[ - stacked("nary.binomial", "Summation over binomials", "∑_k▒〖(n¦k)〗"), - stacked("nary.sum0n", "Summation from 0 to n", "∑_(i=0)^n▒⬚"), - stacked("nary.double", "Double summation", "∑_(0≤i≤m,0 impl Iterator { - STRUCTURES.iter().flat_map(|g| g.sections.iter().flat_map(|(_, ts)| ts.iter())) -} - -pub fn template(id: &str) -> Option<&'static Template> { - templates().find(|t| t.id == id) -} - -/// Symbol sets (the Symbols gallery's categories). -pub const SYMBOL_SETS: &[(&str, &str)] = &[ - ("Basic Math", "±∞=≠~×÷!∝<≪>≫≤≥∓≅≈≡∀∁∂√∛∜∪∩∅%°℉℃∆∇∃∄∈∋←↑→↓↔∴+−¬αβγδεϵθϑμπρστφωω∗∙⋮⋯⋰⋱ℵℶ∎"), - ("Greek Letters", "αβγδεϵζηθϑικλμνξοπϖρϱσςτυφϕχψωΑΒΓΔΕΖΗΘΙΚΛΜΝΞΟΠΡΣΤΥΦΧΨΩ"), - ("Letter-Like Symbols", "∀∁ℂ∂ðℇϝℱℊℋℌℍℎℏıℑȷℒℓℕ℘ℙℚℛℜℝℤ℧Åℬℭℰℯℳℴℵℶℷℸⅅⅆⅇⅈⅉ"), - ("Operators", "+−÷×±∓∝∕∗∘∙√∛∜∩∪⊎⊓⊔∧∨⊕⊖⊗⊘⊙⊚⊛⊞⊟⊠⊡⋄⋅⋆⋉⋊∔∸∖≀=≠<>≤≥≮≰≯≱≡∼≃≈≅≢≄≉≇∝≪≫∈∋∉⊂⊃⊆⊇≺≻≼≽⊏⊐⊑⊒∥⊥⊢⊣⋈≍∴∵∷≐≑≒≓≔≕≖≗≜≝≞≟∑∫∬∭∮∯∰∏∐⋂⋃⋀⋁⨀⨁⨂⨄⨆"), - ("Arrows", "←→↑↓↔↕⇐⇒⇑⇓⇔⇕⟵⟶⟷⟸⟹⟺↦⟼↩↪↼↽⇀⇁↿↾⇃⇂⇋⇌⇄⇆⇇⇉⇈⇊↖↗↘↙↶↷↺↻⊸↜↝↞↠↢↣↫↬↭⇚⇛↰↱⇝"), - ("Negated Relations", "≠≮≯≰≱≢≁≄≉≇≭≨≩⊀⊁⋠⋡∉∌⊄⊅⊈⊉⊊⊋⋢⋣⋦⋧⋨⋩⋪⋫⋬⋭∤∦⊬⊭⊮⊯∄"), - ("Scripts", "𝒜ℬ𝒞𝒟ℰℱ𝒢ℋℐ𝒥𝒦ℒℳ𝒩𝒪𝒫𝒬ℛ𝒮𝒯𝒰𝒱𝒲𝒳𝒴𝒵𝒶𝒷𝒸𝒹ℯ𝒻ℊ𝒽𝒾𝒿𝓀𝓁𝓂𝓃ℴ𝓅𝓆𝓇𝓈𝓉𝓊𝓋𝓌𝓍𝓎𝓏𝔄𝔅ℭ𝔇𝔈𝔉𝔊ℌℑ𝔍𝔎𝔏𝔐𝔑𝔒𝔓𝔔ℜ𝔖𝔗𝔘𝔙𝔚𝔛𝔜ℨ𝔸𝔹ℂ𝔻𝔼𝔽𝔾ℍ𝕀𝕁𝕂𝕃𝕄ℕ𝕆ℙℚℝ𝕊𝕋𝕌𝕍𝕎𝕏𝕐ℤ"), - ("Geometry", "∟∠∡∢⊾⊿⋕⊥∤∥∦∶∷∴∵∎△▭▱○◊□◇⊡⊙"), -]; - -/// Ready-made equations (Insert › Equation), grouped by subject: geometry, algebra, calculus, -/// trigonometry, probability. The labels are our own descriptions of the formulas. -pub const BUILT_INS: &[(&str, &str, &str)] = &[ - ("pythagoras", "Sides of a right triangle", "a^2+b^2=c^2"), - ("area", "Circle area from its radius", "A=πr^2"), - ("quadratic", "Roots of ax²+bx+c", "x=(−b±√(b^2−4ac))/2a"), - ("binomial", "Binomial expansion of (x+a)ⁿ", "(x+a)^n=∑_(k=0)^n▒〖(n¦k) x^k a^(n−k)〗"), - ("expansion", "Power series of (1+x)ⁿ", "(1+x)^n=1+nx/1!+(n(n−1)x^2)/2!+⋯"), - ("euler", "Euler's identity", "e^(iπ)+1=0"), - ("derivative", "Derivative as a limit", "f'(x)=lim┬(h→0)⁡〖(f(x+h)−f(x))/h〗"), - ("taylor", "Exponential series", "e^x=1+x/1!+x^2/2!+x^3/3!+⋯, −∞ bool { - a.iter().any(|n| match n { - MNode::Run(r) => r.text.contains('⬚') || r.text.contains('▒') || r.text.contains('┬') || r.text.contains('┴'), - other => { - (0..wordcraft_doc::math_edit::child_count(other)).any(|c| wordcraft_doc::math_edit::child(other, c).is_some_and(|x| has_leftover(x))) - } - }) - } - - /// A script or above/below operand takes one letter unless it is grouped (`a_(mn)`), as in - /// Word; letters right after one mean a template split a word (`⟶┴yields` put only `y` above - /// the arrow). - fn split_operand(a: &[MNode]) -> bool { - a.windows(2).any(|w| { - matches!(w[0], MNode::Lim { .. } | MNode::Script { .. }) - && matches!(&w[1], MNode::Run(r) if r.text.starts_with(|c: char| c.is_alphabetic())) - }) || a - .iter() - .any(|n| (0..wordcraft_doc::math_edit::child_count(n)).any(|c| wordcraft_doc::math_edit::child(n, c).is_some_and(|x| split_operand(x)))) - } - - #[test] - fn multi_letter_operands_are_grouped() { - for tpl in templates() { - assert!(!split_operand(&tpl.nodes()), "{} splits an operand: {}", tpl.id, tpl.linear); - } - for (id, _, lin) in BUILT_INS { - assert!(!split_operand(&parse_linear(lin)), "{id} splits an operand: {lin}"); - } - } - - #[test] - fn every_template_builds() { - let mut ids = std::collections::HashSet::new(); - for tpl in templates() { - assert!(ids.insert(tpl.id), "duplicate id {}", tpl.id); - let n = tpl.nodes(); - assert!(!n.is_empty(), "{} builds nothing", tpl.id); - assert!(!has_leftover(&n), "{} left linear operators: {}", tpl.id, to_linear(&n)); - // Templates with placeholders are structures, not text. - if tpl.linear.contains('⬚') { - assert!(n.iter().any(|x| !matches!(x, MNode::Run(_))), "{} → {n:?}", tpl.id); - } - } - for (id, _, lin) in BUILT_INS { - let n = parse_linear(lin); - assert!(!has_leftover(&n), "{id}: {}", to_linear(&n)); - } - } -} +//! The Equation tab's galleries: structure templates (fractions, scripts, radicals, integrals, +//! large operators, brackets, functions, accents, limits and logs, operators, matrices), symbol +//! sets and ready-made equations. Every template is linear-format text (`⬚` = empty slot) that +//! [`wordcraft_doc::math::parse_linear`] builds into structures. + +use wordcraft_doc::math::{Arg, LimLoc, parse_linear}; + +/// One template. +#[derive(Clone, Copy, Debug)] +pub struct Template { + pub id: &'static str, + pub label: &'static str, + /// Linear format; `⬚` marks an empty argument. + pub linear: &'static str, + /// Limit placement for n-ary operators (`None`: the operator's default). + pub limits: Option, +} + +const fn t(id: &'static str, label: &'static str, linear: &'static str) -> Template { + Template { id, label, linear, limits: None } +} +const fn stacked(id: &'static str, label: &'static str, linear: &'static str) -> Template { + Template { id, label, linear, limits: Some(LimLoc::UndOvr) } +} +const fn beside(id: &'static str, label: &'static str, linear: &'static str) -> Template { + Template { id, label, linear, limits: Some(LimLoc::SubSup) } +} + +impl Template { + /// The template as nodes. + pub fn nodes(&self) -> Arg { + let mut n = parse_linear(self.linear); + if self.limits.is_some() { + wordcraft_doc::math_edit::set_limits(&mut n, self.limits); + } + n + } +} + +/// A structure gallery (one Equation tab button) with its sections. +pub struct Gallery { + pub id: &'static str, + pub label: &'static str, + pub sections: &'static [(&'static str, &'static [Template])], +} + +pub const STRUCTURES: &[Gallery] = &[ + Gallery { + id: "fraction", + label: "Fraction", + sections: &[ + ( + "Fraction", + &[ + t("frac.stacked", "Stacked Fraction", "⬚/⬚"), + t("frac.skewed", "Skewed Fraction", "⬚⁄⬚"), + t("frac.linear", "Linear Fraction", "⬚∕⬚"), + t("frac.nobar", "Stacked, no bar", "⬚¦⬚"), + ], + ), + ( + "Common Fractions", + &[ + t("frac.dydx", "Differential", "dy/dx"), + t("frac.DyDx", "Change in y over change in x", "Δy/Δx"), + t("frac.partial", "Partial Differential", "∂y/∂x"), + t("frac.delta", "Small change", "δy/δx"), + t("frac.pi2", "Pi Over 2", "π/2"), + t("frac.half", "One half", "1/2"), + ], + ), + ], + }, + Gallery { + id: "script", + label: "Script", + sections: &[ + ( + "Subscripts and Superscripts", + &[ + t("script.sup", "Superscript", "⬚^⬚"), + t("script.sub", "Subscript", "⬚_⬚"), + t("script.subsup", "Subscript-Superscript", "⬚_⬚^⬚"), + t("script.pre", "Left Subscript-Superscript", "_⬚^⬚▒⬚"), + ], + ), + ( + "Common Subscripts and Superscripts", + &[ + t("script.xy2", "Subscript-Superscript", "x_y^2"), + t("script.eiwt", "Exponential", "e^(−iωt)"), + t("script.x2", "Square", "x^2"), + t("script.pre1n", "Left Subscript-Superscript", "_1^n▒Y"), + ], + ), + ], + }, + Gallery { + id: "radical", + label: "Radical", + sections: &[ + ( + "Radicals", + &[ + t("rad.sqrt", "Square Root", "√⬚"), + t("rad.nth", "Radical with Degree", "√(⬚&⬚)"), + t("rad.sqrt2", "Square Root with Degree", "√(2&⬚)"), + t("rad.cbrt", "Cube Root", "√(3&⬚)"), + ], + ), + ("Common Radicals", &[t("rad.quadratic", "Quadratic Root", "(−b±√(b^2−4ac))/2a"), t("rad.hyp", "Hypotenuse", "√(a^2+b^2)")]), + ], + }, + Gallery { + id: "integral", + label: "Integral", + sections: &[ + ( + "Integrals", + &[ + t("int.plain", "Integral", "∫▒⬚"), + beside("int.limits", "Integral with Limits", "∫_⬚^⬚▒⬚"), + stacked("int.stacked", "Integral with Stacked Limits", "∫_⬚^⬚▒⬚"), + t("int.double", "Double Integral", "∬▒⬚"), + beside("int.doubleLimits", "Double Integral with Limits", "∬_⬚^⬚▒⬚"), + stacked("int.doubleStacked", "Double Integral with Stacked Limits", "∬_⬚^⬚▒⬚"), + t("int.triple", "Triple Integral", "∭▒⬚"), + beside("int.tripleLimits", "Triple Integral with Limits", "∭_⬚^⬚▒⬚"), + stacked("int.tripleStacked", "Triple Integral with Stacked Limits", "∭_⬚^⬚▒⬚"), + ], + ), + ( + "Contour Integrals", + &[ + t("int.contour", "Contour Integral", "∮▒⬚"), + beside("int.contourLimits", "Contour Integral with Limits", "∮_⬚^⬚▒⬚"), + stacked("int.contourStacked", "Contour Integral with Stacked Limits", "∮_⬚^⬚▒⬚"), + t("int.surface", "Surface Integral", "∯▒⬚"), + beside("int.surfaceLimits", "Surface Integral with Limits", "∯_⬚^⬚▒⬚"), + stacked("int.surfaceStacked", "Surface Integral with Stacked Limits", "∯_⬚^⬚▒⬚"), + t("int.volume", "Volume Integral", "∰▒⬚"), + beside("int.volumeLimits", "Volume Integral with Limits", "∰_⬚^⬚▒⬚"), + stacked("int.volumeStacked", "Volume Integral with Stacked Limits", "∰_⬚^⬚▒⬚"), + ], + ), + ( + "Differentials", + &[t("int.dx", "Differential x", "dx"), t("int.dy", "Differential y", "dy"), t("int.dtheta", "Differential theta", "dθ")], + ), + ], + }, + Gallery { + id: "largeOperator", + label: "Large\nOperator", + sections: &[ + ( + "Summations", + &[ + t("nary.sum", "Summation", "∑▒⬚"), + stacked("nary.sumLimits", "Summation with Limits", "∑_⬚^⬚▒⬚"), + beside("nary.sumScripts", "Summation with Subscript-Superscript Limits", "∑_⬚^⬚▒⬚"), + stacked("nary.sumLower", "Summation with Lower Limit", "∑_⬚▒⬚"), + beside("nary.sumSub", "Summation with Subscript Limit", "∑_⬚▒⬚"), + ], + ), + ( + "Products and Coproducts", + &[ + t("nary.prod", "Product", "∏▒⬚"), + stacked("nary.prodLimits", "Product with Limits", "∏_⬚^⬚▒⬚"), + beside("nary.prodScripts", "Product with Subscript-Superscript Limits", "∏_⬚^⬚▒⬚"), + stacked("nary.prodLower", "Product with Lower Limit", "∏_⬚▒⬚"), + t("nary.coprod", "Coproduct", "∐▒⬚"), + stacked("nary.coprodLimits", "Coproduct with Limits", "∐_⬚^⬚▒⬚"), + beside("nary.coprodScripts", "Coproduct with Subscript-Superscript Limits", "∐_⬚^⬚▒⬚"), + stacked("nary.coprodLower", "Coproduct with Lower Limit", "∐_⬚▒⬚"), + ], + ), + ( + "Unions and Intersections", + &[ + t("nary.union", "Union", "⋃▒⬚"), + stacked("nary.unionLimits", "Union with Limits", "⋃_⬚^⬚▒⬚"), + beside("nary.unionScripts", "Union with Subscript-Superscript Limits", "⋃_⬚^⬚▒⬚"), + stacked("nary.unionLower", "Union with Lower Limit", "⋃_⬚▒⬚"), + t("nary.intersection", "Intersection", "⋂▒⬚"), + stacked("nary.intersectionLimits", "Intersection with Limits", "⋂_⬚^⬚▒⬚"), + beside("nary.intersectionScripts", "Intersection with Subscript-Superscript Limits", "⋂_⬚^⬚▒⬚"), + stacked("nary.intersectionLower", "Intersection with Lower Limit", "⋂_⬚▒⬚"), + ], + ), + ( + "Other Large Operators", + &[ + t("nary.or", "Logical OR", "⋁▒⬚"), + stacked("nary.orLimits", "Logical OR with Limits", "⋁_⬚^⬚▒⬚"), + t("nary.and", "Logical AND", "⋀▒⬚"), + stacked("nary.andLimits", "Logical AND with Limits", "⋀_⬚^⬚▒⬚"), + t("nary.oplus", "Circled Plus", "⨁▒⬚"), + t("nary.otimes", "Circled Times", "⨂▒⬚"), + t("nary.odot", "Circled Dot", "⨀▒⬚"), + t("nary.uplus", "Union with Plus", "⨄▒⬚"), + t("nary.sqcup", "Square Union", "⨆▒⬚"), + ], + ), + ( + "Common Large Operators", + &[ + stacked("nary.binomial", "Summation over binomials", "∑_k▒〖(n¦k)〗"), + stacked("nary.sum0n", "Summation from 0 to n", "∑_(i=0)^n▒⬚"), + stacked("nary.double", "Double summation", "∑_(0≤i≤m,0 impl Iterator { + STRUCTURES.iter().flat_map(|g| g.sections.iter().flat_map(|(_, ts)| ts.iter())) +} + +pub fn template(id: &str) -> Option<&'static Template> { + templates().find(|t| t.id == id) +} + +/// Symbol sets (the Symbols gallery's categories). +pub const SYMBOL_SETS: &[(&str, &str)] = &[ + ("Basic Math", "±∞=≠~×÷!∝<≪>≫≤≥∓≅≈≡∀∁∂√∛∜∪∩∅%°℉℃∆∇∃∄∈∋←↑→↓↔∴+−¬αβγδεϵθϑμπρστφωω∗∙⋮⋯⋰⋱ℵℶ∎"), + ("Greek Letters", "αβγδεϵζηθϑικλμνξοπϖρϱσςτυφϕχψωΑΒΓΔΕΖΗΘΙΚΛΜΝΞΟΠΡΣΤΥΦΧΨΩ"), + ("Letter-Like Symbols", "∀∁ℂ∂ðℇϝℱℊℋℌℍℎℏıℑȷℒℓℕ℘ℙℚℛℜℝℤ℧Åℬℭℰℯℳℴℵℶℷℸⅅⅆⅇⅈⅉ"), + ("Operators", "+−÷×±∓∝∕∗∘∙√∛∜∩∪⊎⊓⊔∧∨⊕⊖⊗⊘⊙⊚⊛⊞⊟⊠⊡⋄⋅⋆⋉⋊∔∸∖≀=≠<>≤≥≮≰≯≱≡∼≃≈≅≢≄≉≇∝≪≫∈∋∉⊂⊃⊆⊇≺≻≼≽⊏⊐⊑⊒∥⊥⊢⊣⋈≍∴∵∷≐≑≒≓≔≕≖≗≜≝≞≟∑∫∬∭∮∯∰∏∐⋂⋃⋀⋁⨀⨁⨂⨄⨆"), + ("Arrows", "←→↑↓↔↕⇐⇒⇑⇓⇔⇕⟵⟶⟷⟸⟹⟺↦⟼↩↪↼↽⇀⇁↿↾⇃⇂⇋⇌⇄⇆⇇⇉⇈⇊↖↗↘↙↶↷↺↻⊸↜↝↞↠↢↣↫↬↭⇚⇛↰↱⇝"), + ("Negated Relations", "≠≮≯≰≱≢≁≄≉≇≭≨≩⊀⊁⋠⋡∉∌⊄⊅⊈⊉⊊⊋⋢⋣⋦⋧⋨⋩⋪⋫⋬⋭∤∦⊬⊭⊮⊯∄"), + ("Scripts", "𝒜ℬ𝒞𝒟ℰℱ𝒢ℋℐ𝒥𝒦ℒℳ𝒩𝒪𝒫𝒬ℛ𝒮𝒯𝒰𝒱𝒲𝒳𝒴𝒵𝒶𝒷𝒸𝒹ℯ𝒻ℊ𝒽𝒾𝒿𝓀𝓁𝓂𝓃ℴ𝓅𝓆𝓇𝓈𝓉𝓊𝓋𝓌𝓍𝓎𝓏𝔄𝔅ℭ𝔇𝔈𝔉𝔊ℌℑ𝔍𝔎𝔏𝔐𝔑𝔒𝔓𝔔ℜ𝔖𝔗𝔘𝔙𝔚𝔛𝔜ℨ𝔸𝔹ℂ𝔻𝔼𝔽𝔾ℍ𝕀𝕁𝕂𝕃𝕄ℕ𝕆ℙℚℝ𝕊𝕋𝕌𝕍𝕎𝕏𝕐ℤ"), + ("Geometry", "∟∠∡∢⊾⊿⋕⊥∤∥∦∶∷∴∵∎△▭▱○◊□◇⊡⊙"), +]; + +/// Ready-made equations (Insert › Equation), grouped by subject: geometry, algebra, calculus, +/// trigonometry, probability. The labels are our own descriptions of the formulas. +pub const BUILT_INS: &[(&str, &str, &str)] = &[ + ("pythagoras", "Sides of a right triangle", "a^2+b^2=c^2"), + ("area", "Circle area from its radius", "A=πr^2"), + ("quadratic", "Roots of ax²+bx+c", "x=(−b±√(b^2−4ac))/2a"), + ("binomial", "Binomial expansion of (x+a)ⁿ", "(x+a)^n=∑_(k=0)^n▒〖(n¦k) x^k a^(n−k)〗"), + ("expansion", "Power series of (1+x)ⁿ", "(1+x)^n=1+nx/1!+(n(n−1)x^2)/2!+⋯"), + ("euler", "Euler's identity", "e^(iπ)+1=0"), + ("derivative", "Derivative as a limit", "f'(x)=lim┬(h→0)⁡〖(f(x+h)−f(x))/h〗"), + ("taylor", "Exponential series", "e^x=1+x/1!+x^2/2!+x^3/3!+⋯, −∞ bool { + a.iter().any(|n| match n { + MNode::Run(r) => r.text.contains('⬚') || r.text.contains('▒') || r.text.contains('┬') || r.text.contains('┴'), + other => { + (0..wordcraft_doc::math_edit::child_count(other)).any(|c| wordcraft_doc::math_edit::child(other, c).is_some_and(|x| has_leftover(x))) + } + }) + } + + /// A script or above/below operand takes one letter unless it is grouped (`a_(mn)`), as in + /// Word; letters right after one mean a template split a word (`⟶┴yields` put only `y` above + /// the arrow). + fn split_operand(a: &[MNode]) -> bool { + a.windows(2).any(|w| { + matches!(w[0], MNode::Lim { .. } | MNode::Script { .. }) + && matches!(&w[1], MNode::Run(r) if r.text.starts_with(|c: char| c.is_alphabetic())) + }) || a + .iter() + .any(|n| (0..wordcraft_doc::math_edit::child_count(n)).any(|c| wordcraft_doc::math_edit::child(n, c).is_some_and(|x| split_operand(x)))) + } + + #[test] + fn multi_letter_operands_are_grouped() { + for tpl in templates() { + assert!(!split_operand(&tpl.nodes()), "{} splits an operand: {}", tpl.id, tpl.linear); + } + for (id, _, lin) in BUILT_INS { + assert!(!split_operand(&parse_linear(lin)), "{id} splits an operand: {lin}"); + } + } + + #[test] + fn every_template_builds() { + let mut ids = std::collections::HashSet::new(); + for tpl in templates() { + assert!(ids.insert(tpl.id), "duplicate id {}", tpl.id); + let n = tpl.nodes(); + assert!(!n.is_empty(), "{} builds nothing", tpl.id); + assert!(!has_leftover(&n), "{} left linear operators: {}", tpl.id, to_linear(&n)); + // Templates with placeholders are structures, not text. + if tpl.linear.contains('⬚') { + assert!(n.iter().any(|x| !matches!(x, MNode::Run(_))), "{} → {n:?}", tpl.id); + } + } + for (id, _, lin) in BUILT_INS { + let n = parse_linear(lin); + assert!(!has_leftover(&n), "{id}: {}", to_linear(&n)); + } + } +} diff --git a/crates/engine/src/packs.rs b/crates/engine/src/packs.rs index 7320c78663..5619114bb9 100644 --- a/crates/engine/src/packs.rs +++ b/crates/engine/src/packs.rs @@ -1,1163 +1,1163 @@ -//! Language packs (CP4.3): spelling dictionaries and hyphenation patterns for languages beyond -//! the embedded English, one pack per locale, downloaded and verified by the font store -//! (craft-libs `craft-font-store`, item kind `language-pack`) and read by `craft-proof` from the -//! store's `language-packs/` folder. -//! -//! The commands (`proof.packs`, `proof.installPack`, `proof.removePack`), Options › Language, -//! the missing-pack bar and About › Language data all go through this module. It never asks -//! anything: an install needs the font store's single consent setting (downloading on), which only -//! a person in the app can turn on ([`Backend::enable_network`], called from an Install click). -//! Agents (MCP, the CLI, the control channel) install only while it is on. -//! -//! The [`Backend`] is set once at start-up by the desktop app and the CLI ([`use_font_store`]). -//! Without one (tests, the web build, `ui_shot`) nothing is available and nothing is downloaded. - -use std::collections::BTreeSet; -use std::path::PathBuf; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::{Arc, Mutex, PoisonError, RwLock}; - -use serde::Serialize; - -use crate::{CmdError, Session}; - -#[cfg(not(target_arch = "wasm32"))] -mod store; - -/// Longest locale tag taken, in bytes (BCP 47 tags in practice stay under 20). -pub const MAX_LOCALE_LEN: usize = 35; -/// Most packs an index may list that are read. -const MAX_PACKS: usize = 1000; -/// Paragraphs of a document looked at for its languages (the missing-pack bar). -const MAX_PARAGRAPHS_SCANNED: usize = 20_000; -/// Distinct languages of a document collected. -const MAX_DOC_LANGUAGES: usize = 64; - -/// A pack the pinned index lists. -#[derive(Clone, Debug, PartialEq, Eq, Serialize)] -pub struct Pack { - /// Lowercase BCP 47 (`nl`, `pt-pt`). - pub locale: String, - /// The language's own name (`Nederlands`). - pub name: String, - /// The craft-i18n commit the pack was built from. - pub version: String, - /// The archive. - pub url: String, - /// The upstream folder the data comes from, at its pinned commit (the index's optional - /// `source`), shown before install; the archive when the index gives none. - pub source: Option, - /// Bytes of the archive. - pub size: u64, - /// SPDX expression. - pub licence: String, - /// `Embeddable` or `Download-only`. - pub class: String, - /// Has a spelling dictionary. - pub spelling: bool, - /// Has hyphenation patterns. - pub hyphenation: bool, -} - -/// An installed pack. -#[derive(Clone, Debug, PartialEq, Eq, Serialize)] -pub struct Installed { - pub locale: String, - pub name: String, - pub licence: String, - pub path: PathBuf, -} - -/// Stops a download. On the desktop it is the font store's own flag, which a download watches -/// even while the server sends nothing. -#[derive(Clone, Debug, Default)] -pub struct Cancel(#[cfg(not(target_arch = "wasm32"))] pub(crate) craft_font_store::Cancel, #[cfg(target_arch = "wasm32")] Arc); - -impl Cancel { - pub fn new() -> Self { - Self::default() - } - #[cfg(not(target_arch = "wasm32"))] - pub fn cancel(&self) { - self.0.cancel(); - } - #[cfg(not(target_arch = "wasm32"))] - pub fn is_cancelled(&self) -> bool { - self.0.is_cancelled() - } - #[cfg(target_arch = "wasm32")] - pub fn cancel(&self) { - self.0.store(true, Ordering::Relaxed); - } - #[cfg(target_arch = "wasm32")] - pub fn is_cancelled(&self) -> bool { - self.0.load(Ordering::Relaxed) - } -} - -/// Where packs come from: the font store on the desktop, a fixed list in tests and `ui_shot`. -pub trait Backend: Send + Sync { - /// The pinned index as last fetched (no request); empty when it never was. - fn index(&self) -> Result, String>; - /// Fetch the pinned index (a request; only while downloading is on). - fn fetch_index(&self, cancel: &Cancel) -> Result, String>; - /// Whether the font store may download. - fn network_on(&self) -> bool; - /// Turn downloading on. Only from the person's own action in the app (an Install click): - /// the commands never call it, and a backend for an agent refuses. - fn enable_network(&self) -> Result<(), String>; - /// Download, verify and unpack `pack` into the packs folder. `progress` gets (done, total) bytes. - fn install(&self, pack: &Pack, cancel: &Cancel, progress: &mut dyn FnMut(u64, u64)) -> Result<(), String>; - /// Remove the installed pack for `locale`. - fn remove(&self, locale: &str) -> Result<(), String>; - /// The installed-packs folder (`/language-packs`). - fn packs_dir(&self) -> Option; - /// The packs installed now. - fn installed(&self) -> Vec { - installed_from_proof() - } - /// The test index in use instead of the pinned one (`CRAFT_LANGUAGE_PACK_INDEX`), if any. - fn index_override(&self) -> Option { - None - } -} - -static BACKEND: RwLock>> = RwLock::new(None); -/// The index last read, so the bar and the list don't read the store's cache every frame. -static INDEX: Mutex>> = Mutex::new(None); -/// Bumped whenever [`INDEX`] changes. -static INDEX_GENERATION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); - -fn set_index(index: Option>) { - *INDEX.lock().unwrap_or_else(PoisonError::into_inner) = index; - INDEX_GENERATION.fetch_add(1, Ordering::Relaxed); -} - -/// Changes whenever the list of available packs does (a fetch, another backend). -pub fn index_generation() -> u64 { - INDEX_GENERATION.load(Ordering::Relaxed) -} - -/// Use `backend` for packs from now on (`None`: no packs), and point proofing at its folder. -pub fn set_backend(backend: Option>) { - let dir = backend.as_ref().and_then(|b| b.packs_dir()); - *BACKEND.write().unwrap_or_else(PoisonError::into_inner) = backend; - set_index(None); - FETCHED.store(false, Ordering::Relaxed); - AUTO_REFRESHED.store(false, Ordering::Relaxed); - WANTED.lock().unwrap_or_else(PoisonError::into_inner).clear(); - jobs().errors.clear(); - wordcraft_proof::packs::set_packs_dir(dir); -} - -/// Use the suite's font store for packs (`/language-packs`), as the person in the app -/// (`user`: an Install click may turn downloading on) or as an agent (the CLI, headless MCP: it -/// never can). The pack index is the pinned one, or `CRAFT_LANGUAGE_PACK_INDEX` (a URL or a path, -/// for testing packs before they are published), which the font store reads itself. Returns -/// whether there is a store folder. -#[cfg(not(target_arch = "wasm32"))] -pub fn use_font_store(user: bool) -> bool { - match store::FontStoreBackend::new(user) { - Some(b) => { - set_backend(Some(Arc::new(b))); - true - } - None => false, - } -} - -/// [`use_font_store`] with the store in `dir` instead of the person's (`ui_shot`, tests). -#[cfg(not(target_arch = "wasm32"))] -pub fn use_font_store_in(dir: PathBuf, user: bool) { - set_backend(Some(Arc::new(store::FontStoreBackend::in_dir(dir, user)))); -} - -/// The backend in use. -pub fn backend() -> Option> { - BACKEND.read().unwrap_or_else(PoisonError::into_inner).clone() -} - -fn installed_from_proof() -> Vec { - wordcraft_proof::packs::installed().into_iter().map(|p| Installed { locale: p.locale, name: p.name, licence: p.licence, path: p.path }).collect() -} - -/// The test source packs come from instead of the pinned index (`CRAFT_LANGUAGE_PACK_INDEX`): -/// Options › Language says so. -pub fn index_override() -> Option { - backend().and_then(|b| b.index_override()) -} - -/// The packs installed now (none without a backend). -pub fn installed() -> Vec { - backend().map(|b| b.installed()).unwrap_or_default() -} - -/// The available packs: the index as last read, read from the store's cache the first time. -pub fn available() -> Vec { - let Some(b) = backend() else { return Vec::new() }; - let mut cached = INDEX.lock().unwrap_or_else(PoisonError::into_inner); - if let Some(i) = cached.as_ref() { - return i.clone(); - } - let index = clean_index(b.index().unwrap_or_default()); - *cached = Some(index.clone()); - drop(cached); - INDEX_GENERATION.fetch_add(1, Ordering::Relaxed); - index -} - -/// How many packs [`available`] lists, without copying them (the window asks every frame). -pub fn available_len() -> usize { - if let Some(i) = INDEX.lock().unwrap_or_else(PoisonError::into_inner).as_ref() { - return i.len(); - } - available().len() -} - -/// Fetch the index again (downloading must be on). -pub fn refresh(cancel: &Cancel) -> Result, String> { - let b = backend().ok_or_else(no_store)?; - if !b.network_on() { - return Err(network_off()); - } - let index = clean_index(b.fetch_index(cancel)?); - set_index(Some(index.clone())); - FETCHED.store(true, Ordering::Relaxed); - install_wanted(&index); - Ok(index) -} - -/// Drop packs with a malformed locale, duplicates and English (embedded), and cap the list. -fn clean_index(mut packs: Vec) -> Vec { - packs.truncate(MAX_PACKS); - let mut seen = BTreeSet::new(); - packs.retain(|p| valid_locale(&p.locale) && !is_embedded(&p.locale) && seen.insert(p.locale.clone())); - packs.sort_by(|a, b| a.locale.cmp(&b.locale)); - packs -} - -/// English is embedded in every build; it never comes as a pack. -fn is_embedded(locale: &str) -> bool { - locale == "en" || locale.starts_with("en-") -} - -/// A lowercase BCP 47-shaped tag: letters, digits and `-`, starting with a letter. -pub fn valid_locale(locale: &str) -> bool { - !locale.is_empty() - && locale.len() <= MAX_LOCALE_LEN - && locale.starts_with(|c: char| c.is_ascii_lowercase()) - && locale.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') - && !locale.contains("--") - && !locale.ends_with('-') -} - -/// A locale from a command's params: trimmed, lowercased, `_` as `-`. -pub fn parse_locale(raw: &str) -> Result { - let l = raw.trim().replace('_', "-").to_ascii_lowercase(); - if valid_locale(&l) { Ok(l) } else { Err(CmdError::Params(format!("`locale` must be a language tag like nl or pt-PT, not {raw:?}"))) } -} - -fn no_store() -> String { - if cfg!(target_arch = "wasm32") { - "Language packs aren't available in the browser".into() - } else { - "there is no font store folder for language packs".into() - } -} - -fn network_off() -> String { - "downloading is turned off: turn it on in WordCraft (File › Options › Language, or an Install button there)".into() -} - -/// The pack for `locale` in the index. -pub fn find(locale: &str) -> Option { - available().into_iter().find(|p| p.locale == locale) -} - -/// Whether a pack is installed for `locale`. -pub fn is_installed(locale: &str) -> bool { - installed().iter().any(|p| p.locale == locale) -} - -/// Install the pack for `locale` (downloading must be on): the commands and the Install button. -/// Proofing picks it up at once. -pub fn install(locale: &str, cancel: &Cancel, progress: &mut dyn FnMut(u64, u64)) -> Result { - let b = backend().ok_or_else(no_store)?; - let pack = find(locale).ok_or_else(|| format!("there is no language pack for {locale}"))?; - if !b.network_on() { - return Err(network_off()); - } - if let Some(dir) = b.packs_dir() { - crate::file_access::check_write(&dir.to_string_lossy())?; - } - b.install(&pack, cancel, progress)?; - reload(&b); - b.installed().into_iter().find(|p| p.locale == locale).ok_or_else(|| format!("the {locale} pack didn't install")) -} - -/// Remove the installed pack for `locale`. -pub fn remove(locale: &str) -> Result<(), String> { - let b = backend().ok_or_else(no_store)?; - if let Some(job) = jobs().installs.iter().find(|j| j.locale == locale) { - return Err(if job.stopping() { - format!("the {locale} pack's cancelled install is still stopping: try again in a moment") - } else { - format!("the {locale} pack is being installed: cancel it first") - }); - } - if !b.installed().iter().any(|p| p.locale == locale) { - return Err(format!("no language pack is installed for {locale}")); - } - if let Some(dir) = b.packs_dir() { - crate::file_access::check_write(&dir.to_string_lossy())?; - } - b.remove(locale)?; - reload(&b); - Ok(()) -} - -/// What the app does with a long pack request (an install or a fetch of the list): `None` runs it -/// in the caller (the CLI, headless MCP, tests); `Some(notify)` runs it on a thread, so the window -/// keeps drawing whoever asked (the person, the control channel, MCP), and calls `notify` on each -/// change (the window repaints). Set once by the desktop app. -pub type Notify = Arc; - -static BACKGROUND: RwLock> = RwLock::new(None); - -/// An install running on a thread. -struct InstallJob { - locale: String, - /// Bytes done. - done: u64, - /// Bytes in all. - total: u64, - /// Stops this run. A cancelled job stays listed (hidden from [`installing`]) until its thread - /// notices and ends, so two downloads of a locale never write its folder at once. - cancel: Cancel, - /// Install was asked again after a cancel, before the cancelled run ended: the thread starts - /// over (with the fresh `cancel`) instead of ending. - restart: bool, -} - -impl InstallJob { - /// Cancelled, and its thread hasn't ended yet. - fn stopping(&self) -> bool { - self.cancel.is_cancelled() - } -} - -/// Installs and fetches running on a thread, and how the finished ones ended. -#[derive(Default)] -struct Jobs { - /// Running installs, one per locale. - installs: Vec, - fetching: bool, - /// The last failure of each locale's install ("" for the list), until it is tried again. - errors: Vec<(String, String)>, - /// Bumped as each job ends: the window relays out and shows the result. - finished: u64, -} - -static JOBS: Mutex = Mutex::new(Jobs { installs: Vec::new(), fetching: false, errors: Vec::new(), finished: 0 }); - -fn jobs() -> std::sync::MutexGuard<'static, Jobs> { - JOBS.lock().unwrap_or_else(PoisonError::into_inner) -} - -/// Run long pack requests on a thread (`Some`: the desktop app), or in the caller (`None`). -pub fn set_background(notify: Option) { - *BACKGROUND.write().unwrap_or_else(PoisonError::into_inner) = notify; -} - -fn background() -> Option { - if cfg!(target_arch = "wasm32") { - return None; - } - BACKGROUND.read().unwrap_or_else(PoisonError::into_inner).clone() -} - -/// Whether installs and fetches run on a thread ([`set_background`]). -pub fn runs_in_background() -> bool { - background().is_some() -} - -/// A running install: (locale, bytes done, bytes in all). -#[derive(Clone, Debug, PartialEq, Eq, Serialize)] -pub struct Progress { - pub locale: String, - pub done: u64, - pub total: u64, -} - -/// The installs running now (not the cancelled ones still stopping). -pub fn installing() -> Vec { - jobs().installs.iter().filter(|j| !j.stopping()).map(|j| Progress { locale: j.locale.clone(), done: j.done, total: j.total }).collect() -} - -/// Whether the list is being fetched. -pub fn fetching() -> bool { - jobs().fetching -} - -/// The last failure of `locale`'s install ("" for the list), until it is tried again. -pub fn error(locale: &str) -> Option { - jobs().errors.iter().find(|(l, _)| l == locale).map(|(_, e)| e.clone()) -} - -/// Every recorded failure, (locale or "" for the list, message). -pub fn errors() -> Vec<(String, String)> { - jobs().errors.clone() -} - -/// Counts finished jobs: when it changes, the window lays the documents out again. -pub fn finished() -> u64 { - jobs().finished -} - -/// Stop `locale`'s running install. The person's own choice: it ends without an error, and -/// [`installing`] stops listing it at once (Install shows again). -pub fn cancel(locale: &str) { - if let Some(job) = jobs().installs.iter_mut().find(|j| j.locale == locale) { - job.restart = false; - job.cancel.cancel(); - } -} - -fn set_error(locale: &str, e: Option) { - let mut j = jobs(); - j.errors.retain(|(l, _)| l != locale); - if let Some(e) = e { - j.errors.push((locale.to_string(), e)); - } -} - -/// Start installing `locale` ([`install`]): on a thread when [`runs_in_background`], else now. -/// What's wrong up front (no pack, downloading off, outside the write root) is an error at once. -/// Returns whether it is still running (`false`: done). Asked again while it runs, it carries on; -/// right after a cancel, before the cancelled download has stopped, it starts over once that -/// one ends (never two downloads of a pack at once). -pub fn start_install(locale: &str) -> Result { - let b = backend().ok_or_else(no_store)?; - let pack = find(locale).ok_or_else(|| format!("there is no language pack for {locale}"))?; - if !b.network_on() { - return Err(network_off()); - } - if let Some(dir) = b.packs_dir() { - crate::file_access::check_write(&dir.to_string_lossy())?; - } - let Some(notify) = background() else { - let r = install(locale, &Cancel::new(), &mut |_, _| {}); - set_error(locale, r.as_ref().err().cloned()); - return r.map(|_| false); - }; - let cancel = Cancel::new(); - { - let mut j = jobs(); - j.errors.retain(|(l, _)| l != locale); - if let Some(job) = j.installs.iter_mut().find(|j| j.locale == locale) { - if job.stopping() { - // Cancelled and not ended yet: its thread starts over when it does. - job.done = 0; - job.total = pack.size; - job.cancel = cancel; - job.restart = true; - } - return Ok(true); - } - j.installs.push(InstallJob { locale: locale.to_string(), done: 0, total: pack.size, cancel: cancel.clone(), restart: false }); - } - let key = locale.to_string(); - let locale = locale.to_string(); - let started = spawn(move || { - let mut cancel = cancel; - loop { - let progress_notify = notify.clone(); - let r = install(&locale, &cancel, &mut |done, total| { - if let Some(job) = jobs().installs.iter_mut().find(|j| j.locale == locale) { - job.done = done; - job.total = total; - } - progress_notify(); - }); - let mut j = jobs(); - if let Some(job) = j.installs.iter_mut().find(|j| j.locale == locale && j.restart) { - job.restart = false; - cancel = job.cancel.clone(); - drop(j); - notify(); - continue; - } - j.installs.retain(|j| j.locale != locale); - // The person's own cancel isn't a failure: nothing to show. - if let Err(e) = r - && !cancel.is_cancelled() - { - j.errors.push((locale, e)); - } - j.finished = j.finished.wrapping_add(1); - drop(j); - notify(); - break; - } - }); - if let Err(e) = started { - jobs().installs.retain(|j| j.locale != key); - return Err(e); - } - Ok(true) -} - -/// Start fetching the list again ([`refresh`]): on a thread when [`runs_in_background`], else now. -/// Returns whether it is still running. -pub fn start_refresh() -> Result { - let b = backend().ok_or_else(no_store)?; - if !b.network_on() { - return Err(network_off()); - } - let Some(notify) = background() else { - let r = refresh(&Cancel::new()); - set_error("", r.as_ref().err().cloned()); - return r.map(|_| false); - }; - { - let mut j = jobs(); - if j.fetching { - return Ok(true); - } - j.fetching = true; - j.errors.retain(|(l, _)| !l.is_empty()); - } - let started = spawn(move || { - let r = refresh(&Cancel::new()); - let mut j = jobs(); - j.fetching = false; - if let Err(e) = r { - j.errors.push((String::new(), e)); - } - j.finished = j.finished.wrapping_add(1); - drop(j); - notify(); - }); - if let Err(e) = started { - jobs().fetching = false; - return Err(e); - } - Ok(true) -} - -#[cfg(not(target_arch = "wasm32"))] -fn spawn(f: impl FnOnce() + Send + 'static) -> Result<(), String> { - std::thread::Builder::new().name("language-pack".into()).spawn(f).map(|_| ()).map_err(|e| format!("couldn't start the download: {e}")) -} - -#[cfg(target_arch = "wasm32")] -fn spawn(_: impl FnOnce() + Send + 'static) -> Result<(), String> { - Err(no_store()) -} - -/// Have proofing look at the packs folder again (its dictionaries are reloaded). -fn reload(b: &Arc) { - if wordcraft_proof::packs::packs_dir() == b.packs_dir() { - wordcraft_proof::packs::reload(); - } else { - wordcraft_proof::packs::set_packs_dir(b.packs_dir()); - } -} - -/// The full licence texts for About › Language data: the embedded English data, then each -/// installed pack. -pub fn licence_texts() -> Vec<(String, String)> { - let mut texts = wordcraft_proof::packs::licence_texts(); - // SCOWL's notices (with the Ispell BSD licence of its affix files), when English spells from - // the embedded SCOWL data; after the Moby and supplement entries. - if let Some(notice) = wordcraft_proof::scowl::notice() { - let at = texts.len().min(2); - texts.insert(at, (SCOWL_TITLE.into(), notice.into())); - } - texts -} - -/// About › Language data's title for SCOWL's notices. -pub const SCOWL_TITLE: &str = "English spelling dictionaries (SCOWL)"; - -/// The pack locale for a document language tag: the whole tag (`pt-pt`), else each shorter -/// prefix of it (`sr-latn` for `sr-Latn-RS`, `nl` for `nl-NL`), the first the index has. A -/// variant after the region is also tried without the region, before the region alone -/// (`ca-ES-valencia` is the `ca-valencia` pack's), as craft-proof looks its dictionary up. `None` -/// for English and tags with no pack. -pub fn pack_for_tag<'a>(tag: &str, packs: &'a [Pack]) -> Option<&'a Pack> { - pack_candidates(tag).iter().find_map(|c| packs.iter().find(|p| &p.locale == c)) -} - -/// Is `subtag` a region, as it follows a tag's language (and script): two letters (`NL`) or -/// three digits (`419`)? The one test of what a region is; the lists, the menus and -/// [`pack_for_tag`] all use it. -pub fn is_region(subtag: &str) -> bool { - (subtag.len() == 2 && subtag.bytes().all(|b| b.is_ascii_alphabetic())) || (subtag.len() == 3 && subtag.bytes().all(|b| b.is_ascii_digit())) -} - -/// `tag` without its region subtags (`nl-NL` is `nl`, `sr-Latn-RS` is `sr-Latn`, `ca-ES-valencia` -/// is `ca-valencia`), joined with `-` whichever of `-` and `_` separated them; the case is kept. -/// The language itself (the first subtag) is never a region. -pub fn without_region(tag: &str) -> String { - tag.split(['-', '_']).enumerate().filter(|(i, p)| *i == 0 || !is_region(p)).map(|(_, p)| p).collect::>().join("-") -} - -/// The pack locales [`pack_for_tag`] tries for `tag`, best first. -fn pack_candidates(tag: &str) -> Vec { - // A tag is a handful of short subtags; anything longer is junk and only its start is read. - let tag: String = tag.trim().chars().take(64).collect::().replace('_', "-").to_ascii_lowercase(); - let parts: Vec<&str> = tag.split('-').collect(); - let base = parts.first().copied().unwrap_or(""); - if base.is_empty() || base == "en" { - return Vec::new(); - } - let mut out: Vec = Vec::new(); - for n in (1..=parts.len()).rev() { - let prefix = parts.get(..n).unwrap_or_default().join("-"); - // The prefix, then without its region: the variant's pack before the region alone. - let stripped = without_region(&prefix); - for c in [prefix, stripped] { - if !out.contains(&c) { - out.push(c); - } - } - } - out -} - -/// The languages (`w:lang`, resolved through the styles) of the body text, in first-seen order. -pub fn document_languages(s: &Session) -> Vec { - let mut out: Vec = Vec::new(); - for path in s.doc.para_paths(wordcraft_doc::StoryRef::Body).into_iter().take(MAX_PARAGRAPHS_SCANNED) { - let Some(p) = s.doc.para(wordcraft_doc::StoryRef::Body, &path) else { continue }; - if p.text.trim().is_empty() { - continue; - } - let style = p.props.style.as_deref(); - for (range, c) in p.run_ranges() { - let Some(text) = p.text.get(range) else { continue }; - if !text.chars().any(char::is_alphabetic) { - continue; - } - let rc = s.doc.styles.resolve_char_in(style, None, c); - if rc.no_proof { - continue; - } - let tag = rc.lang.unwrap_or_else(|| "en-US".into()); - if !out.contains(&tag) { - out.push(tag); - if out.len() >= MAX_DOC_LANGUAGES { - return out; - } - } - } - } - out -} - -/// Whether proofing reads a pack for text in `tag`: every language craft-proof can name, its own -/// (Polish…) and any other (`ProofLang::Pack`, read from the pack alone); not `und`, `zxx` or a -/// malformed tag. The one place the bar's language rule lives. -pub fn proofable(tag: &str) -> bool { - wordcraft_proof::ProofLang::from_tag(Some(tag)) != wordcraft_proof::ProofLang::Other -} - -/// The Hunspell dictionary `tag`'s language needs and doesn't have, for the status bar's -/// "(no dictionary)": craft-proof's [`wordcraft_proof::missing_dictionary`], except for a -/// language craft-proof has no rules of its own for (`ProofLang::Pack`: Japanese, Chinese, -/// Thai…) when no pack for it is available either, as no Hunspell dictionary may exist at all. -pub fn missing_dictionary(tag: Option<&str>) -> Option<&'static str> { - let name = wordcraft_proof::missing_dictionary(tag)?; - let lang = wordcraft_proof::ProofLang::from_tag(tag); - if lang.pack_locale().is_some() && !has_pack_for_tag(tag.unwrap_or("")) { - return None; - } - Some(name) -} - -/// The last answer of whether the index has a pack for a tag, for the index generation it was -/// worked out for: the status bar asks every frame while the caret is in a pack language, and -/// working it out copies the whole index. -#[derive(Default)] -struct PackLookup { - /// (index generation, tag, has a pack). - entry: Option<(u64, String, bool)>, -} - -impl PackLookup { - /// The remembered answer for `tag` at `generation`, else `compute`'s (then remembered). - fn get(&mut self, generation: u64, tag: &str, compute: impl FnOnce() -> bool) -> bool { - if let Some((g, t, has)) = &self.entry - && *g == generation - && t == tag - { - return *has; - } - let has = compute(); - self.entry = Some((generation, tag.to_string(), has)); - has - } -} - -static PACK_LOOKUP: Mutex = Mutex::new(PackLookup { entry: None }); - -/// Whether the index has a pack for `tag` ([`pack_for_tag`]), worked out again only when the -/// index changes ([`index_generation`]) or the tag does. -fn has_pack_for_tag(tag: &str) -> bool { - // Read before [`available`], which may fill the index and bump it: the answer is then worked - // out once more, never kept for a list it wasn't worked out from. - let generation = index_generation(); - PACK_LOOKUP.lock().unwrap_or_else(PoisonError::into_inner).get(generation, tag, || pack_for_tag(tag, &available()).is_some()) -} - -/// The language new text at the caret gets (its typing language, resolved through the styles -/// and the document default), unless it is marked Do not check spelling or grammar: choosing a -/// language with nothing selected asks for its pack before anything is typed in it. -pub fn caret_language(s: &Session) -> Option { - let style = s.doc.para_at(&s.sel.focus).and_then(|p| p.props.style.as_deref()); - let rc = s.doc.styles.resolve_char(style, &s.typing_props()); - if rc.no_proof { - return None; - } - Some(rc.lang.unwrap_or_else(|| "en-US".into())) -} - -/// The languages the missing-pack bar looks at: the body text's ([`document_languages`]), then -/// the caret's typing language ([`caret_language`]). -pub fn wanted_languages(s: &Session) -> Vec { - let mut out = document_languages(s); - if let Some(tag) = caret_language(s) - && !out.contains(&tag) - { - out.push(tag); - } - out -} - -/// The packs the document's text (or the caret's typing language) asks for that are available -/// and not installed: what the missing-pack bar offers. Never a language without a pack, nor one -/// proofing can't use. -pub fn missing(s: &Session) -> Vec { - let packs = available(); - if packs.is_empty() { - return Vec::new(); - } - let installed = installed(); - let mut out: Vec = Vec::new(); - for tag in wanted_languages(s) { - if proofable(&tag) - && let Some(p) = pack_for_tag(&tag, &packs) - && !installed.iter().any(|i| i.locale == p.locale) - && !out.iter().any(|o| o.locale == p.locale) - { - out.push(p.clone()); - } - } - out -} - -/// Where a language's proofing comes from (Options › Language › Your languages). -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] -#[serde(rename_all = "snake_case")] -pub enum Proofing { - /// English: the embedded dictionaries. - BuiltIn, - /// An installed Language pack. - Installed, - /// A Hunspell dictionary in the person's own dictionaries folder. - OwnDictionary, - /// A Hunspell dictionary the system has. - SystemDictionary, - /// No dictionary yet; a Language pack can be installed. - Install, - /// Grammar and hyphenation rules built in; a Language pack adds spelling. - GrammarBuiltIn, - /// No dictionary and no pack for it. - NotAvailable, -} - -/// A row of Options › Language › Your languages. -#[derive(Clone, Debug, PartialEq, Eq, Serialize)] -pub struct LanguageRow { - /// The tags the default proofing language or the open document use for this language (the - /// first is what Set as default sets); empty for a language listed for its installed pack. - pub tags: Vec, - /// The pack locale: the installed pack, else the one the index offers. - pub pack: Option, - /// Is the pack installed? - pub installed: bool, - /// Bytes of the offered pack (0 when the index has none). - pub size: u64, - pub proofing: Proofing, - /// Is this the default proofing language? - pub default: bool, -} - -impl LanguageRow { - /// Is `tag` (the insertion point's language) one of this row's languages? - pub fn is_tag(&self, tag: &str) -> bool { - self.tags.iter().any(|t| t.eq_ignore_ascii_case(tag)) - } -} - -/// The first locale `has` accepts for `tag`, in [`pack_for_tag`]'s order. `None` for English and junk. -fn locale_for_tag(tag: &str, has: &dyn Fn(&str) -> bool) -> Option { - pack_candidates(tag).into_iter().find(|c| has(c)) -} - -/// [`your_languages_with`] against the dictionaries on this computer. -pub fn your_languages(s: &Session) -> Vec { - let folder = s.prefs.dictionaries_folder.clone(); - your_languages_with(s, folder.as_deref(), &dictionary_file) -} - -/// The Hunspell `.dic` file proofing reads for `tag`'s language now (a built-in-rules language's -/// own, or a pack language's), if any. -fn dictionary_file(tag: &str) -> Option { - let lang = wordcraft_proof::ProofLang::from_tag(Some(tag)); - if let Some((_, f)) = wordcraft_proof::installed_dictionaries().into_iter().find(|(l, _)| *l == lang) { - return f; - } - wordcraft_proof::pack_dictionary_file(lang) -} - -/// Options › Language › Your languages (unsorted; the window names and sorts them): each English -/// variant in use (the default proofing language, American English and any in the open document), every language -/// with an installed Language pack, and every proofing language of the open document. One row per -/// language: tags one pack proofs share a row. `folder` is the person's own dictionaries folder; -/// `dictionary` finds the `.dic` file proofing reads for a tag. -pub fn your_languages_with(s: &Session, folder: Option<&str>, dictionary: &dyn Fn(&str) -> Option) -> Vec { - let index = available(); - let installed = installed(); - let default_tag = s - .prefs - .default_language - .clone() - .or_else(|| s.editing_lang.clone()) - .or_else(|| s.doc.styles.default_chr.lang.clone()) - .unwrap_or_else(|| "en-US".to_string()); - // American English stays listed whatever the default is, so it can always be set back. - let mut tags = vec![default_tag.clone(), "en-US".to_string()]; - tags.dedup_by(|a, b| a.eq_ignore_ascii_case(b)); - for t in document_languages(s) { - if !tags.iter().any(|x| x.eq_ignore_ascii_case(&t)) { - tags.push(t); - } - } - let mut rows: Vec = Vec::new(); - let mut add = |tag: Option<&str>, locale: Option| { - let english = tag.is_some_and(|t| wordcraft_proof::ProofLang::from_tag(Some(t)).is_english()); - // English variants stay apart (en-GB and en-US are two rows); a pack's tags share one. - let existing = rows.iter().position(|r| match (&locale, &r.pack) { - (Some(l), Some(p)) if !english => l == p, - _ => tag.is_some_and(|t| r.is_tag(t)), - }); - let i = existing.unwrap_or_else(|| { - rows.push(LanguageRow { - tags: Vec::new(), - pack: locale.clone(), - installed: false, - size: 0, - proofing: Proofing::NotAvailable, - default: false, - }); - rows.len() - 1 - }); - if let (Some(t), Some(row)) = (tag, rows.get_mut(i)) - && !row.is_tag(t) - { - row.tags.push(t.to_string()); - } - }; - for t in &tags { - if !proofable(t) { - continue; - } - // The most specific pack, installed or offered, as the bar picks it ([`pack_for_tag`]): with - // `ca` installed, Valencian text is still the `ca-valencia` pack's row. - let locale = locale_for_tag(t, &|l| installed.iter().any(|i| i.locale == l) || index.iter().any(|p| p.locale == l)); - add(Some(t), locale); - } - for i in &installed { - add(None, Some(i.locale.clone())); - } - for row in &mut rows { - let installed_here = row.pack.as_ref().is_some_and(|l| installed.iter().any(|i| &i.locale == l)); - let offered = row.pack.as_ref().and_then(|l| index.iter().find(|p| &p.locale == l)); - row.installed = installed_here; - row.size = offered.map_or(0, |p| p.size); - row.default = row.is_tag(&default_tag); - let tag = row.tags.first().cloned().or_else(|| row.pack.clone()).unwrap_or_default(); - let lang = wordcraft_proof::ProofLang::from_tag(Some(&tag)); - let file = if lang.is_english() { None } else { dictionary(&tag) }; - let own = file.as_ref().is_some_and(|f| folder.is_some_and(|d| !d.trim().is_empty() && f.starts_with(d.trim()))); - row.proofing = if lang.is_english() { - Proofing::BuiltIn - } else if own { - Proofing::OwnDictionary - } else if installed_here { - Proofing::Installed - } else if file.is_some() { - Proofing::SystemDictionary - } else if offered.is_some() { - if matches!(lang, wordcraft_proof::ProofLang::Pack(_)) { Proofing::Install } else { Proofing::GrammarBuiltIn } - } else { - Proofing::NotAvailable - }; - } - rows -} - -/// The index was fetched this session (since the backend was set): what it lists is then the -/// whole answer, even when it lists nothing. -static FETCHED: AtomicBool = AtomicBool::new(false); -/// The automatic fetch of a missing or stale list ([`refresh_if_stale`]) was tried. -static AUTO_REFRESHED: AtomicBool = AtomicBool::new(false); -/// Languages (document tags) whose pack the person asked for before the list was known -/// ([`start_get`]): installed once a fetch lists one. -static WANTED: Mutex> = Mutex::new(Vec::new()); - -/// Whether the index was fetched since the backend was set. -pub fn fetched() -> bool { - FETCHED.load(Ordering::Relaxed) -} - -/// The languages the document (or the caret) asks for while there is no list of packs yet (a -/// fresh install, or a list cached for an older pin): proofable, not English, with no dictionary -/// and no pack installed, one per proofing language, as the document tags them (`tr-TR`). The -/// bar offers to get them: the list is fetched first, and the language then installed if it has -/// a pack. Nothing once a list is known or was fetched this session ([`missing`] then answers). -pub fn unlisted(s: &Session) -> Vec { - if backend().is_none() || fetched() || available_len() > 0 { - return Vec::new(); - } - let installed = installed(); - let mut seen: Vec = Vec::new(); - let mut out = Vec::new(); - for tag in wanted_languages(s) { - let base = tag.split(['-', '_']).next().unwrap_or("").to_ascii_lowercase(); - let lang = wordcraft_proof::ProofLang::from_tag(Some(&tag)); - if base.is_empty() - || base == "en" - || !proofable(&tag) - || wordcraft_proof::missing_dictionary(Some(&tag)).is_none() - || seen.contains(&lang) - || installed.iter().any(|i| i.locale == base || tag.eq_ignore_ascii_case(&i.locale)) - { - continue; - } - seen.push(lang); - out.push(tag); - } - out -} - -/// The person asked for `tag`'s pack before the list was known (the bar's Install, after their -/// consent turned downloading on): fetch the list ([`start_refresh`]), then install the pack it -/// lists for `tag`, if any; with none, the bar stops offering it. Downloading must be on. -/// Returns whether it is still running. -pub fn start_get(tag: &str) -> Result { - let tag = tag.trim(); - if tag.is_empty() || tag.len() > MAX_LOCALE_LEN { - return Err(format!("`{tag}` isn't a language tag")); - } - if let Some(p) = pack_for_tag(tag, &available()) { - return start_install(&p.locale); - } - let b = backend().ok_or_else(no_store)?; - if !b.network_on() { - // Never queued: a later fetch mustn't install what was refused. - return Err(network_off()); - } - let queued = { - let mut w = WANTED.lock().unwrap_or_else(PoisonError::into_inner); - let new = !w.iter().any(|t| t == tag) && w.len() < MAX_DOC_LANGUAGES; - if new { - w.push(tag.to_string()); - } - new - }; - let r = start_refresh(); - if r.is_err() && queued { - WANTED.lock().unwrap_or_else(PoisonError::into_inner).retain(|t| t != tag); - } - r -} - -/// After a fetch: install the packs asked for before the list was known ([`start_get`]). -fn install_wanted(index: &[Pack]) { - let wanted = std::mem::take(&mut *WANTED.lock().unwrap_or_else(PoisonError::into_inner)); - for tag in wanted { - let Some(p) = pack_for_tag(&tag, index) else { continue }; - if is_installed(&p.locale) { - continue; - } - if let Err(e) = start_install(&p.locale) { - set_error(&p.locale, Some(e)); - } - } -} - -/// Once a session (the first time it is asked once requests run on a thread, [`runs_in_background`]: -/// the window), with downloading already on and no list (none cached, or one cached for an older -/// pin): fetch it in the background, so the bar can offer packs. The person's consent is the -/// downloading setting itself; nothing is asked. Its failure is recorded for Options (""). The -/// window asks every frame: after the first time this reads nothing (the downloading setting is -/// a file). Returns whether a fetch was started. -pub fn refresh_if_stale() -> bool { - let Some(b) = backend() else { return false }; - if !runs_in_background() || AUTO_REFRESHED.swap(true, Ordering::Relaxed) { - return false; - } - if !b.network_on() || fetched() || fetching() || available_len() > 0 { - return false; - } - start_refresh().is_ok() -} - -/// A backend with a fixed list and installs that only record themselves: tests and `ui_shot`. -#[derive(Default)] -pub struct FixedBackend { - pub packs: Vec, - pub network: AtomicBool, - /// Agents can't turn the network on. - pub agent: bool, - pub installed: Mutex>, -} - -impl Pack { - /// Where the data comes from: the upstream source when the index names it, else the archive. - pub fn source_link(&self) -> &str { - self.source.as_deref().filter(|s| !s.is_empty()).unwrap_or(&self.url) - } -} - -impl FixedBackend { - /// A few packs shaped like the published ones, for screenshots and tests. - pub fn sample() -> Self { - let pack = |locale: &str, name: &str, size: u64, licence: &str, class: &str| Pack { - locale: locale.into(), - name: name.into(), - version: "0000000".into(), - url: format!("https://github.com/storytold/craft-i18n/releases/download/language-packs-2/{locale}.tar.zst"), - source: Some("https://github.com/LibreOffice/dictionaries".into()), - size, - licence: licence.into(), - class: class.into(), - spelling: true, - hyphenation: true, - }; - FixedBackend { - packs: vec![ - pack("ca", "Català", 1_480_000, "GPL-2.0-or-later OR LGPL-2.1-or-later", "Download-only"), - pack("el", "Ελληνικά", 2_310_000, "MPL-1.1 OR GPL-2.0-or-later OR LGPL-2.1-or-later", "Embeddable"), - pack("fr", "Français", 1_120_000, "MPL-2.0", "Embeddable"), - pack("nl", "Nederlands", 1_950_000, "BSD-3-Clause", "Embeddable"), - pack("pl", "Polski", 4_620_000, "GPL-2.0-or-later OR LGPL-2.1-or-later OR MPL-1.1 OR Apache-2.0 OR CC-BY-4.0", "Embeddable"), - pack("sk", "Slovenčina", 2_040_000, "GPL-2.0-only OR LGPL-2.1-only OR MPL-1.1", "Download-only"), - ], - ..Default::default() - } - } -} - -impl Backend for FixedBackend { - fn index(&self) -> Result, String> { - Ok(self.packs.clone()) - } - fn fetch_index(&self, _: &Cancel) -> Result, String> { - Ok(self.packs.clone()) - } - fn network_on(&self) -> bool { - self.network.load(Ordering::Relaxed) - } - fn enable_network(&self) -> Result<(), String> { - if self.agent { - return Err("only the user can turn on downloading".into()); - } - self.network.store(true, Ordering::Relaxed); - Ok(()) - } - fn install(&self, pack: &Pack, cancel: &Cancel, progress: &mut dyn FnMut(u64, u64)) -> Result<(), String> { - if cancel.is_cancelled() { - return Err("cancelled".into()); - } - progress(pack.size, pack.size); - let mut i = self.installed.lock().unwrap_or_else(PoisonError::into_inner); - i.retain(|p| p.locale != pack.locale); - i.push(Installed { locale: pack.locale.clone(), name: pack.name.clone(), licence: pack.licence.clone(), path: PathBuf::new() }); - Ok(()) - } - fn remove(&self, locale: &str) -> Result<(), String> { - self.installed.lock().unwrap_or_else(PoisonError::into_inner).retain(|p| p.locale != locale); - Ok(()) - } - fn packs_dir(&self) -> Option { - None - } - fn installed(&self) -> Vec { - self.installed.lock().unwrap_or_else(PoisonError::into_inner).clone() - } -} - -#[cfg(test)] -mod tests { - /// CP4.4 (`cargo xtask language`): a build embeds English Language data and nothing it - /// wasn't asked to. A Language build names the locales it embeds in `CRAFT_PROOF_EMBED` - /// (`en` alone: English only); the default embeds the carry-over locales too, until Language - /// packs are published and pinned (craft-proof's `DEFAULT_EMBED`; TODO(CP4.4): English only - /// then). - /// About › Language data shows SCOWL's notices (the Ispell BSD licence among them) whenever - /// English spells from the embedded SCOWL data. - #[test] - fn about_shows_the_scowl_notice_when_scowl_is_embedded() { - let texts = super::licence_texts(); - match wordcraft_proof::scowl::notice() { - Some(notice) => { - let entry = texts.iter().find(|(t, _)| t == super::SCOWL_TITLE).map(|(_, x)| x.as_str()); - assert_eq!(entry, Some(notice)); - assert!(notice.contains("Ispell") || notice.contains("ispell"), "the Ispell notice is part of it"); - } - None => assert!(texts.iter().all(|(t, _)| t != super::SCOWL_TITLE), "no SCOWL data: no SCOWL entry"), - } - } - - /// FU.3: the status bar's "(no dictionary)" asks every frame whether the index has a pack - /// for the caret's language; the answer is reused until the index changes (a new - /// generation) or the tag does, so the index isn't copied every frame. - #[test] - fn the_pack_lookup_is_reused_until_the_index_changes() { - let mut lookup = super::PackLookup::default(); - let mut computed = 0; - let mut ask = |lookup: &mut super::PackLookup, generation: u64, tag: &str, answer: bool| { - lookup.get(generation, tag, || { - computed += 1; - answer - }) - }; - assert!(ask(&mut lookup, 1, "ja-JP", true)); - // Same generation and tag: the remembered answer, even where the list would now say no. - for _ in 0..10 { - assert!(ask(&mut lookup, 1, "ja-JP", false)); - } - assert!(!ask(&mut lookup, 2, "ja-JP", false), "a new generation works it out again"); - assert!(ask(&mut lookup, 2, "th-TH", true), "another tag too"); - assert_eq!(computed, 3); - } - - #[test] - fn the_default_build_embeds_only_english() { - const CARRY_OVER: [&str; 5] = ["de", "pl", "pt", "it", "ru"]; - let named: Vec = std::env::var("CRAFT_PROOF_EMBED") - .unwrap_or_default() - .split(',') - .map(|l| l.trim().to_ascii_lowercase()) - .filter(|l| !l.is_empty()) - .collect(); - let allowed = |base: &str| base == "en" || if named.is_empty() { CARRY_OVER.contains(&base) } else { named.iter().any(|n| n == base) }; - let embedded = wordcraft_proof::packs::embedded_locales(); - let extra: Vec<&&str> = embedded.iter().filter(|l| !allowed(l.split('-').next().unwrap_or(l))).collect(); - assert!(extra.is_empty(), "embedded beyond English and {named:?} (or the carry-over by default): {extra:?}"); - } -} +//! Language packs (CP4.3): spelling dictionaries and hyphenation patterns for languages beyond +//! the embedded English, one pack per locale, downloaded and verified by the font store +//! (craft-libs `craft-font-store`, item kind `language-pack`) and read by `craft-proof` from the +//! store's `language-packs/` folder. +//! +//! The commands (`proof.packs`, `proof.installPack`, `proof.removePack`), Options › Language, +//! the missing-pack bar and About › Language data all go through this module. It never asks +//! anything: an install needs the font store's single consent setting (downloading on), which only +//! a person in the app can turn on ([`Backend::enable_network`], called from an Install click). +//! Agents (MCP, the CLI, the control channel) install only while it is on. +//! +//! The [`Backend`] is set once at start-up by the desktop app and the CLI ([`use_font_store`]). +//! Without one (tests, the web build, `ui_shot`) nothing is available and nothing is downloaded. + +use std::collections::BTreeSet; +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex, PoisonError, RwLock}; + +use serde::Serialize; + +use crate::{CmdError, Session}; + +#[cfg(not(target_arch = "wasm32"))] +mod store; + +/// Longest locale tag taken, in bytes (BCP 47 tags in practice stay under 20). +pub const MAX_LOCALE_LEN: usize = 35; +/// Most packs an index may list that are read. +const MAX_PACKS: usize = 1000; +/// Paragraphs of a document looked at for its languages (the missing-pack bar). +const MAX_PARAGRAPHS_SCANNED: usize = 20_000; +/// Distinct languages of a document collected. +const MAX_DOC_LANGUAGES: usize = 64; + +/// A pack the pinned index lists. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct Pack { + /// Lowercase BCP 47 (`nl`, `pt-pt`). + pub locale: String, + /// The language's own name (`Nederlands`). + pub name: String, + /// The craft-i18n commit the pack was built from. + pub version: String, + /// The archive. + pub url: String, + /// The upstream folder the data comes from, at its pinned commit (the index's optional + /// `source`), shown before install; the archive when the index gives none. + pub source: Option, + /// Bytes of the archive. + pub size: u64, + /// SPDX expression. + pub licence: String, + /// `Embeddable` or `Download-only`. + pub class: String, + /// Has a spelling dictionary. + pub spelling: bool, + /// Has hyphenation patterns. + pub hyphenation: bool, +} + +/// An installed pack. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct Installed { + pub locale: String, + pub name: String, + pub licence: String, + pub path: PathBuf, +} + +/// Stops a download. On the desktop it is the font store's own flag, which a download watches +/// even while the server sends nothing. +#[derive(Clone, Debug, Default)] +pub struct Cancel(#[cfg(not(target_arch = "wasm32"))] pub(crate) craft_font_store::Cancel, #[cfg(target_arch = "wasm32")] Arc); + +impl Cancel { + pub fn new() -> Self { + Self::default() + } + #[cfg(not(target_arch = "wasm32"))] + pub fn cancel(&self) { + self.0.cancel(); + } + #[cfg(not(target_arch = "wasm32"))] + pub fn is_cancelled(&self) -> bool { + self.0.is_cancelled() + } + #[cfg(target_arch = "wasm32")] + pub fn cancel(&self) { + self.0.store(true, Ordering::Relaxed); + } + #[cfg(target_arch = "wasm32")] + pub fn is_cancelled(&self) -> bool { + self.0.load(Ordering::Relaxed) + } +} + +/// Where packs come from: the font store on the desktop, a fixed list in tests and `ui_shot`. +pub trait Backend: Send + Sync { + /// The pinned index as last fetched (no request); empty when it never was. + fn index(&self) -> Result, String>; + /// Fetch the pinned index (a request; only while downloading is on). + fn fetch_index(&self, cancel: &Cancel) -> Result, String>; + /// Whether the font store may download. + fn network_on(&self) -> bool; + /// Turn downloading on. Only from the person's own action in the app (an Install click): + /// the commands never call it, and a backend for an agent refuses. + fn enable_network(&self) -> Result<(), String>; + /// Download, verify and unpack `pack` into the packs folder. `progress` gets (done, total) bytes. + fn install(&self, pack: &Pack, cancel: &Cancel, progress: &mut dyn FnMut(u64, u64)) -> Result<(), String>; + /// Remove the installed pack for `locale`. + fn remove(&self, locale: &str) -> Result<(), String>; + /// The installed-packs folder (`/language-packs`). + fn packs_dir(&self) -> Option; + /// The packs installed now. + fn installed(&self) -> Vec { + installed_from_proof() + } + /// The test index in use instead of the pinned one (`CRAFT_LANGUAGE_PACK_INDEX`), if any. + fn index_override(&self) -> Option { + None + } +} + +static BACKEND: RwLock>> = RwLock::new(None); +/// The index last read, so the bar and the list don't read the store's cache every frame. +static INDEX: Mutex>> = Mutex::new(None); +/// Bumped whenever [`INDEX`] changes. +static INDEX_GENERATION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + +fn set_index(index: Option>) { + *INDEX.lock().unwrap_or_else(PoisonError::into_inner) = index; + INDEX_GENERATION.fetch_add(1, Ordering::Relaxed); +} + +/// Changes whenever the list of available packs does (a fetch, another backend). +pub fn index_generation() -> u64 { + INDEX_GENERATION.load(Ordering::Relaxed) +} + +/// Use `backend` for packs from now on (`None`: no packs), and point proofing at its folder. +pub fn set_backend(backend: Option>) { + let dir = backend.as_ref().and_then(|b| b.packs_dir()); + *BACKEND.write().unwrap_or_else(PoisonError::into_inner) = backend; + set_index(None); + FETCHED.store(false, Ordering::Relaxed); + AUTO_REFRESHED.store(false, Ordering::Relaxed); + WANTED.lock().unwrap_or_else(PoisonError::into_inner).clear(); + jobs().errors.clear(); + wordcraft_proof::packs::set_packs_dir(dir); +} + +/// Use the suite's font store for packs (`/language-packs`), as the person in the app +/// (`user`: an Install click may turn downloading on) or as an agent (the CLI, headless MCP: it +/// never can). The pack index is the pinned one, or `CRAFT_LANGUAGE_PACK_INDEX` (a URL or a path, +/// for testing packs before they are published), which the font store reads itself. Returns +/// whether there is a store folder. +#[cfg(not(target_arch = "wasm32"))] +pub fn use_font_store(user: bool) -> bool { + match store::FontStoreBackend::new(user) { + Some(b) => { + set_backend(Some(Arc::new(b))); + true + } + None => false, + } +} + +/// [`use_font_store`] with the store in `dir` instead of the person's (`ui_shot`, tests). +#[cfg(not(target_arch = "wasm32"))] +pub fn use_font_store_in(dir: PathBuf, user: bool) { + set_backend(Some(Arc::new(store::FontStoreBackend::in_dir(dir, user)))); +} + +/// The backend in use. +pub fn backend() -> Option> { + BACKEND.read().unwrap_or_else(PoisonError::into_inner).clone() +} + +fn installed_from_proof() -> Vec { + wordcraft_proof::packs::installed().into_iter().map(|p| Installed { locale: p.locale, name: p.name, licence: p.licence, path: p.path }).collect() +} + +/// The test source packs come from instead of the pinned index (`CRAFT_LANGUAGE_PACK_INDEX`): +/// Options › Language says so. +pub fn index_override() -> Option { + backend().and_then(|b| b.index_override()) +} + +/// The packs installed now (none without a backend). +pub fn installed() -> Vec { + backend().map(|b| b.installed()).unwrap_or_default() +} + +/// The available packs: the index as last read, read from the store's cache the first time. +pub fn available() -> Vec { + let Some(b) = backend() else { return Vec::new() }; + let mut cached = INDEX.lock().unwrap_or_else(PoisonError::into_inner); + if let Some(i) = cached.as_ref() { + return i.clone(); + } + let index = clean_index(b.index().unwrap_or_default()); + *cached = Some(index.clone()); + drop(cached); + INDEX_GENERATION.fetch_add(1, Ordering::Relaxed); + index +} + +/// How many packs [`available`] lists, without copying them (the window asks every frame). +pub fn available_len() -> usize { + if let Some(i) = INDEX.lock().unwrap_or_else(PoisonError::into_inner).as_ref() { + return i.len(); + } + available().len() +} + +/// Fetch the index again (downloading must be on). +pub fn refresh(cancel: &Cancel) -> Result, String> { + let b = backend().ok_or_else(no_store)?; + if !b.network_on() { + return Err(network_off()); + } + let index = clean_index(b.fetch_index(cancel)?); + set_index(Some(index.clone())); + FETCHED.store(true, Ordering::Relaxed); + install_wanted(&index); + Ok(index) +} + +/// Drop packs with a malformed locale, duplicates and English (embedded), and cap the list. +fn clean_index(mut packs: Vec) -> Vec { + packs.truncate(MAX_PACKS); + let mut seen = BTreeSet::new(); + packs.retain(|p| valid_locale(&p.locale) && !is_embedded(&p.locale) && seen.insert(p.locale.clone())); + packs.sort_by(|a, b| a.locale.cmp(&b.locale)); + packs +} + +/// English is embedded in every build; it never comes as a pack. +fn is_embedded(locale: &str) -> bool { + locale == "en" || locale.starts_with("en-") +} + +/// A lowercase BCP 47-shaped tag: letters, digits and `-`, starting with a letter. +pub fn valid_locale(locale: &str) -> bool { + !locale.is_empty() + && locale.len() <= MAX_LOCALE_LEN + && locale.starts_with(|c: char| c.is_ascii_lowercase()) + && locale.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') + && !locale.contains("--") + && !locale.ends_with('-') +} + +/// A locale from a command's params: trimmed, lowercased, `_` as `-`. +pub fn parse_locale(raw: &str) -> Result { + let l = raw.trim().replace('_', "-").to_ascii_lowercase(); + if valid_locale(&l) { Ok(l) } else { Err(CmdError::Params(format!("`locale` must be a language tag like nl or pt-PT, not {raw:?}"))) } +} + +fn no_store() -> String { + if cfg!(target_arch = "wasm32") { + "Language packs aren't available in the browser".into() + } else { + "there is no font store folder for language packs".into() + } +} + +fn network_off() -> String { + "downloading is turned off: turn it on in WordCraft (File › Options › Language, or an Install button there)".into() +} + +/// The pack for `locale` in the index. +pub fn find(locale: &str) -> Option { + available().into_iter().find(|p| p.locale == locale) +} + +/// Whether a pack is installed for `locale`. +pub fn is_installed(locale: &str) -> bool { + installed().iter().any(|p| p.locale == locale) +} + +/// Install the pack for `locale` (downloading must be on): the commands and the Install button. +/// Proofing picks it up at once. +pub fn install(locale: &str, cancel: &Cancel, progress: &mut dyn FnMut(u64, u64)) -> Result { + let b = backend().ok_or_else(no_store)?; + let pack = find(locale).ok_or_else(|| format!("there is no language pack for {locale}"))?; + if !b.network_on() { + return Err(network_off()); + } + if let Some(dir) = b.packs_dir() { + crate::file_access::check_write(&dir.to_string_lossy())?; + } + b.install(&pack, cancel, progress)?; + reload(&b); + b.installed().into_iter().find(|p| p.locale == locale).ok_or_else(|| format!("the {locale} pack didn't install")) +} + +/// Remove the installed pack for `locale`. +pub fn remove(locale: &str) -> Result<(), String> { + let b = backend().ok_or_else(no_store)?; + if let Some(job) = jobs().installs.iter().find(|j| j.locale == locale) { + return Err(if job.stopping() { + format!("the {locale} pack's cancelled install is still stopping: try again in a moment") + } else { + format!("the {locale} pack is being installed: cancel it first") + }); + } + if !b.installed().iter().any(|p| p.locale == locale) { + return Err(format!("no language pack is installed for {locale}")); + } + if let Some(dir) = b.packs_dir() { + crate::file_access::check_write(&dir.to_string_lossy())?; + } + b.remove(locale)?; + reload(&b); + Ok(()) +} + +/// What the app does with a long pack request (an install or a fetch of the list): `None` runs it +/// in the caller (the CLI, headless MCP, tests); `Some(notify)` runs it on a thread, so the window +/// keeps drawing whoever asked (the person, the control channel, MCP), and calls `notify` on each +/// change (the window repaints). Set once by the desktop app. +pub type Notify = Arc; + +static BACKGROUND: RwLock> = RwLock::new(None); + +/// An install running on a thread. +struct InstallJob { + locale: String, + /// Bytes done. + done: u64, + /// Bytes in all. + total: u64, + /// Stops this run. A cancelled job stays listed (hidden from [`installing`]) until its thread + /// notices and ends, so two downloads of a locale never write its folder at once. + cancel: Cancel, + /// Install was asked again after a cancel, before the cancelled run ended: the thread starts + /// over (with the fresh `cancel`) instead of ending. + restart: bool, +} + +impl InstallJob { + /// Cancelled, and its thread hasn't ended yet. + fn stopping(&self) -> bool { + self.cancel.is_cancelled() + } +} + +/// Installs and fetches running on a thread, and how the finished ones ended. +#[derive(Default)] +struct Jobs { + /// Running installs, one per locale. + installs: Vec, + fetching: bool, + /// The last failure of each locale's install ("" for the list), until it is tried again. + errors: Vec<(String, String)>, + /// Bumped as each job ends: the window relays out and shows the result. + finished: u64, +} + +static JOBS: Mutex = Mutex::new(Jobs { installs: Vec::new(), fetching: false, errors: Vec::new(), finished: 0 }); + +fn jobs() -> std::sync::MutexGuard<'static, Jobs> { + JOBS.lock().unwrap_or_else(PoisonError::into_inner) +} + +/// Run long pack requests on a thread (`Some`: the desktop app), or in the caller (`None`). +pub fn set_background(notify: Option) { + *BACKGROUND.write().unwrap_or_else(PoisonError::into_inner) = notify; +} + +fn background() -> Option { + if cfg!(target_arch = "wasm32") { + return None; + } + BACKGROUND.read().unwrap_or_else(PoisonError::into_inner).clone() +} + +/// Whether installs and fetches run on a thread ([`set_background`]). +pub fn runs_in_background() -> bool { + background().is_some() +} + +/// A running install: (locale, bytes done, bytes in all). +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct Progress { + pub locale: String, + pub done: u64, + pub total: u64, +} + +/// The installs running now (not the cancelled ones still stopping). +pub fn installing() -> Vec { + jobs().installs.iter().filter(|j| !j.stopping()).map(|j| Progress { locale: j.locale.clone(), done: j.done, total: j.total }).collect() +} + +/// Whether the list is being fetched. +pub fn fetching() -> bool { + jobs().fetching +} + +/// The last failure of `locale`'s install ("" for the list), until it is tried again. +pub fn error(locale: &str) -> Option { + jobs().errors.iter().find(|(l, _)| l == locale).map(|(_, e)| e.clone()) +} + +/// Every recorded failure, (locale or "" for the list, message). +pub fn errors() -> Vec<(String, String)> { + jobs().errors.clone() +} + +/// Counts finished jobs: when it changes, the window lays the documents out again. +pub fn finished() -> u64 { + jobs().finished +} + +/// Stop `locale`'s running install. The person's own choice: it ends without an error, and +/// [`installing`] stops listing it at once (Install shows again). +pub fn cancel(locale: &str) { + if let Some(job) = jobs().installs.iter_mut().find(|j| j.locale == locale) { + job.restart = false; + job.cancel.cancel(); + } +} + +fn set_error(locale: &str, e: Option) { + let mut j = jobs(); + j.errors.retain(|(l, _)| l != locale); + if let Some(e) = e { + j.errors.push((locale.to_string(), e)); + } +} + +/// Start installing `locale` ([`install`]): on a thread when [`runs_in_background`], else now. +/// What's wrong up front (no pack, downloading off, outside the write root) is an error at once. +/// Returns whether it is still running (`false`: done). Asked again while it runs, it carries on; +/// right after a cancel, before the cancelled download has stopped, it starts over once that +/// one ends (never two downloads of a pack at once). +pub fn start_install(locale: &str) -> Result { + let b = backend().ok_or_else(no_store)?; + let pack = find(locale).ok_or_else(|| format!("there is no language pack for {locale}"))?; + if !b.network_on() { + return Err(network_off()); + } + if let Some(dir) = b.packs_dir() { + crate::file_access::check_write(&dir.to_string_lossy())?; + } + let Some(notify) = background() else { + let r = install(locale, &Cancel::new(), &mut |_, _| {}); + set_error(locale, r.as_ref().err().cloned()); + return r.map(|_| false); + }; + let cancel = Cancel::new(); + { + let mut j = jobs(); + j.errors.retain(|(l, _)| l != locale); + if let Some(job) = j.installs.iter_mut().find(|j| j.locale == locale) { + if job.stopping() { + // Cancelled and not ended yet: its thread starts over when it does. + job.done = 0; + job.total = pack.size; + job.cancel = cancel; + job.restart = true; + } + return Ok(true); + } + j.installs.push(InstallJob { locale: locale.to_string(), done: 0, total: pack.size, cancel: cancel.clone(), restart: false }); + } + let key = locale.to_string(); + let locale = locale.to_string(); + let started = spawn(move || { + let mut cancel = cancel; + loop { + let progress_notify = notify.clone(); + let r = install(&locale, &cancel, &mut |done, total| { + if let Some(job) = jobs().installs.iter_mut().find(|j| j.locale == locale) { + job.done = done; + job.total = total; + } + progress_notify(); + }); + let mut j = jobs(); + if let Some(job) = j.installs.iter_mut().find(|j| j.locale == locale && j.restart) { + job.restart = false; + cancel = job.cancel.clone(); + drop(j); + notify(); + continue; + } + j.installs.retain(|j| j.locale != locale); + // The person's own cancel isn't a failure: nothing to show. + if let Err(e) = r + && !cancel.is_cancelled() + { + j.errors.push((locale, e)); + } + j.finished = j.finished.wrapping_add(1); + drop(j); + notify(); + break; + } + }); + if let Err(e) = started { + jobs().installs.retain(|j| j.locale != key); + return Err(e); + } + Ok(true) +} + +/// Start fetching the list again ([`refresh`]): on a thread when [`runs_in_background`], else now. +/// Returns whether it is still running. +pub fn start_refresh() -> Result { + let b = backend().ok_or_else(no_store)?; + if !b.network_on() { + return Err(network_off()); + } + let Some(notify) = background() else { + let r = refresh(&Cancel::new()); + set_error("", r.as_ref().err().cloned()); + return r.map(|_| false); + }; + { + let mut j = jobs(); + if j.fetching { + return Ok(true); + } + j.fetching = true; + j.errors.retain(|(l, _)| !l.is_empty()); + } + let started = spawn(move || { + let r = refresh(&Cancel::new()); + let mut j = jobs(); + j.fetching = false; + if let Err(e) = r { + j.errors.push((String::new(), e)); + } + j.finished = j.finished.wrapping_add(1); + drop(j); + notify(); + }); + if let Err(e) = started { + jobs().fetching = false; + return Err(e); + } + Ok(true) +} + +#[cfg(not(target_arch = "wasm32"))] +fn spawn(f: impl FnOnce() + Send + 'static) -> Result<(), String> { + std::thread::Builder::new().name("language-pack".into()).spawn(f).map(|_| ()).map_err(|e| format!("couldn't start the download: {e}")) +} + +#[cfg(target_arch = "wasm32")] +fn spawn(_: impl FnOnce() + Send + 'static) -> Result<(), String> { + Err(no_store()) +} + +/// Have proofing look at the packs folder again (its dictionaries are reloaded). +fn reload(b: &Arc) { + if wordcraft_proof::packs::packs_dir() == b.packs_dir() { + wordcraft_proof::packs::reload(); + } else { + wordcraft_proof::packs::set_packs_dir(b.packs_dir()); + } +} + +/// The full licence texts for About › Language data: the embedded English data, then each +/// installed pack. +pub fn licence_texts() -> Vec<(String, String)> { + let mut texts = wordcraft_proof::packs::licence_texts(); + // SCOWL's notices (with the Ispell BSD licence of its affix files), when English spells from + // the embedded SCOWL data; after the Moby and supplement entries. + if let Some(notice) = wordcraft_proof::scowl::notice() { + let at = texts.len().min(2); + texts.insert(at, (SCOWL_TITLE.into(), notice.into())); + } + texts +} + +/// About › Language data's title for SCOWL's notices. +pub const SCOWL_TITLE: &str = "English spelling dictionaries (SCOWL)"; + +/// The pack locale for a document language tag: the whole tag (`pt-pt`), else each shorter +/// prefix of it (`sr-latn` for `sr-Latn-RS`, `nl` for `nl-NL`), the first the index has. A +/// variant after the region is also tried without the region, before the region alone +/// (`ca-ES-valencia` is the `ca-valencia` pack's), as craft-proof looks its dictionary up. `None` +/// for English and tags with no pack. +pub fn pack_for_tag<'a>(tag: &str, packs: &'a [Pack]) -> Option<&'a Pack> { + pack_candidates(tag).iter().find_map(|c| packs.iter().find(|p| &p.locale == c)) +} + +/// Is `subtag` a region, as it follows a tag's language (and script): two letters (`NL`) or +/// three digits (`419`)? The one test of what a region is; the lists, the menus and +/// [`pack_for_tag`] all use it. +pub fn is_region(subtag: &str) -> bool { + (subtag.len() == 2 && subtag.bytes().all(|b| b.is_ascii_alphabetic())) || (subtag.len() == 3 && subtag.bytes().all(|b| b.is_ascii_digit())) +} + +/// `tag` without its region subtags (`nl-NL` is `nl`, `sr-Latn-RS` is `sr-Latn`, `ca-ES-valencia` +/// is `ca-valencia`), joined with `-` whichever of `-` and `_` separated them; the case is kept. +/// The language itself (the first subtag) is never a region. +pub fn without_region(tag: &str) -> String { + tag.split(['-', '_']).enumerate().filter(|(i, p)| *i == 0 || !is_region(p)).map(|(_, p)| p).collect::>().join("-") +} + +/// The pack locales [`pack_for_tag`] tries for `tag`, best first. +fn pack_candidates(tag: &str) -> Vec { + // A tag is a handful of short subtags; anything longer is junk and only its start is read. + let tag: String = tag.trim().chars().take(64).collect::().replace('_', "-").to_ascii_lowercase(); + let parts: Vec<&str> = tag.split('-').collect(); + let base = parts.first().copied().unwrap_or(""); + if base.is_empty() || base == "en" { + return Vec::new(); + } + let mut out: Vec = Vec::new(); + for n in (1..=parts.len()).rev() { + let prefix = parts.get(..n).unwrap_or_default().join("-"); + // The prefix, then without its region: the variant's pack before the region alone. + let stripped = without_region(&prefix); + for c in [prefix, stripped] { + if !out.contains(&c) { + out.push(c); + } + } + } + out +} + +/// The languages (`w:lang`, resolved through the styles) of the body text, in first-seen order. +pub fn document_languages(s: &Session) -> Vec { + let mut out: Vec = Vec::new(); + for path in s.doc.para_paths(wordcraft_doc::StoryRef::Body).into_iter().take(MAX_PARAGRAPHS_SCANNED) { + let Some(p) = s.doc.para(wordcraft_doc::StoryRef::Body, &path) else { continue }; + if p.text.trim().is_empty() { + continue; + } + let style = p.props.style.as_deref(); + for (range, c) in p.run_ranges() { + let Some(text) = p.text.get(range) else { continue }; + if !text.chars().any(char::is_alphabetic) { + continue; + } + let rc = s.doc.styles.resolve_char_in(style, None, c); + if rc.no_proof { + continue; + } + let tag = rc.lang.unwrap_or_else(|| "en-US".into()); + if !out.contains(&tag) { + out.push(tag); + if out.len() >= MAX_DOC_LANGUAGES { + return out; + } + } + } + } + out +} + +/// Whether proofing reads a pack for text in `tag`: every language craft-proof can name, its own +/// (Polish…) and any other (`ProofLang::Pack`, read from the pack alone); not `und`, `zxx` or a +/// malformed tag. The one place the bar's language rule lives. +pub fn proofable(tag: &str) -> bool { + wordcraft_proof::ProofLang::from_tag(Some(tag)) != wordcraft_proof::ProofLang::Other +} + +/// The Hunspell dictionary `tag`'s language needs and doesn't have, for the status bar's +/// "(no dictionary)": craft-proof's [`wordcraft_proof::missing_dictionary`], except for a +/// language craft-proof has no rules of its own for (`ProofLang::Pack`: Japanese, Chinese, +/// Thai…) when no pack for it is available either, as no Hunspell dictionary may exist at all. +pub fn missing_dictionary(tag: Option<&str>) -> Option<&'static str> { + let name = wordcraft_proof::missing_dictionary(tag)?; + let lang = wordcraft_proof::ProofLang::from_tag(tag); + if lang.pack_locale().is_some() && !has_pack_for_tag(tag.unwrap_or("")) { + return None; + } + Some(name) +} + +/// The last answer of whether the index has a pack for a tag, for the index generation it was +/// worked out for: the status bar asks every frame while the caret is in a pack language, and +/// working it out copies the whole index. +#[derive(Default)] +struct PackLookup { + /// (index generation, tag, has a pack). + entry: Option<(u64, String, bool)>, +} + +impl PackLookup { + /// The remembered answer for `tag` at `generation`, else `compute`'s (then remembered). + fn get(&mut self, generation: u64, tag: &str, compute: impl FnOnce() -> bool) -> bool { + if let Some((g, t, has)) = &self.entry + && *g == generation + && t == tag + { + return *has; + } + let has = compute(); + self.entry = Some((generation, tag.to_string(), has)); + has + } +} + +static PACK_LOOKUP: Mutex = Mutex::new(PackLookup { entry: None }); + +/// Whether the index has a pack for `tag` ([`pack_for_tag`]), worked out again only when the +/// index changes ([`index_generation`]) or the tag does. +fn has_pack_for_tag(tag: &str) -> bool { + // Read before [`available`], which may fill the index and bump it: the answer is then worked + // out once more, never kept for a list it wasn't worked out from. + let generation = index_generation(); + PACK_LOOKUP.lock().unwrap_or_else(PoisonError::into_inner).get(generation, tag, || pack_for_tag(tag, &available()).is_some()) +} + +/// The language new text at the caret gets (its typing language, resolved through the styles +/// and the document default), unless it is marked Do not check spelling or grammar: choosing a +/// language with nothing selected asks for its pack before anything is typed in it. +pub fn caret_language(s: &Session) -> Option { + let style = s.doc.para_at(&s.sel.focus).and_then(|p| p.props.style.as_deref()); + let rc = s.doc.styles.resolve_char(style, &s.typing_props()); + if rc.no_proof { + return None; + } + Some(rc.lang.unwrap_or_else(|| "en-US".into())) +} + +/// The languages the missing-pack bar looks at: the body text's ([`document_languages`]), then +/// the caret's typing language ([`caret_language`]). +pub fn wanted_languages(s: &Session) -> Vec { + let mut out = document_languages(s); + if let Some(tag) = caret_language(s) + && !out.contains(&tag) + { + out.push(tag); + } + out +} + +/// The packs the document's text (or the caret's typing language) asks for that are available +/// and not installed: what the missing-pack bar offers. Never a language without a pack, nor one +/// proofing can't use. +pub fn missing(s: &Session) -> Vec { + let packs = available(); + if packs.is_empty() { + return Vec::new(); + } + let installed = installed(); + let mut out: Vec = Vec::new(); + for tag in wanted_languages(s) { + if proofable(&tag) + && let Some(p) = pack_for_tag(&tag, &packs) + && !installed.iter().any(|i| i.locale == p.locale) + && !out.iter().any(|o| o.locale == p.locale) + { + out.push(p.clone()); + } + } + out +} + +/// Where a language's proofing comes from (Options › Language › Your languages). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum Proofing { + /// English: the embedded dictionaries. + BuiltIn, + /// An installed Language pack. + Installed, + /// A Hunspell dictionary in the person's own dictionaries folder. + OwnDictionary, + /// A Hunspell dictionary the system has. + SystemDictionary, + /// No dictionary yet; a Language pack can be installed. + Install, + /// Grammar and hyphenation rules built in; a Language pack adds spelling. + GrammarBuiltIn, + /// No dictionary and no pack for it. + NotAvailable, +} + +/// A row of Options › Language › Your languages. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct LanguageRow { + /// The tags the default proofing language or the open document use for this language (the + /// first is what Set as default sets); empty for a language listed for its installed pack. + pub tags: Vec, + /// The pack locale: the installed pack, else the one the index offers. + pub pack: Option, + /// Is the pack installed? + pub installed: bool, + /// Bytes of the offered pack (0 when the index has none). + pub size: u64, + pub proofing: Proofing, + /// Is this the default proofing language? + pub default: bool, +} + +impl LanguageRow { + /// Is `tag` (the insertion point's language) one of this row's languages? + pub fn is_tag(&self, tag: &str) -> bool { + self.tags.iter().any(|t| t.eq_ignore_ascii_case(tag)) + } +} + +/// The first locale `has` accepts for `tag`, in [`pack_for_tag`]'s order. `None` for English and junk. +fn locale_for_tag(tag: &str, has: &dyn Fn(&str) -> bool) -> Option { + pack_candidates(tag).into_iter().find(|c| has(c)) +} + +/// [`your_languages_with`] against the dictionaries on this computer. +pub fn your_languages(s: &Session) -> Vec { + let folder = s.prefs.dictionaries_folder.clone(); + your_languages_with(s, folder.as_deref(), &dictionary_file) +} + +/// The Hunspell `.dic` file proofing reads for `tag`'s language now (a built-in-rules language's +/// own, or a pack language's), if any. +fn dictionary_file(tag: &str) -> Option { + let lang = wordcraft_proof::ProofLang::from_tag(Some(tag)); + if let Some((_, f)) = wordcraft_proof::installed_dictionaries().into_iter().find(|(l, _)| *l == lang) { + return f; + } + wordcraft_proof::pack_dictionary_file(lang) +} + +/// Options › Language › Your languages (unsorted; the window names and sorts them): each English +/// variant in use (the default proofing language, American English and any in the open document), every language +/// with an installed Language pack, and every proofing language of the open document. One row per +/// language: tags one pack proofs share a row. `folder` is the person's own dictionaries folder; +/// `dictionary` finds the `.dic` file proofing reads for a tag. +pub fn your_languages_with(s: &Session, folder: Option<&str>, dictionary: &dyn Fn(&str) -> Option) -> Vec { + let index = available(); + let installed = installed(); + let default_tag = s + .prefs + .default_language + .clone() + .or_else(|| s.editing_lang.clone()) + .or_else(|| s.doc.styles.default_chr.lang.clone()) + .unwrap_or_else(|| "en-US".to_string()); + // American English stays listed whatever the default is, so it can always be set back. + let mut tags = vec![default_tag.clone(), "en-US".to_string()]; + tags.dedup_by(|a, b| a.eq_ignore_ascii_case(b)); + for t in document_languages(s) { + if !tags.iter().any(|x| x.eq_ignore_ascii_case(&t)) { + tags.push(t); + } + } + let mut rows: Vec = Vec::new(); + let mut add = |tag: Option<&str>, locale: Option| { + let english = tag.is_some_and(|t| wordcraft_proof::ProofLang::from_tag(Some(t)).is_english()); + // English variants stay apart (en-GB and en-US are two rows); a pack's tags share one. + let existing = rows.iter().position(|r| match (&locale, &r.pack) { + (Some(l), Some(p)) if !english => l == p, + _ => tag.is_some_and(|t| r.is_tag(t)), + }); + let i = existing.unwrap_or_else(|| { + rows.push(LanguageRow { + tags: Vec::new(), + pack: locale.clone(), + installed: false, + size: 0, + proofing: Proofing::NotAvailable, + default: false, + }); + rows.len() - 1 + }); + if let (Some(t), Some(row)) = (tag, rows.get_mut(i)) + && !row.is_tag(t) + { + row.tags.push(t.to_string()); + } + }; + for t in &tags { + if !proofable(t) { + continue; + } + // The most specific pack, installed or offered, as the bar picks it ([`pack_for_tag`]): with + // `ca` installed, Valencian text is still the `ca-valencia` pack's row. + let locale = locale_for_tag(t, &|l| installed.iter().any(|i| i.locale == l) || index.iter().any(|p| p.locale == l)); + add(Some(t), locale); + } + for i in &installed { + add(None, Some(i.locale.clone())); + } + for row in &mut rows { + let installed_here = row.pack.as_ref().is_some_and(|l| installed.iter().any(|i| &i.locale == l)); + let offered = row.pack.as_ref().and_then(|l| index.iter().find(|p| &p.locale == l)); + row.installed = installed_here; + row.size = offered.map_or(0, |p| p.size); + row.default = row.is_tag(&default_tag); + let tag = row.tags.first().cloned().or_else(|| row.pack.clone()).unwrap_or_default(); + let lang = wordcraft_proof::ProofLang::from_tag(Some(&tag)); + let file = if lang.is_english() { None } else { dictionary(&tag) }; + let own = file.as_ref().is_some_and(|f| folder.is_some_and(|d| !d.trim().is_empty() && f.starts_with(d.trim()))); + row.proofing = if lang.is_english() { + Proofing::BuiltIn + } else if own { + Proofing::OwnDictionary + } else if installed_here { + Proofing::Installed + } else if file.is_some() { + Proofing::SystemDictionary + } else if offered.is_some() { + if matches!(lang, wordcraft_proof::ProofLang::Pack(_)) { Proofing::Install } else { Proofing::GrammarBuiltIn } + } else { + Proofing::NotAvailable + }; + } + rows +} + +/// The index was fetched this session (since the backend was set): what it lists is then the +/// whole answer, even when it lists nothing. +static FETCHED: AtomicBool = AtomicBool::new(false); +/// The automatic fetch of a missing or stale list ([`refresh_if_stale`]) was tried. +static AUTO_REFRESHED: AtomicBool = AtomicBool::new(false); +/// Languages (document tags) whose pack the person asked for before the list was known +/// ([`start_get`]): installed once a fetch lists one. +static WANTED: Mutex> = Mutex::new(Vec::new()); + +/// Whether the index was fetched since the backend was set. +pub fn fetched() -> bool { + FETCHED.load(Ordering::Relaxed) +} + +/// The languages the document (or the caret) asks for while there is no list of packs yet (a +/// fresh install, or a list cached for an older pin): proofable, not English, with no dictionary +/// and no pack installed, one per proofing language, as the document tags them (`tr-TR`). The +/// bar offers to get them: the list is fetched first, and the language then installed if it has +/// a pack. Nothing once a list is known or was fetched this session ([`missing`] then answers). +pub fn unlisted(s: &Session) -> Vec { + if backend().is_none() || fetched() || available_len() > 0 { + return Vec::new(); + } + let installed = installed(); + let mut seen: Vec = Vec::new(); + let mut out = Vec::new(); + for tag in wanted_languages(s) { + let base = tag.split(['-', '_']).next().unwrap_or("").to_ascii_lowercase(); + let lang = wordcraft_proof::ProofLang::from_tag(Some(&tag)); + if base.is_empty() + || base == "en" + || !proofable(&tag) + || wordcraft_proof::missing_dictionary(Some(&tag)).is_none() + || seen.contains(&lang) + || installed.iter().any(|i| i.locale == base || tag.eq_ignore_ascii_case(&i.locale)) + { + continue; + } + seen.push(lang); + out.push(tag); + } + out +} + +/// The person asked for `tag`'s pack before the list was known (the bar's Install, after their +/// consent turned downloading on): fetch the list ([`start_refresh`]), then install the pack it +/// lists for `tag`, if any; with none, the bar stops offering it. Downloading must be on. +/// Returns whether it is still running. +pub fn start_get(tag: &str) -> Result { + let tag = tag.trim(); + if tag.is_empty() || tag.len() > MAX_LOCALE_LEN { + return Err(format!("`{tag}` isn't a language tag")); + } + if let Some(p) = pack_for_tag(tag, &available()) { + return start_install(&p.locale); + } + let b = backend().ok_or_else(no_store)?; + if !b.network_on() { + // Never queued: a later fetch mustn't install what was refused. + return Err(network_off()); + } + let queued = { + let mut w = WANTED.lock().unwrap_or_else(PoisonError::into_inner); + let new = !w.iter().any(|t| t == tag) && w.len() < MAX_DOC_LANGUAGES; + if new { + w.push(tag.to_string()); + } + new + }; + let r = start_refresh(); + if r.is_err() && queued { + WANTED.lock().unwrap_or_else(PoisonError::into_inner).retain(|t| t != tag); + } + r +} + +/// After a fetch: install the packs asked for before the list was known ([`start_get`]). +fn install_wanted(index: &[Pack]) { + let wanted = std::mem::take(&mut *WANTED.lock().unwrap_or_else(PoisonError::into_inner)); + for tag in wanted { + let Some(p) = pack_for_tag(&tag, index) else { continue }; + if is_installed(&p.locale) { + continue; + } + if let Err(e) = start_install(&p.locale) { + set_error(&p.locale, Some(e)); + } + } +} + +/// Once a session (the first time it is asked once requests run on a thread, [`runs_in_background`]: +/// the window), with downloading already on and no list (none cached, or one cached for an older +/// pin): fetch it in the background, so the bar can offer packs. The person's consent is the +/// downloading setting itself; nothing is asked. Its failure is recorded for Options (""). The +/// window asks every frame: after the first time this reads nothing (the downloading setting is +/// a file). Returns whether a fetch was started. +pub fn refresh_if_stale() -> bool { + let Some(b) = backend() else { return false }; + if !runs_in_background() || AUTO_REFRESHED.swap(true, Ordering::Relaxed) { + return false; + } + if !b.network_on() || fetched() || fetching() || available_len() > 0 { + return false; + } + start_refresh().is_ok() +} + +/// A backend with a fixed list and installs that only record themselves: tests and `ui_shot`. +#[derive(Default)] +pub struct FixedBackend { + pub packs: Vec, + pub network: AtomicBool, + /// Agents can't turn the network on. + pub agent: bool, + pub installed: Mutex>, +} + +impl Pack { + /// Where the data comes from: the upstream source when the index names it, else the archive. + pub fn source_link(&self) -> &str { + self.source.as_deref().filter(|s| !s.is_empty()).unwrap_or(&self.url) + } +} + +impl FixedBackend { + /// A few packs shaped like the published ones, for screenshots and tests. + pub fn sample() -> Self { + let pack = |locale: &str, name: &str, size: u64, licence: &str, class: &str| Pack { + locale: locale.into(), + name: name.into(), + version: "0000000".into(), + url: format!("https://github.com/storytold/craft-i18n/releases/download/language-packs-2/{locale}.tar.zst"), + source: Some("https://github.com/LibreOffice/dictionaries".into()), + size, + licence: licence.into(), + class: class.into(), + spelling: true, + hyphenation: true, + }; + FixedBackend { + packs: vec![ + pack("ca", "Català", 1_480_000, "GPL-2.0-or-later OR LGPL-2.1-or-later", "Download-only"), + pack("el", "Ελληνικά", 2_310_000, "MPL-1.1 OR GPL-2.0-or-later OR LGPL-2.1-or-later", "Embeddable"), + pack("fr", "Français", 1_120_000, "MPL-2.0", "Embeddable"), + pack("nl", "Nederlands", 1_950_000, "BSD-3-Clause", "Embeddable"), + pack("pl", "Polski", 4_620_000, "GPL-2.0-or-later OR LGPL-2.1-or-later OR MPL-1.1 OR Apache-2.0 OR CC-BY-4.0", "Embeddable"), + pack("sk", "Slovenčina", 2_040_000, "GPL-2.0-only OR LGPL-2.1-only OR MPL-1.1", "Download-only"), + ], + ..Default::default() + } + } +} + +impl Backend for FixedBackend { + fn index(&self) -> Result, String> { + Ok(self.packs.clone()) + } + fn fetch_index(&self, _: &Cancel) -> Result, String> { + Ok(self.packs.clone()) + } + fn network_on(&self) -> bool { + self.network.load(Ordering::Relaxed) + } + fn enable_network(&self) -> Result<(), String> { + if self.agent { + return Err("only the user can turn on downloading".into()); + } + self.network.store(true, Ordering::Relaxed); + Ok(()) + } + fn install(&self, pack: &Pack, cancel: &Cancel, progress: &mut dyn FnMut(u64, u64)) -> Result<(), String> { + if cancel.is_cancelled() { + return Err("cancelled".into()); + } + progress(pack.size, pack.size); + let mut i = self.installed.lock().unwrap_or_else(PoisonError::into_inner); + i.retain(|p| p.locale != pack.locale); + i.push(Installed { locale: pack.locale.clone(), name: pack.name.clone(), licence: pack.licence.clone(), path: PathBuf::new() }); + Ok(()) + } + fn remove(&self, locale: &str) -> Result<(), String> { + self.installed.lock().unwrap_or_else(PoisonError::into_inner).retain(|p| p.locale != locale); + Ok(()) + } + fn packs_dir(&self) -> Option { + None + } + fn installed(&self) -> Vec { + self.installed.lock().unwrap_or_else(PoisonError::into_inner).clone() + } +} + +#[cfg(test)] +mod tests { + /// CP4.4 (`cargo xtask language`): a build embeds English Language data and nothing it + /// wasn't asked to. A Language build names the locales it embeds in `CRAFT_PROOF_EMBED` + /// (`en` alone: English only); the default embeds the carry-over locales too, until Language + /// packs are published and pinned (craft-proof's `DEFAULT_EMBED`; TODO(CP4.4): English only + /// then). + /// About › Language data shows SCOWL's notices (the Ispell BSD licence among them) whenever + /// English spells from the embedded SCOWL data. + #[test] + fn about_shows_the_scowl_notice_when_scowl_is_embedded() { + let texts = super::licence_texts(); + match wordcraft_proof::scowl::notice() { + Some(notice) => { + let entry = texts.iter().find(|(t, _)| t == super::SCOWL_TITLE).map(|(_, x)| x.as_str()); + assert_eq!(entry, Some(notice)); + assert!(notice.contains("Ispell") || notice.contains("ispell"), "the Ispell notice is part of it"); + } + None => assert!(texts.iter().all(|(t, _)| t != super::SCOWL_TITLE), "no SCOWL data: no SCOWL entry"), + } + } + + /// FU.3: the status bar's "(no dictionary)" asks every frame whether the index has a pack + /// for the caret's language; the answer is reused until the index changes (a new + /// generation) or the tag does, so the index isn't copied every frame. + #[test] + fn the_pack_lookup_is_reused_until_the_index_changes() { + let mut lookup = super::PackLookup::default(); + let mut computed = 0; + let mut ask = |lookup: &mut super::PackLookup, generation: u64, tag: &str, answer: bool| { + lookup.get(generation, tag, || { + computed += 1; + answer + }) + }; + assert!(ask(&mut lookup, 1, "ja-JP", true)); + // Same generation and tag: the remembered answer, even where the list would now say no. + for _ in 0..10 { + assert!(ask(&mut lookup, 1, "ja-JP", false)); + } + assert!(!ask(&mut lookup, 2, "ja-JP", false), "a new generation works it out again"); + assert!(ask(&mut lookup, 2, "th-TH", true), "another tag too"); + assert_eq!(computed, 3); + } + + #[test] + fn the_default_build_embeds_only_english() { + const CARRY_OVER: [&str; 5] = ["de", "pl", "pt", "it", "ru"]; + let named: Vec = std::env::var("CRAFT_PROOF_EMBED") + .unwrap_or_default() + .split(',') + .map(|l| l.trim().to_ascii_lowercase()) + .filter(|l| !l.is_empty()) + .collect(); + let allowed = |base: &str| base == "en" || if named.is_empty() { CARRY_OVER.contains(&base) } else { named.iter().any(|n| n == base) }; + let embedded = wordcraft_proof::packs::embedded_locales(); + let extra: Vec<&&str> = embedded.iter().filter(|l| !allowed(l.split('-').next().unwrap_or(l))).collect(); + assert!(extra.is_empty(), "embedded beyond English and {named:?} (or the carry-over by default): {extra:?}"); + } +} diff --git a/crates/engine/src/packs/store.rs b/crates/engine/src/packs/store.rs index abc3b53471..fbc89d13b9 100644 --- a/crates/engine/src/packs/store.rs +++ b/crates/engine/src/packs/store.rs @@ -1,104 +1,104 @@ -//! The font store as the packs [`Backend`](super::Backend): its folder, its consent setting, the -//! pinned pack index and verified installs (craft-libs `craft-font-store`, kind `language-pack`). - -use std::path::PathBuf; -use std::sync::{Mutex, PoisonError}; - -use craft_font_catalog::FontEnv; -use craft_font_store::{FontStore, Network, PackEntry, PackIndex, PackIndexPin}; - -use super::{Cancel, Pack}; - -/// The pack index this build trusts: craft-i18n's second Language pack release, -/// language-packs-2 (75 packs, built from craft-i18n `20467b63`), pinned by the sha256 of its `index.json`. -/// `CRAFT_LANGUAGE_PACK_INDEX` (read by the font store itself) points WordCraft at a local or test -/// index instead, for trying packs before they are released. -pub const PACK_INDEX: PackIndexPin = PackIndexPin { - url: "https://github.com/storytold/craft-i18n/releases/download/language-packs-2/index.json", - sha256: "b35c7aef0591177f18233b551da16035dcd615549ff874e169ffd19f16fea74b", -}; - -pub(super) struct FontStoreBackend { - store: FontStore, - /// The index last read or fetched; installs take their entry (url, size, sha256) from it. - index: Mutex>, -} - -impl FontStoreBackend { - pub(super) fn new(user: bool) -> Option { - let env = FontEnv::from_process(); - let store = if user { FontStore::for_user(&env)? } else { FontStore::for_agent(&env)? }; - Some(Self { store, index: Mutex::new(None) }) - } - - /// A store in `dir`. - pub(super) fn in_dir(dir: PathBuf, user: bool) -> Self { - let caller = if user { craft_font_store::Caller::User } else { craft_font_store::Caller::Agent }; - Self { store: FontStore::new(dir, caller), index: Mutex::new(None) } - } - - fn keep(&self, index: PackIndex) -> Vec { - let packs = index.packs.iter().map(pack).collect(); - *self.index.lock().unwrap_or_else(PoisonError::into_inner) = Some(index); - packs - } -} - -fn pack(e: &PackEntry) -> Pack { - Pack { - locale: e.locale.clone(), - name: e.name.clone(), - version: e.version.clone(), - url: e.url.clone(), - // TODO(CP4): the index's optional `source` once craft-font-store's PackEntry carries it. - source: None, - size: e.size, - licence: e.licence.clone(), - class: e.class.clone(), - spelling: e.spelling, - hyphenation: e.hyphenation, - } -} - -impl super::Backend for FontStoreBackend { - fn index(&self) -> Result, String> { - Ok(self.store.cached_language_pack_index(&PACK_INDEX).map(|i| self.keep(i)).unwrap_or_default()) - } - - fn fetch_index(&self, cancel: &Cancel) -> Result, String> { - let index = self.store.fetch_language_pack_index(&PACK_INDEX, &cancel.0).map_err(|e| e.to_string())?; - Ok(self.keep(index)) - } - - fn network_on(&self) -> bool { - self.store.network() == Network::On - } - - fn enable_network(&self) -> Result<(), String> { - self.store.enable_network().map_err(|e| e.to_string()) - } - - fn install(&self, pack: &Pack, cancel: &Cancel, progress: &mut dyn FnMut(u64, u64)) -> Result<(), String> { - let index = { - let held = self.index.lock().unwrap_or_else(PoisonError::into_inner).clone(); - match held { - Some(i) => i, - None => self.store.cached_language_pack_index(&PACK_INDEX).ok_or("the list of Language packs hasn't been fetched")?, - } - }; - let entry = index.pack(&pack.locale).ok_or_else(|| format!("there is no language pack for {}", pack.locale))?.clone(); - self.store.install_language_pack(&index, &entry, &cancel.0, &mut |p| progress(p.done, p.total)).map(|_| ()).map_err(|e| e.to_string()) - } - - fn remove(&self, locale: &str) -> Result<(), String> { - self.store.remove_language_pack(locale).map_err(|e| e.to_string()) - } - - fn index_override(&self) -> Option { - craft_font_store::language_pack_index_overridden() - } - - fn packs_dir(&self) -> Option { - Some(self.store.language_packs_dir()) - } -} +//! The font store as the packs [`Backend`](super::Backend): its folder, its consent setting, the +//! pinned pack index and verified installs (craft-libs `craft-font-store`, kind `language-pack`). + +use std::path::PathBuf; +use std::sync::{Mutex, PoisonError}; + +use craft_font_catalog::FontEnv; +use craft_font_store::{FontStore, Network, PackEntry, PackIndex, PackIndexPin}; + +use super::{Cancel, Pack}; + +/// The pack index this build trusts: craft-i18n's second Language pack release, +/// language-packs-2 (75 packs, built from craft-i18n `20467b63`), pinned by the sha256 of its `index.json`. +/// `CRAFT_LANGUAGE_PACK_INDEX` (read by the font store itself) points WordCraft at a local or test +/// index instead, for trying packs before they are released. +pub const PACK_INDEX: PackIndexPin = PackIndexPin { + url: "https://github.com/storytold/craft-i18n/releases/download/language-packs-2/index.json", + sha256: "b35c7aef0591177f18233b551da16035dcd615549ff874e169ffd19f16fea74b", +}; + +pub(super) struct FontStoreBackend { + store: FontStore, + /// The index last read or fetched; installs take their entry (url, size, sha256) from it. + index: Mutex>, +} + +impl FontStoreBackend { + pub(super) fn new(user: bool) -> Option { + let env = FontEnv::from_process(); + let store = if user { FontStore::for_user(&env)? } else { FontStore::for_agent(&env)? }; + Some(Self { store, index: Mutex::new(None) }) + } + + /// A store in `dir`. + pub(super) fn in_dir(dir: PathBuf, user: bool) -> Self { + let caller = if user { craft_font_store::Caller::User } else { craft_font_store::Caller::Agent }; + Self { store: FontStore::new(dir, caller), index: Mutex::new(None) } + } + + fn keep(&self, index: PackIndex) -> Vec { + let packs = index.packs.iter().map(pack).collect(); + *self.index.lock().unwrap_or_else(PoisonError::into_inner) = Some(index); + packs + } +} + +fn pack(e: &PackEntry) -> Pack { + Pack { + locale: e.locale.clone(), + name: e.name.clone(), + version: e.version.clone(), + url: e.url.clone(), + // TODO(CP4): the index's optional `source` once craft-font-store's PackEntry carries it. + source: None, + size: e.size, + licence: e.licence.clone(), + class: e.class.clone(), + spelling: e.spelling, + hyphenation: e.hyphenation, + } +} + +impl super::Backend for FontStoreBackend { + fn index(&self) -> Result, String> { + Ok(self.store.cached_language_pack_index(&PACK_INDEX).map(|i| self.keep(i)).unwrap_or_default()) + } + + fn fetch_index(&self, cancel: &Cancel) -> Result, String> { + let index = self.store.fetch_language_pack_index(&PACK_INDEX, &cancel.0).map_err(|e| e.to_string())?; + Ok(self.keep(index)) + } + + fn network_on(&self) -> bool { + self.store.network() == Network::On + } + + fn enable_network(&self) -> Result<(), String> { + self.store.enable_network().map_err(|e| e.to_string()) + } + + fn install(&self, pack: &Pack, cancel: &Cancel, progress: &mut dyn FnMut(u64, u64)) -> Result<(), String> { + let index = { + let held = self.index.lock().unwrap_or_else(PoisonError::into_inner).clone(); + match held { + Some(i) => i, + None => self.store.cached_language_pack_index(&PACK_INDEX).ok_or("the list of Language packs hasn't been fetched")?, + } + }; + let entry = index.pack(&pack.locale).ok_or_else(|| format!("there is no language pack for {}", pack.locale))?.clone(); + self.store.install_language_pack(&index, &entry, &cancel.0, &mut |p| progress(p.done, p.total)).map(|_| ()).map_err(|e| e.to_string()) + } + + fn remove(&self, locale: &str) -> Result<(), String> { + self.store.remove_language_pack(locale).map_err(|e| e.to_string()) + } + + fn index_override(&self) -> Option { + craft_font_store::language_pack_index_overridden() + } + + fn packs_dir(&self) -> Option { + Some(self.store.language_packs_dir()) + } +} diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 0dd6300279..735a0b6094 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -1,142 +1,142 @@ -use serde::{Deserialize, Serialize}; - -/// Editing preferences that persist between runs (the front end saves and restores them). -/// File › Options sets most of them (`file.options`). -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(default, rename_all = "camelCase")] -pub struct Prefs { - /// Word Count includes text boxes, footnotes and endnotes (Word's default). - pub count_notes: bool, - /// On-screen paper for pages without a document background; excluded from saved documents. - #[serde(deserialize_with = "lenient_paper_color")] - pub paper_color: crate::cmd::view::PaperColor, - /// Track Changes Options: what markup shows and how revisions are drawn (per user, as in - /// Word). - pub markup: wordcraft_layout::display::MarkupOptions, - /// File › Print / Export: Print Markup. PDFs, prints and page images show comments in - /// balloons beside each page and tracked changes as marked up; HTML, Markdown and text - /// copies end with the comments (`file.printMarkup`). - pub print_markup: bool, - /// Language › Detect language automatically (per user, as in Word). - pub detect_language: bool, - /// Set Proofing Language › Set As Default: overrides the interface language for future - /// blank documents and plain text. Older preferences keep following the interface. - #[serde(deserialize_with = "lenient_default_language")] - pub default_language: Option, - /// Typing over a selection replaces it; off, the typed text goes in front of it. - pub typing_replaces_selection: bool, - /// The Insert key switches overtype on and off. - pub insert_key_overtype: bool, - /// Overtype: typed characters replace the ones after the caret. - pub overtype: bool, - /// The unit rulers and dialogs show lengths in, when the user chose one (File › Options › - /// Advanced › Measurement units); `None` follows the region ([`Session::units`]). Saved under - /// a new key; an older `units` value other than inches (the old default) carries over on load. - #[serde(rename = "measurementUnits")] - pub units: Option, - /// The region the user chose for new documents, units and the decimal separator (a locale - /// tag like `pl-PL`, File › Options › Advanced › Region); `None` follows the system. - pub region: Option, - /// A folder searched for Hunspell dictionaries before the system locations (File › Options › - /// Language, #1063): an app started from Finder or the Dock doesn't see - /// `WORDCRAFT_DICTIONARIES`. `None` searches the system locations only. - pub dictionaries_folder: Option, - /// The user templates folder File › New lists personal templates from and Save as Word - /// Template starts in (File › Options › Advanced › File locations, #2852). `None`: the app's - /// own `Templates` folder in its settings folder. - pub user_templates_folder: Option, - /// Formatting marks shown even while Show/Hide ¶ is off. - pub marks: wordcraft_layout::display::FormattingMarks, - /// Mark grammar errors as you type (with Check spelling as you type on). - pub mark_grammar: bool, - /// Check spelling as you type (File › Options › Proofing). The live value is - /// `ViewState::proofing`; this is the copy that is saved and restored (#1792). - pub check_spelling: bool, - /// Read Mode's Page Color. The live value is `ViewState::read_page_color`. - #[serde(deserialize_with = "lenient_read_page_color")] - pub read_page_color: crate::cmd::view::ReadPageColor, - pub ignore_uppercase: bool, - pub ignore_numbers: bool, - pub ignore_internet: bool, - /// AutoCorrect Options: replacements, capitalization and AutoFormat As You Type (per user, as - /// in Word). A damaged saved value reads as the defaults. - #[serde(deserialize_with = "crate::cmd::tools::lenient_autocorrect")] - pub autocorrect: crate::cmd::tools::AutoCorrectPrefs, -} - -impl Default for Prefs { - fn default() -> Self { - Prefs { - paper_color: Default::default(), - count_notes: true, - markup: Default::default(), - print_markup: false, - typing_replaces_selection: true, - insert_key_overtype: false, - overtype: false, - units: Default::default(), - region: None, - dictionaries_folder: None, - user_templates_folder: None, - marks: Default::default(), - mark_grammar: true, - check_spelling: true, - read_page_color: Default::default(), - ignore_uppercase: true, - ignore_numbers: true, - ignore_internet: true, - detect_language: true, - default_language: None, - autocorrect: Default::default(), - } - } -} - -impl Prefs { - /// What the spelling check skips. - pub fn spelling(&self) -> wordcraft_proof::Options { - wordcraft_proof::Options { - ignore_uppercase: self.ignore_uppercase, - ignore_numbers: self.ignore_numbers, - ignore_internet: self.ignore_internet, - } - } -} - -/// Ignore malformed saved language tags without discarding the other preferences. -fn lenient_default_language<'de, D: serde::Deserializer<'de>>(d: D) -> Result, D::Error> { - let value = serde_json::Value::deserialize(d)?; - Ok(value.as_str().and_then(wordcraft_proof::lang::normalize_tag)) -} - -fn lenient_paper_color<'de, D: serde::Deserializer<'de>>(d: D) -> Result { - let value = serde_json::Value::deserialize(d)?; - Ok(serde_json::from_value(value).unwrap_or_default()) -} - -/// Ignore an invalid saved Page Color without discarding other preferences. -fn lenient_read_page_color<'de, D: serde::Deserializer<'de>>(d: D) -> Result { - let value = serde_json::Value::deserialize(d)?; - Ok(serde_json::from_value(value).unwrap_or_default()) -} - -#[cfg(test)] -mod tests { - use super::Prefs; - use crate::cmd::view::ReadPageColor; - - #[test] - fn read_page_color_loads_from_old_and_damaged_prefs() { - let old: Prefs = serde_json::from_str(r#"{"checkSpelling":false}"#).unwrap(); - assert_eq!(old.read_page_color, ReadPageColor::None); - assert!(!old.check_spelling); - for bad in [r#"{"readPageColor":"unknown","checkSpelling":false}"#, r#"{"readPageColor":42,"checkSpelling":false}"#] { - let prefs: Prefs = serde_json::from_str(bad).unwrap(); - assert_eq!(prefs.read_page_color, ReadPageColor::None); - assert!(!prefs.check_spelling); - } - let saved: Prefs = serde_json::from_str(r#"{"readPageColor":"sepia"}"#).unwrap(); - assert_eq!(saved.read_page_color, ReadPageColor::Sepia); - assert_eq!(serde_json::to_value(saved).unwrap()["readPageColor"], "sepia"); - } -} +use serde::{Deserialize, Serialize}; + +/// Editing preferences that persist between runs (the front end saves and restores them). +/// File › Options sets most of them (`file.options`). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Prefs { + /// Word Count includes text boxes, footnotes and endnotes (Word's default). + pub count_notes: bool, + /// On-screen paper for pages without a document background; excluded from saved documents. + #[serde(deserialize_with = "lenient_paper_color")] + pub paper_color: crate::cmd::view::PaperColor, + /// Track Changes Options: what markup shows and how revisions are drawn (per user, as in + /// Word). + pub markup: wordcraft_layout::display::MarkupOptions, + /// File › Print / Export: Print Markup. PDFs, prints and page images show comments in + /// balloons beside each page and tracked changes as marked up; HTML, Markdown and text + /// copies end with the comments (`file.printMarkup`). + pub print_markup: bool, + /// Language › Detect language automatically (per user, as in Word). + pub detect_language: bool, + /// Set Proofing Language › Set As Default: overrides the interface language for future + /// blank documents and plain text. Older preferences keep following the interface. + #[serde(deserialize_with = "lenient_default_language")] + pub default_language: Option, + /// Typing over a selection replaces it; off, the typed text goes in front of it. + pub typing_replaces_selection: bool, + /// The Insert key switches overtype on and off. + pub insert_key_overtype: bool, + /// Overtype: typed characters replace the ones after the caret. + pub overtype: bool, + /// The unit rulers and dialogs show lengths in, when the user chose one (File › Options › + /// Advanced › Measurement units); `None` follows the region ([`Session::units`]). Saved under + /// a new key; an older `units` value other than inches (the old default) carries over on load. + #[serde(rename = "measurementUnits")] + pub units: Option, + /// The region the user chose for new documents, units and the decimal separator (a locale + /// tag like `pl-PL`, File › Options › Advanced › Region); `None` follows the system. + pub region: Option, + /// A folder searched for Hunspell dictionaries before the system locations (File › Options › + /// Language, #1063): an app started from Finder or the Dock doesn't see + /// `WORDCRAFT_DICTIONARIES`. `None` searches the system locations only. + pub dictionaries_folder: Option, + /// The user templates folder File › New lists personal templates from and Save as Word + /// Template starts in (File › Options › Advanced › File locations, #2852). `None`: the app's + /// own `Templates` folder in its settings folder. + pub user_templates_folder: Option, + /// Formatting marks shown even while Show/Hide ¶ is off. + pub marks: wordcraft_layout::display::FormattingMarks, + /// Mark grammar errors as you type (with Check spelling as you type on). + pub mark_grammar: bool, + /// Check spelling as you type (File › Options › Proofing). The live value is + /// `ViewState::proofing`; this is the copy that is saved and restored (#1792). + pub check_spelling: bool, + /// Read Mode's Page Color. The live value is `ViewState::read_page_color`. + #[serde(deserialize_with = "lenient_read_page_color")] + pub read_page_color: crate::cmd::view::ReadPageColor, + pub ignore_uppercase: bool, + pub ignore_numbers: bool, + pub ignore_internet: bool, + /// AutoCorrect Options: replacements, capitalization and AutoFormat As You Type (per user, as + /// in Word). A damaged saved value reads as the defaults. + #[serde(deserialize_with = "crate::cmd::tools::lenient_autocorrect")] + pub autocorrect: crate::cmd::tools::AutoCorrectPrefs, +} + +impl Default for Prefs { + fn default() -> Self { + Prefs { + paper_color: Default::default(), + count_notes: true, + markup: Default::default(), + print_markup: false, + typing_replaces_selection: true, + insert_key_overtype: false, + overtype: false, + units: Default::default(), + region: None, + dictionaries_folder: None, + user_templates_folder: None, + marks: Default::default(), + mark_grammar: true, + check_spelling: true, + read_page_color: Default::default(), + ignore_uppercase: true, + ignore_numbers: true, + ignore_internet: true, + detect_language: true, + default_language: None, + autocorrect: Default::default(), + } + } +} + +impl Prefs { + /// What the spelling check skips. + pub fn spelling(&self) -> wordcraft_proof::Options { + wordcraft_proof::Options { + ignore_uppercase: self.ignore_uppercase, + ignore_numbers: self.ignore_numbers, + ignore_internet: self.ignore_internet, + } + } +} + +/// Ignore malformed saved language tags without discarding the other preferences. +fn lenient_default_language<'de, D: serde::Deserializer<'de>>(d: D) -> Result, D::Error> { + let value = serde_json::Value::deserialize(d)?; + Ok(value.as_str().and_then(wordcraft_proof::lang::normalize_tag)) +} + +fn lenient_paper_color<'de, D: serde::Deserializer<'de>>(d: D) -> Result { + let value = serde_json::Value::deserialize(d)?; + Ok(serde_json::from_value(value).unwrap_or_default()) +} + +/// Ignore an invalid saved Page Color without discarding other preferences. +fn lenient_read_page_color<'de, D: serde::Deserializer<'de>>(d: D) -> Result { + let value = serde_json::Value::deserialize(d)?; + Ok(serde_json::from_value(value).unwrap_or_default()) +} + +#[cfg(test)] +mod tests { + use super::Prefs; + use crate::cmd::view::ReadPageColor; + + #[test] + fn read_page_color_loads_from_old_and_damaged_prefs() { + let old: Prefs = serde_json::from_str(r#"{"checkSpelling":false}"#).unwrap(); + assert_eq!(old.read_page_color, ReadPageColor::None); + assert!(!old.check_spelling); + for bad in [r#"{"readPageColor":"unknown","checkSpelling":false}"#, r#"{"readPageColor":42,"checkSpelling":false}"#] { + let prefs: Prefs = serde_json::from_str(bad).unwrap(); + assert_eq!(prefs.read_page_color, ReadPageColor::None); + assert!(!prefs.check_spelling); + } + let saved: Prefs = serde_json::from_str(r#"{"readPageColor":"sepia"}"#).unwrap(); + assert_eq!(saved.read_page_color, ReadPageColor::Sepia); + assert_eq!(serde_json::to_value(saved).unwrap()["readPageColor"], "sepia"); + } +} diff --git a/crates/engine/src/print.rs b/crates/engine/src/print.rs index 29dd4fed37..32c620eba9 100644 --- a/crates/engine/src/print.rs +++ b/crates/engine/src/print.rs @@ -1,425 +1,425 @@ -//! Print settings (#641): which pages go out, how many copies, as a PDF. -//! -//! WordCraft prints through a PDF (the host opens it in the system's print flow), so the settings -//! that can be delivered are the ones that shape the PDF: the page range, odd/even pages, copies -//! and collation. Physical sheet imposition is shared with the preview; printer choice and duplex -//! are handled by the host. -//! -//! Page ranges and copies come from users and control channels, so they are bounded: a range -//! text is at most [`MAX_SPEC_CHARS`] long, copies are clamped to [`MAX_COPIES`], and the -//! resulting page list never exceeds [`MAX_PRINT_PAGES`]. - -use serde_json::{Value, json}; -use wordcraft_layout::DocLayout; -pub use wordcraft_pdf::imposition::Imposition; - -use crate::{CmdError, Session, p}; - -/// Longest page-range text accepted. -pub const MAX_SPEC_CHARS: usize = 500; -/// Most copies one print job asks for. -pub const MAX_COPIES: u32 = 999; -/// Most pages (counting copies) one print job holds. -pub const MAX_PRINT_PAGES: usize = 5000; - -/// Parses a custom page range like `1-3, 5, 8-` into 0-based page indexes, in the order typed -/// (Word prints them in document order; so does this: sorted, without repeats). Pages are -/// 1-based in the text; `8-` means "to the last page" and `-3` "from the first". An empty text, -/// page 0, a page past `total`, a reversed range or anything else is an error. -pub fn parse_page_range(spec: &str, total: usize) -> Result, String> { - if spec.chars().count() > MAX_SPEC_CHARS { - return Err("the page range is too long".into()); - } - if total == 0 { - return Err("the document has no pages".into()); - } - let mut keep = vec![false; total]; - let mut any = false; - for item in spec.split([',', ';']) { - let item: String = item.chars().filter(|c| !c.is_whitespace()).collect(); - if item.is_empty() { - continue; - } - let page = |t: &str| -> Result { - let not_a_page = || format!("`{item}` isn't a page or a range like 2-4"); - if !t.bytes().all(|b| b.is_ascii_digit()) { - return Err(not_a_page()); - } - let n: usize = t.parse().map_err(|_| not_a_page())?; - if n == 0 || n > total { - return Err(format!("page {n} doesn't exist; the document has {total} page{}", if total == 1 { "" } else { "s" })); - } - Ok(n) - }; - let (from, to) = match item.split_once('-') { - None => { - let n = page(&item)?; - (n, n) - } - Some(("", "")) => return Err("`-` isn't a page or a range like 2-4".into()), - Some((a, b)) => { - let from = if a.is_empty() { 1 } else { page(a)? }; - let to = if b.is_empty() { total } else { page(b)? }; - if from > to { - return Err(format!("`{item}` runs backwards")); - } - (from, to) - } - }; - for flag in keep.iter_mut().take(to).skip(from - 1) { - *flag = true; - } - any = true; - } - if !any { - return Err("type the pages to print, like 1-3, 5".into()); - } - Ok(keep.iter().enumerate().filter(|(_, k)| **k).map(|(i, _)| i).collect()) -} - -/// What a print job holds: the pages (0-based, copies included, in print order). -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct PrintPlan { - /// Page indexes in print order, repeated for copies. - pub pages: Vec, - /// Distinct pages chosen (before copies). - pub chosen: usize, - pub copies: u32, -} - -/// Works out the print plan for `v` against a layout of `total` pages whose caret and selection -/// sit on pages `current` (first, last). Params: -/// `range` (`all` | `current` | `selection` | `custom`; `custom` is implied by `pages`), -/// `pages` (text like `1-3,5`), `parity` (`all` | `odd` | `even`), `copies` (1–999), -/// `collate` (default true: 1,2,3 1,2,3; false: 1,1,2,2,3,3). -pub fn plan(v: &Value, total: usize, current: (usize, usize)) -> Result { - let bad = |m: String| CmdError::Params(m); - let pages_text = p::str(v, "pages").filter(|t| !t.trim().is_empty()); - let range = p::str(v, "range").unwrap_or(if pages_text.is_some() { "custom" } else { "all" }); - let last = total.saturating_sub(1); - let mut pages: Vec = match range { - "all" => (0..total).collect(), - "current" => vec![current.0.min(last)], - "selection" => (current.0.min(last)..=current.1.min(last).max(current.0.min(last))).collect(), - "custom" => parse_page_range(pages_text.unwrap_or(""), total).map_err(bad)?, - other => return Err(bad(format!("unknown range `{other}`; use all, current, selection or custom"))), - }; - match p::str(v, "parity").unwrap_or("all") { - "all" => {} - // Page numbers are 1-based: the odd ones are indexes 0, 2, 4… - "odd" => pages.retain(|i| i % 2 == 0), - "even" => pages.retain(|i| i % 2 == 1), - other => return Err(bad(format!("unknown parity `{other}`; use all, odd or even"))), - } - if pages.is_empty() { - return Err(bad("no pages to print with these settings".into())); - } - let copies = p::u64(v, "copies").unwrap_or(1).clamp(1, u64::from(MAX_COPIES)) as u32; - let chosen = pages.len(); - let total_out = chosen.checked_mul(copies as usize).filter(|n| *n <= MAX_PRINT_PAGES); - let Some(total_out) = total_out else { - return Err(bad(format!("{chosen} pages × {copies} copies is more than the {MAX_PRINT_PAGES} pages one print job can hold"))); - }; - let mut out = Vec::with_capacity(total_out); - if p::bool(v, "collate").unwrap_or(true) { - for _ in 0..copies { - out.extend_from_slice(&pages); - } - } else { - for &pg in &pages { - out.extend(std::iter::repeat_n(pg, copies as usize)); - } - } - Ok(PrintPlan { pages: out, chosen, copies }) -} - -/// The `paper` param of a print job: the standard paper (see [`wordcraft_geom::PAPER_SIZES`]) to -/// scale every page to (Scale to paper size, #2108), or `None` to print the document's own page -/// sizes (absent, null, `""`, `none` or `document`). Any other value is an error. -pub fn paper(v: &Value) -> Result, CmdError> { - match v.get("paper") { - None | Some(Value::Null) => Ok(None), - Some(Value::String(name)) if name.trim().is_empty() || ["none", "document"].contains(&name.trim().to_ascii_lowercase().as_str()) => Ok(None), - Some(Value::String(name)) => wordcraft_geom::paper_by_name(name).map(Some).ok_or_else(|| { - let names: Vec<&str> = wordcraft_geom::PAPER_SIZES.iter().map(|(n, _, _)| *n).collect(); - CmdError::Params(format!("unknown paper `{}`; use none or one of {}", name.chars().take(40).collect::(), names.join(", "))) - }), - Some(_) => Err(CmdError::Params("paper must be a paper size name or none".into())), - } -} - -/// Validate the physical sheet layout without touching document page setup. -pub fn imposition(v: &Value) -> Result { - match v.get("pagesPerSheet") { - None => Ok(Imposition::Single), - Some(Value::Number(n)) => match n.as_u64() { - Some(1) => Ok(Imposition::Single), - Some(n @ (2 | 4 | 6 | 9 | 16)) => Ok(Imposition::NUp(n as u8)), - _ => Err(CmdError::Params("pagesPerSheet must be 1, 2, 4, 6, 9, 16, booklet or bookletReverse".into())), - }, - Some(Value::String(s)) if s == "booklet" || s == "bookletReverse" => Ok(Imposition::Booklet { reverse: s == "bookletReverse" }), - _ => Err(CmdError::Params("pagesPerSheet must be 1, 2, 4, 6, 9, 16, booklet or bookletReverse".into())), - } -} - -/// Resolve physical paper orientation for imposed jobs; ordinary export keeps its existing policy. -fn imposed_paper(v: &Value, lay: &DocLayout, pages: &[usize]) -> Result, CmdError> { - let paper = paper(v)?.map(|(_, w, h)| (w, h)); - if imposition(v)? == Imposition::Single { - return Ok(paper); - } - let own = pages.first().and_then(|p| lay.pages.get(*p)).map(|p| (p.w - p.markup, p.h)); - let Some((w, h)) = paper.or(own) else { return Ok(paper) }; - match v.get("orientation").and_then(Value::as_str).unwrap_or("auto") { - "auto" => Ok(paper), - "portrait" => Ok(Some((w.min(h), w.max(h)))), - "landscape" => Ok(Some((w.max(h), w.min(h)))), - _ => Err(CmdError::Params("orientation must be auto, portrait or landscape".into())), - } -} - -/// Plan physical sheet sides for one copy, using the same selection and geometry as printing. -pub fn sheets(v: &Value, lay: &DocLayout, current: (usize, usize)) -> Result, CmdError> { - if !v.is_object() { - return Err(CmdError::Params("print settings must be an object".into())); - } - let mut single = v.clone(); - single["copies"] = json!(1); - let pages = plan(&single, lay.pages.len(), current)?.pages; - imposition(v)?.sheets(lay, &pages, imposed_paper(v, lay, &pages)?).map_err(|e| CmdError::Failed(e.to_string())) -} - -impl Session { - /// The pages the caret (or selection) is on, in the output layout: (first, last). - pub fn current_pages(&self, lay: &DocLayout) -> (usize, usize) { - let (a, b) = self.sel.ordered(); - let first = lay.caret_on(&a, self.page_hint).map_or(0, |c| c.page); - let last = lay.caret_on(&b, self.page_hint).map_or(first, |c| c.page); - (first, last.max(first)) - } - - /// The document as the PDF of a print job `v` describes (see [`plan`]), with the number of - /// distinct pages chosen and copies, as File › Print › Save these pages as PDF keeps it: - /// links work. Does not touch the document. - pub fn print_pdf(&self, v: &Value) -> Result<(Vec, PrintPlan), CmdError> { - self.print_pdf_with(v, true) - } - - /// The PDF sent to a printer for job `v`: [`Self::print_pdf`] without link annotations, - /// which print nothing, and which a printer driver can't fit to the paper ("flatten page - /// annotations before using shrink-to-fit", #4734). - pub fn print_job_pdf(&self, v: &Value) -> Result<(Vec, PrintPlan), CmdError> { - self.print_pdf_with(v, false) - } - - /// Render a shared sheet plan for a saved PDF or an annotation-free printer job. - fn print_pdf_with(&self, v: &Value, links: bool) -> Result<(Vec, PrintPlan), CmdError> { - if !v.is_object() { - return Err(CmdError::Params("print settings must be an object".into())); - } - // Print Markup (File › Print) decides whether tracked changes and comments print. - let markup = crate::cmd::file::export_options(self, v); - let out = crate::cmd::references::output_fields(&self.doc); - let doc = out.as_ref().unwrap_or(&self.doc); - let lay = self.export_layout_of(doc, markup.markup); - let plan = plan(v, lay.pages.len(), self.current_pages(&lay))?; - let mut single = v.clone(); - single["copies"] = json!(1); - let single_plan = self::plan(&single, lay.pages.len(), self.current_pages(&lay))?; - let paper = imposed_paper(v, &lay, &single_plan.pages)?; - let opts = wordcraft_pdf::PdfOptions { - pages: Some(single_plan.pages), - imposition: imposition(v)?, - sheet_copies: plan.copies, - sheet_collate: p::bool(v, "collate").unwrap_or(true), - // Repeated pages would repeat the structure tree's entries too. - tagged: plan.copies <= 1, - include_markup: markup.markup, - comments: markup.comments, - paper, - links, - ..Default::default() - }; - let bytes = wordcraft_pdf::export_layout(doc, &lay, &opts).map_err(|e| CmdError::Failed(e.to_string()))?; - Ok((bytes, plan)) - } -} - -/// `file.printPdf`: write the print job's PDF to `path` (no dialogs). -pub(crate) fn print_pdf_to_file(s: &mut Session, v: &Value) -> Result { - let path = p::req_str(v, "path")?; - let path = if path.to_ascii_lowercase().ends_with(".pdf") { path.to_string() } else { format!("{path}.pdf") }; - let (bytes, plan) = s.print_pdf(v)?; - // Through `io`, so an agent confined to an automation write root (#769) can't print elsewhere. - #[cfg(not(target_arch = "wasm32"))] - crate::io::write_file(&path, &bytes).map_err(CmdError::Failed)?; - Ok(json!({"path": path, "pages": plan.pages.len(), "chosen": plan.chosen, "copies": plan.copies, "bytes": bytes.len()})) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn ranges_parse_and_hostile_ones_are_errors() { - assert_eq!(parse_page_range("1-3, 5", 6), Ok(vec![0, 1, 2, 4])); - assert_eq!(parse_page_range("5,1-2,2", 6), Ok(vec![0, 1, 4]), "document order, no repeats"); - assert_eq!(parse_page_range("4-", 6), Ok(vec![3, 4, 5])); - assert_eq!(parse_page_range("-2", 6), Ok(vec![0, 1])); - for bad in ["", " , ", "0", "7", "3-1", "a", "1-2-3", "99999999999999999999999", "-", "1..3", "+3", "1-+3", "+2-+4", &"1,".repeat(400)] { - assert!(parse_page_range(bad, 6).is_err(), "`{bad}` is refused"); - } - assert!(parse_page_range("1", 0).is_err()); - } - - #[test] - fn plans_apply_range_parity_copies_and_caps() { - let p = |v: Value| plan(&v, 6, (2, 3)).map(|p| p.pages); - assert_eq!(p(json!({})), Ok(vec![0, 1, 2, 3, 4, 5])); - assert_eq!(p(json!({"range": "current"})), Ok(vec![2])); - assert_eq!(p(json!({"range": "selection"})), Ok(vec![2, 3])); - assert_eq!(p(json!({"pages": "2-4", "copies": 2})), Ok(vec![1, 2, 3, 1, 2, 3])); - assert_eq!(p(json!({"pages": "2-3", "copies": 2, "collate": false})), Ok(vec![1, 1, 2, 2])); - assert_eq!(p(json!({"parity": "even"})), Ok(vec![1, 3, 5])); - assert!(p(json!({"range": "custom"})).is_err(), "custom needs pages"); - assert!(p(json!({"range": "everything"})).is_err()); - assert!(p(json!({"range": "current", "parity": "even"})).is_err(), "page 3 is odd: nothing left"); - // Hostile copies are clamped; a job that would be too big is refused, not allocated. - assert_eq!(p(json!({"range": "current", "copies": 1e12})).map(|v| v.len()), Ok(MAX_COPIES as usize)); - assert!(plan(&json!({"copies": 999}), 600, (0, 0)).is_err()); - assert_eq!(p(json!({"copies": -5, "range": "current"})), Ok(vec![2])); - } - - #[test] - fn print_pdf_writes_only_the_chosen_pages() { - let mut s = Session::new(crate::sample::sample_document()); - let pages = s.export_layout().pages.len(); - let (all, _) = s.print_pdf(&json!({})).unwrap(); - let (one, plan) = s.print_pdf(&json!({"range": "current", "copies": 2})).unwrap(); - assert!(all.starts_with(b"%PDF") && one.starts_with(b"%PDF")); - assert_eq!(plan.pages.len(), 2); - if pages > 2 { - assert!(one.len() < all.len()); - } - assert!(s.print_pdf(&json!({"pages": "9999"})).is_err()); - let r = s.run("file.printPdf", &json!({})).is_err(); - assert!(r, "printPdf needs a path"); - } - - /// #4734: the PDF sent to a printer has no link annotations, which a printer driver can't fit - /// to the paper ("flatten page annotations before using shrink-to-fit"). An exported PDF and - /// one saved with Save these pages as PDF (`file.printPdf`) keep them. - #[test] - fn only_the_print_job_leaves_out_link_annotations() { - let mut s = Session::new(wordcraft_doc::Document::new()); - s.run("insert.link", &json!({"url": "https://example.com", "text": "a link"})).unwrap(); - let lay = s.export_layout(); - let exported = wordcraft_pdf::export_layout(&s.doc, &lay, &wordcraft_pdf::PdfOptions::default()).unwrap(); - let links = |bytes: &[u8]| { - let raw = String::from_utf8_lossy(bytes); - raw.contains("/Annots") && raw.contains("/Link") - }; - assert!(links(&exported), "an exported PDF keeps the link"); - for params in [json!({}), json!({"paper": "A4"}), json!({"markup": true})] { - let (job, _) = s.print_job_pdf(¶ms).unwrap(); - assert!(!links(&job) && !String::from_utf8_lossy(&job).contains("/Annots"), "{params}"); - let (saved, _) = s.print_pdf(¶ms).unwrap(); - assert!(links(&saved), "{params}"); - } - #[cfg(not(target_arch = "wasm32"))] - { - let path = std::env::temp_dir().join(format!("wc-print-links-{}.pdf", std::process::id())); - s.run("file.printPdf", &json!({"path": path.to_string_lossy()})).unwrap(); - let saved = std::fs::read(&path).unwrap(); - let _ = std::fs::remove_file(&path); - assert!(links(&saved), "Save these pages as PDF keeps the link"); - } - } - - /// Scale to paper size (#2108): the PDF goes on the chosen paper; the document keeps its size. - #[test] - fn print_pdf_scales_to_the_chosen_paper() { - // Page dictionaries are written uncompressed: `/MediaBox[0 0 w h]`. - let media = |bytes: &[u8]| { - let raw = String::from_utf8_lossy(bytes).into_owned(); - raw.split("/MediaBox[") - .skip(1) - .filter_map(|r| r.split(']').next()) - .map(|r| r.split_whitespace().filter_map(|n| n.parse::().ok()).collect::>()) - .map(|n| (n[2].round(), n[3].round())) - .collect::>() - }; - let s = Session::new(crate::sample::sample_document()); - let (w, h) = (s.doc.last_section.page_w, s.doc.last_section.page_h); - let rev = s.rev(); - let (own, _) = s.print_pdf(&json!({"range": "current"})).unwrap(); - assert_eq!(media(&own), vec![(w.round(), h.round())]); - for (name, want) in [("A4", (595.0, 842.0)), ("letter", (612.0, 792.0)), ("Legal", (612.0, 1008.0))] { - let (bytes, _) = s.print_pdf(&json!({"range": "current", "paper": name})).unwrap(); - assert_eq!(media(&bytes), vec![want], "{name}"); - } - for none in [json!(null), json!(""), json!("none"), json!("Document")] { - let (bytes, _) = s.print_pdf(&json!({"range": "current", "paper": none})).unwrap(); - assert_eq!(media(&bytes), media(&own)); - } - for bad in [json!("Quarto"), json!(5), json!({"w": 1}), json!("x".repeat(10_000))] { - assert!(s.print_pdf(&json!({"paper": bad})).is_err(), "{bad}"); - } - assert_eq!((s.doc.last_section.page_w, s.doc.last_section.page_h, s.rev()), (w, h, rev), "the document is unchanged"); - } - - /// `file.printPdf` writes only inside the automation write root when there is one (#769). - #[cfg(not(target_arch = "wasm32"))] - #[test] - fn print_pdf_to_file_is_held_to_the_write_root() { - use crate::file_access::{AutomationRoots, confine}; - let base = std::env::temp_dir().join(format!("wc-print-roots-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&base); - for d in ["out", "outside"] { - std::fs::create_dir_all(base.join(d)).unwrap(); - } - let roots = AutomationRoots::new(None, Some(&base.join("out"))).unwrap(); - let mut s = Session::new(crate::sample::sample_document()); - let inside = base.join("out/job").to_string_lossy().into_owned(); - let outside = base.join("outside/job").to_string_lossy().into_owned(); - confine(roots.as_ref(), || { - let e = s.run("file.printPdf", &json!({"path": outside})).unwrap_err().to_string(); - assert!(e.contains("outside the write root"), "{e}"); - s.run("file.printPdf", &json!({"path": inside})).unwrap(); - }); - assert!(!base.join("outside/job.pdf").exists(), "nothing printed outside the root"); - assert!(std::fs::read(base.join("out/job.pdf")).unwrap().starts_with(b"%PDF")); - // Without roots nothing is checked: the behaviour is unchanged. - s.run("file.printPdf", &json!({"path": outside})).unwrap(); - assert!(base.join("outside/job.pdf").is_file()); - let _ = std::fs::remove_dir_all(&base); - } -} - -#[cfg(test)] -mod imposition_tests { - use super::*; - /// Sheet printing leaves document bytes, dirty state, selection and undo history untouched. - #[test] - fn print_imposition_is_pure_and_rejects_hostile_modes() { - let s = Session::new(wordcraft_doc::Document::from_text("one\u{c}two\u{c}three\u{c}four\u{c}five")); - let before = (s.rev(), s.dirty, s.sel.clone(), serde_json::to_value(&s.doc).unwrap()); - for (mode, sides) in - [(json!(2), 3), (json!(4), 2), (json!(6), 1), (json!(9), 1), (json!(16), 1), (json!("booklet"), 4), (json!("bookletReverse"), 4)] - { - let params = json!({"pagesPerSheet":mode, "copies":2}); - let lay = s.export_layout(); - assert_eq!(sheets(¶ms, &lay, s.current_pages(&lay)).unwrap().len(), sides); - let (bytes, plan) = s.print_pdf(¶ms).unwrap(); - assert_eq!(plan.copies, 2); - assert_eq!(String::from_utf8_lossy(&bytes).matches("/MediaBox[").count(), sides * 2); - } - for bad in [json!(0), json!(3), json!(-1), json!(1.5), json!(true), json!(null), json!({}), json!("2"), json!("x".repeat(10000))] { - assert!(s.print_pdf(&json!({"pagesPerSheet":bad})).is_err()); - } - for params in [json!(null), json!([]), json!(false)] { - assert!(s.print_pdf(¶ms).is_err()); - } - assert_eq!((s.rev(), s.dirty, s.sel.clone(), serde_json::to_value(&s.doc).unwrap()), before); - } -} +//! Print settings (#641): which pages go out, how many copies, as a PDF. +//! +//! WordCraft prints through a PDF (the host opens it in the system's print flow), so the settings +//! that can be delivered are the ones that shape the PDF: the page range, odd/even pages, copies +//! and collation. Physical sheet imposition is shared with the preview; printer choice and duplex +//! are handled by the host. +//! +//! Page ranges and copies come from users and control channels, so they are bounded: a range +//! text is at most [`MAX_SPEC_CHARS`] long, copies are clamped to [`MAX_COPIES`], and the +//! resulting page list never exceeds [`MAX_PRINT_PAGES`]. + +use serde_json::{Value, json}; +use wordcraft_layout::DocLayout; +pub use wordcraft_pdf::imposition::Imposition; + +use crate::{CmdError, Session, p}; + +/// Longest page-range text accepted. +pub const MAX_SPEC_CHARS: usize = 500; +/// Most copies one print job asks for. +pub const MAX_COPIES: u32 = 999; +/// Most pages (counting copies) one print job holds. +pub const MAX_PRINT_PAGES: usize = 5000; + +/// Parses a custom page range like `1-3, 5, 8-` into 0-based page indexes, in the order typed +/// (Word prints them in document order; so does this: sorted, without repeats). Pages are +/// 1-based in the text; `8-` means "to the last page" and `-3` "from the first". An empty text, +/// page 0, a page past `total`, a reversed range or anything else is an error. +pub fn parse_page_range(spec: &str, total: usize) -> Result, String> { + if spec.chars().count() > MAX_SPEC_CHARS { + return Err("the page range is too long".into()); + } + if total == 0 { + return Err("the document has no pages".into()); + } + let mut keep = vec![false; total]; + let mut any = false; + for item in spec.split([',', ';']) { + let item: String = item.chars().filter(|c| !c.is_whitespace()).collect(); + if item.is_empty() { + continue; + } + let page = |t: &str| -> Result { + let not_a_page = || format!("`{item}` isn't a page or a range like 2-4"); + if !t.bytes().all(|b| b.is_ascii_digit()) { + return Err(not_a_page()); + } + let n: usize = t.parse().map_err(|_| not_a_page())?; + if n == 0 || n > total { + return Err(format!("page {n} doesn't exist; the document has {total} page{}", if total == 1 { "" } else { "s" })); + } + Ok(n) + }; + let (from, to) = match item.split_once('-') { + None => { + let n = page(&item)?; + (n, n) + } + Some(("", "")) => return Err("`-` isn't a page or a range like 2-4".into()), + Some((a, b)) => { + let from = if a.is_empty() { 1 } else { page(a)? }; + let to = if b.is_empty() { total } else { page(b)? }; + if from > to { + return Err(format!("`{item}` runs backwards")); + } + (from, to) + } + }; + for flag in keep.iter_mut().take(to).skip(from - 1) { + *flag = true; + } + any = true; + } + if !any { + return Err("type the pages to print, like 1-3, 5".into()); + } + Ok(keep.iter().enumerate().filter(|(_, k)| **k).map(|(i, _)| i).collect()) +} + +/// What a print job holds: the pages (0-based, copies included, in print order). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PrintPlan { + /// Page indexes in print order, repeated for copies. + pub pages: Vec, + /// Distinct pages chosen (before copies). + pub chosen: usize, + pub copies: u32, +} + +/// Works out the print plan for `v` against a layout of `total` pages whose caret and selection +/// sit on pages `current` (first, last). Params: +/// `range` (`all` | `current` | `selection` | `custom`; `custom` is implied by `pages`), +/// `pages` (text like `1-3,5`), `parity` (`all` | `odd` | `even`), `copies` (1–999), +/// `collate` (default true: 1,2,3 1,2,3; false: 1,1,2,2,3,3). +pub fn plan(v: &Value, total: usize, current: (usize, usize)) -> Result { + let bad = |m: String| CmdError::Params(m); + let pages_text = p::str(v, "pages").filter(|t| !t.trim().is_empty()); + let range = p::str(v, "range").unwrap_or(if pages_text.is_some() { "custom" } else { "all" }); + let last = total.saturating_sub(1); + let mut pages: Vec = match range { + "all" => (0..total).collect(), + "current" => vec![current.0.min(last)], + "selection" => (current.0.min(last)..=current.1.min(last).max(current.0.min(last))).collect(), + "custom" => parse_page_range(pages_text.unwrap_or(""), total).map_err(bad)?, + other => return Err(bad(format!("unknown range `{other}`; use all, current, selection or custom"))), + }; + match p::str(v, "parity").unwrap_or("all") { + "all" => {} + // Page numbers are 1-based: the odd ones are indexes 0, 2, 4… + "odd" => pages.retain(|i| i % 2 == 0), + "even" => pages.retain(|i| i % 2 == 1), + other => return Err(bad(format!("unknown parity `{other}`; use all, odd or even"))), + } + if pages.is_empty() { + return Err(bad("no pages to print with these settings".into())); + } + let copies = p::u64(v, "copies").unwrap_or(1).clamp(1, u64::from(MAX_COPIES)) as u32; + let chosen = pages.len(); + let total_out = chosen.checked_mul(copies as usize).filter(|n| *n <= MAX_PRINT_PAGES); + let Some(total_out) = total_out else { + return Err(bad(format!("{chosen} pages × {copies} copies is more than the {MAX_PRINT_PAGES} pages one print job can hold"))); + }; + let mut out = Vec::with_capacity(total_out); + if p::bool(v, "collate").unwrap_or(true) { + for _ in 0..copies { + out.extend_from_slice(&pages); + } + } else { + for &pg in &pages { + out.extend(std::iter::repeat_n(pg, copies as usize)); + } + } + Ok(PrintPlan { pages: out, chosen, copies }) +} + +/// The `paper` param of a print job: the standard paper (see [`wordcraft_geom::PAPER_SIZES`]) to +/// scale every page to (Scale to paper size, #2108), or `None` to print the document's own page +/// sizes (absent, null, `""`, `none` or `document`). Any other value is an error. +pub fn paper(v: &Value) -> Result, CmdError> { + match v.get("paper") { + None | Some(Value::Null) => Ok(None), + Some(Value::String(name)) if name.trim().is_empty() || ["none", "document"].contains(&name.trim().to_ascii_lowercase().as_str()) => Ok(None), + Some(Value::String(name)) => wordcraft_geom::paper_by_name(name).map(Some).ok_or_else(|| { + let names: Vec<&str> = wordcraft_geom::PAPER_SIZES.iter().map(|(n, _, _)| *n).collect(); + CmdError::Params(format!("unknown paper `{}`; use none or one of {}", name.chars().take(40).collect::(), names.join(", "))) + }), + Some(_) => Err(CmdError::Params("paper must be a paper size name or none".into())), + } +} + +/// Validate the physical sheet layout without touching document page setup. +pub fn imposition(v: &Value) -> Result { + match v.get("pagesPerSheet") { + None => Ok(Imposition::Single), + Some(Value::Number(n)) => match n.as_u64() { + Some(1) => Ok(Imposition::Single), + Some(n @ (2 | 4 | 6 | 9 | 16)) => Ok(Imposition::NUp(n as u8)), + _ => Err(CmdError::Params("pagesPerSheet must be 1, 2, 4, 6, 9, 16, booklet or bookletReverse".into())), + }, + Some(Value::String(s)) if s == "booklet" || s == "bookletReverse" => Ok(Imposition::Booklet { reverse: s == "bookletReverse" }), + _ => Err(CmdError::Params("pagesPerSheet must be 1, 2, 4, 6, 9, 16, booklet or bookletReverse".into())), + } +} + +/// Resolve physical paper orientation for imposed jobs; ordinary export keeps its existing policy. +fn imposed_paper(v: &Value, lay: &DocLayout, pages: &[usize]) -> Result, CmdError> { + let paper = paper(v)?.map(|(_, w, h)| (w, h)); + if imposition(v)? == Imposition::Single { + return Ok(paper); + } + let own = pages.first().and_then(|p| lay.pages.get(*p)).map(|p| (p.w - p.markup, p.h)); + let Some((w, h)) = paper.or(own) else { return Ok(paper) }; + match v.get("orientation").and_then(Value::as_str).unwrap_or("auto") { + "auto" => Ok(paper), + "portrait" => Ok(Some((w.min(h), w.max(h)))), + "landscape" => Ok(Some((w.max(h), w.min(h)))), + _ => Err(CmdError::Params("orientation must be auto, portrait or landscape".into())), + } +} + +/// Plan physical sheet sides for one copy, using the same selection and geometry as printing. +pub fn sheets(v: &Value, lay: &DocLayout, current: (usize, usize)) -> Result, CmdError> { + if !v.is_object() { + return Err(CmdError::Params("print settings must be an object".into())); + } + let mut single = v.clone(); + single["copies"] = json!(1); + let pages = plan(&single, lay.pages.len(), current)?.pages; + imposition(v)?.sheets(lay, &pages, imposed_paper(v, lay, &pages)?).map_err(|e| CmdError::Failed(e.to_string())) +} + +impl Session { + /// The pages the caret (or selection) is on, in the output layout: (first, last). + pub fn current_pages(&self, lay: &DocLayout) -> (usize, usize) { + let (a, b) = self.sel.ordered(); + let first = lay.caret_on(&a, self.page_hint).map_or(0, |c| c.page); + let last = lay.caret_on(&b, self.page_hint).map_or(first, |c| c.page); + (first, last.max(first)) + } + + /// The document as the PDF of a print job `v` describes (see [`plan`]), with the number of + /// distinct pages chosen and copies, as File › Print › Save these pages as PDF keeps it: + /// links work. Does not touch the document. + pub fn print_pdf(&self, v: &Value) -> Result<(Vec, PrintPlan), CmdError> { + self.print_pdf_with(v, true) + } + + /// The PDF sent to a printer for job `v`: [`Self::print_pdf`] without link annotations, + /// which print nothing, and which a printer driver can't fit to the paper ("flatten page + /// annotations before using shrink-to-fit", #4734). + pub fn print_job_pdf(&self, v: &Value) -> Result<(Vec, PrintPlan), CmdError> { + self.print_pdf_with(v, false) + } + + /// Render a shared sheet plan for a saved PDF or an annotation-free printer job. + fn print_pdf_with(&self, v: &Value, links: bool) -> Result<(Vec, PrintPlan), CmdError> { + if !v.is_object() { + return Err(CmdError::Params("print settings must be an object".into())); + } + // Print Markup (File › Print) decides whether tracked changes and comments print. + let markup = crate::cmd::file::export_options(self, v); + let out = crate::cmd::references::output_fields(&self.doc); + let doc = out.as_ref().unwrap_or(&self.doc); + let lay = self.export_layout_of(doc, markup.markup); + let plan = plan(v, lay.pages.len(), self.current_pages(&lay))?; + let mut single = v.clone(); + single["copies"] = json!(1); + let single_plan = self::plan(&single, lay.pages.len(), self.current_pages(&lay))?; + let paper = imposed_paper(v, &lay, &single_plan.pages)?; + let opts = wordcraft_pdf::PdfOptions { + pages: Some(single_plan.pages), + imposition: imposition(v)?, + sheet_copies: plan.copies, + sheet_collate: p::bool(v, "collate").unwrap_or(true), + // Repeated pages would repeat the structure tree's entries too. + tagged: plan.copies <= 1, + include_markup: markup.markup, + comments: markup.comments, + paper, + links, + ..Default::default() + }; + let bytes = wordcraft_pdf::export_layout(doc, &lay, &opts).map_err(|e| CmdError::Failed(e.to_string()))?; + Ok((bytes, plan)) + } +} + +/// `file.printPdf`: write the print job's PDF to `path` (no dialogs). +pub(crate) fn print_pdf_to_file(s: &mut Session, v: &Value) -> Result { + let path = p::req_str(v, "path")?; + let path = if path.to_ascii_lowercase().ends_with(".pdf") { path.to_string() } else { format!("{path}.pdf") }; + let (bytes, plan) = s.print_pdf(v)?; + // Through `io`, so an agent confined to an automation write root (#769) can't print elsewhere. + #[cfg(not(target_arch = "wasm32"))] + crate::io::write_file(&path, &bytes).map_err(CmdError::Failed)?; + Ok(json!({"path": path, "pages": plan.pages.len(), "chosen": plan.chosen, "copies": plan.copies, "bytes": bytes.len()})) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ranges_parse_and_hostile_ones_are_errors() { + assert_eq!(parse_page_range("1-3, 5", 6), Ok(vec![0, 1, 2, 4])); + assert_eq!(parse_page_range("5,1-2,2", 6), Ok(vec![0, 1, 4]), "document order, no repeats"); + assert_eq!(parse_page_range("4-", 6), Ok(vec![3, 4, 5])); + assert_eq!(parse_page_range("-2", 6), Ok(vec![0, 1])); + for bad in ["", " , ", "0", "7", "3-1", "a", "1-2-3", "99999999999999999999999", "-", "1..3", "+3", "1-+3", "+2-+4", &"1,".repeat(400)] { + assert!(parse_page_range(bad, 6).is_err(), "`{bad}` is refused"); + } + assert!(parse_page_range("1", 0).is_err()); + } + + #[test] + fn plans_apply_range_parity_copies_and_caps() { + let p = |v: Value| plan(&v, 6, (2, 3)).map(|p| p.pages); + assert_eq!(p(json!({})), Ok(vec![0, 1, 2, 3, 4, 5])); + assert_eq!(p(json!({"range": "current"})), Ok(vec![2])); + assert_eq!(p(json!({"range": "selection"})), Ok(vec![2, 3])); + assert_eq!(p(json!({"pages": "2-4", "copies": 2})), Ok(vec![1, 2, 3, 1, 2, 3])); + assert_eq!(p(json!({"pages": "2-3", "copies": 2, "collate": false})), Ok(vec![1, 1, 2, 2])); + assert_eq!(p(json!({"parity": "even"})), Ok(vec![1, 3, 5])); + assert!(p(json!({"range": "custom"})).is_err(), "custom needs pages"); + assert!(p(json!({"range": "everything"})).is_err()); + assert!(p(json!({"range": "current", "parity": "even"})).is_err(), "page 3 is odd: nothing left"); + // Hostile copies are clamped; a job that would be too big is refused, not allocated. + assert_eq!(p(json!({"range": "current", "copies": 1e12})).map(|v| v.len()), Ok(MAX_COPIES as usize)); + assert!(plan(&json!({"copies": 999}), 600, (0, 0)).is_err()); + assert_eq!(p(json!({"copies": -5, "range": "current"})), Ok(vec![2])); + } + + #[test] + fn print_pdf_writes_only_the_chosen_pages() { + let mut s = Session::new(crate::sample::sample_document()); + let pages = s.export_layout().pages.len(); + let (all, _) = s.print_pdf(&json!({})).unwrap(); + let (one, plan) = s.print_pdf(&json!({"range": "current", "copies": 2})).unwrap(); + assert!(all.starts_with(b"%PDF") && one.starts_with(b"%PDF")); + assert_eq!(plan.pages.len(), 2); + if pages > 2 { + assert!(one.len() < all.len()); + } + assert!(s.print_pdf(&json!({"pages": "9999"})).is_err()); + let r = s.run("file.printPdf", &json!({})).is_err(); + assert!(r, "printPdf needs a path"); + } + + /// #4734: the PDF sent to a printer has no link annotations, which a printer driver can't fit + /// to the paper ("flatten page annotations before using shrink-to-fit"). An exported PDF and + /// one saved with Save these pages as PDF (`file.printPdf`) keep them. + #[test] + fn only_the_print_job_leaves_out_link_annotations() { + let mut s = Session::new(wordcraft_doc::Document::new()); + s.run("insert.link", &json!({"url": "https://example.com", "text": "a link"})).unwrap(); + let lay = s.export_layout(); + let exported = wordcraft_pdf::export_layout(&s.doc, &lay, &wordcraft_pdf::PdfOptions::default()).unwrap(); + let links = |bytes: &[u8]| { + let raw = String::from_utf8_lossy(bytes); + raw.contains("/Annots") && raw.contains("/Link") + }; + assert!(links(&exported), "an exported PDF keeps the link"); + for params in [json!({}), json!({"paper": "A4"}), json!({"markup": true})] { + let (job, _) = s.print_job_pdf(¶ms).unwrap(); + assert!(!links(&job) && !String::from_utf8_lossy(&job).contains("/Annots"), "{params}"); + let (saved, _) = s.print_pdf(¶ms).unwrap(); + assert!(links(&saved), "{params}"); + } + #[cfg(not(target_arch = "wasm32"))] + { + let path = std::env::temp_dir().join(format!("wc-print-links-{}.pdf", std::process::id())); + s.run("file.printPdf", &json!({"path": path.to_string_lossy()})).unwrap(); + let saved = std::fs::read(&path).unwrap(); + let _ = std::fs::remove_file(&path); + assert!(links(&saved), "Save these pages as PDF keeps the link"); + } + } + + /// Scale to paper size (#2108): the PDF goes on the chosen paper; the document keeps its size. + #[test] + fn print_pdf_scales_to_the_chosen_paper() { + // Page dictionaries are written uncompressed: `/MediaBox[0 0 w h]`. + let media = |bytes: &[u8]| { + let raw = String::from_utf8_lossy(bytes).into_owned(); + raw.split("/MediaBox[") + .skip(1) + .filter_map(|r| r.split(']').next()) + .map(|r| r.split_whitespace().filter_map(|n| n.parse::().ok()).collect::>()) + .map(|n| (n[2].round(), n[3].round())) + .collect::>() + }; + let s = Session::new(crate::sample::sample_document()); + let (w, h) = (s.doc.last_section.page_w, s.doc.last_section.page_h); + let rev = s.rev(); + let (own, _) = s.print_pdf(&json!({"range": "current"})).unwrap(); + assert_eq!(media(&own), vec![(w.round(), h.round())]); + for (name, want) in [("A4", (595.0, 842.0)), ("letter", (612.0, 792.0)), ("Legal", (612.0, 1008.0))] { + let (bytes, _) = s.print_pdf(&json!({"range": "current", "paper": name})).unwrap(); + assert_eq!(media(&bytes), vec![want], "{name}"); + } + for none in [json!(null), json!(""), json!("none"), json!("Document")] { + let (bytes, _) = s.print_pdf(&json!({"range": "current", "paper": none})).unwrap(); + assert_eq!(media(&bytes), media(&own)); + } + for bad in [json!("Quarto"), json!(5), json!({"w": 1}), json!("x".repeat(10_000))] { + assert!(s.print_pdf(&json!({"paper": bad})).is_err(), "{bad}"); + } + assert_eq!((s.doc.last_section.page_w, s.doc.last_section.page_h, s.rev()), (w, h, rev), "the document is unchanged"); + } + + /// `file.printPdf` writes only inside the automation write root when there is one (#769). + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn print_pdf_to_file_is_held_to_the_write_root() { + use crate::file_access::{AutomationRoots, confine}; + let base = std::env::temp_dir().join(format!("wc-print-roots-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + for d in ["out", "outside"] { + std::fs::create_dir_all(base.join(d)).unwrap(); + } + let roots = AutomationRoots::new(None, Some(&base.join("out"))).unwrap(); + let mut s = Session::new(crate::sample::sample_document()); + let inside = base.join("out/job").to_string_lossy().into_owned(); + let outside = base.join("outside/job").to_string_lossy().into_owned(); + confine(roots.as_ref(), || { + let e = s.run("file.printPdf", &json!({"path": outside})).unwrap_err().to_string(); + assert!(e.contains("outside the write root"), "{e}"); + s.run("file.printPdf", &json!({"path": inside})).unwrap(); + }); + assert!(!base.join("outside/job.pdf").exists(), "nothing printed outside the root"); + assert!(std::fs::read(base.join("out/job.pdf")).unwrap().starts_with(b"%PDF")); + // Without roots nothing is checked: the behaviour is unchanged. + s.run("file.printPdf", &json!({"path": outside})).unwrap(); + assert!(base.join("outside/job.pdf").is_file()); + let _ = std::fs::remove_dir_all(&base); + } +} + +#[cfg(test)] +mod imposition_tests { + use super::*; + /// Sheet printing leaves document bytes, dirty state, selection and undo history untouched. + #[test] + fn print_imposition_is_pure_and_rejects_hostile_modes() { + let s = Session::new(wordcraft_doc::Document::from_text("one\u{c}two\u{c}three\u{c}four\u{c}five")); + let before = (s.rev(), s.dirty, s.sel.clone(), serde_json::to_value(&s.doc).unwrap()); + for (mode, sides) in + [(json!(2), 3), (json!(4), 2), (json!(6), 1), (json!(9), 1), (json!(16), 1), (json!("booklet"), 4), (json!("bookletReverse"), 4)] + { + let params = json!({"pagesPerSheet":mode, "copies":2}); + let lay = s.export_layout(); + assert_eq!(sheets(¶ms, &lay, s.current_pages(&lay)).unwrap().len(), sides); + let (bytes, plan) = s.print_pdf(¶ms).unwrap(); + assert_eq!(plan.copies, 2); + assert_eq!(String::from_utf8_lossy(&bytes).matches("/MediaBox[").count(), sides * 2); + } + for bad in [json!(0), json!(3), json!(-1), json!(1.5), json!(true), json!(null), json!({}), json!("2"), json!("x".repeat(10000))] { + assert!(s.print_pdf(&json!({"pagesPerSheet":bad})).is_err()); + } + for params in [json!(null), json!([]), json!(false)] { + assert!(s.print_pdf(¶ms).is_err()); + } + assert_eq!((s.rev(), s.dirty, s.sel.clone(), serde_json::to_value(&s.doc).unwrap()), before); + } +} diff --git a/crates/engine/src/recovery.rs b/crates/engine/src/recovery.rs index 6df7fb3371..ce210f5acc 100644 --- a/crates/engine/src/recovery.rs +++ b/crates/engine/src/recovery.rs @@ -1,229 +1,229 @@ -//! Local desktop recovery. Encoding and file I/O run on a worker, outside typing. -use crate::{CmdError, CmdResult, Session}; -use serde::{Deserialize, Serialize}; -use serde_json::{Value, json}; - -#[derive(Clone, Debug, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct RecoveryInfo { - pub id: String, - pub name: String, - pub saved_at: String, -} - -#[cfg(not(target_arch = "wasm32"))] -mod desktop; -#[cfg(not(target_arch = "wasm32"))] -use desktop::{Applied, Request}; -#[cfg(not(target_arch = "wasm32"))] -pub(crate) use desktop::{DocRecovery, Recovery, Slot}; - -impl Session { - /// The desktop host supplies the profile folder and a thread-safe wakeup. - /// Headless sessions and the browser never write recovery files implicitly. - #[cfg(not(target_arch = "wasm32"))] - pub fn enable_recovery(&mut self, root: std::path::PathBuf, wake: std::sync::Arc) -> Result<(), String> { - self.recovery = Some(Recovery::start(root, wake)?); - Ok(()) - } - /// Copies left by an earlier run (or another window's documents that have closed), newest - /// first. The copies this session's open documents keep are not listed: they are not lost. - pub fn recovery_documents(&self) -> Vec { - #[cfg(not(target_arch = "wasm32"))] - if let Some(r) = &self.recovery { - let held = |id: &str| self.recovery_slots().any(|(_, _, s)| s.id.as_deref() == Some(id)) || r.closing().any(|c| c == id); - return r.documents.iter().filter(|d| !held(&d.id)).cloned().collect(); - } - Vec::new() - } - pub fn recovery_ready(&self) -> bool { - #[cfg(not(target_arch = "wasm32"))] - if let Some(r) = &self.recovery { - return r.ready; - } - false - } - pub fn recovery_busy(&self) -> bool { - #[cfg(not(target_arch = "wasm32"))] - if let Some(r) = &self.recovery { - return r.busy; - } - false - } - pub fn recovery_error(&self) -> Option<&str> { - #[cfg(not(target_arch = "wasm32"))] - if let Some(r) = &self.recovery { - return r.error.as_deref(); - } - None - } - /// Called every frame: takes in the worker's answer, opens a restored copy, and queues the - /// next job. Every open document's copy is kept, the parked ones' too, without making them - /// active: a frame compares each document's revision and timer, and clones a document only - /// when its copy is due. - pub fn recovery_tick(&mut self) { - #[cfg(not(target_arch = "wasm32"))] - { - let Some(mut r) = self.recovery.take() else { return }; - if let Some(e) = r.receive(false) { - self.apply_recovery(&mut r, e); - } - self.open_held_restore(&mut r); - if let Err(e) = r.tick(self.recovery_docs(), std::time::Instant::now()) { - r.error = Some(e); - } - if let Some(e) = &r.error { - self.status = e.clone(); - } - self.recovery = Some(r); - } - } - #[cfg(not(target_arch = "wasm32"))] - fn apply_recovery(&mut self, r: &mut Recovery, e: desktop::Event) { - match r.apply(e) { - // Opened by the next tick, never in the middle of a command. - Applied::Loaded(snapshot, request) => r.held = Some((snapshot, request)), - applied => { - for d in self.recovery_docs() { - if let Applied::Failed { document, .. } = &applied - && *document != d.id - { - continue; - } - d.slot.apply(&applied); - } - } - } - } - /// A restored copy opens as another document, or replaces the untouched blank document that - /// asked for it once that document is active, so no work is ever replaced. The restored - /// document keeps its copy in the record it came from. - #[cfg(not(target_arch = "wasm32"))] - fn open_held_restore(&mut self, r: &mut Recovery) { - let Some((_, request)) = &r.held else { return }; - let request = *request; - if request.replace && self.is_open(request.document) && request.document != self.document_id() { - return; - } - let Some((snapshot, _)) = r.held.take() else { return }; - let id = snapshot.info.id.clone(); - if !request.replace { - self.add_document(snapshot.document); - } else if request.document == self.document_id() && request.revision == self.rev() && self.password.is_none() { - self.set_document(snapshot.document); - self.path = None; - } else { - r.error = Some("Recovery cancelled: the document changed while the copy was loading.".into()); - return; - } - self.dirty = true; - self.recovery_slot.adopt(id, self.rev()); - } - pub fn recovery_wait(&self) -> Option { - #[cfg(not(target_arch = "wasm32"))] - if let Some(r) = &self.recovery { - return r.wait(self.recovery_slots()); - } - None - } - /// Waits for recovery to finish anything already under way, then deletes the copies no - /// open document needs: closed documents', and those of documents saved or protected since - /// their last copy. Dirty documents keep theirs. Desktop hosts call this right before the - /// process exits, the same as they wait out pending print jobs: otherwise a save just before - /// close can race the process exiting before a now-redundant recovery copy actually gets - /// removed, so the next launch wrongly offers to recover a document that was in fact saved - /// cleanly. - pub fn finish_recovery(&mut self) { - #[cfg(not(target_arch = "wasm32"))] - if let Some(mut r) = self.recovery.take() { - if let Some(e) = r.receive(true) { - self.apply_recovery(&mut r, e); - } - let mut discards: Vec<(String, Option)> = r.take_closed().into_iter().map(|id| (id, None)).collect(); - discards.extend(self.recovery_docs().filter(|d| !d.wants_copy).filter_map(|d| d.slot.id.clone().map(|id| (id, Some(d.id))))); - for (id, document) in discards { - match r.discard_now(&id, document) { - Ok(e) => self.apply_recovery(&mut r, e), - Err(e) => r.error = Some(e), - } - } - if let Some(e) = &r.error { - log::warn!("recovery: {e}"); - } - self.recovery = Some(r); - } - } - /// The user discarded the active document's unsaved changes without closing it (Don't Save - /// when quitting): its recovery copy goes too, and another is made only after a further edit. - pub fn discard_recovery_copy(&mut self) { - #[cfg(not(target_arch = "wasm32"))] - if let Some(r) = &mut self.recovery - && let Some(id) = self.recovery_slot.id.take() - { - r.close(id); - self.recovery_slot.revision = Some(self.rev()); - } - } - /// Only an explicit protect/save action waits for the worker and removes plaintext copies. - /// A failure prevents applying the password; typing never waits. - pub(crate) fn clear_recovery_before_password(&mut self) -> Result<(), CmdError> { - #[cfg(not(target_arch = "wasm32"))] - if let Some(mut r) = self.recovery.take() { - let result = self.purge_recovery(&mut r); - self.recovery = Some(r); - result.map_err(CmdError::Failed)?; - } - Ok(()) - } - /// Waits out the job under way (it may be this document's copy being written), then deletes - /// the active document's copy. - #[cfg(not(target_arch = "wasm32"))] - fn purge_recovery(&mut self, r: &mut Recovery) -> Result<(), String> { - if let Some(e) = r.receive(true) { - self.apply_recovery(r, e); - } - let Some(id) = self.recovery_slot.id.clone() else { return Ok(()) }; - let e = r.discard_now(&id, Some(self.document_id()))?; - self.apply_recovery(r, e); - if self.recovery_slot.id.is_some() { - return Err(r.error.clone().unwrap_or_else(|| "the recovery copy could not be removed".into())); - } - self.recovery_slot.revision = None; - Ok(()) - } - pub(crate) fn recover(&mut self, v: &Value) -> CmdResult { - let restore = v.get("restore"); - let discard = v.get("discard"); - if restore.is_some() && discard.is_some() { - return Err(CmdError::Params("choose restore or discard".into())); - } - if restore.is_some() || discard.is_some() || v.get("refresh").is_some() { - #[cfg(not(target_arch = "wasm32"))] - { - let request = - Request { document: self.document_id(), revision: self.rev(), replace: self.is_untouched_blank() && self.password.is_none() }; - // Only listed copies: never one an open document keeps. - let listed = self.recovery_documents(); - let known = |id: Option<&str>, what: &str| -> Result { - let id = id.ok_or_else(|| CmdError::Params(format!("{what} must be a recovery id")))?; - if listed.iter().any(|d| d.id == id) { Ok(id.to_string()) } else { Err(CmdError::Failed("no such recovery copy".into())) } - }; - let r = self.recovery.as_mut().ok_or_else(|| CmdError::Disabled("local recovery requires the desktop host".into()))?; - if let Some(id) = restore { - r.restore(&known(id.as_str(), "restore")?, request).map_err(CmdError::Failed)?; - } else if let Some(id) = discard { - r.discard(&known(id.as_str(), "discard")?).map_err(CmdError::Failed)?; - } else if v.get("refresh").and_then(Value::as_bool) == Some(true) { - r.refresh().map_err(CmdError::Failed)?; - } else { - return Err(CmdError::Params("refresh must be true".into())); - } - } - #[cfg(target_arch = "wasm32")] - return Err(CmdError::Disabled("local recovery is available on desktop only".into())); - } - Ok( - json!({"enabled": self.recovery_ready(), "busy": self.recovery_busy(), "documents": self.recovery_documents(), "error": self.recovery_error()}), - ) - } -} +//! Local desktop recovery. Encoding and file I/O run on a worker, outside typing. +use crate::{CmdError, CmdResult, Session}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RecoveryInfo { + pub id: String, + pub name: String, + pub saved_at: String, +} + +#[cfg(not(target_arch = "wasm32"))] +mod desktop; +#[cfg(not(target_arch = "wasm32"))] +use desktop::{Applied, Request}; +#[cfg(not(target_arch = "wasm32"))] +pub(crate) use desktop::{DocRecovery, Recovery, Slot}; + +impl Session { + /// The desktop host supplies the profile folder and a thread-safe wakeup. + /// Headless sessions and the browser never write recovery files implicitly. + #[cfg(not(target_arch = "wasm32"))] + pub fn enable_recovery(&mut self, root: std::path::PathBuf, wake: std::sync::Arc) -> Result<(), String> { + self.recovery = Some(Recovery::start(root, wake)?); + Ok(()) + } + /// Copies left by an earlier run (or another window's documents that have closed), newest + /// first. The copies this session's open documents keep are not listed: they are not lost. + pub fn recovery_documents(&self) -> Vec { + #[cfg(not(target_arch = "wasm32"))] + if let Some(r) = &self.recovery { + let held = |id: &str| self.recovery_slots().any(|(_, _, s)| s.id.as_deref() == Some(id)) || r.closing().any(|c| c == id); + return r.documents.iter().filter(|d| !held(&d.id)).cloned().collect(); + } + Vec::new() + } + pub fn recovery_ready(&self) -> bool { + #[cfg(not(target_arch = "wasm32"))] + if let Some(r) = &self.recovery { + return r.ready; + } + false + } + pub fn recovery_busy(&self) -> bool { + #[cfg(not(target_arch = "wasm32"))] + if let Some(r) = &self.recovery { + return r.busy; + } + false + } + pub fn recovery_error(&self) -> Option<&str> { + #[cfg(not(target_arch = "wasm32"))] + if let Some(r) = &self.recovery { + return r.error.as_deref(); + } + None + } + /// Called every frame: takes in the worker's answer, opens a restored copy, and queues the + /// next job. Every open document's copy is kept, the parked ones' too, without making them + /// active: a frame compares each document's revision and timer, and clones a document only + /// when its copy is due. + pub fn recovery_tick(&mut self) { + #[cfg(not(target_arch = "wasm32"))] + { + let Some(mut r) = self.recovery.take() else { return }; + if let Some(e) = r.receive(false) { + self.apply_recovery(&mut r, e); + } + self.open_held_restore(&mut r); + if let Err(e) = r.tick(self.recovery_docs(), std::time::Instant::now()) { + r.error = Some(e); + } + if let Some(e) = &r.error { + self.status = e.clone(); + } + self.recovery = Some(r); + } + } + #[cfg(not(target_arch = "wasm32"))] + fn apply_recovery(&mut self, r: &mut Recovery, e: desktop::Event) { + match r.apply(e) { + // Opened by the next tick, never in the middle of a command. + Applied::Loaded(snapshot, request) => r.held = Some((snapshot, request)), + applied => { + for d in self.recovery_docs() { + if let Applied::Failed { document, .. } = &applied + && *document != d.id + { + continue; + } + d.slot.apply(&applied); + } + } + } + } + /// A restored copy opens as another document, or replaces the untouched blank document that + /// asked for it once that document is active, so no work is ever replaced. The restored + /// document keeps its copy in the record it came from. + #[cfg(not(target_arch = "wasm32"))] + fn open_held_restore(&mut self, r: &mut Recovery) { + let Some((_, request)) = &r.held else { return }; + let request = *request; + if request.replace && self.is_open(request.document) && request.document != self.document_id() { + return; + } + let Some((snapshot, _)) = r.held.take() else { return }; + let id = snapshot.info.id.clone(); + if !request.replace { + self.add_document(snapshot.document); + } else if request.document == self.document_id() && request.revision == self.rev() && self.password.is_none() { + self.set_document(snapshot.document); + self.path = None; + } else { + r.error = Some("Recovery cancelled: the document changed while the copy was loading.".into()); + return; + } + self.dirty = true; + self.recovery_slot.adopt(id, self.rev()); + } + pub fn recovery_wait(&self) -> Option { + #[cfg(not(target_arch = "wasm32"))] + if let Some(r) = &self.recovery { + return r.wait(self.recovery_slots()); + } + None + } + /// Waits for recovery to finish anything already under way, then deletes the copies no + /// open document needs: closed documents', and those of documents saved or protected since + /// their last copy. Dirty documents keep theirs. Desktop hosts call this right before the + /// process exits, the same as they wait out pending print jobs: otherwise a save just before + /// close can race the process exiting before a now-redundant recovery copy actually gets + /// removed, so the next launch wrongly offers to recover a document that was in fact saved + /// cleanly. + pub fn finish_recovery(&mut self) { + #[cfg(not(target_arch = "wasm32"))] + if let Some(mut r) = self.recovery.take() { + if let Some(e) = r.receive(true) { + self.apply_recovery(&mut r, e); + } + let mut discards: Vec<(String, Option)> = r.take_closed().into_iter().map(|id| (id, None)).collect(); + discards.extend(self.recovery_docs().filter(|d| !d.wants_copy).filter_map(|d| d.slot.id.clone().map(|id| (id, Some(d.id))))); + for (id, document) in discards { + match r.discard_now(&id, document) { + Ok(e) => self.apply_recovery(&mut r, e), + Err(e) => r.error = Some(e), + } + } + if let Some(e) = &r.error { + log::warn!("recovery: {e}"); + } + self.recovery = Some(r); + } + } + /// The user discarded the active document's unsaved changes without closing it (Don't Save + /// when quitting): its recovery copy goes too, and another is made only after a further edit. + pub fn discard_recovery_copy(&mut self) { + #[cfg(not(target_arch = "wasm32"))] + if let Some(r) = &mut self.recovery + && let Some(id) = self.recovery_slot.id.take() + { + r.close(id); + self.recovery_slot.revision = Some(self.rev()); + } + } + /// Only an explicit protect/save action waits for the worker and removes plaintext copies. + /// A failure prevents applying the password; typing never waits. + pub(crate) fn clear_recovery_before_password(&mut self) -> Result<(), CmdError> { + #[cfg(not(target_arch = "wasm32"))] + if let Some(mut r) = self.recovery.take() { + let result = self.purge_recovery(&mut r); + self.recovery = Some(r); + result.map_err(CmdError::Failed)?; + } + Ok(()) + } + /// Waits out the job under way (it may be this document's copy being written), then deletes + /// the active document's copy. + #[cfg(not(target_arch = "wasm32"))] + fn purge_recovery(&mut self, r: &mut Recovery) -> Result<(), String> { + if let Some(e) = r.receive(true) { + self.apply_recovery(r, e); + } + let Some(id) = self.recovery_slot.id.clone() else { return Ok(()) }; + let e = r.discard_now(&id, Some(self.document_id()))?; + self.apply_recovery(r, e); + if self.recovery_slot.id.is_some() { + return Err(r.error.clone().unwrap_or_else(|| "the recovery copy could not be removed".into())); + } + self.recovery_slot.revision = None; + Ok(()) + } + pub(crate) fn recover(&mut self, v: &Value) -> CmdResult { + let restore = v.get("restore"); + let discard = v.get("discard"); + if restore.is_some() && discard.is_some() { + return Err(CmdError::Params("choose restore or discard".into())); + } + if restore.is_some() || discard.is_some() || v.get("refresh").is_some() { + #[cfg(not(target_arch = "wasm32"))] + { + let request = + Request { document: self.document_id(), revision: self.rev(), replace: self.is_untouched_blank() && self.password.is_none() }; + // Only listed copies: never one an open document keeps. + let listed = self.recovery_documents(); + let known = |id: Option<&str>, what: &str| -> Result { + let id = id.ok_or_else(|| CmdError::Params(format!("{what} must be a recovery id")))?; + if listed.iter().any(|d| d.id == id) { Ok(id.to_string()) } else { Err(CmdError::Failed("no such recovery copy".into())) } + }; + let r = self.recovery.as_mut().ok_or_else(|| CmdError::Disabled("local recovery requires the desktop host".into()))?; + if let Some(id) = restore { + r.restore(&known(id.as_str(), "restore")?, request).map_err(CmdError::Failed)?; + } else if let Some(id) = discard { + r.discard(&known(id.as_str(), "discard")?).map_err(CmdError::Failed)?; + } else if v.get("refresh").and_then(Value::as_bool) == Some(true) { + r.refresh().map_err(CmdError::Failed)?; + } else { + return Err(CmdError::Params("refresh must be true".into())); + } + } + #[cfg(target_arch = "wasm32")] + return Err(CmdError::Disabled("local recovery is available on desktop only".into())); + } + Ok( + json!({"enabled": self.recovery_ready(), "busy": self.recovery_busy(), "documents": self.recovery_documents(), "error": self.recovery_error()}), + ) + } +} diff --git a/crates/engine/src/recovery/desktop.rs b/crates/engine/src/recovery/desktop.rs index 934ce32857..c0e12abfd5 100644 --- a/crates/engine/src/recovery/desktop.rs +++ b/crates/engine/src/recovery/desktop.rs @@ -1,721 +1,721 @@ -use super::RecoveryInfo; -use serde::{Deserialize, Serialize}; -use std::collections::BTreeMap; -use std::fs::{self, File, OpenOptions}; -use std::io::{self, BufReader, BufWriter, Read, Write}; -use std::path::{Path, PathBuf}; -use std::sync::{Arc, mpsc}; -use std::time::{Duration, Instant}; -use wordcraft_doc::Document; - -const INTERVAL: Duration = Duration::from_secs(10); -const MAX_BYTES: u64 = 256 << 20; -const MAX_RECORDS: usize = 128; -const MAX_FILES: usize = 16; -const DISK_BUDGET: u64 = 2 << 30; -/// Times a record lock held for a moment (by a process being started) is tried again. -const LOCK_RETRIES: u32 = 10; - -#[derive(Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct Snapshot { - version: u32, - pub info: RecoveryInfo, - generation: u64, - pub document: Document, -} -/// A failed snapshot write. `permanent` failures come from the snapshot itself (its size, the -/// folder or disk budget, a value the format cannot keep): writing the same document again would -/// fail the same way, so it is not retried until the document changes (#2625). -#[derive(Debug)] -struct SaveError { - message: String, - permanent: bool, -} -impl SaveError { - fn permanent(message: impl Into) -> Self { - Self { message: message.into(), permanent: true } - } -} -impl From for SaveError { - fn from(message: String) -> Self { - Self { message, permanent: false } - } -} -impl From<&str> for SaveError { - fn from(message: &str) -> Self { - message.to_owned().into() - } -} -struct Entry { - info: RecoveryInfo, - path: PathBuf, -} -struct Store { - root: PathBuf, - entries: BTreeMap, - // Advisory locks prevent another running instance from restoring/discarding a live copy. - locks: BTreeMap, -} -fn private_dir(path: &Path) -> io::Result<()> { - let mut b = fs::DirBuilder::new(); - b.recursive(true); - #[cfg(unix)] - { - use std::os::unix::fs::DirBuilderExt; - b.mode(0o700); - } - b.create(path) -} -fn private_file(path: &Path, exclusive: bool) -> io::Result { - let mut o = OpenOptions::new(); - o.read(true).write(true); - if exclusive { - o.create_new(true); - } else { - o.create(true).truncate(false); - } - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - o.mode(0o600); - } - o.open(path) -} -fn sync_dir(path: &Path) -> io::Result<()> { - #[cfg(unix)] - { - File::open(path)?.sync_all()?; - } - #[cfg(not(unix))] - { - let _ = path; - } - Ok(()) -} -fn id_ok(id: &str) -> bool { - id.len() == 32 && id.bytes().all(|b| b.is_ascii_hexdigit()) -} -fn new_id() -> Result { - let mut bytes = [0u8; 16]; - getrandom::fill(&mut bytes).map_err(|e| e.to_string())?; - Ok(bytes.iter().map(|b| format!("{b:02x}")).collect()) -} -fn generation(path: &Path) -> Option { - path.file_name()?.to_str()?.strip_prefix("snapshot-")?.strip_suffix(".json")?.parse().ok() -} -fn read_snapshot(path: &Path) -> Result { - let meta = fs::symlink_metadata(path).map_err(|e| e.to_string())?; - if !meta.is_file() || meta.len() > MAX_BYTES { - return Err("not a regular recovery file, or it exceeds 256 MiB".into()); - } - let file = File::open(path).map_err(|e| e.to_string())?; - if !file.metadata().map_err(|e| e.to_string())?.is_file() { - return Err("not a regular recovery file".into()); - } - let s: Snapshot = serde_json::from_reader(BufReader::new(file.take(MAX_BYTES + 1))).map_err(|e| e.to_string())?; - if s.version != 1 || !id_ok(&s.info.id) { - return Err("unsupported recovery file".into()); - } - Ok(s) -} -impl Store { - fn new(root: PathBuf) -> Result { - private_dir(&root).map_err(|e| e.to_string())?; - Ok(Self { root, entries: BTreeMap::new(), locks: BTreeMap::new() }) - } - fn directories(&self) -> Result, String> { - let mut dirs = Vec::new(); - for e in fs::read_dir(&self.root).map_err(|e| e.to_string())? { - let e = e.map_err(|e| e.to_string())?; - if e.file_type().map_err(|e| e.to_string())?.is_dir() && e.file_name().to_str().is_some_and(id_ok) { - if dirs.len() >= MAX_RECORDS { - return Err("recovery folder has too many records".into()); - } - dirs.push(e.path()); - } - } - Ok(dirs) - } - fn files(dir: &Path) -> Result, String> { - let mut paths = Vec::new(); - for e in fs::read_dir(dir).map_err(|e| e.to_string())? { - let e = e.map_err(|e| e.to_string())?; - if e.file_type().map_err(|e| e.to_string())?.is_file() && generation(&e.path()).is_some() { - if paths.len() >= MAX_FILES { - return Err("recovery record has too many generations".into()); - } - paths.push(e.path()); - } - } - paths.sort_by_key(|p| std::cmp::Reverse(generation(p))); - Ok(paths) - } - /// Locks a record's `owner.lock`, retrying [`LOCK_RETRIES`] times 10 ms apart. - fn lock(&self, id: &str) -> Result { - if !id_ok(id) { - return Err("invalid recovery id".into()); - } - let file = private_file(&self.root.join(id).join("owner.lock"), false).map_err(|e| e.to_string())?; - // A process another part of the app (or a test) is starting holds the lock for a moment - // (a forked child shares it until it execs): wait that out, but not a live owner. - for _ in 0..LOCK_RETRIES { - match file.try_lock() { - Ok(()) => return Ok(file), - Err(std::fs::TryLockError::WouldBlock) => std::thread::sleep(Duration::from_millis(10)), - Err(e) => return Err(format!("recovery copy is in use or cannot be locked: {e}")), - } - } - file.try_lock().map_err(|e| format!("recovery copy is in use or cannot be locked: {e}"))?; - Ok(file) - } - fn acquire(&mut self, id: &str) -> Result<(), String> { - if !self.locks.contains_key(id) { - let lock = self.lock(id)?; - self.locks.insert(id.to_string(), lock); - } - Ok(()) - } - fn scan(&mut self) -> Result<(), String> { - let mut entries = BTreeMap::new(); - let mut unreadable = false; - for dir in self.directories()? { - let Some(id) = dir.file_name().and_then(std::ffi::OsStr::to_str) else { continue }; - let guard = if self.locks.contains_key(id) { - None - } else { - match self.lock(id) { - Ok(f) => Some(f), - Err(e) => { - log::debug!("recovery: {e}"); - continue; - } - } - }; - // A crashed writer can leave an incomplete temporary file. Only clean records - // we have locked, so another instance's write is never interrupted. - for entry in fs::read_dir(&dir).map_err(|e| e.to_string())? { - let entry = entry.map_err(|e| e.to_string())?; - if entry.file_type().map_err(|e| e.to_string())?.is_file() - && entry.file_name().to_str().is_some_and(|n| n.strip_prefix("pending-").and_then(|n| n.strip_suffix(".tmp")).is_some_and(id_ok)) - { - fs::remove_file(entry.path()).map_err(|e| e.to_string())?; - } - } - for path in Self::files(&dir)? { - match read_snapshot(&path) { - Ok(s) if s.info.id == id && Some(s.generation) == generation(&path) => { - entries.insert(id.to_string(), Entry { info: s.info, path }); - break; - } - Ok(_) => { - unreadable = true; - log::warn!("recovery: mismatched record {}", path.display()); - } - Err(e) => { - unreadable = true; - log::warn!("recovery: unreadable {}: {e}", path.display()); - } - } - } - drop(guard); - // A crash before the first commit leaves no document to recover. Reclaim - // that slot only under a fresh lock and the discard path's entry checks. - if Self::files(&dir)?.is_empty() - && let Err(e) = self.discard(id) - { - log::warn!("recovery: cannot remove empty record: {e}"); - } - } - self.entries = entries; - if unreadable { Err("Some recovery files could not be read. Earlier valid copies are listed when available.".into()) } else { Ok(()) } - } - fn inventory(&self) -> Vec { - let mut v: Vec<_> = self.entries.values().map(|e| e.info.clone()).collect(); - v.sort_by(|a, b| b.saved_at.cmp(&a.saved_at).then(a.id.cmp(&b.id))); - v - } - fn disk_used(&self) -> Result { - let mut used = 0u64; - for dir in self.directories()? { - for p in Self::files(&dir)? { - used = used.saturating_add(fs::metadata(p).map_err(|e| e.to_string())?.len()); - } - } - Ok(used) - } - fn save(&mut self, s: Snapshot) -> Result<(), SaveError> { - let id = s.info.id.clone(); - let result = self.write_snapshot(s); - // Include failures before temp-file creation (quota checks, locking, etc.). - // A record containing any committed generation is never removed here. - if result.is_err() - && Self::files(&self.root.join(&id)).is_ok_and(|files| files.is_empty()) - && let Err(e) = self.discard(&id) - { - log::warn!("recovery: cannot remove failed empty record: {e}"); - } - result - } - fn write_snapshot(&mut self, mut s: Snapshot) -> Result<(), SaveError> { - let dir = self.root.join(&s.info.id); - if !dir.exists() { - if self.directories()?.len() >= MAX_RECORDS { - return Err(SaveError::permanent("recovery folder is full; discard an older copy")); - } - private_dir(&dir).map_err(|e| e.to_string())?; - } - self.acquire(&s.info.id)?; - let files = Self::files(&dir)?; - if files.len() >= MAX_FILES { - return Err("older recovery generations could not be pruned; keeping the last valid copy".into()); - } - s.generation = files.first().and_then(|p| generation(p)).unwrap_or(0).checked_add(1).ok_or("recovery generation overflow")?; - let previous = self.entries.get(&s.info.id).map(|e| e.path.clone()); - let limit = MAX_BYTES.min(DISK_BUDGET.saturating_sub(self.disk_used()?)); - let tmp = dir.join(format!("pending-{}.tmp", new_id()?)); - let final_path = dir.join(format!("snapshot-{:020}.json", s.generation)); - let result = (|| -> Result<(), SaveError> { - let file = private_file(&tmp, true).map_err(|e| e.to_string())?; - let mut writer = Limited { inner: BufWriter::new(file), remaining: limit, exceeded: false }; - let written = serde_json::to_writer(&mut writer, &s).map_err(|e| e.to_string()).and_then(|()| writer.flush().map_err(|e| e.to_string())); - if let Err(e) = written { - return Err(SaveError { message: e, permanent: writer.exceeded }); - } - writer.inner.get_ref().sync_all().map_err(|e| e.to_string())?; - drop(writer); - // The copy was written and synchronized; one that does not decode back to the same - // document fails the same way every time. - let check = read_snapshot(&tmp).map_err(SaveError::permanent)?; - if check.document != s.document { - return Err(SaveError::permanent("recovery format did not preserve the full document")); - } - // A new generation is never written over the last valid file. - fs::rename(&tmp, &final_path).map_err(|e| e.to_string())?; - sync_dir(&dir).map_err(|e| e.to_string())?; - self.entries.insert(s.info.id.clone(), Entry { info: s.info, path: final_path.clone() }); - for old in Self::files(&dir)?.into_iter().filter(|p| p != &final_path && Some(p) != previous.as_ref()) { - if let Err(e) = fs::remove_file(old) { - log::warn!("recovery: cannot prune old generation: {e}"); - } - } - Ok(()) - })(); - if result.is_err() { - let _ = fs::remove_file(&tmp); - } - result - } - fn load(&mut self, id: &str) -> Result { - if !self.entries.contains_key(id) { - return Err("no such recovery copy".into()); - } - self.acquire(id)?; - read_snapshot(&self.entries.get(id).ok_or("no such recovery copy")?.path) - } - fn discard(&mut self, id: &str) -> Result<(), String> { - if !id_ok(id) { - return Err("invalid recovery id".into()); - } - let dir = self.root.join(id); - if !dir.exists() { - self.entries.remove(id); - return Ok(()); - } - self.acquire(id)?; - for entry in fs::read_dir(&dir).map_err(|e| e.to_string())? { - let entry = entry.map_err(|e| e.to_string())?; - let name = entry.file_name(); - let known = name == "owner.lock" - || generation(&entry.path()).is_some() - || name.to_str().is_some_and(|n| n.strip_prefix("pending-").and_then(|n| n.strip_suffix(".tmp")).is_some_and(id_ok)); - if !known || !entry.file_type().map_err(|e| e.to_string())?.is_file() { - return Err("recovery folder contains an unexpected entry".into()); - } - } - for p in Self::files(&dir)? { - fs::remove_file(p).map_err(|e| e.to_string())?; - } - for e in fs::read_dir(&dir).map_err(|e| e.to_string())? { - let e = e.map_err(|e| e.to_string())?; - let name = e.file_name(); - if name.to_str().is_some_and(|n| n.strip_prefix("pending-").and_then(|n| n.strip_suffix(".tmp")).is_some_and(id_ok)) { - fs::remove_file(e.path()).map_err(|e| e.to_string())?; - } - } - sync_dir(&dir).map_err(|e| e.to_string())?; - self.entries.remove(id); - self.locks.remove(id); - fs::remove_file(dir.join("owner.lock")).map_err(|e| e.to_string())?; - fs::remove_dir(&dir).map_err(|e| e.to_string())?; - sync_dir(&self.root).map_err(|e| e.to_string())?; - Ok(()) - } -} -struct Limited { - inner: W, - remaining: u64, - exceeded: bool, -} -impl Write for Limited { - fn write(&mut self, bytes: &[u8]) -> io::Result { - if bytes.len() as u64 > self.remaining { - self.exceeded = true; - return Err(io::Error::other("recovery snapshot or folder size limit exceeded")); - } - let n = self.inner.write(bytes)?; - self.remaining -= n as u64; - Ok(n) - } - fn flush(&mut self) -> io::Result<()> { - self.inner.flush() - } -} -enum Job { - /// Write `document`'s copy. - Save(Box, u64), - Scan, - Load(String, Request), - /// Delete a record: an open document's copy it no longer needs, a closed document's, or one - /// from an earlier run that the user discarded (`None`). - Discard(String, Option), -} -enum Outcome { - Done, - Discarded(String), - Loaded(Box, Request), -} -pub(crate) struct Event { - documents: Vec, - outcome: Result, - /// The failure would repeat for the same document; see [`SaveError`]. - permanent: bool, - /// The open document a failed save or discard was for. - document: Option, - /// The record a failed discard was for. - discard: Option, -} -/// Who asked for a restore, and whether the copy replaces that document (an untouched blank -/// one) or opens as another document. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) struct Request { - pub document: u64, - pub revision: u64, - pub replace: bool, -} -/// One open document's recovery state: the record its copy is kept in, the revision that copy -/// holds, and when the next copy may be written. Each open document has its own, parked with it. -#[derive(Clone, Debug)] -pub(crate) struct Slot { - pub id: Option, - pub revision: Option, - pub due: Instant, - /// A failed discard of this document's copy is tried again from then on. - pub retry: Option, -} -impl Default for Slot { - fn default() -> Self { - Self { id: None, revision: None, due: Instant::now() + INTERVAL, retry: None } - } -} -impl Slot { - /// The document now keeps its copy in record `id`, which holds `revision`. - pub fn adopt(&mut self, id: String, revision: u64) { - self.id = Some(id); - self.revision = Some(revision); - self.due = Instant::now() + INTERVAL; - self.retry = None; - } -} -/// A worker report, for the session to apply to its documents. -pub(crate) enum Applied { - Nothing, - /// Record `id` is gone: no document keeps its copy there any more. - Discarded(String), - /// A restored copy, to open. - Loaded(Box, Request), - /// Writing or discarding `document`'s copy failed. - Failed { - document: u64, - discard: bool, - permanent: bool, - }, -} -impl Slot { - /// Take in a report about this document's copy. - pub fn apply(&mut self, applied: &Applied) { - match applied { - Applied::Discarded(id) if self.id.as_deref() == Some(id) => { - self.id = None; - self.retry = None; - } - Applied::Failed { discard, permanent, .. } => { - let now = Instant::now(); - if *discard { - self.retry = Some(now + INTERVAL); - } else if !permanent { - // A transient failure (a lock, an I/O error) is retried at the next - // interval; one the snapshot itself causes waits for the next edit. - self.revision = None; - } - self.due = now + INTERVAL; - } - _ => {} - } - } -} -/// What [`Recovery::tick`] reads from an open document, and the document's [`Slot`]. -pub(crate) struct DocRecovery<'a> { - pub id: u64, - /// Dirty and without a password: the document should have a current copy. - pub wants_copy: bool, - pub rev: u64, - pub doc: &'a Document, - pub path: Option<&'a Path>, - pub slot: &'a mut Slot, -} -/// The process's recovery worker: one thread and one store for every open document, so records -/// are locked once per process and the list of copies is the same whichever document is shown. -pub(crate) struct Recovery { - tx: mpsc::SyncSender, - rx: mpsc::Receiver, - /// Every record the store found or wrote, this process's own included. - pub documents: Vec, - pub ready: bool, - pub busy: bool, - pub error: Option, - alive: bool, - /// Records of closed documents waiting to be deleted, and whether that is queued. - closed: Vec<(String, bool)>, - /// A loaded copy that replaces its (untouched blank) document once that document is active. - pub held: Option<(Box, Request)>, -} -impl Recovery { - pub fn start(root: PathBuf, wake: Arc) -> Result { - let (tx, jobs) = mpsc::sync_channel(1); - let (events, rx) = mpsc::channel(); - std::thread::Builder::new() - .name("wordcraft-recovery".into()) - .spawn(move || { - let mut store = match Store::new(root) { - Ok(s) => s, - Err(e) => { - let _ = events.send(Event { documents: Vec::new(), outcome: Err(e), permanent: false, document: None, discard: None }); - wake(); - return; - } - }; - let initial = store.scan().map(|_| Outcome::Done); - if events.send(Event { documents: store.inventory(), outcome: initial, permanent: false, document: None, discard: None }).is_err() { - return; - } - wake(); - while let Ok(job) = jobs.recv() { - let mut permanent = false; - let (mut document, mut discard) = (None, None); - let result = match job { - Job::Save(s, d) => { - document = Some(d); - store.save(*s).map(|_| Outcome::Done).map_err(|e| { - permanent = e.permanent; - e.message - }) - } - Job::Scan => store.scan().map(|_| Outcome::Done), - Job::Load(id, request) => store.load(&id).map(|s| Outcome::Loaded(Box::new(s), request)), - Job::Discard(id, d) => { - document = d; - let r = store.discard(&id).map(|_| Outcome::Discarded(id.clone())); - discard = Some(id); - r - } - }; - if result.is_ok() { - (document, discard) = (None, None); - } - if events.send(Event { documents: store.inventory(), outcome: result, permanent, document, discard }).is_err() { - break; - } - wake(); - } - }) - .map_err(|e| e.to_string())?; - Ok(Self { tx, rx, documents: Vec::new(), ready: false, busy: true, error: None, alive: true, closed: Vec::new(), held: None }) - } - fn queue(&mut self, job: Job) -> Result<(), String> { - if !self.alive { - return Err("recovery worker is unavailable".into()); - } - if self.busy { - return Err("recovery is busy; try again when it finishes".into()); - } - self.tx.try_send(job).map_err(|e| e.to_string())?; - self.busy = true; - self.error = None; - Ok(()) - } - /// The worker's answer to the job under way: waits for it when `block`, else only takes one - /// that has arrived. - pub fn receive(&mut self, block: bool) -> Option { - let e = if block { - if !self.busy { - return None; - } - self.rx.recv().map_err(|_| mpsc::TryRecvError::Disconnected) - } else { - self.rx.try_recv() - }; - match e { - Ok(e) => { - self.busy = false; - self.ready = true; - self.documents.clone_from(&e.documents); - Some(e) - } - Err(mpsc::TryRecvError::Empty) => None, - Err(mpsc::TryRecvError::Disconnected) => { - self.busy = false; - self.alive = false; - if self.error.is_none() { - self.error = Some("recovery worker is unavailable".into()); - } - None - } - } - } - /// Run `job` now and wait for its answer. - fn run_now(&mut self, job: Job) -> Result { - self.queue(job)?; - self.receive(true).ok_or_else(|| "recovery worker is unavailable".to_string()) - } - /// Discard record `id` now (the worker must be idle) and wait for it. - pub fn discard_now(&mut self, id: &str, document: Option) -> Result { - self.run_now(Job::Discard(id.into(), document)) - } - /// A closed document's record: deleted at the next chance, and not listed meanwhile. - pub fn close(&mut self, id: String) { - if !self.closed.iter().any(|(c, _)| *c == id) { - self.closed.push((id, false)); - } - } - /// Records the open documents of this process don't hold any more but haven't deleted yet. - pub fn closing(&self) -> impl Iterator { - self.closed.iter().map(|(id, _)| id.as_str()) - } - /// Closed documents' records not yet queued for deletion. - pub fn take_closed(&mut self) -> Vec { - self.closed - .iter_mut() - .filter(|(_, queued)| !*queued) - .map(|(id, queued)| { - *queued = true; - id.clone() - }) - .collect() - } - /// Take in what the worker reported: the record lists here, and what the session applies - /// to its documents. - pub fn apply(&mut self, e: Event) -> Applied { - match e.outcome { - Ok(Outcome::Done) => Applied::Nothing, - Ok(Outcome::Discarded(id)) => { - self.closed.retain(|(c, _)| *c != id); - Applied::Discarded(id) - } - Ok(Outcome::Loaded(s, request)) => Applied::Loaded(s, request), - Err(message) => { - self.error = Some(message); - if let Some(id) = &e.discard { - // A closed document's copy that can't be deleted stays listed, for the user - // to restore or discard. - self.closed.retain(|(c, _)| c != id); - } - match e.document { - Some(document) => Applied::Failed { document, discard: e.discard.is_some(), permanent: e.permanent }, - None => Applied::Nothing, - } - } - } - } - /// Queue at most one job: a closed document's discard first, then, document by document in - /// the order given, a discard of a copy its document no longer needs (saved, closed without - /// saving, password-protected) or a new copy of a dirty document whose interval has passed. - pub fn tick<'a>(&mut self, docs: impl Iterator>, now: Instant) -> Result<(), String> { - if self.busy || !self.ready || !self.alive { - return Ok(()); - } - if let Some(at) = self.closed.iter().position(|(_, queued)| !queued) { - let id = self.closed.get(at).map(|(id, _)| id.clone()).ok_or("recovery record list changed")?; - self.queue(Job::Discard(id, None))?; - if let Some(c) = self.closed.get_mut(at) { - c.1 = true; - } - return Ok(()); - } - for d in docs { - if !d.wants_copy { - if let Some(id) = d.slot.id.clone() - && d.slot.retry.is_none_or(|t| now >= t) - { - return self.queue(Job::Discard(id, Some(d.id))); - } - continue; - } - if d.slot.revision == Some(d.rev) || now < d.slot.due { - continue; - } - let id = match &d.slot.id { - Some(id) => id.clone(), - None => new_id()?, - }; - let name = d.path.and_then(|p| p.file_name()).map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "Untitled draft".into()); - let info = RecoveryInfo { id: id.clone(), name, saved_at: crate::cmd::now_iso() }; - self.queue(Job::Save(Box::new(Snapshot { version: 1, info, generation: 0, document: d.doc.clone() }), d.id))?; - d.slot.id = Some(id); - d.slot.revision = Some(d.rev); - d.slot.due = now + INTERVAL; - return Ok(()); - } - Ok(()) - } - /// How long until [`tick`](Self::tick) has work, for a host that repaints only on demand. - pub fn wait<'a>(&self, docs: impl Iterator) -> Option { - if !self.alive || !self.ready || self.busy { - return None; - } - let now = Instant::now(); - let min = Duration::from_millis(100); - if self.closed.iter().any(|(_, queued)| !queued) || self.held.as_ref().is_some_and(|(_, r)| !r.replace) { - return Some(min); - } - docs.filter_map(|(wants_copy, rev, slot)| match (wants_copy, &slot.id) { - (false, Some(_)) => Some(slot.retry.map_or(min, |t| t.saturating_duration_since(now))), - (false, None) => None, - (true, _) if slot.revision == Some(rev) => None, - (true, _) => Some(slot.due.saturating_duration_since(now)), - }) - .min() - .map(|d| d.max(min)) - } - pub fn restore(&mut self, id: &str, request: Request) -> Result<(), String> { - if !self.documents.iter().any(|d| d.id == id) { - return Err("no such recovery copy".into()); - } - if self.held.is_some() { - return Err("another restored copy is waiting for its document".into()); - } - self.queue(Job::Load(id.into(), request)) - } - pub fn discard(&mut self, id: &str) -> Result<(), String> { - if !self.documents.iter().any(|d| d.id == id) { - return Err("no such recovery copy".into()); - } - self.queue(Job::Discard(id.into(), None)) - } - pub fn refresh(&mut self) -> Result<(), String> { - self.queue(Job::Scan) - } -} - -#[cfg(test)] -mod tests; +use super::RecoveryInfo; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::fs::{self, File, OpenOptions}; +use std::io::{self, BufReader, BufWriter, Read, Write}; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, mpsc}; +use std::time::{Duration, Instant}; +use wordcraft_doc::Document; + +const INTERVAL: Duration = Duration::from_secs(10); +const MAX_BYTES: u64 = 256 << 20; +const MAX_RECORDS: usize = 128; +const MAX_FILES: usize = 16; +const DISK_BUDGET: u64 = 2 << 30; +/// Times a record lock held for a moment (by a process being started) is tried again. +const LOCK_RETRIES: u32 = 10; + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct Snapshot { + version: u32, + pub info: RecoveryInfo, + generation: u64, + pub document: Document, +} +/// A failed snapshot write. `permanent` failures come from the snapshot itself (its size, the +/// folder or disk budget, a value the format cannot keep): writing the same document again would +/// fail the same way, so it is not retried until the document changes (#2625). +#[derive(Debug)] +struct SaveError { + message: String, + permanent: bool, +} +impl SaveError { + fn permanent(message: impl Into) -> Self { + Self { message: message.into(), permanent: true } + } +} +impl From for SaveError { + fn from(message: String) -> Self { + Self { message, permanent: false } + } +} +impl From<&str> for SaveError { + fn from(message: &str) -> Self { + message.to_owned().into() + } +} +struct Entry { + info: RecoveryInfo, + path: PathBuf, +} +struct Store { + root: PathBuf, + entries: BTreeMap, + // Advisory locks prevent another running instance from restoring/discarding a live copy. + locks: BTreeMap, +} +fn private_dir(path: &Path) -> io::Result<()> { + let mut b = fs::DirBuilder::new(); + b.recursive(true); + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + b.mode(0o700); + } + b.create(path) +} +fn private_file(path: &Path, exclusive: bool) -> io::Result { + let mut o = OpenOptions::new(); + o.read(true).write(true); + if exclusive { + o.create_new(true); + } else { + o.create(true).truncate(false); + } + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + o.mode(0o600); + } + o.open(path) +} +fn sync_dir(path: &Path) -> io::Result<()> { + #[cfg(unix)] + { + File::open(path)?.sync_all()?; + } + #[cfg(not(unix))] + { + let _ = path; + } + Ok(()) +} +fn id_ok(id: &str) -> bool { + id.len() == 32 && id.bytes().all(|b| b.is_ascii_hexdigit()) +} +fn new_id() -> Result { + let mut bytes = [0u8; 16]; + getrandom::fill(&mut bytes).map_err(|e| e.to_string())?; + Ok(bytes.iter().map(|b| format!("{b:02x}")).collect()) +} +fn generation(path: &Path) -> Option { + path.file_name()?.to_str()?.strip_prefix("snapshot-")?.strip_suffix(".json")?.parse().ok() +} +fn read_snapshot(path: &Path) -> Result { + let meta = fs::symlink_metadata(path).map_err(|e| e.to_string())?; + if !meta.is_file() || meta.len() > MAX_BYTES { + return Err("not a regular recovery file, or it exceeds 256 MiB".into()); + } + let file = File::open(path).map_err(|e| e.to_string())?; + if !file.metadata().map_err(|e| e.to_string())?.is_file() { + return Err("not a regular recovery file".into()); + } + let s: Snapshot = serde_json::from_reader(BufReader::new(file.take(MAX_BYTES + 1))).map_err(|e| e.to_string())?; + if s.version != 1 || !id_ok(&s.info.id) { + return Err("unsupported recovery file".into()); + } + Ok(s) +} +impl Store { + fn new(root: PathBuf) -> Result { + private_dir(&root).map_err(|e| e.to_string())?; + Ok(Self { root, entries: BTreeMap::new(), locks: BTreeMap::new() }) + } + fn directories(&self) -> Result, String> { + let mut dirs = Vec::new(); + for e in fs::read_dir(&self.root).map_err(|e| e.to_string())? { + let e = e.map_err(|e| e.to_string())?; + if e.file_type().map_err(|e| e.to_string())?.is_dir() && e.file_name().to_str().is_some_and(id_ok) { + if dirs.len() >= MAX_RECORDS { + return Err("recovery folder has too many records".into()); + } + dirs.push(e.path()); + } + } + Ok(dirs) + } + fn files(dir: &Path) -> Result, String> { + let mut paths = Vec::new(); + for e in fs::read_dir(dir).map_err(|e| e.to_string())? { + let e = e.map_err(|e| e.to_string())?; + if e.file_type().map_err(|e| e.to_string())?.is_file() && generation(&e.path()).is_some() { + if paths.len() >= MAX_FILES { + return Err("recovery record has too many generations".into()); + } + paths.push(e.path()); + } + } + paths.sort_by_key(|p| std::cmp::Reverse(generation(p))); + Ok(paths) + } + /// Locks a record's `owner.lock`, retrying [`LOCK_RETRIES`] times 10 ms apart. + fn lock(&self, id: &str) -> Result { + if !id_ok(id) { + return Err("invalid recovery id".into()); + } + let file = private_file(&self.root.join(id).join("owner.lock"), false).map_err(|e| e.to_string())?; + // A process another part of the app (or a test) is starting holds the lock for a moment + // (a forked child shares it until it execs): wait that out, but not a live owner. + for _ in 0..LOCK_RETRIES { + match file.try_lock() { + Ok(()) => return Ok(file), + Err(std::fs::TryLockError::WouldBlock) => std::thread::sleep(Duration::from_millis(10)), + Err(e) => return Err(format!("recovery copy is in use or cannot be locked: {e}")), + } + } + file.try_lock().map_err(|e| format!("recovery copy is in use or cannot be locked: {e}"))?; + Ok(file) + } + fn acquire(&mut self, id: &str) -> Result<(), String> { + if !self.locks.contains_key(id) { + let lock = self.lock(id)?; + self.locks.insert(id.to_string(), lock); + } + Ok(()) + } + fn scan(&mut self) -> Result<(), String> { + let mut entries = BTreeMap::new(); + let mut unreadable = false; + for dir in self.directories()? { + let Some(id) = dir.file_name().and_then(std::ffi::OsStr::to_str) else { continue }; + let guard = if self.locks.contains_key(id) { + None + } else { + match self.lock(id) { + Ok(f) => Some(f), + Err(e) => { + log::debug!("recovery: {e}"); + continue; + } + } + }; + // A crashed writer can leave an incomplete temporary file. Only clean records + // we have locked, so another instance's write is never interrupted. + for entry in fs::read_dir(&dir).map_err(|e| e.to_string())? { + let entry = entry.map_err(|e| e.to_string())?; + if entry.file_type().map_err(|e| e.to_string())?.is_file() + && entry.file_name().to_str().is_some_and(|n| n.strip_prefix("pending-").and_then(|n| n.strip_suffix(".tmp")).is_some_and(id_ok)) + { + fs::remove_file(entry.path()).map_err(|e| e.to_string())?; + } + } + for path in Self::files(&dir)? { + match read_snapshot(&path) { + Ok(s) if s.info.id == id && Some(s.generation) == generation(&path) => { + entries.insert(id.to_string(), Entry { info: s.info, path }); + break; + } + Ok(_) => { + unreadable = true; + log::warn!("recovery: mismatched record {}", path.display()); + } + Err(e) => { + unreadable = true; + log::warn!("recovery: unreadable {}: {e}", path.display()); + } + } + } + drop(guard); + // A crash before the first commit leaves no document to recover. Reclaim + // that slot only under a fresh lock and the discard path's entry checks. + if Self::files(&dir)?.is_empty() + && let Err(e) = self.discard(id) + { + log::warn!("recovery: cannot remove empty record: {e}"); + } + } + self.entries = entries; + if unreadable { Err("Some recovery files could not be read. Earlier valid copies are listed when available.".into()) } else { Ok(()) } + } + fn inventory(&self) -> Vec { + let mut v: Vec<_> = self.entries.values().map(|e| e.info.clone()).collect(); + v.sort_by(|a, b| b.saved_at.cmp(&a.saved_at).then(a.id.cmp(&b.id))); + v + } + fn disk_used(&self) -> Result { + let mut used = 0u64; + for dir in self.directories()? { + for p in Self::files(&dir)? { + used = used.saturating_add(fs::metadata(p).map_err(|e| e.to_string())?.len()); + } + } + Ok(used) + } + fn save(&mut self, s: Snapshot) -> Result<(), SaveError> { + let id = s.info.id.clone(); + let result = self.write_snapshot(s); + // Include failures before temp-file creation (quota checks, locking, etc.). + // A record containing any committed generation is never removed here. + if result.is_err() + && Self::files(&self.root.join(&id)).is_ok_and(|files| files.is_empty()) + && let Err(e) = self.discard(&id) + { + log::warn!("recovery: cannot remove failed empty record: {e}"); + } + result + } + fn write_snapshot(&mut self, mut s: Snapshot) -> Result<(), SaveError> { + let dir = self.root.join(&s.info.id); + if !dir.exists() { + if self.directories()?.len() >= MAX_RECORDS { + return Err(SaveError::permanent("recovery folder is full; discard an older copy")); + } + private_dir(&dir).map_err(|e| e.to_string())?; + } + self.acquire(&s.info.id)?; + let files = Self::files(&dir)?; + if files.len() >= MAX_FILES { + return Err("older recovery generations could not be pruned; keeping the last valid copy".into()); + } + s.generation = files.first().and_then(|p| generation(p)).unwrap_or(0).checked_add(1).ok_or("recovery generation overflow")?; + let previous = self.entries.get(&s.info.id).map(|e| e.path.clone()); + let limit = MAX_BYTES.min(DISK_BUDGET.saturating_sub(self.disk_used()?)); + let tmp = dir.join(format!("pending-{}.tmp", new_id()?)); + let final_path = dir.join(format!("snapshot-{:020}.json", s.generation)); + let result = (|| -> Result<(), SaveError> { + let file = private_file(&tmp, true).map_err(|e| e.to_string())?; + let mut writer = Limited { inner: BufWriter::new(file), remaining: limit, exceeded: false }; + let written = serde_json::to_writer(&mut writer, &s).map_err(|e| e.to_string()).and_then(|()| writer.flush().map_err(|e| e.to_string())); + if let Err(e) = written { + return Err(SaveError { message: e, permanent: writer.exceeded }); + } + writer.inner.get_ref().sync_all().map_err(|e| e.to_string())?; + drop(writer); + // The copy was written and synchronized; one that does not decode back to the same + // document fails the same way every time. + let check = read_snapshot(&tmp).map_err(SaveError::permanent)?; + if check.document != s.document { + return Err(SaveError::permanent("recovery format did not preserve the full document")); + } + // A new generation is never written over the last valid file. + fs::rename(&tmp, &final_path).map_err(|e| e.to_string())?; + sync_dir(&dir).map_err(|e| e.to_string())?; + self.entries.insert(s.info.id.clone(), Entry { info: s.info, path: final_path.clone() }); + for old in Self::files(&dir)?.into_iter().filter(|p| p != &final_path && Some(p) != previous.as_ref()) { + if let Err(e) = fs::remove_file(old) { + log::warn!("recovery: cannot prune old generation: {e}"); + } + } + Ok(()) + })(); + if result.is_err() { + let _ = fs::remove_file(&tmp); + } + result + } + fn load(&mut self, id: &str) -> Result { + if !self.entries.contains_key(id) { + return Err("no such recovery copy".into()); + } + self.acquire(id)?; + read_snapshot(&self.entries.get(id).ok_or("no such recovery copy")?.path) + } + fn discard(&mut self, id: &str) -> Result<(), String> { + if !id_ok(id) { + return Err("invalid recovery id".into()); + } + let dir = self.root.join(id); + if !dir.exists() { + self.entries.remove(id); + return Ok(()); + } + self.acquire(id)?; + for entry in fs::read_dir(&dir).map_err(|e| e.to_string())? { + let entry = entry.map_err(|e| e.to_string())?; + let name = entry.file_name(); + let known = name == "owner.lock" + || generation(&entry.path()).is_some() + || name.to_str().is_some_and(|n| n.strip_prefix("pending-").and_then(|n| n.strip_suffix(".tmp")).is_some_and(id_ok)); + if !known || !entry.file_type().map_err(|e| e.to_string())?.is_file() { + return Err("recovery folder contains an unexpected entry".into()); + } + } + for p in Self::files(&dir)? { + fs::remove_file(p).map_err(|e| e.to_string())?; + } + for e in fs::read_dir(&dir).map_err(|e| e.to_string())? { + let e = e.map_err(|e| e.to_string())?; + let name = e.file_name(); + if name.to_str().is_some_and(|n| n.strip_prefix("pending-").and_then(|n| n.strip_suffix(".tmp")).is_some_and(id_ok)) { + fs::remove_file(e.path()).map_err(|e| e.to_string())?; + } + } + sync_dir(&dir).map_err(|e| e.to_string())?; + self.entries.remove(id); + self.locks.remove(id); + fs::remove_file(dir.join("owner.lock")).map_err(|e| e.to_string())?; + fs::remove_dir(&dir).map_err(|e| e.to_string())?; + sync_dir(&self.root).map_err(|e| e.to_string())?; + Ok(()) + } +} +struct Limited { + inner: W, + remaining: u64, + exceeded: bool, +} +impl Write for Limited { + fn write(&mut self, bytes: &[u8]) -> io::Result { + if bytes.len() as u64 > self.remaining { + self.exceeded = true; + return Err(io::Error::other("recovery snapshot or folder size limit exceeded")); + } + let n = self.inner.write(bytes)?; + self.remaining -= n as u64; + Ok(n) + } + fn flush(&mut self) -> io::Result<()> { + self.inner.flush() + } +} +enum Job { + /// Write `document`'s copy. + Save(Box, u64), + Scan, + Load(String, Request), + /// Delete a record: an open document's copy it no longer needs, a closed document's, or one + /// from an earlier run that the user discarded (`None`). + Discard(String, Option), +} +enum Outcome { + Done, + Discarded(String), + Loaded(Box, Request), +} +pub(crate) struct Event { + documents: Vec, + outcome: Result, + /// The failure would repeat for the same document; see [`SaveError`]. + permanent: bool, + /// The open document a failed save or discard was for. + document: Option, + /// The record a failed discard was for. + discard: Option, +} +/// Who asked for a restore, and whether the copy replaces that document (an untouched blank +/// one) or opens as another document. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct Request { + pub document: u64, + pub revision: u64, + pub replace: bool, +} +/// One open document's recovery state: the record its copy is kept in, the revision that copy +/// holds, and when the next copy may be written. Each open document has its own, parked with it. +#[derive(Clone, Debug)] +pub(crate) struct Slot { + pub id: Option, + pub revision: Option, + pub due: Instant, + /// A failed discard of this document's copy is tried again from then on. + pub retry: Option, +} +impl Default for Slot { + fn default() -> Self { + Self { id: None, revision: None, due: Instant::now() + INTERVAL, retry: None } + } +} +impl Slot { + /// The document now keeps its copy in record `id`, which holds `revision`. + pub fn adopt(&mut self, id: String, revision: u64) { + self.id = Some(id); + self.revision = Some(revision); + self.due = Instant::now() + INTERVAL; + self.retry = None; + } +} +/// A worker report, for the session to apply to its documents. +pub(crate) enum Applied { + Nothing, + /// Record `id` is gone: no document keeps its copy there any more. + Discarded(String), + /// A restored copy, to open. + Loaded(Box, Request), + /// Writing or discarding `document`'s copy failed. + Failed { + document: u64, + discard: bool, + permanent: bool, + }, +} +impl Slot { + /// Take in a report about this document's copy. + pub fn apply(&mut self, applied: &Applied) { + match applied { + Applied::Discarded(id) if self.id.as_deref() == Some(id) => { + self.id = None; + self.retry = None; + } + Applied::Failed { discard, permanent, .. } => { + let now = Instant::now(); + if *discard { + self.retry = Some(now + INTERVAL); + } else if !permanent { + // A transient failure (a lock, an I/O error) is retried at the next + // interval; one the snapshot itself causes waits for the next edit. + self.revision = None; + } + self.due = now + INTERVAL; + } + _ => {} + } + } +} +/// What [`Recovery::tick`] reads from an open document, and the document's [`Slot`]. +pub(crate) struct DocRecovery<'a> { + pub id: u64, + /// Dirty and without a password: the document should have a current copy. + pub wants_copy: bool, + pub rev: u64, + pub doc: &'a Document, + pub path: Option<&'a Path>, + pub slot: &'a mut Slot, +} +/// The process's recovery worker: one thread and one store for every open document, so records +/// are locked once per process and the list of copies is the same whichever document is shown. +pub(crate) struct Recovery { + tx: mpsc::SyncSender, + rx: mpsc::Receiver, + /// Every record the store found or wrote, this process's own included. + pub documents: Vec, + pub ready: bool, + pub busy: bool, + pub error: Option, + alive: bool, + /// Records of closed documents waiting to be deleted, and whether that is queued. + closed: Vec<(String, bool)>, + /// A loaded copy that replaces its (untouched blank) document once that document is active. + pub held: Option<(Box, Request)>, +} +impl Recovery { + pub fn start(root: PathBuf, wake: Arc) -> Result { + let (tx, jobs) = mpsc::sync_channel(1); + let (events, rx) = mpsc::channel(); + std::thread::Builder::new() + .name("wordcraft-recovery".into()) + .spawn(move || { + let mut store = match Store::new(root) { + Ok(s) => s, + Err(e) => { + let _ = events.send(Event { documents: Vec::new(), outcome: Err(e), permanent: false, document: None, discard: None }); + wake(); + return; + } + }; + let initial = store.scan().map(|_| Outcome::Done); + if events.send(Event { documents: store.inventory(), outcome: initial, permanent: false, document: None, discard: None }).is_err() { + return; + } + wake(); + while let Ok(job) = jobs.recv() { + let mut permanent = false; + let (mut document, mut discard) = (None, None); + let result = match job { + Job::Save(s, d) => { + document = Some(d); + store.save(*s).map(|_| Outcome::Done).map_err(|e| { + permanent = e.permanent; + e.message + }) + } + Job::Scan => store.scan().map(|_| Outcome::Done), + Job::Load(id, request) => store.load(&id).map(|s| Outcome::Loaded(Box::new(s), request)), + Job::Discard(id, d) => { + document = d; + let r = store.discard(&id).map(|_| Outcome::Discarded(id.clone())); + discard = Some(id); + r + } + }; + if result.is_ok() { + (document, discard) = (None, None); + } + if events.send(Event { documents: store.inventory(), outcome: result, permanent, document, discard }).is_err() { + break; + } + wake(); + } + }) + .map_err(|e| e.to_string())?; + Ok(Self { tx, rx, documents: Vec::new(), ready: false, busy: true, error: None, alive: true, closed: Vec::new(), held: None }) + } + fn queue(&mut self, job: Job) -> Result<(), String> { + if !self.alive { + return Err("recovery worker is unavailable".into()); + } + if self.busy { + return Err("recovery is busy; try again when it finishes".into()); + } + self.tx.try_send(job).map_err(|e| e.to_string())?; + self.busy = true; + self.error = None; + Ok(()) + } + /// The worker's answer to the job under way: waits for it when `block`, else only takes one + /// that has arrived. + pub fn receive(&mut self, block: bool) -> Option { + let e = if block { + if !self.busy { + return None; + } + self.rx.recv().map_err(|_| mpsc::TryRecvError::Disconnected) + } else { + self.rx.try_recv() + }; + match e { + Ok(e) => { + self.busy = false; + self.ready = true; + self.documents.clone_from(&e.documents); + Some(e) + } + Err(mpsc::TryRecvError::Empty) => None, + Err(mpsc::TryRecvError::Disconnected) => { + self.busy = false; + self.alive = false; + if self.error.is_none() { + self.error = Some("recovery worker is unavailable".into()); + } + None + } + } + } + /// Run `job` now and wait for its answer. + fn run_now(&mut self, job: Job) -> Result { + self.queue(job)?; + self.receive(true).ok_or_else(|| "recovery worker is unavailable".to_string()) + } + /// Discard record `id` now (the worker must be idle) and wait for it. + pub fn discard_now(&mut self, id: &str, document: Option) -> Result { + self.run_now(Job::Discard(id.into(), document)) + } + /// A closed document's record: deleted at the next chance, and not listed meanwhile. + pub fn close(&mut self, id: String) { + if !self.closed.iter().any(|(c, _)| *c == id) { + self.closed.push((id, false)); + } + } + /// Records the open documents of this process don't hold any more but haven't deleted yet. + pub fn closing(&self) -> impl Iterator { + self.closed.iter().map(|(id, _)| id.as_str()) + } + /// Closed documents' records not yet queued for deletion. + pub fn take_closed(&mut self) -> Vec { + self.closed + .iter_mut() + .filter(|(_, queued)| !*queued) + .map(|(id, queued)| { + *queued = true; + id.clone() + }) + .collect() + } + /// Take in what the worker reported: the record lists here, and what the session applies + /// to its documents. + pub fn apply(&mut self, e: Event) -> Applied { + match e.outcome { + Ok(Outcome::Done) => Applied::Nothing, + Ok(Outcome::Discarded(id)) => { + self.closed.retain(|(c, _)| *c != id); + Applied::Discarded(id) + } + Ok(Outcome::Loaded(s, request)) => Applied::Loaded(s, request), + Err(message) => { + self.error = Some(message); + if let Some(id) = &e.discard { + // A closed document's copy that can't be deleted stays listed, for the user + // to restore or discard. + self.closed.retain(|(c, _)| c != id); + } + match e.document { + Some(document) => Applied::Failed { document, discard: e.discard.is_some(), permanent: e.permanent }, + None => Applied::Nothing, + } + } + } + } + /// Queue at most one job: a closed document's discard first, then, document by document in + /// the order given, a discard of a copy its document no longer needs (saved, closed without + /// saving, password-protected) or a new copy of a dirty document whose interval has passed. + pub fn tick<'a>(&mut self, docs: impl Iterator>, now: Instant) -> Result<(), String> { + if self.busy || !self.ready || !self.alive { + return Ok(()); + } + if let Some(at) = self.closed.iter().position(|(_, queued)| !queued) { + let id = self.closed.get(at).map(|(id, _)| id.clone()).ok_or("recovery record list changed")?; + self.queue(Job::Discard(id, None))?; + if let Some(c) = self.closed.get_mut(at) { + c.1 = true; + } + return Ok(()); + } + for d in docs { + if !d.wants_copy { + if let Some(id) = d.slot.id.clone() + && d.slot.retry.is_none_or(|t| now >= t) + { + return self.queue(Job::Discard(id, Some(d.id))); + } + continue; + } + if d.slot.revision == Some(d.rev) || now < d.slot.due { + continue; + } + let id = match &d.slot.id { + Some(id) => id.clone(), + None => new_id()?, + }; + let name = d.path.and_then(|p| p.file_name()).map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "Untitled draft".into()); + let info = RecoveryInfo { id: id.clone(), name, saved_at: crate::cmd::now_iso() }; + self.queue(Job::Save(Box::new(Snapshot { version: 1, info, generation: 0, document: d.doc.clone() }), d.id))?; + d.slot.id = Some(id); + d.slot.revision = Some(d.rev); + d.slot.due = now + INTERVAL; + return Ok(()); + } + Ok(()) + } + /// How long until [`tick`](Self::tick) has work, for a host that repaints only on demand. + pub fn wait<'a>(&self, docs: impl Iterator) -> Option { + if !self.alive || !self.ready || self.busy { + return None; + } + let now = Instant::now(); + let min = Duration::from_millis(100); + if self.closed.iter().any(|(_, queued)| !queued) || self.held.as_ref().is_some_and(|(_, r)| !r.replace) { + return Some(min); + } + docs.filter_map(|(wants_copy, rev, slot)| match (wants_copy, &slot.id) { + (false, Some(_)) => Some(slot.retry.map_or(min, |t| t.saturating_duration_since(now))), + (false, None) => None, + (true, _) if slot.revision == Some(rev) => None, + (true, _) => Some(slot.due.saturating_duration_since(now)), + }) + .min() + .map(|d| d.max(min)) + } + pub fn restore(&mut self, id: &str, request: Request) -> Result<(), String> { + if !self.documents.iter().any(|d| d.id == id) { + return Err("no such recovery copy".into()); + } + if self.held.is_some() { + return Err("another restored copy is waiting for its document".into()); + } + self.queue(Job::Load(id.into(), request)) + } + pub fn discard(&mut self, id: &str) -> Result<(), String> { + if !self.documents.iter().any(|d| d.id == id) { + return Err("no such recovery copy".into()); + } + self.queue(Job::Discard(id.into(), None)) + } + pub fn refresh(&mut self) -> Result<(), String> { + self.queue(Job::Scan) + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/engine/src/recovery/desktop/tests.rs b/crates/engine/src/recovery/desktop/tests.rs index 36f22faf23..5891b26d4d 100644 --- a/crates/engine/src/recovery/desktop/tests.rs +++ b/crates/engine/src/recovery/desktop/tests.rs @@ -1,621 +1,621 @@ -use super::*; -use crate::Session; -use serde_json::json; -use wordcraft_doc::StoryRef; - -struct Temp(PathBuf); -impl Temp { - fn new() -> Self { - let path = std::env::temp_dir().join(format!("wordcraft-recovery-test-{}", new_id().unwrap())); - private_dir(&path).unwrap(); - Self(path) - } -} -impl Drop for Temp { - fn drop(&mut self) { - let _ = fs::remove_dir_all(&self.0); - } -} -/// A store reopened on `root` whose scan reports an unreadable record. The record's lock can be -/// briefly held by a process another test is starting (a forked child holds the descriptor -/// until it execs), and a record that can't be locked is skipped, not reported: so try again -/// for up to a second rather than read a skipped record as a clean scan. -fn reopen_reporting_unreadable(root: &std::path::Path) -> Store { - for _ in 0..50 { - let mut store = Store::new(root.to_path_buf()).unwrap(); - if store.scan().is_err() { - return store; - } - std::thread::sleep(std::time::Duration::from_millis(20)); - } - panic!("the scan never reported the unreadable record"); -} -/// `store.load(id)`, tried again for up to a second while the record can't be locked: a process -/// another test is starting holds the lock for a moment (see [`reopen_reporting_unreadable`]). -fn load_retrying(store: &mut Store, id: &str) -> Snapshot { - let mut last = String::new(); - for _ in 0..50 { - match store.load(id) { - Ok(snapshot) => return snapshot, - Err(e) => last = e, - } - std::thread::sleep(std::time::Duration::from_millis(20)); - } - panic!("the record never loaded: {last}"); -} -fn snapshot(id: &str, text: &str) -> Snapshot { - Snapshot { - version: 1, - info: RecoveryInfo { id: id.into(), name: "Synthetic draft".into(), saved_at: crate::cmd::now_iso() }, - generation: 0, - document: Document::from_text(text), - } -} -fn settle(s: &mut Session) { - let end = Instant::now() + Duration::from_secs(5); - loop { - s.recovery_tick(); - if s.recovery_ready() && !s.recovery_busy() { - break; - } - assert!(Instant::now() < end, "recovery did not finish"); - std::thread::sleep(Duration::from_millis(2)); - } -} -fn session(root: &Path) -> Session { - let mut s = Session::new(Document::new()); - s.enable_recovery(root.to_owned(), Arc::new(|| {})).unwrap(); - settle(&mut s); - s -} -/// Every open document's interval has passed. -fn due_now(s: &mut Session) { - for d in s.recovery_docs() { - d.slot.due = Instant::now(); - } -} -fn save_now(s: &mut Session) { - due_now(s); - s.recovery_tick(); - settle(s); -} -/// The records in the profile folder: the copies on disk. -fn records(root: &Path) -> Vec { - let mut ids: Vec = fs::read_dir(root) - .unwrap() - .filter_map(|e| e.ok()) - .filter(|e| e.path().is_dir()) - .map(|e| e.file_name().to_string_lossy().into_owned()) - .collect(); - ids.sort(); - ids -} - -/// #3140: a save that lands right before the app closes can race the process exiting before -/// the now-redundant recovery copy is actually removed, so the next launch wrongly offers to -/// recover a document that was in fact saved cleanly. `finish_recovery` (desktop hosts call it -/// on exit, the same as they wait for pending print jobs) must discard it synchronously -/// instead, with no regular tick in between — simulating closing right after Save, not after -/// however long it'd take the next normal autosave tick to notice and clean up. -#[test] -fn finish_recovery_discards_its_own_copy_for_a_document_saved_right_before_exit() { - let profile = Temp::new(); - let mut s = session(&profile.0); - s.run("document.setText", &json!({"text": "About to be saved"})).unwrap(); - save_now(&mut s); - assert_eq!(records(&profile.0).len(), 1, "a recovery copy exists for the dirty document"); - // A real Save does this, then the app can close on the very next frame, with no regular - // recovery_tick() in between to notice `!s.dirty` and queue the discard itself. - s.dirty = false; - assert_eq!(records(&profile.0).len(), 1, "confirms the race: setting dirty=false alone (what Save does) doesn't discard anything by itself"); - s.finish_recovery(); - assert!(records(&profile.0).is_empty(), "finish_recovery should have discarded the now-redundant copy before returning"); -} - -/// `tick` resets `self.id` the moment the session's document no longer matches what it last -/// saw (so it never discards under a stale identity); `finish_recovery` must guard the same -/// way. Switching documents and exiting immediately, with no regular tick in between to reset -/// that identity, must not let the *new* (clean, blank) document's state discard the -/// *previous* document's still-live recovery copy. -#[test] -fn finish_recovery_does_not_discard_a_previous_document_s_copy_after_switching() { - let profile = Temp::new(); - let mut s = session(&profile.0); - s.run("document.setText", &json!({"text": "The document the user is still working on"})).unwrap(); - save_now(&mut s); - assert_eq!(records(&profile.0).len(), 1, "a recovery copy exists for the dirty document"); - s.set_document(Document::new()); // the document is replaced, with no tick() in between - assert!(!s.dirty, "a freshly switched-to document starts clean"); - s.finish_recovery(); - assert_eq!(records(&profile.0).len(), 1, "replacing the document must not discard the previous one's recovery copy"); - assert_eq!(s.recovery_documents().len(), 1, "no open document keeps it: it is listed"); -} - -#[test] -fn recovery_commands_only_address_known_profile_records() { - use crate::file_access::{AutomationRoots, confine}; - let profile = Temp::new(); - let files = Temp::new(); - let original = files.0.join("original.txt"); - fs::write(&original, b"External synthetic original").unwrap(); - let roots = AutomationRoots::new(Some(&files.0), Some(&files.0)).unwrap().unwrap(); - let mut s = session(&profile.0); - s.run("document.setText", &json!({"text": "Recoverable synthetic draft"})).unwrap(); - save_now(&mut s); - drop(s); - let mut s = session(&profile.0); - let id = s.recovery_documents()[0].id.clone(); - confine(Some(&roots), || { - // A command cannot use a file path or choose a different storage folder. - for bad in [original.to_string_lossy().into_owned(), "../original.txt".into()] { - for action in ["restore", "discard"] { - let err = s.run("file.recover", &json!({action: bad, "root": files.0})).unwrap_err(); - assert!(err.to_string().contains("no such recovery copy"), "{err}"); - } - } - assert_eq!(s.recovery_documents().len(), 1); - s.run("file.recover", &json!({"restore": id})).unwrap(); - settle(&mut s); - assert!(s.path.is_none()); - assert!(s.dirty); - assert_eq!(s.doc.plain_text(StoryRef::Body), "Recoverable synthetic draft"); - // The restored document keeps its copy there: it isn't listed, and can't be discarded. - assert!(s.recovery_documents().is_empty()); - let err = s.run("file.recover", &json!({"discard": id})).unwrap_err(); - assert!(err.to_string().contains("no such recovery copy"), "{err}"); - settle(&mut s); - assert_eq!(records(&profile.0), std::slice::from_ref(&id)); - }); - assert_eq!(fs::read(&original).unwrap(), b"External synthetic original"); - assert_eq!(fs::read_dir(&files.0).unwrap().count(), 1); -} - -#[test] -fn failed_write_keeps_last_valid_generation_and_discovery_falls_back() { - let t = Temp::new(); - let id = new_id().unwrap(); - let mut store = Store::new(t.0.clone()).unwrap(); - store.save(snapshot(&id, "First valid copy")).unwrap(); - let first = store.entries[&id].path.clone(); - let bytes = fs::read(&first).unwrap(); - // Simulate a final rename failure without relying on permissions (root can bypass them). - let obstruction = t.0.join(&id).join("snapshot-00000000000000000002.json"); - fs::create_dir(&obstruction).unwrap(); - assert!(store.save(snapshot(&id, "Failed copy")).is_err()); - assert_eq!(fs::read(&first).unwrap(), bytes); - fs::remove_dir(obstruction).unwrap(); - store.save(snapshot(&id, "Second valid copy")).unwrap(); - let second = store.entries[&id].path.clone(); - fs::write(second, b"{truncated").unwrap(); - drop(store); - let mut reopened = reopen_reporting_unreadable(&t.0); - assert_eq!(load_retrying(&mut reopened, &id).document.plain_text(StoryRef::Body), "First valid copy"); - // Never reuse the corrupt generation's name or prune the valid fallback. - reopened.save(snapshot(&id, "Third valid copy")).unwrap(); - assert_eq!(fs::read(first).unwrap(), bytes); - assert_eq!(Store::files(&t.0.join(&id)).unwrap().len(), 2); -} - -#[test] -fn size_limit_and_lossy_values_fail_without_committing() { - let mut out = Limited { inner: Vec::new(), remaining: 3, exceeded: false }; - assert!(out.write_all(b"four").is_err()); - assert!(out.inner.is_empty()); - assert!(out.exceeded); - let t = Temp::new(); - let id = new_id().unwrap(); - let mut store = Store::new(t.0.clone()).unwrap(); - store.save(snapshot(&id, "valid")).unwrap(); - let first = store.entries[&id].path.clone(); - let bytes = fs::read(&first).unwrap(); - let mut invalid = snapshot(&id, "invalid"); - invalid.document.last_section.page_w = f32::NAN; - assert!(store.save(invalid).is_err_and(|e| e.permanent)); - assert_eq!(fs::read(first).unwrap(), bytes); - assert_eq!(Store::files(&t.0.join(id)).unwrap().len(), 1); -} - -/// Copies are written with picture bytes in base64 (#586 follow-up); those written before kept -/// them as arrays of numbers and are still listed and restored, bytes intact. -#[test] -fn copies_with_byte_arrays_written_before_still_restore() { - let t = Temp::new(); - let id = new_id().unwrap(); - let mut s = snapshot(&id, "Synthetic draft with a picture"); - s.generation = 1; - s.document.media.insert("media/image1.png".into(), Arc::new(vec![0, 1, 127, 128, 254, 255])); - let mut value = serde_json::to_value(&s).unwrap(); - assert_eq!(value["document"]["media"]["media/image1.png"], "AAF/gP7/"); - value["document"]["media"]["media/image1.png"] = json!([0, 1, 127, 128, 254, 255]); - private_dir(&t.0.join(&id)).unwrap(); - fs::write(t.0.join(&id).join("snapshot-00000000000000000001.json"), serde_json::to_vec(&value).unwrap()).unwrap(); - let mut store = Store::new(t.0.clone()).unwrap(); - store.scan().unwrap(); - assert_eq!(store.inventory().len(), 1); - assert_eq!(store.load(&id).unwrap().document, s.document); -} - -#[test] -fn snapshot_that_cannot_be_copied_is_not_retried_until_the_document_changes() { - // #2625: a failure caused by the snapshot itself (here a value the format cannot keep; - // in the field, the 256 MiB limit) used to be rewritten every interval while dirty. - let t = Temp::new(); - let mut s = session(&t.0); - s.run("document.setText", &json!({"text": "Synthetic draft"})).unwrap(); - s.doc.last_section.page_w = f32::NAN; - save_now(&mut s); - assert!(s.recovery_error().is_some()); - assert!(s.recovery_wait().is_none()); - due_now(&mut s); - s.recovery_tick(); - assert!(!s.recovery_busy(), "the unchanged document was snapshotted again"); - assert!(s.recovery_error().is_some()); - // An edit tries again. - s.doc.last_section.page_w = 612.0; - s.run("text.insert", &json!({"text": "!"})).unwrap(); - save_now(&mut s); - assert!(s.recovery_error().is_none()); - assert_eq!(records(&t.0).len(), 1); -} - -#[test] -fn failed_pruning_cannot_push_a_record_beyond_the_discovery_limit() { - let t = Temp::new(); - let id = new_id().unwrap(); - let mut store = Store::new(t.0.clone()).unwrap(); - store.save(snapshot(&id, "Last valid copy")).unwrap(); - let first = store.entries[&id].path.clone(); - let bytes = fs::read(&first).unwrap(); - // A failed cleanup must not allow the next write to exceed the reader's bound. - for n in 2..=MAX_FILES { - fs::write(t.0.join(&id).join(format!("snapshot-{n:020}.json")), b"{truncated").unwrap(); - } - assert!(store.save(snapshot(&id, "New copy")).is_err()); - assert_eq!(fs::read(first).unwrap(), bytes); - drop(store); - let mut reopened = reopen_reporting_unreadable(&t.0); - assert_eq!(load_retrying(&mut reopened, &id).document.plain_text(StoryRef::Body), "Last valid copy"); -} - -#[test] -fn failed_first_write_does_not_consume_a_record_slot() { - let t = Temp::new(); - let mut store = Store::new(t.0.clone()).unwrap(); - let mut invalid = snapshot(&new_id().unwrap(), "Synthetic draft"); - invalid.document.last_section.page_w = f32::NAN; - assert!(store.save(invalid).is_err()); - assert!(store.directories().unwrap().is_empty()); -} - -#[test] -fn discovery_reclaims_a_crashed_first_write_but_keeps_valid_records() { - let t = Temp::new(); - let id = new_id().unwrap(); - let empty = new_id().unwrap(); - let mut store = Store::new(t.0.clone()).unwrap(); - store.save(snapshot(&id, "Valid synthetic draft")).unwrap(); - drop(store); - let dir = t.0.join(&empty); - private_dir(&dir).unwrap(); - fs::write(dir.join(format!("pending-{}.tmp", new_id().unwrap())), b"{unfinished").unwrap(); - let mut reopened = Store::new(t.0.clone()).unwrap(); - reopened.scan().unwrap(); - assert!(!dir.exists()); - assert_eq!(load_retrying(&mut reopened, &id).document.plain_text(StoryRef::Body), "Valid synthetic draft"); -} - -#[test] -fn live_records_are_locked_and_discard_releases_storage() { - let t = Temp::new(); - let id = new_id().unwrap(); - let mut owner = Store::new(t.0.clone()).unwrap(); - owner.save(snapshot(&id, "live")).unwrap(); - let mut other = Store::new(t.0.clone()).unwrap(); - other.scan().unwrap(); - assert!(other.inventory().is_empty()); - assert!(other.discard(&id).is_err()); - assert!(other.discard("../outside").is_err()); - drop(owner); - other.scan().unwrap(); - assert_eq!(other.inventory().len(), 1); - other.discard(&id).unwrap(); - assert!(!t.0.join(id).exists(), "discard must release the record slot"); -} - -#[test] -fn new_draft_restarts_restores_unsaved_and_explicitly_discards() { - let t = Temp::new(); - let mut s = session(&t.0); - s.run("text.insert", &json!({"text": "Unsaved synthetic draft"})).unwrap(); - save_now(&mut s); - assert!(s.recovery_wait().is_none(), "unchanged documents must not schedule idle work"); - let expected = s.doc.clone(); - drop(s); - let mut s = session(&t.0); - let id = s.recovery_documents()[0].id.clone(); - s.run("file.recover", &json!({"restore": id})).unwrap(); - settle(&mut s); - assert_eq!(s.doc, expected); - assert!(s.dirty && s.path.is_none()); - assert!(s.recovery_documents().is_empty(), "the restored document keeps the copy"); - // A crash leaves the restored document's copy, in the same record. - drop(s); - let mut s = session(&t.0); - assert_eq!(s.recovery_documents().len(), 1); - s.run("file.recover", &json!({"discard": id})).unwrap(); - settle(&mut s); - save_now(&mut s); - assert!(s.recovery_documents().is_empty()); - assert!(records(&t.0).is_empty()); -} - -#[test] -fn existing_file_is_unchanged_and_successful_save_removes_copy() { - let t = Temp::new(); - let file = t.0.join("original.docx"); - let root = t.0.join("profile"); - crate::io::save_path(&file, &Document::from_text("Original business note")).unwrap(); - let original = fs::read(&file).unwrap(); - let mut s = session(&root); - s.run("file.open", &json!({"path": file})).unwrap(); - s.run("text.insert", &json!({"text": "Unsaved edit. "})).unwrap(); - save_now(&mut s); - let expected = s.doc.clone(); - drop(s); - let mut s = session(&root); - let id = s.recovery_documents()[0].id.clone(); - s.run("file.recover", &json!({"restore": id})).unwrap(); - settle(&mut s); - assert_eq!(s.doc, expected); - assert!(s.path.is_none() && s.dirty); - assert_eq!(fs::read(&file).unwrap(), original); - let saved = t.0.join("recovered.json"); - s.run("file.save", &json!({"path": saved})).unwrap(); - settle(&mut s); - assert!(records(&root).is_empty()); - assert_eq!(fs::read(file).unwrap(), original); -} - -#[test] -fn password_barrier_clears_in_flight_plaintext_and_blocks_new_copies() { - let t = Temp::new(); - let mut s = session(&t.0); - s.run("text.insert", &json!({"text": "Confidential synthetic draft"})).unwrap(); - due_now(&mut s); - s.recovery_tick(); - assert!(s.recovery_busy()); - s.run("file.encrypt", &json!({"password": "synthetic-password"})).unwrap(); - assert!(records(&t.0).is_empty()); - s.run("text.insert", &json!({"text": "More confidential edits"})).unwrap(); - save_now(&mut s); - assert!(fs::read_dir(&t.0).unwrap().next().is_none()); - // Removing protection makes future recovery possible again. - s.run("file.encrypt", &json!({"password": null})).unwrap(); - save_now(&mut s); - assert_eq!(records(&t.0).len(), 1); -} - -#[test] -fn failed_password_cleanup_rejects_protection_and_preserves_snapshot() { - let t = Temp::new(); - let mut s = session(&t.0); - s.run("text.insert", &json!({"text": "Synthetic text"})).unwrap(); - save_now(&mut s); - let id = records(&t.0).remove(0); - let dir = t.0.join(id); - // An unknown file prevents removal: don't report that plaintext was purged. - let first = Store::files(&dir).unwrap()[0].clone(); - let bytes = fs::read(&first).unwrap(); - fs::write(dir.join("unexpected"), b"unexpected").unwrap(); - assert!(s.run("file.encrypt", &json!({"password": "synthetic-password"})).is_err()); - assert!(s.password.is_none()); - assert_eq!(fs::read(first).unwrap(), bytes); -} - -#[test] -fn delayed_restore_cannot_replace_new_edits() { - let t = Temp::new(); - let mut s = session(&t.0); - s.run("text.insert", &json!({"text": "Saved draft"})).unwrap(); - save_now(&mut s); - drop(s); - let mut s = session(&t.0); - let id = s.recovery_documents()[0].id.clone(); - s.run("file.recover", &json!({"restore": id})).unwrap(); - s.run("text.insert", &json!({"text": "New work"})).unwrap(); - settle(&mut s); - assert_eq!(s.doc.plain_text(StoryRef::Body), "New work"); - assert!(s.recovery_error().unwrap().contains("document changed")); -} - -/// A restore asked for in one document lands in that document, even when another document -/// is active while the copy loads. -#[test] -fn delayed_restore_lands_in_the_document_that_asked_for_it() { - let t = Temp::new(); - let mut s = session(&t.0); - s.run("text.insert", &json!({"text": "Saved draft"})).unwrap(); - save_now(&mut s); - drop(s); - let mut s = session(&t.0); - let first = s.document_id(); - let id = s.recovery_documents()[0].id.clone(); - s.run("file.recover", &json!({"restore": id})).unwrap(); - s.run("file.new", &json!({})).unwrap(); - let second = s.document_id(); - s.run("text.insert", &json!({"text": "Other work"})).unwrap(); - settle(&mut s); - assert_eq!(s.doc.plain_text(StoryRef::Body), "Other work", "the active document is left alone"); - s.activate(first).unwrap(); - settle(&mut s); - assert_eq!(s.doc.plain_text(StoryRef::Body), "Saved draft"); - assert_eq!(s.with_document(second, |s| s.doc.plain_text(StoryRef::Body)).unwrap(), "Other work"); - assert_eq!(s.documents().len(), 2); -} - -#[test] -fn opening_protected_file_and_password_save_never_leave_plaintext_copies() { - let t = Temp::new(); - let original = t.0.join("protected.docx"); - let root = t.0.join("profile"); - crate::io::save_path_with( - &original, - &Document::from_text("Synthetic confidential note"), - Some("synthetic-password"), - &crate::io::ExportOptions::default(), - ) - .unwrap(); - let bytes = fs::read(&original).unwrap(); - let mut s = session(&root); - s.run("file.open", &json!({"path": original, "password": "synthetic-password"})).unwrap(); - s.run("text.insert", &json!({"text": "Confidential edit"})).unwrap(); - save_now(&mut s); - assert!(fs::read_dir(&root).unwrap().next().is_none()); - assert_eq!(fs::read(&original).unwrap(), bytes); - s.run("file.new", &json!({})).unwrap(); - s.run("text.insert", &json!({"text": "New confidential draft"})).unwrap(); - save_now(&mut s); - assert_eq!(records(&root).len(), 1); - let saved = t.0.join("saved.docx"); - s.run("file.save", &json!({"path": saved, "password": "synthetic-password"})).unwrap(); - settle(&mut s); - assert!(fs::read_dir(root).unwrap().next().is_none()); - assert_eq!(fs::read(original).unwrap(), bytes); -} - -/// Each open document keeps its own recovery copy: switching between documents doesn't start -/// a new copy of either. -#[test] -fn each_open_document_keeps_its_own_copy() { - let t = Temp::new(); - let mut s = session(&t.0); - let first = s.document_id(); - s.run("text.insert", &json!({"text": "First synthetic draft"})).unwrap(); - save_now(&mut s); - s.run("file.new", &json!({})).unwrap(); - settle(&mut s); - s.run("text.insert", &json!({"text": "Second synthetic draft"})).unwrap(); - save_now(&mut s); - s.activate(first).unwrap(); - s.run("text.insert", &json!({"text": ", edited"})).unwrap(); - save_now(&mut s); - assert_eq!(records(&t.0).len(), 2, "one copy per document"); - assert!(s.recovery_documents().is_empty(), "the copies open documents keep are not listed"); -} - -#[test] -fn protection_after_document_switch_does_not_discard_another_draft() { - let t = Temp::new(); - let mut s = session(&t.0); - s.run("text.insert", &json!({"text": "First synthetic draft"})).unwrap(); - save_now(&mut s); - let first = s.document_id(); - s.run("file.new", &json!({})).unwrap(); - // Protection can be commanded before the host's next recovery tick. - s.run("file.encrypt", &json!({"password": "synthetic-password"})).unwrap(); - settle(&mut s); - assert!(s.with_document(first, |s| s.recovery_slot.id.is_some()).unwrap()); - assert_eq!(records(&t.0).len(), 1, "the first draft is kept"); -} - -/// Two dirty documents, one of them parked since before its first copy: both get copies once -/// their interval has passed, without being made active. -#[test] -fn parked_documents_keep_their_copies() { - let t = Temp::new(); - let mut s = session(&t.0); - s.run("text.insert", &json!({"text": "Parked synthetic draft"})).unwrap(); - let parked = s.document_id(); - s.run("file.new", &json!({})).unwrap(); - s.run("text.insert", &json!({"text": "Active synthetic draft"})).unwrap(); - let active = s.document_id(); - assert_ne!(parked, active); - save_now(&mut s); - assert_eq!(s.document_id(), active, "the parked document was not made active"); - assert_eq!(records(&t.0).len(), 2, "both documents have copies"); - // An edit to the parked document reaches its copy too. - s.with_document(parked, |s| s.run("text.insert", &json!({"text": ", edited"})).unwrap()).unwrap(); - let id = s.with_document(parked, |s| s.recovery_slot.id.clone()).unwrap().unwrap(); - save_now(&mut s); - drop(s); - let mut store = Store::new(t.0.clone()).unwrap(); - store.scan().unwrap(); - assert_eq!(load_retrying(&mut store, &id).document.plain_text(StoryRef::Body), "Parked synthetic draft, edited"); -} - -/// Closing a dirty document and saving another delete both their copies; a third document -/// keeps its own. -#[test] -fn closing_or_saving_a_document_deletes_only_its_copy() { - let t = Temp::new(); - let files = Temp::new(); - let mut s = session(&t.0); - let mut ids = Vec::new(); - for text in ["Closed draft", "Saved draft", "Kept draft"] { - if !ids.is_empty() { - s.run("file.new", &json!({})).unwrap(); - } - s.run("text.insert", &json!({"text": text})).unwrap(); - ids.push(s.document_id()); - } - save_now(&mut s); - assert_eq!(records(&t.0).len(), 3); - let kept = s.recovery_slot.id.clone().unwrap(); - s.close_document(ids[0]).unwrap(); - s.with_document(ids[1], |s| s.run("file.save", &json!({"path": files.0.join("saved.json")})).unwrap()).unwrap(); - settle(&mut s); - assert_eq!(records(&t.0), [kept], "only the open dirty document's copy is left"); - assert!(s.recovery_documents().is_empty()); -} - -/// A crash with two dirty documents leaves two copies; the next run lists both and restores -/// each into a document of its own, beside the work already there. -#[test] -fn a_crash_leaves_every_dirty_document_s_copy() { - let t = Temp::new(); - let mut s = session(&t.0); - s.run("text.insert", &json!({"text": "First synthetic draft"})).unwrap(); - s.run("file.new", &json!({})).unwrap(); - s.run("text.insert", &json!({"text": "Second synthetic draft"})).unwrap(); - save_now(&mut s); - drop(s); // no finish_recovery: the process died - let mut s = session(&t.0); - let copies = s.recovery_documents(); - assert_eq!(copies.len(), 2); - s.run("text.insert", &json!({"text": "Work in progress"})).unwrap(); - let work = s.document_id(); - for copy in &copies { - s.run("file.recover", &json!({"restore": copy.id})).unwrap(); - settle(&mut s); - } - assert!(s.recovery_documents().is_empty(), "the restored documents keep their copies"); - let mut texts: Vec = s.documents().iter().map(|d| s.with_document(d.id, |s| s.doc.plain_text(StoryRef::Body)).unwrap()).collect(); - texts.sort(); - assert_eq!(texts, ["First synthetic draft", "Second synthetic draft", "Work in progress"]); - assert!(s.with_document(work, |s| s.dirty).unwrap()); -} - -/// A clean exit deletes the copies of documents saved or closed since their last copy, the -/// parked ones' too, and keeps the dirty ones'. -#[test] -fn finish_recovery_covers_every_open_document() { - let t = Temp::new(); - let files = Temp::new(); - let mut s = session(&t.0); - let mut ids = Vec::new(); - for text in ["Saved draft", "Closed draft", "Dirty draft"] { - if !ids.is_empty() { - s.run("file.new", &json!({})).unwrap(); - } - s.run("text.insert", &json!({"text": text})).unwrap(); - ids.push(s.document_id()); - } - save_now(&mut s); - assert_eq!(records(&t.0).len(), 3); - let dirty = s.recovery_slot.id.clone().unwrap(); - // No tick between these and the exit. - s.with_document(ids[0], |s| s.run("file.save", &json!({"path": files.0.join("saved.json")})).unwrap()).unwrap(); - s.close_document(ids[1]).unwrap(); - s.finish_recovery(); - assert_eq!(records(&t.0), [dirty]); -} +use super::*; +use crate::Session; +use serde_json::json; +use wordcraft_doc::StoryRef; + +struct Temp(PathBuf); +impl Temp { + fn new() -> Self { + let path = std::env::temp_dir().join(format!("wordcraft-recovery-test-{}", new_id().unwrap())); + private_dir(&path).unwrap(); + Self(path) + } +} +impl Drop for Temp { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} +/// A store reopened on `root` whose scan reports an unreadable record. The record's lock can be +/// briefly held by a process another test is starting (a forked child holds the descriptor +/// until it execs), and a record that can't be locked is skipped, not reported: so try again +/// for up to a second rather than read a skipped record as a clean scan. +fn reopen_reporting_unreadable(root: &std::path::Path) -> Store { + for _ in 0..50 { + let mut store = Store::new(root.to_path_buf()).unwrap(); + if store.scan().is_err() { + return store; + } + std::thread::sleep(std::time::Duration::from_millis(20)); + } + panic!("the scan never reported the unreadable record"); +} +/// `store.load(id)`, tried again for up to a second while the record can't be locked: a process +/// another test is starting holds the lock for a moment (see [`reopen_reporting_unreadable`]). +fn load_retrying(store: &mut Store, id: &str) -> Snapshot { + let mut last = String::new(); + for _ in 0..50 { + match store.load(id) { + Ok(snapshot) => return snapshot, + Err(e) => last = e, + } + std::thread::sleep(std::time::Duration::from_millis(20)); + } + panic!("the record never loaded: {last}"); +} +fn snapshot(id: &str, text: &str) -> Snapshot { + Snapshot { + version: 1, + info: RecoveryInfo { id: id.into(), name: "Synthetic draft".into(), saved_at: crate::cmd::now_iso() }, + generation: 0, + document: Document::from_text(text), + } +} +fn settle(s: &mut Session) { + let end = Instant::now() + Duration::from_secs(5); + loop { + s.recovery_tick(); + if s.recovery_ready() && !s.recovery_busy() { + break; + } + assert!(Instant::now() < end, "recovery did not finish"); + std::thread::sleep(Duration::from_millis(2)); + } +} +fn session(root: &Path) -> Session { + let mut s = Session::new(Document::new()); + s.enable_recovery(root.to_owned(), Arc::new(|| {})).unwrap(); + settle(&mut s); + s +} +/// Every open document's interval has passed. +fn due_now(s: &mut Session) { + for d in s.recovery_docs() { + d.slot.due = Instant::now(); + } +} +fn save_now(s: &mut Session) { + due_now(s); + s.recovery_tick(); + settle(s); +} +/// The records in the profile folder: the copies on disk. +fn records(root: &Path) -> Vec { + let mut ids: Vec = fs::read_dir(root) + .unwrap() + .filter_map(|e| e.ok()) + .filter(|e| e.path().is_dir()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .collect(); + ids.sort(); + ids +} + +/// #3140: a save that lands right before the app closes can race the process exiting before +/// the now-redundant recovery copy is actually removed, so the next launch wrongly offers to +/// recover a document that was in fact saved cleanly. `finish_recovery` (desktop hosts call it +/// on exit, the same as they wait for pending print jobs) must discard it synchronously +/// instead, with no regular tick in between — simulating closing right after Save, not after +/// however long it'd take the next normal autosave tick to notice and clean up. +#[test] +fn finish_recovery_discards_its_own_copy_for_a_document_saved_right_before_exit() { + let profile = Temp::new(); + let mut s = session(&profile.0); + s.run("document.setText", &json!({"text": "About to be saved"})).unwrap(); + save_now(&mut s); + assert_eq!(records(&profile.0).len(), 1, "a recovery copy exists for the dirty document"); + // A real Save does this, then the app can close on the very next frame, with no regular + // recovery_tick() in between to notice `!s.dirty` and queue the discard itself. + s.dirty = false; + assert_eq!(records(&profile.0).len(), 1, "confirms the race: setting dirty=false alone (what Save does) doesn't discard anything by itself"); + s.finish_recovery(); + assert!(records(&profile.0).is_empty(), "finish_recovery should have discarded the now-redundant copy before returning"); +} + +/// `tick` resets `self.id` the moment the session's document no longer matches what it last +/// saw (so it never discards under a stale identity); `finish_recovery` must guard the same +/// way. Switching documents and exiting immediately, with no regular tick in between to reset +/// that identity, must not let the *new* (clean, blank) document's state discard the +/// *previous* document's still-live recovery copy. +#[test] +fn finish_recovery_does_not_discard_a_previous_document_s_copy_after_switching() { + let profile = Temp::new(); + let mut s = session(&profile.0); + s.run("document.setText", &json!({"text": "The document the user is still working on"})).unwrap(); + save_now(&mut s); + assert_eq!(records(&profile.0).len(), 1, "a recovery copy exists for the dirty document"); + s.set_document(Document::new()); // the document is replaced, with no tick() in between + assert!(!s.dirty, "a freshly switched-to document starts clean"); + s.finish_recovery(); + assert_eq!(records(&profile.0).len(), 1, "replacing the document must not discard the previous one's recovery copy"); + assert_eq!(s.recovery_documents().len(), 1, "no open document keeps it: it is listed"); +} + +#[test] +fn recovery_commands_only_address_known_profile_records() { + use crate::file_access::{AutomationRoots, confine}; + let profile = Temp::new(); + let files = Temp::new(); + let original = files.0.join("original.txt"); + fs::write(&original, b"External synthetic original").unwrap(); + let roots = AutomationRoots::new(Some(&files.0), Some(&files.0)).unwrap().unwrap(); + let mut s = session(&profile.0); + s.run("document.setText", &json!({"text": "Recoverable synthetic draft"})).unwrap(); + save_now(&mut s); + drop(s); + let mut s = session(&profile.0); + let id = s.recovery_documents()[0].id.clone(); + confine(Some(&roots), || { + // A command cannot use a file path or choose a different storage folder. + for bad in [original.to_string_lossy().into_owned(), "../original.txt".into()] { + for action in ["restore", "discard"] { + let err = s.run("file.recover", &json!({action: bad, "root": files.0})).unwrap_err(); + assert!(err.to_string().contains("no such recovery copy"), "{err}"); + } + } + assert_eq!(s.recovery_documents().len(), 1); + s.run("file.recover", &json!({"restore": id})).unwrap(); + settle(&mut s); + assert!(s.path.is_none()); + assert!(s.dirty); + assert_eq!(s.doc.plain_text(StoryRef::Body), "Recoverable synthetic draft"); + // The restored document keeps its copy there: it isn't listed, and can't be discarded. + assert!(s.recovery_documents().is_empty()); + let err = s.run("file.recover", &json!({"discard": id})).unwrap_err(); + assert!(err.to_string().contains("no such recovery copy"), "{err}"); + settle(&mut s); + assert_eq!(records(&profile.0), std::slice::from_ref(&id)); + }); + assert_eq!(fs::read(&original).unwrap(), b"External synthetic original"); + assert_eq!(fs::read_dir(&files.0).unwrap().count(), 1); +} + +#[test] +fn failed_write_keeps_last_valid_generation_and_discovery_falls_back() { + let t = Temp::new(); + let id = new_id().unwrap(); + let mut store = Store::new(t.0.clone()).unwrap(); + store.save(snapshot(&id, "First valid copy")).unwrap(); + let first = store.entries[&id].path.clone(); + let bytes = fs::read(&first).unwrap(); + // Simulate a final rename failure without relying on permissions (root can bypass them). + let obstruction = t.0.join(&id).join("snapshot-00000000000000000002.json"); + fs::create_dir(&obstruction).unwrap(); + assert!(store.save(snapshot(&id, "Failed copy")).is_err()); + assert_eq!(fs::read(&first).unwrap(), bytes); + fs::remove_dir(obstruction).unwrap(); + store.save(snapshot(&id, "Second valid copy")).unwrap(); + let second = store.entries[&id].path.clone(); + fs::write(second, b"{truncated").unwrap(); + drop(store); + let mut reopened = reopen_reporting_unreadable(&t.0); + assert_eq!(load_retrying(&mut reopened, &id).document.plain_text(StoryRef::Body), "First valid copy"); + // Never reuse the corrupt generation's name or prune the valid fallback. + reopened.save(snapshot(&id, "Third valid copy")).unwrap(); + assert_eq!(fs::read(first).unwrap(), bytes); + assert_eq!(Store::files(&t.0.join(&id)).unwrap().len(), 2); +} + +#[test] +fn size_limit_and_lossy_values_fail_without_committing() { + let mut out = Limited { inner: Vec::new(), remaining: 3, exceeded: false }; + assert!(out.write_all(b"four").is_err()); + assert!(out.inner.is_empty()); + assert!(out.exceeded); + let t = Temp::new(); + let id = new_id().unwrap(); + let mut store = Store::new(t.0.clone()).unwrap(); + store.save(snapshot(&id, "valid")).unwrap(); + let first = store.entries[&id].path.clone(); + let bytes = fs::read(&first).unwrap(); + let mut invalid = snapshot(&id, "invalid"); + invalid.document.last_section.page_w = f32::NAN; + assert!(store.save(invalid).is_err_and(|e| e.permanent)); + assert_eq!(fs::read(first).unwrap(), bytes); + assert_eq!(Store::files(&t.0.join(id)).unwrap().len(), 1); +} + +/// Copies are written with picture bytes in base64 (#586 follow-up); those written before kept +/// them as arrays of numbers and are still listed and restored, bytes intact. +#[test] +fn copies_with_byte_arrays_written_before_still_restore() { + let t = Temp::new(); + let id = new_id().unwrap(); + let mut s = snapshot(&id, "Synthetic draft with a picture"); + s.generation = 1; + s.document.media.insert("media/image1.png".into(), Arc::new(vec![0, 1, 127, 128, 254, 255])); + let mut value = serde_json::to_value(&s).unwrap(); + assert_eq!(value["document"]["media"]["media/image1.png"], "AAF/gP7/"); + value["document"]["media"]["media/image1.png"] = json!([0, 1, 127, 128, 254, 255]); + private_dir(&t.0.join(&id)).unwrap(); + fs::write(t.0.join(&id).join("snapshot-00000000000000000001.json"), serde_json::to_vec(&value).unwrap()).unwrap(); + let mut store = Store::new(t.0.clone()).unwrap(); + store.scan().unwrap(); + assert_eq!(store.inventory().len(), 1); + assert_eq!(store.load(&id).unwrap().document, s.document); +} + +#[test] +fn snapshot_that_cannot_be_copied_is_not_retried_until_the_document_changes() { + // #2625: a failure caused by the snapshot itself (here a value the format cannot keep; + // in the field, the 256 MiB limit) used to be rewritten every interval while dirty. + let t = Temp::new(); + let mut s = session(&t.0); + s.run("document.setText", &json!({"text": "Synthetic draft"})).unwrap(); + s.doc.last_section.page_w = f32::NAN; + save_now(&mut s); + assert!(s.recovery_error().is_some()); + assert!(s.recovery_wait().is_none()); + due_now(&mut s); + s.recovery_tick(); + assert!(!s.recovery_busy(), "the unchanged document was snapshotted again"); + assert!(s.recovery_error().is_some()); + // An edit tries again. + s.doc.last_section.page_w = 612.0; + s.run("text.insert", &json!({"text": "!"})).unwrap(); + save_now(&mut s); + assert!(s.recovery_error().is_none()); + assert_eq!(records(&t.0).len(), 1); +} + +#[test] +fn failed_pruning_cannot_push_a_record_beyond_the_discovery_limit() { + let t = Temp::new(); + let id = new_id().unwrap(); + let mut store = Store::new(t.0.clone()).unwrap(); + store.save(snapshot(&id, "Last valid copy")).unwrap(); + let first = store.entries[&id].path.clone(); + let bytes = fs::read(&first).unwrap(); + // A failed cleanup must not allow the next write to exceed the reader's bound. + for n in 2..=MAX_FILES { + fs::write(t.0.join(&id).join(format!("snapshot-{n:020}.json")), b"{truncated").unwrap(); + } + assert!(store.save(snapshot(&id, "New copy")).is_err()); + assert_eq!(fs::read(first).unwrap(), bytes); + drop(store); + let mut reopened = reopen_reporting_unreadable(&t.0); + assert_eq!(load_retrying(&mut reopened, &id).document.plain_text(StoryRef::Body), "Last valid copy"); +} + +#[test] +fn failed_first_write_does_not_consume_a_record_slot() { + let t = Temp::new(); + let mut store = Store::new(t.0.clone()).unwrap(); + let mut invalid = snapshot(&new_id().unwrap(), "Synthetic draft"); + invalid.document.last_section.page_w = f32::NAN; + assert!(store.save(invalid).is_err()); + assert!(store.directories().unwrap().is_empty()); +} + +#[test] +fn discovery_reclaims_a_crashed_first_write_but_keeps_valid_records() { + let t = Temp::new(); + let id = new_id().unwrap(); + let empty = new_id().unwrap(); + let mut store = Store::new(t.0.clone()).unwrap(); + store.save(snapshot(&id, "Valid synthetic draft")).unwrap(); + drop(store); + let dir = t.0.join(&empty); + private_dir(&dir).unwrap(); + fs::write(dir.join(format!("pending-{}.tmp", new_id().unwrap())), b"{unfinished").unwrap(); + let mut reopened = Store::new(t.0.clone()).unwrap(); + reopened.scan().unwrap(); + assert!(!dir.exists()); + assert_eq!(load_retrying(&mut reopened, &id).document.plain_text(StoryRef::Body), "Valid synthetic draft"); +} + +#[test] +fn live_records_are_locked_and_discard_releases_storage() { + let t = Temp::new(); + let id = new_id().unwrap(); + let mut owner = Store::new(t.0.clone()).unwrap(); + owner.save(snapshot(&id, "live")).unwrap(); + let mut other = Store::new(t.0.clone()).unwrap(); + other.scan().unwrap(); + assert!(other.inventory().is_empty()); + assert!(other.discard(&id).is_err()); + assert!(other.discard("../outside").is_err()); + drop(owner); + other.scan().unwrap(); + assert_eq!(other.inventory().len(), 1); + other.discard(&id).unwrap(); + assert!(!t.0.join(id).exists(), "discard must release the record slot"); +} + +#[test] +fn new_draft_restarts_restores_unsaved_and_explicitly_discards() { + let t = Temp::new(); + let mut s = session(&t.0); + s.run("text.insert", &json!({"text": "Unsaved synthetic draft"})).unwrap(); + save_now(&mut s); + assert!(s.recovery_wait().is_none(), "unchanged documents must not schedule idle work"); + let expected = s.doc.clone(); + drop(s); + let mut s = session(&t.0); + let id = s.recovery_documents()[0].id.clone(); + s.run("file.recover", &json!({"restore": id})).unwrap(); + settle(&mut s); + assert_eq!(s.doc, expected); + assert!(s.dirty && s.path.is_none()); + assert!(s.recovery_documents().is_empty(), "the restored document keeps the copy"); + // A crash leaves the restored document's copy, in the same record. + drop(s); + let mut s = session(&t.0); + assert_eq!(s.recovery_documents().len(), 1); + s.run("file.recover", &json!({"discard": id})).unwrap(); + settle(&mut s); + save_now(&mut s); + assert!(s.recovery_documents().is_empty()); + assert!(records(&t.0).is_empty()); +} + +#[test] +fn existing_file_is_unchanged_and_successful_save_removes_copy() { + let t = Temp::new(); + let file = t.0.join("original.docx"); + let root = t.0.join("profile"); + crate::io::save_path(&file, &Document::from_text("Original business note")).unwrap(); + let original = fs::read(&file).unwrap(); + let mut s = session(&root); + s.run("file.open", &json!({"path": file})).unwrap(); + s.run("text.insert", &json!({"text": "Unsaved edit. "})).unwrap(); + save_now(&mut s); + let expected = s.doc.clone(); + drop(s); + let mut s = session(&root); + let id = s.recovery_documents()[0].id.clone(); + s.run("file.recover", &json!({"restore": id})).unwrap(); + settle(&mut s); + assert_eq!(s.doc, expected); + assert!(s.path.is_none() && s.dirty); + assert_eq!(fs::read(&file).unwrap(), original); + let saved = t.0.join("recovered.json"); + s.run("file.save", &json!({"path": saved})).unwrap(); + settle(&mut s); + assert!(records(&root).is_empty()); + assert_eq!(fs::read(file).unwrap(), original); +} + +#[test] +fn password_barrier_clears_in_flight_plaintext_and_blocks_new_copies() { + let t = Temp::new(); + let mut s = session(&t.0); + s.run("text.insert", &json!({"text": "Confidential synthetic draft"})).unwrap(); + due_now(&mut s); + s.recovery_tick(); + assert!(s.recovery_busy()); + s.run("file.encrypt", &json!({"password": "synthetic-password"})).unwrap(); + assert!(records(&t.0).is_empty()); + s.run("text.insert", &json!({"text": "More confidential edits"})).unwrap(); + save_now(&mut s); + assert!(fs::read_dir(&t.0).unwrap().next().is_none()); + // Removing protection makes future recovery possible again. + s.run("file.encrypt", &json!({"password": null})).unwrap(); + save_now(&mut s); + assert_eq!(records(&t.0).len(), 1); +} + +#[test] +fn failed_password_cleanup_rejects_protection_and_preserves_snapshot() { + let t = Temp::new(); + let mut s = session(&t.0); + s.run("text.insert", &json!({"text": "Synthetic text"})).unwrap(); + save_now(&mut s); + let id = records(&t.0).remove(0); + let dir = t.0.join(id); + // An unknown file prevents removal: don't report that plaintext was purged. + let first = Store::files(&dir).unwrap()[0].clone(); + let bytes = fs::read(&first).unwrap(); + fs::write(dir.join("unexpected"), b"unexpected").unwrap(); + assert!(s.run("file.encrypt", &json!({"password": "synthetic-password"})).is_err()); + assert!(s.password.is_none()); + assert_eq!(fs::read(first).unwrap(), bytes); +} + +#[test] +fn delayed_restore_cannot_replace_new_edits() { + let t = Temp::new(); + let mut s = session(&t.0); + s.run("text.insert", &json!({"text": "Saved draft"})).unwrap(); + save_now(&mut s); + drop(s); + let mut s = session(&t.0); + let id = s.recovery_documents()[0].id.clone(); + s.run("file.recover", &json!({"restore": id})).unwrap(); + s.run("text.insert", &json!({"text": "New work"})).unwrap(); + settle(&mut s); + assert_eq!(s.doc.plain_text(StoryRef::Body), "New work"); + assert!(s.recovery_error().unwrap().contains("document changed")); +} + +/// A restore asked for in one document lands in that document, even when another document +/// is active while the copy loads. +#[test] +fn delayed_restore_lands_in_the_document_that_asked_for_it() { + let t = Temp::new(); + let mut s = session(&t.0); + s.run("text.insert", &json!({"text": "Saved draft"})).unwrap(); + save_now(&mut s); + drop(s); + let mut s = session(&t.0); + let first = s.document_id(); + let id = s.recovery_documents()[0].id.clone(); + s.run("file.recover", &json!({"restore": id})).unwrap(); + s.run("file.new", &json!({})).unwrap(); + let second = s.document_id(); + s.run("text.insert", &json!({"text": "Other work"})).unwrap(); + settle(&mut s); + assert_eq!(s.doc.plain_text(StoryRef::Body), "Other work", "the active document is left alone"); + s.activate(first).unwrap(); + settle(&mut s); + assert_eq!(s.doc.plain_text(StoryRef::Body), "Saved draft"); + assert_eq!(s.with_document(second, |s| s.doc.plain_text(StoryRef::Body)).unwrap(), "Other work"); + assert_eq!(s.documents().len(), 2); +} + +#[test] +fn opening_protected_file_and_password_save_never_leave_plaintext_copies() { + let t = Temp::new(); + let original = t.0.join("protected.docx"); + let root = t.0.join("profile"); + crate::io::save_path_with( + &original, + &Document::from_text("Synthetic confidential note"), + Some("synthetic-password"), + &crate::io::ExportOptions::default(), + ) + .unwrap(); + let bytes = fs::read(&original).unwrap(); + let mut s = session(&root); + s.run("file.open", &json!({"path": original, "password": "synthetic-password"})).unwrap(); + s.run("text.insert", &json!({"text": "Confidential edit"})).unwrap(); + save_now(&mut s); + assert!(fs::read_dir(&root).unwrap().next().is_none()); + assert_eq!(fs::read(&original).unwrap(), bytes); + s.run("file.new", &json!({})).unwrap(); + s.run("text.insert", &json!({"text": "New confidential draft"})).unwrap(); + save_now(&mut s); + assert_eq!(records(&root).len(), 1); + let saved = t.0.join("saved.docx"); + s.run("file.save", &json!({"path": saved, "password": "synthetic-password"})).unwrap(); + settle(&mut s); + assert!(fs::read_dir(root).unwrap().next().is_none()); + assert_eq!(fs::read(original).unwrap(), bytes); +} + +/// Each open document keeps its own recovery copy: switching between documents doesn't start +/// a new copy of either. +#[test] +fn each_open_document_keeps_its_own_copy() { + let t = Temp::new(); + let mut s = session(&t.0); + let first = s.document_id(); + s.run("text.insert", &json!({"text": "First synthetic draft"})).unwrap(); + save_now(&mut s); + s.run("file.new", &json!({})).unwrap(); + settle(&mut s); + s.run("text.insert", &json!({"text": "Second synthetic draft"})).unwrap(); + save_now(&mut s); + s.activate(first).unwrap(); + s.run("text.insert", &json!({"text": ", edited"})).unwrap(); + save_now(&mut s); + assert_eq!(records(&t.0).len(), 2, "one copy per document"); + assert!(s.recovery_documents().is_empty(), "the copies open documents keep are not listed"); +} + +#[test] +fn protection_after_document_switch_does_not_discard_another_draft() { + let t = Temp::new(); + let mut s = session(&t.0); + s.run("text.insert", &json!({"text": "First synthetic draft"})).unwrap(); + save_now(&mut s); + let first = s.document_id(); + s.run("file.new", &json!({})).unwrap(); + // Protection can be commanded before the host's next recovery tick. + s.run("file.encrypt", &json!({"password": "synthetic-password"})).unwrap(); + settle(&mut s); + assert!(s.with_document(first, |s| s.recovery_slot.id.is_some()).unwrap()); + assert_eq!(records(&t.0).len(), 1, "the first draft is kept"); +} + +/// Two dirty documents, one of them parked since before its first copy: both get copies once +/// their interval has passed, without being made active. +#[test] +fn parked_documents_keep_their_copies() { + let t = Temp::new(); + let mut s = session(&t.0); + s.run("text.insert", &json!({"text": "Parked synthetic draft"})).unwrap(); + let parked = s.document_id(); + s.run("file.new", &json!({})).unwrap(); + s.run("text.insert", &json!({"text": "Active synthetic draft"})).unwrap(); + let active = s.document_id(); + assert_ne!(parked, active); + save_now(&mut s); + assert_eq!(s.document_id(), active, "the parked document was not made active"); + assert_eq!(records(&t.0).len(), 2, "both documents have copies"); + // An edit to the parked document reaches its copy too. + s.with_document(parked, |s| s.run("text.insert", &json!({"text": ", edited"})).unwrap()).unwrap(); + let id = s.with_document(parked, |s| s.recovery_slot.id.clone()).unwrap().unwrap(); + save_now(&mut s); + drop(s); + let mut store = Store::new(t.0.clone()).unwrap(); + store.scan().unwrap(); + assert_eq!(load_retrying(&mut store, &id).document.plain_text(StoryRef::Body), "Parked synthetic draft, edited"); +} + +/// Closing a dirty document and saving another delete both their copies; a third document +/// keeps its own. +#[test] +fn closing_or_saving_a_document_deletes_only_its_copy() { + let t = Temp::new(); + let files = Temp::new(); + let mut s = session(&t.0); + let mut ids = Vec::new(); + for text in ["Closed draft", "Saved draft", "Kept draft"] { + if !ids.is_empty() { + s.run("file.new", &json!({})).unwrap(); + } + s.run("text.insert", &json!({"text": text})).unwrap(); + ids.push(s.document_id()); + } + save_now(&mut s); + assert_eq!(records(&t.0).len(), 3); + let kept = s.recovery_slot.id.clone().unwrap(); + s.close_document(ids[0]).unwrap(); + s.with_document(ids[1], |s| s.run("file.save", &json!({"path": files.0.join("saved.json")})).unwrap()).unwrap(); + settle(&mut s); + assert_eq!(records(&t.0), [kept], "only the open dirty document's copy is left"); + assert!(s.recovery_documents().is_empty()); +} + +/// A crash with two dirty documents leaves two copies; the next run lists both and restores +/// each into a document of its own, beside the work already there. +#[test] +fn a_crash_leaves_every_dirty_document_s_copy() { + let t = Temp::new(); + let mut s = session(&t.0); + s.run("text.insert", &json!({"text": "First synthetic draft"})).unwrap(); + s.run("file.new", &json!({})).unwrap(); + s.run("text.insert", &json!({"text": "Second synthetic draft"})).unwrap(); + save_now(&mut s); + drop(s); // no finish_recovery: the process died + let mut s = session(&t.0); + let copies = s.recovery_documents(); + assert_eq!(copies.len(), 2); + s.run("text.insert", &json!({"text": "Work in progress"})).unwrap(); + let work = s.document_id(); + for copy in &copies { + s.run("file.recover", &json!({"restore": copy.id})).unwrap(); + settle(&mut s); + } + assert!(s.recovery_documents().is_empty(), "the restored documents keep their copies"); + let mut texts: Vec = s.documents().iter().map(|d| s.with_document(d.id, |s| s.doc.plain_text(StoryRef::Body)).unwrap()).collect(); + texts.sort(); + assert_eq!(texts, ["First synthetic draft", "Second synthetic draft", "Work in progress"]); + assert!(s.with_document(work, |s| s.dirty).unwrap()); +} + +/// A clean exit deletes the copies of documents saved or closed since their last copy, the +/// parked ones' too, and keeps the dirty ones'. +#[test] +fn finish_recovery_covers_every_open_document() { + let t = Temp::new(); + let files = Temp::new(); + let mut s = session(&t.0); + let mut ids = Vec::new(); + for text in ["Saved draft", "Closed draft", "Dirty draft"] { + if !ids.is_empty() { + s.run("file.new", &json!({})).unwrap(); + } + s.run("text.insert", &json!({"text": text})).unwrap(); + ids.push(s.document_id()); + } + save_now(&mut s); + assert_eq!(records(&t.0).len(), 3); + let dirty = s.recovery_slot.id.clone().unwrap(); + // No tick between these and the exit. + s.with_document(ids[0], |s| s.run("file.save", &json!({"path": files.0.join("saved.json")})).unwrap()).unwrap(); + s.close_document(ids[1]).unwrap(); + s.finish_recovery(); + assert_eq!(records(&t.0), [dirty]); +} diff --git a/crates/engine/src/ribbon.rs b/crates/engine/src/ribbon.rs index f66e9ce824..49cb8793d9 100644 --- a/crates/engine/src/ribbon.rs +++ b/crates/engine/src/ribbon.rs @@ -1,506 +1,506 @@ -//! Customize Ribbon and the Quick Access Toolbar (`tools.customizeRibbon`): which ribbon tabs are -//! shown and in what order, custom tabs, custom groups (on built-in or custom tabs) holding any -//! commands, and the commands on the Quick Access Toolbar. -//! -//! Built-in groups aren't editable: a built-in tab only gains custom groups after its own. The -//! layout is saved with the front end's preferences; reading a saved layout never fails: junk, -//! duplicates and commands this version doesn't have are dropped. - -use std::borrow::Cow; - -use serde::{Deserialize, Serialize}; -use serde_json::Value; - -use crate::Registry; - -/// The ribbon's tabs in their default order. `File` opens Backstage: it is always first and -/// isn't part of the customizable list. -pub const TABS: [&str; 13] = - ["File", "Home", "Insert", "Draw", "Design", "Layout", "References", "Mailings", "Review", "View", "Developer", "Zotero", "Help"]; -/// Tabs that come up with the selection; custom tabs can't take their names. -pub const CONTEXTUAL_TABS: [&str; 7] = - ["Table Design", "Table Layout", "Picture Format", "Shape Format", "Equation", "Chart Design", "SmartArt Design"]; -/// The Quick Access Toolbar's commands until the user changes them. -pub const DEFAULT_QAT: [&str; 3] = ["file.save", "edit.undo", "edit.redo"]; - -/// Limits on what a (possibly hand-edited) saved layout can hold. -const MAX_TABS: usize = 64; -const MAX_GROUPS: usize = 64; -const MAX_COMMANDS: usize = 200; -const MAX_NAME: usize = 64; -const MAX_ID: usize = 128; - -/// A ribbon tab in the customized order: a built-in tab (which can be hidden and gain custom -/// groups) or a custom one. -#[derive(Clone, Debug, PartialEq, Serialize)] -pub struct TabEntry { - pub name: String, - #[serde(skip_serializing_if = "is_false")] - pub custom: bool, - #[serde(skip_serializing_if = "is_false")] - pub hidden: bool, - /// Custom groups, after a built-in tab's own groups. - #[serde(skip_serializing_if = "Vec::is_empty")] - pub groups: Vec, -} - -/// A custom group: a name and the commands it shows, in order. -#[derive(Clone, Debug, PartialEq, Serialize)] -pub struct CustomGroup { - pub name: String, - pub commands: Vec, -} - -/// The user's ribbon and Quick Access Toolbar. -#[derive(Clone, Debug, Default, PartialEq, Serialize)] -pub struct RibbonLayout { - /// Every tab but File, in order, once the ribbon is customized; empty for the default ribbon. - #[serde(skip_serializing_if = "Vec::is_empty")] - pub tabs: Vec, - /// The Quick Access Toolbar's commands; `None` for the default ones ([`DEFAULT_QAT`]). - #[serde(skip_serializing_if = "Option::is_none")] - pub qat: Option>, -} - -fn is_false(b: &bool) -> bool { - !*b -} - -/// A name the user typed: trimmed, without control characters, at most [`MAX_NAME`] characters. -fn clean_name(s: &str) -> Option { - let s: String = s.trim().chars().filter(|c| !c.is_control()).take(MAX_NAME).collect(); - let s = s.trim().to_string(); - (!s.is_empty()).then_some(s) -} - -fn same(a: &str, b: &str) -> bool { - a.to_lowercase() == b.to_lowercase() -} - -fn built_in(name: &str) -> bool { - TABS.iter().skip(1).any(|t| *t == name) -} - -/// Names a custom tab can't have. -fn reserved(name: &str) -> bool { - TABS.iter().chain(CONTEXTUAL_TABS.iter()).any(|t| same(t, name)) -} - -fn default_tabs() -> Vec { - TABS.iter().skip(1).map(|t| TabEntry { name: (*t).to_string(), custom: false, hidden: false, groups: Vec::new() }).collect() -} - -/// `base`, or `base 2`, `base 3`… whichever `taken` doesn't have yet. -fn unique(base: &str, taken: impl Fn(&str) -> bool) -> String { - if !taken(base) { - return base.to_string(); - } - (2..1000).map(|n| format!("{base} {n}")).find(|n| !taken(n)).unwrap_or_else(|| base.to_string()) -} - -/// Move the item at `i` by `by` places (clamped to the list). Returns its new index. -fn shift(v: &mut [T], i: usize, by: i64) -> usize { - let last = v.len().saturating_sub(1) as i64; - let to = (i as i64).saturating_add(by).clamp(0, last) as usize; - if to < i { - if let Some(s) = v.get_mut(to..=i) { - s.rotate_right(1); - } - } else if let Some(s) = v.get_mut(i..=to) { - s.rotate_left(1); - } - to -} - -impl RibbonLayout { - /// Read a saved layout, keeping only well-formed entries (never fails). - pub fn from_value(v: &Value) -> RibbonLayout { - let mut out = RibbonLayout::default(); - for t in v.get("tabs").and_then(Value::as_array).into_iter().flatten().take(MAX_TABS) { - let Some(name) = t.get("name").and_then(Value::as_str).and_then(clean_name) else { continue }; - let custom = t.get("custom").and_then(Value::as_bool).unwrap_or(false); - let hidden = t.get("hidden").and_then(Value::as_bool).unwrap_or(false); - let mut groups: Vec = Vec::new(); - for g in t.get("groups").and_then(Value::as_array).into_iter().flatten().take(MAX_GROUPS) { - let Some(gname) = g.get("name").and_then(Value::as_str).and_then(clean_name) else { continue }; - if groups.iter().any(|x| same(&x.name, &gname)) { - continue; - } - let mut commands: Vec = Vec::new(); - for id in g.get("commands").and_then(Value::as_array).into_iter().flatten().take(MAX_COMMANDS).filter_map(Value::as_str) { - if !id.is_empty() && id.len() <= MAX_ID && !commands.iter().any(|c| c == id) { - commands.push(id.to_string()); - } - } - groups.push(CustomGroup { name: gname, commands }); - } - out.tabs.push(TabEntry { name, custom, hidden, groups }); - } - if let Some(q) = v.get("qat").and_then(Value::as_array) { - let mut ids: Vec = Vec::new(); - for id in q.iter().take(MAX_COMMANDS).filter_map(Value::as_str) { - if !id.is_empty() && id.len() <= MAX_ID && !ids.iter().any(|c| c == id) { - ids.push(id.to_string()); - } - } - out.qat = Some(ids); - } - out.normalize(); - out - } - - /// Keep the tab list consistent: built-in tabs once each (missing ones back in their default - /// place), custom tabs with unique, unreserved names; the default ribbon is stored as empty. - fn normalize(&mut self) { - if self.tabs.is_empty() { - return; - } - let mut tabs: Vec = Vec::new(); - for t in std::mem::take(&mut self.tabs) { - let ok = if t.custom { !reserved(&t.name) } else { built_in(&t.name) }; - if ok && !tabs.iter().any(|x| same(&x.name, &t.name)) { - tabs.push(t); - } - } - // Built-in tabs a saved layout lacks (a newer version's) go after the tab before them. - for (i, name) in TABS.iter().enumerate().skip(1) { - if tabs.iter().any(|t| !t.custom && t.name == *name) { - continue; - } - let at = TABS.get(1..i).and_then(|before| before.iter().rev().find_map(|b| tabs.iter().position(|t| !t.custom && t.name == *b))); - let entry = TabEntry { name: (*name).to_string(), custom: false, hidden: false, groups: Vec::new() }; - tabs.insert(at.map_or(0, |p| p + 1), entry); - } - self.tabs = if tabs == default_tabs() { Vec::new() } else { tabs }; - } - - /// The tabs in order (File excluded), customized or not. - pub fn tabs(&self) -> Cow<'_, [TabEntry]> { - if self.tabs.is_empty() { Cow::Owned(default_tabs()) } else { Cow::Borrowed(&self.tabs) } - } - - /// Whether the ribbon is the default one. - pub fn ribbon_is_default(&self) -> bool { - self.tabs.is_empty() - } - - /// The Quick Access Toolbar's commands. - pub fn qat(&self) -> Vec<&str> { - match &self.qat { - Some(q) => q.iter().map(String::as_str).collect(), - None => DEFAULT_QAT.to_vec(), - } - } - - /// Drop commands that don't exist (saved by another version, or junk). - pub fn retain_known(&mut self, reg: &Registry) { - for t in &mut self.tabs { - for g in &mut t.groups { - g.commands.retain(|id| reg.get(id).is_some()); - } - } - if let Some(q) = &mut self.qat { - q.retain(|id| reg.get(id).is_some()); - } - } - - /// Edit the tab list (customizing a default ribbon first), then tidy it up. - fn edit(&mut self, f: impl FnOnce(&mut Vec) -> Result) -> Result { - let mut tabs = self.tabs().into_owned(); - let r = f(&mut tabs)?; - self.tabs = tabs; - self.normalize(); - Ok(r) - } - - fn tab_index(tabs: &[TabEntry], name: &str) -> Result { - tabs.iter().position(|t| t.name == name).ok_or_else(|| format!("no tab `{name}`")) - } - - fn group_index(tab: &TabEntry, name: &str) -> Result { - tab.groups.iter().position(|g| g.name == name).ok_or_else(|| format!("tab `{}` has no custom group `{name}`", tab.name)) - } - - /// Show or hide a tab. - pub fn set_hidden(&mut self, tab: &str, hidden: bool) -> Result<(), String> { - self.edit(|tabs| { - let i = Self::tab_index(tabs, tab)?; - if let Some(t) = tabs.get_mut(i) { - t.hidden = hidden; - } - Ok(()) - }) - } - - /// Add a custom tab (with one empty group) after `after`, or at the end. Returns its name. - pub fn new_tab(&mut self, name: Option<&str>, after: Option<&str>) -> Result { - let name = match name { - Some(n) => clean_name(n).ok_or("a tab needs a name")?, - None => "New Tab".to_string(), - }; - self.edit(|tabs| { - if tabs.len() >= MAX_TABS { - return Err("too many tabs".into()); - } - let name = unique(&name, |n| reserved(n) || tabs.iter().any(|t| same(&t.name, n))); - let at = match after { - Some(a) => Self::tab_index(tabs, a)? + 1, - None => tabs.len(), - }; - let group = CustomGroup { name: "New Group".into(), commands: Vec::new() }; - tabs.insert(at, TabEntry { name: name.clone(), custom: true, hidden: false, groups: vec![group] }); - Ok(name) - }) - } - - /// Add a custom group at the end of a tab. Returns its name. - pub fn new_group(&mut self, tab: &str, name: Option<&str>) -> Result { - let name = match name { - Some(n) => clean_name(n).ok_or("a group needs a name")?, - None => "New Group".to_string(), - }; - self.edit(|tabs| { - let i = Self::tab_index(tabs, tab)?; - let t = tabs.get_mut(i).ok_or("no such tab")?; - if t.groups.len() >= MAX_GROUPS { - return Err("too many groups on this tab".into()); - } - let name = unique(&name, |n| t.groups.iter().any(|g| same(&g.name, n))); - t.groups.push(CustomGroup { name: name.clone(), commands: Vec::new() }); - Ok(name) - }) - } - - /// Add a command to a custom group (after `after`, or at the end). False when it's already there. - pub fn add(&mut self, reg: &Registry, tab: &str, group: &str, id: &str, after: Option<&str>) -> Result { - if reg.get(id).is_none() { - return Err(format!("unknown command `{id}`")); - } - self.edit(|tabs| { - let i = Self::tab_index(tabs, tab)?; - let t = tabs.get_mut(i).ok_or("no such tab")?; - let j = Self::group_index(t, group)?; - let g = t.groups.get_mut(j).ok_or("no such group")?; - if g.commands.iter().any(|c| c == id) { - return Ok(false); - } - if g.commands.len() >= MAX_COMMANDS { - return Err("too many commands in this group".into()); - } - let at = after.and_then(|a| g.commands.iter().position(|c| c == a)).map_or(g.commands.len(), |p| p + 1); - g.commands.insert(at, id.to_string()); - Ok(true) - }) - } - - /// Remove a command from a custom group, a custom group, or a custom tab. - pub fn remove(&mut self, tab: &str, group: Option<&str>, command: Option<&str>) -> Result<(), String> { - self.edit(|tabs| { - let i = Self::tab_index(tabs, tab)?; - let Some(group) = group else { - if !tabs.get(i).is_some_and(|t| t.custom) { - return Err(format!("`{tab}` is a built-in tab: hide it instead")); - } - tabs.remove(i); - return Ok(()); - }; - let t = tabs.get_mut(i).ok_or("no such tab")?; - let j = Self::group_index(t, group)?; - match command { - None => { - t.groups.remove(j); - } - Some(id) => { - let g = t.groups.get_mut(j).ok_or("no such group")?; - let k = g.commands.iter().position(|c| c == id).ok_or_else(|| format!("`{group}` doesn't have `{id}`"))?; - g.commands.remove(k); - } - } - Ok(()) - }) - } - - /// Rename a custom tab or a custom group. Returns the new name. - pub fn rename(&mut self, tab: &str, group: Option<&str>, to: &str) -> Result { - let to = clean_name(to).ok_or("the new name is empty")?; - self.edit(|tabs| { - let i = Self::tab_index(tabs, tab)?; - match group { - None => { - if !tabs.get(i).is_some_and(|t| t.custom) { - return Err(format!("`{tab}` is a built-in tab and keeps its name")); - } - if reserved(&to) || tabs.iter().enumerate().any(|(k, t)| k != i && same(&t.name, &to)) { - return Err(format!("there is already a tab named `{to}`")); - } - if let Some(t) = tabs.get_mut(i) { - t.name = to.clone(); - } - } - Some(group) => { - let t = tabs.get_mut(i).ok_or("no such tab")?; - let j = Self::group_index(t, group)?; - if t.groups.iter().enumerate().any(|(k, g)| k != j && same(&g.name, &to)) { - return Err(format!("`{tab}` already has a group named `{to}`")); - } - if let Some(g) = t.groups.get_mut(j) { - g.name = to.clone(); - } - } - } - Ok(to) - }) - } - - /// Move a tab, a custom group (within its tab) or a command (within its group) by `by` places. - pub fn move_by(&mut self, tab: &str, group: Option<&str>, command: Option<&str>, by: i64) -> Result<(), String> { - self.edit(|tabs| { - let i = Self::tab_index(tabs, tab)?; - let Some(group) = group else { - shift(tabs, i, by); - return Ok(()); - }; - let t = tabs.get_mut(i).ok_or("no such tab")?; - let j = Self::group_index(t, group)?; - match command { - None => { - shift(&mut t.groups, j, by); - } - Some(id) => { - let g = t.groups.get_mut(j).ok_or("no such group")?; - let k = g.commands.iter().position(|c| c == id).ok_or_else(|| format!("`{group}` doesn't have `{id}`"))?; - shift(&mut g.commands, k, by); - } - } - Ok(()) - }) - } - - /// Back to the default ribbon (the Quick Access Toolbar stays). - pub fn reset_ribbon(&mut self) { - self.tabs.clear(); - } - - /// Back to the default Quick Access Toolbar. - pub fn reset_qat(&mut self) { - self.qat = None; - } - - /// Put a command on the Quick Access Toolbar (after `after`, or at the end). False when it's - /// already there. - pub fn qat_add(&mut self, reg: &Registry, id: &str, after: Option<&str>) -> Result { - if reg.get(id).is_none() { - return Err(format!("unknown command `{id}`")); - } - let mut q: Vec = self.qat().into_iter().map(str::to_string).collect(); - if q.iter().any(|c| c == id) { - return Ok(false); - } - if q.len() >= MAX_COMMANDS { - return Err("the Quick Access Toolbar is full".into()); - } - let at = after.and_then(|a| q.iter().position(|c| c == a)).map_or(q.len(), |p| p + 1); - q.insert(at, id.to_string()); - self.qat = Some(q); - Ok(true) - } - - /// Take a command off the Quick Access Toolbar. False when it wasn't there. - pub fn qat_remove(&mut self, id: &str) -> bool { - let mut q: Vec = self.qat().into_iter().map(str::to_string).collect(); - let Some(i) = q.iter().position(|c| c == id) else { return false }; - q.remove(i); - self.qat = Some(q); - true - } - - /// Move a Quick Access Toolbar command by `by` places. - pub fn qat_move(&mut self, id: &str, by: i64) -> Result<(), String> { - let mut q: Vec = self.qat().into_iter().map(str::to_string).collect(); - let i = q.iter().position(|c| c == id).ok_or_else(|| format!("`{id}` isn't on the Quick Access Toolbar"))?; - shift(&mut q, i, by); - self.qat = Some(q); - Ok(()) - } -} - -impl<'de> Deserialize<'de> for RibbonLayout { - /// Lenient: any JSON reads (junk dropped), so a damaged layout never loses the other - /// preferences saved next to it. - fn deserialize>(d: D) -> Result { - Ok(RibbonLayout::from_value(&Value::deserialize(d)?)) - } -} - -#[cfg(test)] -mod tests { - use serde_json::json; - - use super::*; - - /// #379: custom tabs and groups, hidden and moved tabs, and the Quick Access Toolbar, changed - /// through the command (as agents do) without opening the dialog. - #[test] - fn layout_changes_through_the_command() { - let mut s = crate::Session::new(wordcraft_doc::Document::new()); - let mut run = |v: Value| s.run("tools.customizeRibbon", &v); - let r = run(json!({"newTab": {"name": "Mine", "after": "Home"}, "hide": "Draw"})).unwrap(); - assert_eq!(r["tab"], "Mine"); - run(json!({"add": {"tab": "Mine", "group": "New Group", "command": "format.bold"}})).unwrap(); - run(json!({"newGroup": {"tab": "Insert", "name": "Extras"}})).unwrap(); - run(json!({"add": {"tab": "Insert", "group": "Extras", "command": "edit.copy"}})).unwrap(); - run(json!({"rename": {"tab": "Mine", "to": "Writing"}})).unwrap(); - let r = run(json!({"move": {"tab": "Writing", "by": -5}, "qatAdd": "format.bold", "qatRemove": "file.save"})).unwrap(); - let tabs = r["tabs"].as_array().unwrap(); - assert_eq!(tabs[0]["name"], "Writing"); - assert_eq!(tabs[0]["groups"][0]["commands"], json!(["format.bold"])); - assert!(tabs.iter().any(|t| t["name"] == "Draw" && t["hidden"] == true)); - assert_eq!(r["qat"], json!(["edit.undo", "edit.redo", "format.bold"])); - // Built-in tabs keep their names and can only be hidden; reserved and unknown names fail. - assert!(run(json!({"rename": {"tab": "Home", "to": "Start"}})).is_err()); - assert!(run(json!({"remove": {"tab": "Home"}})).is_err()); - assert!(run(json!({"rename": {"tab": "Writing", "to": "insert"}})).is_err()); - assert!(run(json!({"add": {"tab": "Writing", "group": "New Group", "command": "no.such"}})).is_err()); - assert!(run(json!({"move": {"tab": "Nope", "by": 1}})).is_err()); - assert!(s.ui_requests.is_empty(), "programmatic calls never open dialogs"); - s.run("tools.customizeRibbon", &json!({"reset": true})).unwrap(); - assert_eq!(s.ribbon, RibbonLayout::default()); - s.run("tools.customizeRibbon", &json!({})).unwrap(); - assert_eq!(s.ui_requests.last(), Some(&json!({"open": "customizeRibbon"}))); - } - - #[test] - fn saved_layouts_round_trip_and_junk_is_ignored() { - let reg = crate::cmd::registry(); - let mut l = RibbonLayout::default(); - l.new_tab(None, None).unwrap(); - l.add(®, "New Tab", "New Group", "format.italic", None).unwrap(); - l.set_hidden("Mailings", true).unwrap(); - l.qat_add(®, "edit.repeat", None).unwrap(); - let back: RibbonLayout = serde_json::from_str(&serde_json::to_string(&l).unwrap()).unwrap(); - assert_eq!(back, l); - - let junk = json!({ - "tabs": [ - {"name": "Home", "groups": [{"name": "G", "commands": ["format.bold", "gone.command", 7, "format.bold"]}]}, - {"name": "Home"}, - {"name": "Bogus"}, - {"name": "Review", "custom": true}, - {"name": " "}, - {"name": "Mine", "custom": true, "hidden": "yes", "groups": "x"}, - 5 - ], - "qat": ["edit.undo", "gone.command", 3, "edit.undo"] - }); - let mut j: RibbonLayout = serde_json::from_value(junk).unwrap(); - j.retain_known(®); - let tabs = j.tabs(); - assert_eq!(tabs.len(), TABS.len(), "11 built-in tabs and Mine: {tabs:?}"); - assert_eq!(tabs[0].groups[0].commands, ["format.bold"]); - assert!(tabs.iter().any(|t| t.custom && t.name == "Mine" && !t.hidden)); - assert_eq!(j.qat(), ["edit.undo"]); - for v in [json!(null), json!(5), json!("x"), json!({"tabs": {"a": 1}, "qat": "file.save"})] { - assert_eq!(serde_json::from_value::(v).unwrap(), RibbonLayout::default()); - } - } -} +//! Customize Ribbon and the Quick Access Toolbar (`tools.customizeRibbon`): which ribbon tabs are +//! shown and in what order, custom tabs, custom groups (on built-in or custom tabs) holding any +//! commands, and the commands on the Quick Access Toolbar. +//! +//! Built-in groups aren't editable: a built-in tab only gains custom groups after its own. The +//! layout is saved with the front end's preferences; reading a saved layout never fails: junk, +//! duplicates and commands this version doesn't have are dropped. + +use std::borrow::Cow; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::Registry; + +/// The ribbon's tabs in their default order. `File` opens Backstage: it is always first and +/// isn't part of the customizable list. +pub const TABS: [&str; 13] = + ["File", "Home", "Insert", "Draw", "Design", "Layout", "References", "Mailings", "Review", "View", "Developer", "Zotero", "Help"]; +/// Tabs that come up with the selection; custom tabs can't take their names. +pub const CONTEXTUAL_TABS: [&str; 7] = + ["Table Design", "Table Layout", "Picture Format", "Shape Format", "Equation", "Chart Design", "SmartArt Design"]; +/// The Quick Access Toolbar's commands until the user changes them. +pub const DEFAULT_QAT: [&str; 3] = ["file.save", "edit.undo", "edit.redo"]; + +/// Limits on what a (possibly hand-edited) saved layout can hold. +const MAX_TABS: usize = 64; +const MAX_GROUPS: usize = 64; +const MAX_COMMANDS: usize = 200; +const MAX_NAME: usize = 64; +const MAX_ID: usize = 128; + +/// A ribbon tab in the customized order: a built-in tab (which can be hidden and gain custom +/// groups) or a custom one. +#[derive(Clone, Debug, PartialEq, Serialize)] +pub struct TabEntry { + pub name: String, + #[serde(skip_serializing_if = "is_false")] + pub custom: bool, + #[serde(skip_serializing_if = "is_false")] + pub hidden: bool, + /// Custom groups, after a built-in tab's own groups. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub groups: Vec, +} + +/// A custom group: a name and the commands it shows, in order. +#[derive(Clone, Debug, PartialEq, Serialize)] +pub struct CustomGroup { + pub name: String, + pub commands: Vec, +} + +/// The user's ribbon and Quick Access Toolbar. +#[derive(Clone, Debug, Default, PartialEq, Serialize)] +pub struct RibbonLayout { + /// Every tab but File, in order, once the ribbon is customized; empty for the default ribbon. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub tabs: Vec, + /// The Quick Access Toolbar's commands; `None` for the default ones ([`DEFAULT_QAT`]). + #[serde(skip_serializing_if = "Option::is_none")] + pub qat: Option>, +} + +fn is_false(b: &bool) -> bool { + !*b +} + +/// A name the user typed: trimmed, without control characters, at most [`MAX_NAME`] characters. +fn clean_name(s: &str) -> Option { + let s: String = s.trim().chars().filter(|c| !c.is_control()).take(MAX_NAME).collect(); + let s = s.trim().to_string(); + (!s.is_empty()).then_some(s) +} + +fn same(a: &str, b: &str) -> bool { + a.to_lowercase() == b.to_lowercase() +} + +fn built_in(name: &str) -> bool { + TABS.iter().skip(1).any(|t| *t == name) +} + +/// Names a custom tab can't have. +fn reserved(name: &str) -> bool { + TABS.iter().chain(CONTEXTUAL_TABS.iter()).any(|t| same(t, name)) +} + +fn default_tabs() -> Vec { + TABS.iter().skip(1).map(|t| TabEntry { name: (*t).to_string(), custom: false, hidden: false, groups: Vec::new() }).collect() +} + +/// `base`, or `base 2`, `base 3`… whichever `taken` doesn't have yet. +fn unique(base: &str, taken: impl Fn(&str) -> bool) -> String { + if !taken(base) { + return base.to_string(); + } + (2..1000).map(|n| format!("{base} {n}")).find(|n| !taken(n)).unwrap_or_else(|| base.to_string()) +} + +/// Move the item at `i` by `by` places (clamped to the list). Returns its new index. +fn shift(v: &mut [T], i: usize, by: i64) -> usize { + let last = v.len().saturating_sub(1) as i64; + let to = (i as i64).saturating_add(by).clamp(0, last) as usize; + if to < i { + if let Some(s) = v.get_mut(to..=i) { + s.rotate_right(1); + } + } else if let Some(s) = v.get_mut(i..=to) { + s.rotate_left(1); + } + to +} + +impl RibbonLayout { + /// Read a saved layout, keeping only well-formed entries (never fails). + pub fn from_value(v: &Value) -> RibbonLayout { + let mut out = RibbonLayout::default(); + for t in v.get("tabs").and_then(Value::as_array).into_iter().flatten().take(MAX_TABS) { + let Some(name) = t.get("name").and_then(Value::as_str).and_then(clean_name) else { continue }; + let custom = t.get("custom").and_then(Value::as_bool).unwrap_or(false); + let hidden = t.get("hidden").and_then(Value::as_bool).unwrap_or(false); + let mut groups: Vec = Vec::new(); + for g in t.get("groups").and_then(Value::as_array).into_iter().flatten().take(MAX_GROUPS) { + let Some(gname) = g.get("name").and_then(Value::as_str).and_then(clean_name) else { continue }; + if groups.iter().any(|x| same(&x.name, &gname)) { + continue; + } + let mut commands: Vec = Vec::new(); + for id in g.get("commands").and_then(Value::as_array).into_iter().flatten().take(MAX_COMMANDS).filter_map(Value::as_str) { + if !id.is_empty() && id.len() <= MAX_ID && !commands.iter().any(|c| c == id) { + commands.push(id.to_string()); + } + } + groups.push(CustomGroup { name: gname, commands }); + } + out.tabs.push(TabEntry { name, custom, hidden, groups }); + } + if let Some(q) = v.get("qat").and_then(Value::as_array) { + let mut ids: Vec = Vec::new(); + for id in q.iter().take(MAX_COMMANDS).filter_map(Value::as_str) { + if !id.is_empty() && id.len() <= MAX_ID && !ids.iter().any(|c| c == id) { + ids.push(id.to_string()); + } + } + out.qat = Some(ids); + } + out.normalize(); + out + } + + /// Keep the tab list consistent: built-in tabs once each (missing ones back in their default + /// place), custom tabs with unique, unreserved names; the default ribbon is stored as empty. + fn normalize(&mut self) { + if self.tabs.is_empty() { + return; + } + let mut tabs: Vec = Vec::new(); + for t in std::mem::take(&mut self.tabs) { + let ok = if t.custom { !reserved(&t.name) } else { built_in(&t.name) }; + if ok && !tabs.iter().any(|x| same(&x.name, &t.name)) { + tabs.push(t); + } + } + // Built-in tabs a saved layout lacks (a newer version's) go after the tab before them. + for (i, name) in TABS.iter().enumerate().skip(1) { + if tabs.iter().any(|t| !t.custom && t.name == *name) { + continue; + } + let at = TABS.get(1..i).and_then(|before| before.iter().rev().find_map(|b| tabs.iter().position(|t| !t.custom && t.name == *b))); + let entry = TabEntry { name: (*name).to_string(), custom: false, hidden: false, groups: Vec::new() }; + tabs.insert(at.map_or(0, |p| p + 1), entry); + } + self.tabs = if tabs == default_tabs() { Vec::new() } else { tabs }; + } + + /// The tabs in order (File excluded), customized or not. + pub fn tabs(&self) -> Cow<'_, [TabEntry]> { + if self.tabs.is_empty() { Cow::Owned(default_tabs()) } else { Cow::Borrowed(&self.tabs) } + } + + /// Whether the ribbon is the default one. + pub fn ribbon_is_default(&self) -> bool { + self.tabs.is_empty() + } + + /// The Quick Access Toolbar's commands. + pub fn qat(&self) -> Vec<&str> { + match &self.qat { + Some(q) => q.iter().map(String::as_str).collect(), + None => DEFAULT_QAT.to_vec(), + } + } + + /// Drop commands that don't exist (saved by another version, or junk). + pub fn retain_known(&mut self, reg: &Registry) { + for t in &mut self.tabs { + for g in &mut t.groups { + g.commands.retain(|id| reg.get(id).is_some()); + } + } + if let Some(q) = &mut self.qat { + q.retain(|id| reg.get(id).is_some()); + } + } + + /// Edit the tab list (customizing a default ribbon first), then tidy it up. + fn edit(&mut self, f: impl FnOnce(&mut Vec) -> Result) -> Result { + let mut tabs = self.tabs().into_owned(); + let r = f(&mut tabs)?; + self.tabs = tabs; + self.normalize(); + Ok(r) + } + + fn tab_index(tabs: &[TabEntry], name: &str) -> Result { + tabs.iter().position(|t| t.name == name).ok_or_else(|| format!("no tab `{name}`")) + } + + fn group_index(tab: &TabEntry, name: &str) -> Result { + tab.groups.iter().position(|g| g.name == name).ok_or_else(|| format!("tab `{}` has no custom group `{name}`", tab.name)) + } + + /// Show or hide a tab. + pub fn set_hidden(&mut self, tab: &str, hidden: bool) -> Result<(), String> { + self.edit(|tabs| { + let i = Self::tab_index(tabs, tab)?; + if let Some(t) = tabs.get_mut(i) { + t.hidden = hidden; + } + Ok(()) + }) + } + + /// Add a custom tab (with one empty group) after `after`, or at the end. Returns its name. + pub fn new_tab(&mut self, name: Option<&str>, after: Option<&str>) -> Result { + let name = match name { + Some(n) => clean_name(n).ok_or("a tab needs a name")?, + None => "New Tab".to_string(), + }; + self.edit(|tabs| { + if tabs.len() >= MAX_TABS { + return Err("too many tabs".into()); + } + let name = unique(&name, |n| reserved(n) || tabs.iter().any(|t| same(&t.name, n))); + let at = match after { + Some(a) => Self::tab_index(tabs, a)? + 1, + None => tabs.len(), + }; + let group = CustomGroup { name: "New Group".into(), commands: Vec::new() }; + tabs.insert(at, TabEntry { name: name.clone(), custom: true, hidden: false, groups: vec![group] }); + Ok(name) + }) + } + + /// Add a custom group at the end of a tab. Returns its name. + pub fn new_group(&mut self, tab: &str, name: Option<&str>) -> Result { + let name = match name { + Some(n) => clean_name(n).ok_or("a group needs a name")?, + None => "New Group".to_string(), + }; + self.edit(|tabs| { + let i = Self::tab_index(tabs, tab)?; + let t = tabs.get_mut(i).ok_or("no such tab")?; + if t.groups.len() >= MAX_GROUPS { + return Err("too many groups on this tab".into()); + } + let name = unique(&name, |n| t.groups.iter().any(|g| same(&g.name, n))); + t.groups.push(CustomGroup { name: name.clone(), commands: Vec::new() }); + Ok(name) + }) + } + + /// Add a command to a custom group (after `after`, or at the end). False when it's already there. + pub fn add(&mut self, reg: &Registry, tab: &str, group: &str, id: &str, after: Option<&str>) -> Result { + if reg.get(id).is_none() { + return Err(format!("unknown command `{id}`")); + } + self.edit(|tabs| { + let i = Self::tab_index(tabs, tab)?; + let t = tabs.get_mut(i).ok_or("no such tab")?; + let j = Self::group_index(t, group)?; + let g = t.groups.get_mut(j).ok_or("no such group")?; + if g.commands.iter().any(|c| c == id) { + return Ok(false); + } + if g.commands.len() >= MAX_COMMANDS { + return Err("too many commands in this group".into()); + } + let at = after.and_then(|a| g.commands.iter().position(|c| c == a)).map_or(g.commands.len(), |p| p + 1); + g.commands.insert(at, id.to_string()); + Ok(true) + }) + } + + /// Remove a command from a custom group, a custom group, or a custom tab. + pub fn remove(&mut self, tab: &str, group: Option<&str>, command: Option<&str>) -> Result<(), String> { + self.edit(|tabs| { + let i = Self::tab_index(tabs, tab)?; + let Some(group) = group else { + if !tabs.get(i).is_some_and(|t| t.custom) { + return Err(format!("`{tab}` is a built-in tab: hide it instead")); + } + tabs.remove(i); + return Ok(()); + }; + let t = tabs.get_mut(i).ok_or("no such tab")?; + let j = Self::group_index(t, group)?; + match command { + None => { + t.groups.remove(j); + } + Some(id) => { + let g = t.groups.get_mut(j).ok_or("no such group")?; + let k = g.commands.iter().position(|c| c == id).ok_or_else(|| format!("`{group}` doesn't have `{id}`"))?; + g.commands.remove(k); + } + } + Ok(()) + }) + } + + /// Rename a custom tab or a custom group. Returns the new name. + pub fn rename(&mut self, tab: &str, group: Option<&str>, to: &str) -> Result { + let to = clean_name(to).ok_or("the new name is empty")?; + self.edit(|tabs| { + let i = Self::tab_index(tabs, tab)?; + match group { + None => { + if !tabs.get(i).is_some_and(|t| t.custom) { + return Err(format!("`{tab}` is a built-in tab and keeps its name")); + } + if reserved(&to) || tabs.iter().enumerate().any(|(k, t)| k != i && same(&t.name, &to)) { + return Err(format!("there is already a tab named `{to}`")); + } + if let Some(t) = tabs.get_mut(i) { + t.name = to.clone(); + } + } + Some(group) => { + let t = tabs.get_mut(i).ok_or("no such tab")?; + let j = Self::group_index(t, group)?; + if t.groups.iter().enumerate().any(|(k, g)| k != j && same(&g.name, &to)) { + return Err(format!("`{tab}` already has a group named `{to}`")); + } + if let Some(g) = t.groups.get_mut(j) { + g.name = to.clone(); + } + } + } + Ok(to) + }) + } + + /// Move a tab, a custom group (within its tab) or a command (within its group) by `by` places. + pub fn move_by(&mut self, tab: &str, group: Option<&str>, command: Option<&str>, by: i64) -> Result<(), String> { + self.edit(|tabs| { + let i = Self::tab_index(tabs, tab)?; + let Some(group) = group else { + shift(tabs, i, by); + return Ok(()); + }; + let t = tabs.get_mut(i).ok_or("no such tab")?; + let j = Self::group_index(t, group)?; + match command { + None => { + shift(&mut t.groups, j, by); + } + Some(id) => { + let g = t.groups.get_mut(j).ok_or("no such group")?; + let k = g.commands.iter().position(|c| c == id).ok_or_else(|| format!("`{group}` doesn't have `{id}`"))?; + shift(&mut g.commands, k, by); + } + } + Ok(()) + }) + } + + /// Back to the default ribbon (the Quick Access Toolbar stays). + pub fn reset_ribbon(&mut self) { + self.tabs.clear(); + } + + /// Back to the default Quick Access Toolbar. + pub fn reset_qat(&mut self) { + self.qat = None; + } + + /// Put a command on the Quick Access Toolbar (after `after`, or at the end). False when it's + /// already there. + pub fn qat_add(&mut self, reg: &Registry, id: &str, after: Option<&str>) -> Result { + if reg.get(id).is_none() { + return Err(format!("unknown command `{id}`")); + } + let mut q: Vec = self.qat().into_iter().map(str::to_string).collect(); + if q.iter().any(|c| c == id) { + return Ok(false); + } + if q.len() >= MAX_COMMANDS { + return Err("the Quick Access Toolbar is full".into()); + } + let at = after.and_then(|a| q.iter().position(|c| c == a)).map_or(q.len(), |p| p + 1); + q.insert(at, id.to_string()); + self.qat = Some(q); + Ok(true) + } + + /// Take a command off the Quick Access Toolbar. False when it wasn't there. + pub fn qat_remove(&mut self, id: &str) -> bool { + let mut q: Vec = self.qat().into_iter().map(str::to_string).collect(); + let Some(i) = q.iter().position(|c| c == id) else { return false }; + q.remove(i); + self.qat = Some(q); + true + } + + /// Move a Quick Access Toolbar command by `by` places. + pub fn qat_move(&mut self, id: &str, by: i64) -> Result<(), String> { + let mut q: Vec = self.qat().into_iter().map(str::to_string).collect(); + let i = q.iter().position(|c| c == id).ok_or_else(|| format!("`{id}` isn't on the Quick Access Toolbar"))?; + shift(&mut q, i, by); + self.qat = Some(q); + Ok(()) + } +} + +impl<'de> Deserialize<'de> for RibbonLayout { + /// Lenient: any JSON reads (junk dropped), so a damaged layout never loses the other + /// preferences saved next to it. + fn deserialize>(d: D) -> Result { + Ok(RibbonLayout::from_value(&Value::deserialize(d)?)) + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + /// #379: custom tabs and groups, hidden and moved tabs, and the Quick Access Toolbar, changed + /// through the command (as agents do) without opening the dialog. + #[test] + fn layout_changes_through_the_command() { + let mut s = crate::Session::new(wordcraft_doc::Document::new()); + let mut run = |v: Value| s.run("tools.customizeRibbon", &v); + let r = run(json!({"newTab": {"name": "Mine", "after": "Home"}, "hide": "Draw"})).unwrap(); + assert_eq!(r["tab"], "Mine"); + run(json!({"add": {"tab": "Mine", "group": "New Group", "command": "format.bold"}})).unwrap(); + run(json!({"newGroup": {"tab": "Insert", "name": "Extras"}})).unwrap(); + run(json!({"add": {"tab": "Insert", "group": "Extras", "command": "edit.copy"}})).unwrap(); + run(json!({"rename": {"tab": "Mine", "to": "Writing"}})).unwrap(); + let r = run(json!({"move": {"tab": "Writing", "by": -5}, "qatAdd": "format.bold", "qatRemove": "file.save"})).unwrap(); + let tabs = r["tabs"].as_array().unwrap(); + assert_eq!(tabs[0]["name"], "Writing"); + assert_eq!(tabs[0]["groups"][0]["commands"], json!(["format.bold"])); + assert!(tabs.iter().any(|t| t["name"] == "Draw" && t["hidden"] == true)); + assert_eq!(r["qat"], json!(["edit.undo", "edit.redo", "format.bold"])); + // Built-in tabs keep their names and can only be hidden; reserved and unknown names fail. + assert!(run(json!({"rename": {"tab": "Home", "to": "Start"}})).is_err()); + assert!(run(json!({"remove": {"tab": "Home"}})).is_err()); + assert!(run(json!({"rename": {"tab": "Writing", "to": "insert"}})).is_err()); + assert!(run(json!({"add": {"tab": "Writing", "group": "New Group", "command": "no.such"}})).is_err()); + assert!(run(json!({"move": {"tab": "Nope", "by": 1}})).is_err()); + assert!(s.ui_requests.is_empty(), "programmatic calls never open dialogs"); + s.run("tools.customizeRibbon", &json!({"reset": true})).unwrap(); + assert_eq!(s.ribbon, RibbonLayout::default()); + s.run("tools.customizeRibbon", &json!({})).unwrap(); + assert_eq!(s.ui_requests.last(), Some(&json!({"open": "customizeRibbon"}))); + } + + #[test] + fn saved_layouts_round_trip_and_junk_is_ignored() { + let reg = crate::cmd::registry(); + let mut l = RibbonLayout::default(); + l.new_tab(None, None).unwrap(); + l.add(®, "New Tab", "New Group", "format.italic", None).unwrap(); + l.set_hidden("Mailings", true).unwrap(); + l.qat_add(®, "edit.repeat", None).unwrap(); + let back: RibbonLayout = serde_json::from_str(&serde_json::to_string(&l).unwrap()).unwrap(); + assert_eq!(back, l); + + let junk = json!({ + "tabs": [ + {"name": "Home", "groups": [{"name": "G", "commands": ["format.bold", "gone.command", 7, "format.bold"]}]}, + {"name": "Home"}, + {"name": "Bogus"}, + {"name": "Review", "custom": true}, + {"name": " "}, + {"name": "Mine", "custom": true, "hidden": "yes", "groups": "x"}, + 5 + ], + "qat": ["edit.undo", "gone.command", 3, "edit.undo"] + }); + let mut j: RibbonLayout = serde_json::from_value(junk).unwrap(); + j.retain_known(®); + let tabs = j.tabs(); + assert_eq!(tabs.len(), TABS.len(), "11 built-in tabs and Mine: {tabs:?}"); + assert_eq!(tabs[0].groups[0].commands, ["format.bold"]); + assert!(tabs.iter().any(|t| t.custom && t.name == "Mine" && !t.hidden)); + assert_eq!(j.qat(), ["edit.undo"]); + for v in [json!(null), json!(5), json!("x"), json!({"tabs": {"a": 1}, "qat": "file.save"})] { + assert_eq!(serde_json::from_value::(v).unwrap(), RibbonLayout::default()); + } + } +} diff --git a/crates/engine/src/sample.rs b/crates/engine/src/sample.rs index 59f45b033c..db2719428a 100644 --- a/crates/engine/src/sample.rs +++ b/crates/engine/src/sample.rs @@ -1,224 +1,224 @@ -//! Built-in sample documents and templates (original text, generated in code). - -use std::sync::Arc; -use wordcraft_doc::para::InlineObject; -use wordcraft_doc::props::{Align, Border, BorderStyle, Borders, CharProps, NumRef, Rgb, TabAlign, TabLeader, TabStop, TextColor}; -use wordcraft_doc::{Block, Document, ListKind, Paragraph, PartKind, Table, para_block}; - -fn para(text: &str) -> Paragraph { - Paragraph::with_text(text, CharProps::default()) -} - -fn styled(text: &str, style: &str) -> Paragraph { - para(text).styled(style) -} - -/// Append text with formatting to a paragraph. -fn add(p: &mut Paragraph, text: &str, props: CharProps) { - let at = p.len(); - let _ = p.insert_text(at, text, &props); -} - -fn bold() -> CharProps { - CharProps { bold: Some(true), ..Default::default() } -} -fn italic() -> CharProps { - CharProps { italic: Some(true), ..Default::default() } -} - -/// The showcase document: a short studio handbook with headings, lists, a table, a quote, -/// a footer with page numbers. -pub fn sample_document() -> Document { - let mut d = Document::new(); - d.core.title = "The Open Studio Handbook".into(); - d.core.creator = "WordCraft".into(); - let mut b: Vec = Vec::new(); - b.push(Block::Para(styled("The Open Studio Handbook", "Title"))); - b.push(Block::Para(styled("How a small community of makers shares space, tools and time", "Subtitle"))); - let mut intro = para("Every Thursday evening the old print shop on Alder Street fills with people who make things. "); - add(&mut intro, "Painters", bold()); - add(&mut intro, " set up by the north windows, ", CharProps::default()); - add(&mut intro, "letterpress", italic()); - add( - &mut intro, - " regulars claim the back room, and somebody always brings bread. This handbook collects what we have learned about running a shared studio: the routines that keep it pleasant, the tools we pool, and the small agreements that let thirty strangers become a crew.", - CharProps::default(), - ); - b.push(Block::Para(intro)); - b.push(Block::Para(styled("Why share a studio?", "Heading1"))); - b.push(Block::Para(para("Rent is the obvious reason, but it is not the best one. A shared room puts your work next to work you would never have seen otherwise. A ceramicist explains glaze chemistry to a comic artist; a photographer borrows a weaver's loom for a still life. The cross-talk is the point."))); - let mut quote = styled("A studio is a place where unfinished things are welcome.", "Quote"); - quote.props.space_before = Some(12.0); - b.push(Block::Para(quote)); - b.push(Block::Para(styled("What we agree on", "Heading2"))); - let mut num = (*d.numbering).clone(); - let bullets = num.add_list(ListKind::Bullet); - let steps = num.add_list(ListKind::Numbered); - d.numbering = Arc::new(num); - for (lead, rest) in [ - ("Clean as you go. ", "Leave a table the way you would like to find it."), - ("Label what is yours. ", "Unlabelled supplies on the commons shelf are fair game."), - ("Ask before you borrow. ", "Even from the shelf, a quick note helps everyone keep track."), - ("Quiet hours ", "run from 9 to 11 on weekday mornings."), - ] { - let mut p = Paragraph::new().styled("ListParagraph"); - add(&mut p, lead, bold()); - add(&mut p, rest, CharProps::default()); - p.props.numbering = Some(NumRef { num: bullets, level: 0 }); - b.push(Block::Para(p)); - } - b.push(Block::Para(styled("Shared equipment", "Heading2"))); - b.push(Block::Para(para("The table below lists what the membership owns together and who keeps each item in working order."))); - let rows = [ - ["Equipment", "Location", "Steward", "Booking"], - ["Etching press", "Back room", "Mara O.", "Sign-up sheet"], - ["Kiln (cone 10)", "Yard shed", "Theo P.", "48 h notice"], - ["Large-format printer", "Office", "Ines R.", "Calendar"], - ["Light table", "North windows", "Anyone", "First come"], - ]; - let mut t = Table::new(rows.len(), 4, 468.0); - t.props.style = Some("GridTable4AccentBlue".into()); - for (r, row) in rows.iter().enumerate() { - for (c, txt) in row.iter().enumerate() { - if let Some(cell) = t.rows.get_mut(r).and_then(|x| x.cells.get_mut(c)) { - cell.blocks = vec![para_block(para(txt))]; - } - } - } - if let Some(r) = t.rows.first_mut() { - r.props.header = true; - } - b.push(Block::Table(t)); - b.push(Block::Para(styled("Opening the studio", "Heading2"))); - for s in [ - "Switch on the main breaker by the door.", - "Open both skylights unless it is raining.", - "Start the kettle. This step is not optional.", - "Check the booking sheet for the kiln and press.", - ] { - let mut p = para(s).styled("ListParagraph"); - p.props.numbering = Some(NumRef { num: steps, level: 0 }); - b.push(Block::Para(p)); - } - b.push(Block::Para(styled("Membership", "Heading1"))); - let mut m = para("Membership is "); - add(&mut m, "pay what you can", CharProps { bold: Some(true), color: Some(TextColor::Rgb(Rgb(0x15, 0x60, 0x82))), ..Default::default() }); - add( - &mut m, - ", reviewed every season. New members shadow a regular for their first two visits, and everyone takes one cleaning shift a month. If you have never made anything before, you are exactly who we hoped would show up.", - CharProps::default(), - ); - b.push(Block::Para(m)); - let mut h = para("Questions go to the front desk, or to the notice board by the sink. "); - add( - &mut h, - "Visit the community board", - CharProps { style: Some("Hyperlink".into()), link: Some("https://getartcraft.com/".into()), ..Default::default() }, - ); - add(&mut h, " for events.", CharProps::default()); - b.push(Block::Para(h)); - d.body = b.into_iter().map(std::sync::Arc::new).collect(); - // Footer: centred page number. - let mut f = Paragraph::new().styled("Footer"); - f.props.align = Some(Align::Center); - let _ = f.insert_object( - 0, - InlineObject::Field { instr: "PAGE".into(), result: "1".into(), locked: false, dirty: false, code: false, form: String::new() }, - &CharProps::default(), - ); - let fid = d.add_part(PartKind::Footer, vec![para_block(f)]); - d.last_section.footers.default = Some(fid); - let mut hd = styled("The Open Studio Handbook\t\tSpring edition", "Header"); - hd.props.borders = Some(Borders { - bottom: Some(Border { style: BorderStyle::Single, width: 0.5, color: Some(Rgb(0xBF, 0xBF, 0xBF)), space: 4.0 }), - ..Default::default() - }); - let hid = d.add_part(PartKind::Header, vec![para_block(hd)]); - d.last_section.headers.default = Some(hid); - d -} - -/// A block-style letter. -pub fn letter() -> Document { - let mut d = Document::new(); - let lines: Vec = vec![ - styled("Your Name", "Title"), - para("123 Your Street · City, ST 00000 · you@example.com"), - para(""), - para(&crate::cmd::insert::format_date("MMMM d, yyyy")), - para(""), - para("Recipient Name"), - para("Title, Company"), - para("Street Address"), - para(""), - para("Dear Recipient,"), - para( - "Start with a sentence that says why you are writing. Keep paragraphs short and specific, and close with what you would like to happen next.", - ), - para("Thank you for your time."), - para("Sincerely,"), - para(""), - para("Your Name"), - ]; - d.body = lines.into_iter().map(para_block).collect(); - d -} - -/// A one-page résumé. -pub fn resume() -> Document { - let mut d = Document::new(); - let mut b: Vec = - vec![Block::Para(styled("Alex Rivera", "Title")), Block::Para(para("Illustrator · alex@example.com · portfolio.example.com"))]; - for (head, items) in [ - ("Experience", vec![("Lead Illustrator, Harbor Books", "2021 – present"), ("Freelance Illustrator", "2016 – 2021")]), - ("Education", vec![("BFA Illustration, Coastal College of Art", "2016")]), - ("Skills", vec![("Ink, gouache, digital painting, book layout, lettering", "")]), - ] { - b.push(Block::Para(styled(head, "Heading1"))); - for (what, when) in items { - let mut p = para(what); - p.props.tabs = Some(vec![TabStop { pos: 468.0, align: TabAlign::Right, leader: TabLeader::None }]); - if !when.is_empty() { - add(&mut p, &format!("\t{when}"), italic()); - } - b.push(Block::Para(p)); - } - } - d.body = b.into_iter().map(std::sync::Arc::new).collect(); - d -} - -/// A report with a cover heading and sections. -pub fn report() -> Document { - let mut d = Document::new(); - let b: Vec = vec![ - Block::Para(styled("Report Title", "Title")), - Block::Para(styled("Subtitle or date", "Subtitle")), - Block::Para(styled("Summary", "Heading1")), - Block::Para(para("Summarise the findings in two or three sentences.")), - Block::Para(styled("Background", "Heading1")), - Block::Para(para("Describe the context and the question the report answers.")), - Block::Para(styled("Findings", "Heading1")), - Block::Para(styled("First finding", "Heading2")), - Block::Para(para("Explain the evidence.")), - Block::Para(styled("Recommendations", "Heading1")), - Block::Para(para("List what should happen next.")), - ]; - d.body = b.into_iter().map(std::sync::Arc::new).collect(); - d -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn samples_are_valid() { - assert_eq!(sample_document().word_count(), 361); - for d in [sample_document(), letter(), resume(), report()] { - assert!(d.word_count() > 5); - let l = wordcraft_layout::layout(&d, &mut wordcraft_layout::LayoutCache::new(), &Default::default()); - assert!(!l.pages.is_empty()); - } - } -} +//! Built-in sample documents and templates (original text, generated in code). + +use std::sync::Arc; +use wordcraft_doc::para::InlineObject; +use wordcraft_doc::props::{Align, Border, BorderStyle, Borders, CharProps, NumRef, Rgb, TabAlign, TabLeader, TabStop, TextColor}; +use wordcraft_doc::{Block, Document, ListKind, Paragraph, PartKind, Table, para_block}; + +fn para(text: &str) -> Paragraph { + Paragraph::with_text(text, CharProps::default()) +} + +fn styled(text: &str, style: &str) -> Paragraph { + para(text).styled(style) +} + +/// Append text with formatting to a paragraph. +fn add(p: &mut Paragraph, text: &str, props: CharProps) { + let at = p.len(); + let _ = p.insert_text(at, text, &props); +} + +fn bold() -> CharProps { + CharProps { bold: Some(true), ..Default::default() } +} +fn italic() -> CharProps { + CharProps { italic: Some(true), ..Default::default() } +} + +/// The showcase document: a short studio handbook with headings, lists, a table, a quote, +/// a footer with page numbers. +pub fn sample_document() -> Document { + let mut d = Document::new(); + d.core.title = "The Open Studio Handbook".into(); + d.core.creator = "WordCraft".into(); + let mut b: Vec = Vec::new(); + b.push(Block::Para(styled("The Open Studio Handbook", "Title"))); + b.push(Block::Para(styled("How a small community of makers shares space, tools and time", "Subtitle"))); + let mut intro = para("Every Thursday evening the old print shop on Alder Street fills with people who make things. "); + add(&mut intro, "Painters", bold()); + add(&mut intro, " set up by the north windows, ", CharProps::default()); + add(&mut intro, "letterpress", italic()); + add( + &mut intro, + " regulars claim the back room, and somebody always brings bread. This handbook collects what we have learned about running a shared studio: the routines that keep it pleasant, the tools we pool, and the small agreements that let thirty strangers become a crew.", + CharProps::default(), + ); + b.push(Block::Para(intro)); + b.push(Block::Para(styled("Why share a studio?", "Heading1"))); + b.push(Block::Para(para("Rent is the obvious reason, but it is not the best one. A shared room puts your work next to work you would never have seen otherwise. A ceramicist explains glaze chemistry to a comic artist; a photographer borrows a weaver's loom for a still life. The cross-talk is the point."))); + let mut quote = styled("A studio is a place where unfinished things are welcome.", "Quote"); + quote.props.space_before = Some(12.0); + b.push(Block::Para(quote)); + b.push(Block::Para(styled("What we agree on", "Heading2"))); + let mut num = (*d.numbering).clone(); + let bullets = num.add_list(ListKind::Bullet); + let steps = num.add_list(ListKind::Numbered); + d.numbering = Arc::new(num); + for (lead, rest) in [ + ("Clean as you go. ", "Leave a table the way you would like to find it."), + ("Label what is yours. ", "Unlabelled supplies on the commons shelf are fair game."), + ("Ask before you borrow. ", "Even from the shelf, a quick note helps everyone keep track."), + ("Quiet hours ", "run from 9 to 11 on weekday mornings."), + ] { + let mut p = Paragraph::new().styled("ListParagraph"); + add(&mut p, lead, bold()); + add(&mut p, rest, CharProps::default()); + p.props.numbering = Some(NumRef { num: bullets, level: 0 }); + b.push(Block::Para(p)); + } + b.push(Block::Para(styled("Shared equipment", "Heading2"))); + b.push(Block::Para(para("The table below lists what the membership owns together and who keeps each item in working order."))); + let rows = [ + ["Equipment", "Location", "Steward", "Booking"], + ["Etching press", "Back room", "Mara O.", "Sign-up sheet"], + ["Kiln (cone 10)", "Yard shed", "Theo P.", "48 h notice"], + ["Large-format printer", "Office", "Ines R.", "Calendar"], + ["Light table", "North windows", "Anyone", "First come"], + ]; + let mut t = Table::new(rows.len(), 4, 468.0); + t.props.style = Some("GridTable4AccentBlue".into()); + for (r, row) in rows.iter().enumerate() { + for (c, txt) in row.iter().enumerate() { + if let Some(cell) = t.rows.get_mut(r).and_then(|x| x.cells.get_mut(c)) { + cell.blocks = vec![para_block(para(txt))]; + } + } + } + if let Some(r) = t.rows.first_mut() { + r.props.header = true; + } + b.push(Block::Table(t)); + b.push(Block::Para(styled("Opening the studio", "Heading2"))); + for s in [ + "Switch on the main breaker by the door.", + "Open both skylights unless it is raining.", + "Start the kettle. This step is not optional.", + "Check the booking sheet for the kiln and press.", + ] { + let mut p = para(s).styled("ListParagraph"); + p.props.numbering = Some(NumRef { num: steps, level: 0 }); + b.push(Block::Para(p)); + } + b.push(Block::Para(styled("Membership", "Heading1"))); + let mut m = para("Membership is "); + add(&mut m, "pay what you can", CharProps { bold: Some(true), color: Some(TextColor::Rgb(Rgb(0x15, 0x60, 0x82))), ..Default::default() }); + add( + &mut m, + ", reviewed every season. New members shadow a regular for their first two visits, and everyone takes one cleaning shift a month. If you have never made anything before, you are exactly who we hoped would show up.", + CharProps::default(), + ); + b.push(Block::Para(m)); + let mut h = para("Questions go to the front desk, or to the notice board by the sink. "); + add( + &mut h, + "Visit the community board", + CharProps { style: Some("Hyperlink".into()), link: Some("https://getartcraft.com/".into()), ..Default::default() }, + ); + add(&mut h, " for events.", CharProps::default()); + b.push(Block::Para(h)); + d.body = b.into_iter().map(std::sync::Arc::new).collect(); + // Footer: centred page number. + let mut f = Paragraph::new().styled("Footer"); + f.props.align = Some(Align::Center); + let _ = f.insert_object( + 0, + InlineObject::Field { instr: "PAGE".into(), result: "1".into(), locked: false, dirty: false, code: false, form: String::new() }, + &CharProps::default(), + ); + let fid = d.add_part(PartKind::Footer, vec![para_block(f)]); + d.last_section.footers.default = Some(fid); + let mut hd = styled("The Open Studio Handbook\t\tSpring edition", "Header"); + hd.props.borders = Some(Borders { + bottom: Some(Border { style: BorderStyle::Single, width: 0.5, color: Some(Rgb(0xBF, 0xBF, 0xBF)), space: 4.0 }), + ..Default::default() + }); + let hid = d.add_part(PartKind::Header, vec![para_block(hd)]); + d.last_section.headers.default = Some(hid); + d +} + +/// A block-style letter. +pub fn letter() -> Document { + let mut d = Document::new(); + let lines: Vec = vec![ + styled("Your Name", "Title"), + para("123 Your Street · City, ST 00000 · you@example.com"), + para(""), + para(&crate::cmd::insert::format_date("MMMM d, yyyy")), + para(""), + para("Recipient Name"), + para("Title, Company"), + para("Street Address"), + para(""), + para("Dear Recipient,"), + para( + "Start with a sentence that says why you are writing. Keep paragraphs short and specific, and close with what you would like to happen next.", + ), + para("Thank you for your time."), + para("Sincerely,"), + para(""), + para("Your Name"), + ]; + d.body = lines.into_iter().map(para_block).collect(); + d +} + +/// A one-page résumé. +pub fn resume() -> Document { + let mut d = Document::new(); + let mut b: Vec = + vec![Block::Para(styled("Alex Rivera", "Title")), Block::Para(para("Illustrator · alex@example.com · portfolio.example.com"))]; + for (head, items) in [ + ("Experience", vec![("Lead Illustrator, Harbor Books", "2021 – present"), ("Freelance Illustrator", "2016 – 2021")]), + ("Education", vec![("BFA Illustration, Coastal College of Art", "2016")]), + ("Skills", vec![("Ink, gouache, digital painting, book layout, lettering", "")]), + ] { + b.push(Block::Para(styled(head, "Heading1"))); + for (what, when) in items { + let mut p = para(what); + p.props.tabs = Some(vec![TabStop { pos: 468.0, align: TabAlign::Right, leader: TabLeader::None }]); + if !when.is_empty() { + add(&mut p, &format!("\t{when}"), italic()); + } + b.push(Block::Para(p)); + } + } + d.body = b.into_iter().map(std::sync::Arc::new).collect(); + d +} + +/// A report with a cover heading and sections. +pub fn report() -> Document { + let mut d = Document::new(); + let b: Vec = vec![ + Block::Para(styled("Report Title", "Title")), + Block::Para(styled("Subtitle or date", "Subtitle")), + Block::Para(styled("Summary", "Heading1")), + Block::Para(para("Summarise the findings in two or three sentences.")), + Block::Para(styled("Background", "Heading1")), + Block::Para(para("Describe the context and the question the report answers.")), + Block::Para(styled("Findings", "Heading1")), + Block::Para(styled("First finding", "Heading2")), + Block::Para(para("Explain the evidence.")), + Block::Para(styled("Recommendations", "Heading1")), + Block::Para(para("List what should happen next.")), + ]; + d.body = b.into_iter().map(std::sync::Arc::new).collect(); + d +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn samples_are_valid() { + assert_eq!(sample_document().word_count(), 361); + for d in [sample_document(), letter(), resume(), report()] { + assert!(d.word_count() > 5); + let l = wordcraft_layout::layout(&d, &mut wordcraft_layout::LayoutCache::new(), &Default::default()); + assert!(!l.pages.is_empty()); + } + } +} diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index f8b8de6057..9a15ff6447 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -1,259 +1,259 @@ -//! The editing session: document, selection, undo/redo, layout, view state. - -use std::sync::Arc; - -use serde_json::Value; -use wordcraft_doc::edit::Fragment; -use wordcraft_doc::props::CharProps; -use wordcraft_doc::{Document, PartKind, Path, Pos, StoryRef}; -use wordcraft_layout::{DocLayout, LayoutCache, LayoutOptions, ViewMode}; - -use crate::{CmdError, ImeEditOutcome, Registry}; - -mod documents; -mod views; -pub use documents::{DocInfo, DocState}; -use documents::{document_id_mark, next_document_id, untitled_name}; -pub use views::WindowView; -mod history; -mod ime; -mod layout; -mod lifecycle; -mod query; -mod run; -mod snapshot; - -use crate::doc_state; -use crate::ime as ime_state; -use crate::locale; -use crate::prefs; -use crate::snapshot as snapshot_types; -use crate::view; -pub(crate) use doc_state::Widened; -pub use doc_state::{CellRange, ColumnBlock, FindState, MathEdit, Selection, UNDO_LIST_MAX, next_math_session}; -use doc_state::{TypedWord, Undo, doc_bib_style}; -use ime_state::ImeCaretState; -pub use locale::set_system_locale; -pub(crate) use locale::system_locale; -pub use prefs::Prefs; -pub use query::blank_document; -pub use snapshot_types::EditSnapshot; -use snapshot_types::{LayoutSlots, RunSnapshot}; -pub use view::{StylesPaneDock, ViewState}; - -/// An editing session: the app-wide state (clipboard, preferences, macros, the command registry…) -/// and the open documents. The active document's state is in the session's own fields, which -/// every command works on; the other open documents wait in [`DocState`] values and are swapped -/// in when they become active (see [`Session::documents`]). -pub struct Session { - pub doc: Document, - pub sel: Selection, - pub view: ViewState, - /// Formatting picked with a collapsed caret, applied to the next typed text. - pub pending: Option, - /// Last highlighter choice for the shortcut; palette state, outside document undo. - pub(crate) last_highlight: wordcraft_doc::props::Highlight, - pub path: Option, - /// Name given to a document not saved yet (File › Info › Rename): what Save suggests. A file - /// name, not document metadata, so it is not part of the document or its undo history. - pub file_name: Option, - pub dirty: bool, - /// Where the custom dictionary is kept on disk, if the host wired one up. Only the desktop - /// app does: headless sessions (MCP, `wordcraft-cli run`), tests and the web build leave - /// `None`, so an agent's words stay in memory and never change the person's dictionary. - /// Add to Dictionary and File › Options › Proofing save through it (#40). - pub user_dict_path: Option, - pub clipboard: Option, - /// A tracked cut the next paste can turn into a move (see `cmd::moves`). - pub pending_move: Option, - /// Plain text mirror of the clipboard (for the system clipboard). - pub clipboard_text: String, - /// HTML for the system clipboard beside [`Self::clipboard_text`]: the copy's formatting for - /// programs that read HTML, equations as MathML (#816); empty when nothing was copied. - pub clipboard_html: String, - /// RTF for the system clipboard beside [`Self::clipboard_html`], for programs that read RTF - /// rather than HTML; empty when nothing was copied. - pub clipboard_rtf: String, - /// Items collected by Copy and Cut this session, for the Clipboard pane. - pub clip_history: crate::cmd::edit::ClipHistory, - pub find: FindState, - /// Page x the caret tries to keep on Up/Down. - pub goal_x: Option, - /// Page the caret was last on (headers/footers repeat on many pages). - pub page_hint: usize, - pub registry: Arc, - /// Author name for comments and tracked changes. - pub author: String, - /// Initials for comments (File › Options › General); empty = the name's initials. - pub initials: String, - /// Format painter: copied formatting waiting to be applied (and whether it stays on). - pub painter: Option<(CharProps, wordcraft_doc::props::ParaProps, bool)>, - /// Last message for the status bar / agents. - pub status: String, - /// Headers/footers `insert.createHeader`/`insert.createFooter` made for a page that had none - /// (#960): `insert.closeHeader` takes those still empty away again, as Word does, and saving - /// leaves them out. Kept after that, so one an undo brings back goes again on the next close. - pub new_header_parts: Vec, - /// The body selection a command left for a header or footer: `insert.closeHeader` puts it back. - pub hf_return: Option, - /// The style-definition change Next / Previous Change selected, for the Accept or Reject that - /// follows (`cmd::review`). It names its document and selection, so it is shared by the open - /// documents and stops counting once either changes. - pub(crate) review_style: Option, - /// Undo and redo steps. Shared so `run` can snapshot both stacks cheaply (a pointer per - /// step) and put them back exactly when a command fails. - history: Vec>, - /// Undo steps dropped so far because of the undo limit. - undo_evicted: u64, - redo: Vec>, - /// Typing is coalesced into one undo step until something else happens. - typing_open: bool, - /// The next mutating command joins the previous undo step (later frames of a drag). - join_next: bool, - /// The next equation typing command joins a successful equation `deleteSurrounding`'s undo - /// step. Unlike [`Self::join_next`], any other command consumes and discards it, so an - /// unrelated Bold or document replacement can never be merged into the deletion. - equation_join_next: bool, - /// The next `text.insert` at this exact selection joins a successful body - /// `DeleteSurrounding`'s undo step. Any other command consumes and discards it, so an - /// intervening edit owns its own checkpoint instead of being merged into the deletion. - ime_text_join: Option, - /// Whether the undo group has a step of its own for the next joined command to merge - /// into. A command that changed nothing leaves the group without one, so the next - /// command in the group starts the step the group needs. - join_anchor: bool, - /// The newest automatic capitalisation, for Word's "Automatically add words to list" (#1857). - pub(crate) autocap: Option, - rev: u64, - /// Which document this is, unique in the process: changes when [`Session::set_document`] - /// replaces it (an open into an untouched blank document, recover), never on an edit or when - /// another document becomes active and this one comes back. - document_id: u64, - /// The number in an untitled document's name (`Document2`); 0 for one opened from a file. - untitled: u32, - /// `rev` when the document was made, for [`Session::is_untouched_blank`]. - made_rev: u64, - /// What Restrict Editing's outright refusals depend on, for greying out commands - /// ([`Session::why_disabled`]): computed once per document, revision and author instead of - /// for every button on every frame. - protect_facts: std::sync::Mutex>, - /// How many times the document's body was replaced under the same [`Self::document_id`] - /// (`document.setText`, version restore, compare, Undo/Redo). It is the body's IME - /// replacement epoch: a composition must not survive the swap, even when the new body has - /// the same text or the same bounded surrounding window. - ime_body_epoch: u64, - /// Engine-owned, checked, process-monotonic revision for IME receipts whose target cannot - /// provide surrounding text. `None` means the allocator is exhausted (fail closed). - ime_state_revision: Option, - /// The caret/equation-session token the revision was last allocated for. It covers selection - /// and caret changes without walking the document. - ime_state_seen: Option, - /// The current command's explicit IME effect classification. - ime_edit_outcome: ImeEditOutcome, - /// Simulation of a globally exhausted IME revision allocator for front-end producer tests. - /// Only [`Self::exhaust_ime_state_revision`] sets it; production builds do not compile it, - /// and it only makes receipt classification fail closed. - #[cfg(any(test, feature = "test-support"))] - ime_state_exhausted: bool, - /// A successful non-IME command changed document state since the last IME observation. It is - /// drained before publishing a receipt and at observation time; it is never cleared by a later - /// command. It covers mixed formatting/IME frames when available stamps ignore the revision. - pending_ime_application_change: bool, - /// Whether the current command was entered through [`Session::run_ime`]. Nested commands - /// inherit the origin. - ime_origin: bool, - cache: LayoutCache, - layout: LayoutSlots, - /// The word being typed, left without a spelling squiggle ([`TypedWord`]). - typing: Option, - /// Picture edits: edited media key → original media key (Reset Picture). - pub originals: std::collections::HashMap, - /// View › Markdown Source: its text, while it is open. - pub md_source: Option, - /// A Markdown file's own text, so Save can write it unchanged. - pub md_file: crate::cmd::md_source::FileText, - /// Picture effects, styles and borders: drawn media key → what was drawn on which bitmap, - /// so the next one replaces them instead of drawing over them (#2052). - pub picture_looks: std::collections::HashMap, - /// Last mutating command (Repeat). - pub last_command: Option<(String, Value)>, - /// Macro being recorded: (name, steps). - pub recording: Option<(String, Vec<(String, Value)>)>, - pub macros: std::collections::BTreeMap>, - /// Saved versions: (label, date, document). - pub versions: Vec<(String, String, Document)>, - /// Quick Parts / AutoText entries. - pub building_blocks: std::collections::BTreeMap, - pub autosave: bool, - /// Citation style: APA, MLA, Chicago, IEEE. - pub bib_style: String, - /// Mail merge data source and preview. - pub merge: crate::cmd::mailings::MergeState, - /// Requests from commands to the UI (open a dialog, scroll…), drained by the front end. - pub ui_requests: Vec, - /// View › Window: the other WordCraft windows and Side by Side (#322). - pub windows: crate::cmd::view::Windows, - /// Read Aloud player (Review › Speech). - pub read_aloud: crate::speech::ReadAloud, - /// Preferences the front end saves between runs. - pub prefs: Prefs, - /// The system region (#838): the paper a new blank document starts on, the default - /// measurement unit and the decimal separator. The front end sets it from the platform (the - /// browser's locale on the web); headless sessions keep `en-US`. A region the user chose - /// ([`Prefs::region`]) wins: read it with [`Session::region`]. - pub system_region: wordcraft_geom::Region, - /// Custom keyboard shortcuts (`tools.customizeKeyboard`); the front end saves them too. - pub keymap: crate::KeyMap, - /// Custom ribbon and Quick Access Toolbar (`tools.customizeRibbon`); the front end saves it too. - pub ribbon: crate::RibbonLayout, - /// The language new blank documents (and opened plain text) are written in, a BCP 47 tag; - /// `None` is WordCraft's default, US English. The front end sets it from the interface - /// language with [`Session::set_editing_language`] (a Polish interface writes Polish). - pub editing_lang: Option, - /// Editing inside an equation: which one and the caret in it. - pub math: Option, - /// Equations are typed in LaTeX rather than the linear format. - pub math_latex: bool, - /// Text typed into equations is normal (non-math) text. - pub math_normal_text: bool, - /// The password Word documents are saved with (File › Info › Protect Document › Encrypt with - /// Password, `file.encrypt`); `None` saves them unencrypted. Kept from a password-protected - /// file that was opened, cleared when the document is replaced. - pub password: Option, - /// The recovery worker every open document's copy is kept through (desktop hosts only). - #[cfg(not(target_arch = "wasm32"))] - pub(crate) recovery: Option, - /// The active document's recovery copy. - #[cfg(not(target_arch = "wasm32"))] - pub(crate) recovery_slot: crate::recovery::Slot, - /// More pictures and shapes selected along with the one the selection holds (Shift+click), - /// for Group. Cleared by any edit or selection change other than adding to it. - pub also_selected: Vec, - /// The member of the selected Drawing Canvas picked on its own (#2637), which Shape Format, - /// Delete, Copy and the frame act on; see [`crate::cmd::canvas::selected_member`]. Cleared by - /// any selection change, caret movement or undo. - pub member: Option, - /// Column (block) selection, if one was made (see [`Session::column_segments`]). - pub column: Option, - /// Column selection mode (Ctrl+Shift+F8): caret movement extends the block. - pub column_mode: bool, - /// A selection widened to whole table cells or rows, and where it really runs (see - /// [`Session::sel_ends`]). - pub(crate) widened: Option, - /// Where the last edits happened, oldest first (at most [`EDIT_MARKS`]), for Go Back - /// (Shift+F5). Positions aren't mapped through later edits; they're clamped when used. - edit_marks: Vec, - /// How many Go Back presses since the last edit (which place comes next). - go_back_step: usize, - /// The open documents other than the active one, in no particular order. - others: Vec, - /// Every open document's id, the active one's too, in the order they were opened. - order: Vec, - /// Untitled documents made so far, for the next one's name (`Document3`). - untitled_count: u32, -} - -impl Session { - /// File name of the persisted custom dictionary inside the settings folder. - pub const USER_DICT_FILE: &str = "custom-dictionary.txt"; -} +//! The editing session: document, selection, undo/redo, layout, view state. + +use std::sync::Arc; + +use serde_json::Value; +use wordcraft_doc::edit::Fragment; +use wordcraft_doc::props::CharProps; +use wordcraft_doc::{Document, PartKind, Path, Pos, StoryRef}; +use wordcraft_layout::{DocLayout, LayoutCache, LayoutOptions, ViewMode}; + +use crate::{CmdError, ImeEditOutcome, Registry}; + +mod documents; +mod views; +pub use documents::{DocInfo, DocState}; +use documents::{document_id_mark, next_document_id, untitled_name}; +pub use views::WindowView; +mod history; +mod ime; +mod layout; +mod lifecycle; +mod query; +mod run; +mod snapshot; + +use crate::doc_state; +use crate::ime as ime_state; +use crate::locale; +use crate::prefs; +use crate::snapshot as snapshot_types; +use crate::view; +pub(crate) use doc_state::Widened; +pub use doc_state::{CellRange, ColumnBlock, FindState, MathEdit, Selection, UNDO_LIST_MAX, next_math_session}; +use doc_state::{TypedWord, Undo, doc_bib_style}; +use ime_state::ImeCaretState; +pub use locale::set_system_locale; +pub(crate) use locale::system_locale; +pub use prefs::Prefs; +pub use query::blank_document; +pub use snapshot_types::EditSnapshot; +use snapshot_types::{LayoutSlots, RunSnapshot}; +pub use view::{StylesPaneDock, ViewState}; + +/// An editing session: the app-wide state (clipboard, preferences, macros, the command registry…) +/// and the open documents. The active document's state is in the session's own fields, which +/// every command works on; the other open documents wait in [`DocState`] values and are swapped +/// in when they become active (see [`Session::documents`]). +pub struct Session { + pub doc: Document, + pub sel: Selection, + pub view: ViewState, + /// Formatting picked with a collapsed caret, applied to the next typed text. + pub pending: Option, + /// Last highlighter choice for the shortcut; palette state, outside document undo. + pub(crate) last_highlight: wordcraft_doc::props::Highlight, + pub path: Option, + /// Name given to a document not saved yet (File › Info › Rename): what Save suggests. A file + /// name, not document metadata, so it is not part of the document or its undo history. + pub file_name: Option, + pub dirty: bool, + /// Where the custom dictionary is kept on disk, if the host wired one up. Only the desktop + /// app does: headless sessions (MCP, `wordcraft-cli run`), tests and the web build leave + /// `None`, so an agent's words stay in memory and never change the person's dictionary. + /// Add to Dictionary and File › Options › Proofing save through it (#40). + pub user_dict_path: Option, + pub clipboard: Option, + /// A tracked cut the next paste can turn into a move (see `cmd::moves`). + pub pending_move: Option, + /// Plain text mirror of the clipboard (for the system clipboard). + pub clipboard_text: String, + /// HTML for the system clipboard beside [`Self::clipboard_text`]: the copy's formatting for + /// programs that read HTML, equations as MathML (#816); empty when nothing was copied. + pub clipboard_html: String, + /// RTF for the system clipboard beside [`Self::clipboard_html`], for programs that read RTF + /// rather than HTML; empty when nothing was copied. + pub clipboard_rtf: String, + /// Items collected by Copy and Cut this session, for the Clipboard pane. + pub clip_history: crate::cmd::edit::ClipHistory, + pub find: FindState, + /// Page x the caret tries to keep on Up/Down. + pub goal_x: Option, + /// Page the caret was last on (headers/footers repeat on many pages). + pub page_hint: usize, + pub registry: Arc, + /// Author name for comments and tracked changes. + pub author: String, + /// Initials for comments (File › Options › General); empty = the name's initials. + pub initials: String, + /// Format painter: copied formatting waiting to be applied (and whether it stays on). + pub painter: Option<(CharProps, wordcraft_doc::props::ParaProps, bool)>, + /// Last message for the status bar / agents. + pub status: String, + /// Headers/footers `insert.createHeader`/`insert.createFooter` made for a page that had none + /// (#960): `insert.closeHeader` takes those still empty away again, as Word does, and saving + /// leaves them out. Kept after that, so one an undo brings back goes again on the next close. + pub new_header_parts: Vec, + /// The body selection a command left for a header or footer: `insert.closeHeader` puts it back. + pub hf_return: Option, + /// The style-definition change Next / Previous Change selected, for the Accept or Reject that + /// follows (`cmd::review`). It names its document and selection, so it is shared by the open + /// documents and stops counting once either changes. + pub(crate) review_style: Option, + /// Undo and redo steps. Shared so `run` can snapshot both stacks cheaply (a pointer per + /// step) and put them back exactly when a command fails. + history: Vec>, + /// Undo steps dropped so far because of the undo limit. + undo_evicted: u64, + redo: Vec>, + /// Typing is coalesced into one undo step until something else happens. + typing_open: bool, + /// The next mutating command joins the previous undo step (later frames of a drag). + join_next: bool, + /// The next equation typing command joins a successful equation `deleteSurrounding`'s undo + /// step. Unlike [`Self::join_next`], any other command consumes and discards it, so an + /// unrelated Bold or document replacement can never be merged into the deletion. + equation_join_next: bool, + /// The next `text.insert` at this exact selection joins a successful body + /// `DeleteSurrounding`'s undo step. Any other command consumes and discards it, so an + /// intervening edit owns its own checkpoint instead of being merged into the deletion. + ime_text_join: Option, + /// Whether the undo group has a step of its own for the next joined command to merge + /// into. A command that changed nothing leaves the group without one, so the next + /// command in the group starts the step the group needs. + join_anchor: bool, + /// The newest automatic capitalisation, for Word's "Automatically add words to list" (#1857). + pub(crate) autocap: Option, + rev: u64, + /// Which document this is, unique in the process: changes when [`Session::set_document`] + /// replaces it (an open into an untouched blank document, recover), never on an edit or when + /// another document becomes active and this one comes back. + document_id: u64, + /// The number in an untitled document's name (`Document2`); 0 for one opened from a file. + untitled: u32, + /// `rev` when the document was made, for [`Session::is_untouched_blank`]. + made_rev: u64, + /// What Restrict Editing's outright refusals depend on, for greying out commands + /// ([`Session::why_disabled`]): computed once per document, revision and author instead of + /// for every button on every frame. + protect_facts: std::sync::Mutex>, + /// How many times the document's body was replaced under the same [`Self::document_id`] + /// (`document.setText`, version restore, compare, Undo/Redo). It is the body's IME + /// replacement epoch: a composition must not survive the swap, even when the new body has + /// the same text or the same bounded surrounding window. + ime_body_epoch: u64, + /// Engine-owned, checked, process-monotonic revision for IME receipts whose target cannot + /// provide surrounding text. `None` means the allocator is exhausted (fail closed). + ime_state_revision: Option, + /// The caret/equation-session token the revision was last allocated for. It covers selection + /// and caret changes without walking the document. + ime_state_seen: Option, + /// The current command's explicit IME effect classification. + ime_edit_outcome: ImeEditOutcome, + /// Simulation of a globally exhausted IME revision allocator for front-end producer tests. + /// Only [`Self::exhaust_ime_state_revision`] sets it; production builds do not compile it, + /// and it only makes receipt classification fail closed. + #[cfg(any(test, feature = "test-support"))] + ime_state_exhausted: bool, + /// A successful non-IME command changed document state since the last IME observation. It is + /// drained before publishing a receipt and at observation time; it is never cleared by a later + /// command. It covers mixed formatting/IME frames when available stamps ignore the revision. + pending_ime_application_change: bool, + /// Whether the current command was entered through [`Session::run_ime`]. Nested commands + /// inherit the origin. + ime_origin: bool, + cache: LayoutCache, + layout: LayoutSlots, + /// The word being typed, left without a spelling squiggle ([`TypedWord`]). + typing: Option, + /// Picture edits: edited media key → original media key (Reset Picture). + pub originals: std::collections::HashMap, + /// View › Markdown Source: its text, while it is open. + pub md_source: Option, + /// A Markdown file's own text, so Save can write it unchanged. + pub md_file: crate::cmd::md_source::FileText, + /// Picture effects, styles and borders: drawn media key → what was drawn on which bitmap, + /// so the next one replaces them instead of drawing over them (#2052). + pub picture_looks: std::collections::HashMap, + /// Last mutating command (Repeat). + pub last_command: Option<(String, Value)>, + /// Macro being recorded: (name, steps). + pub recording: Option<(String, Vec<(String, Value)>)>, + pub macros: std::collections::BTreeMap>, + /// Saved versions: (label, date, document). + pub versions: Vec<(String, String, Document)>, + /// Quick Parts / AutoText entries. + pub building_blocks: std::collections::BTreeMap, + pub autosave: bool, + /// Citation style: APA, MLA, Chicago, IEEE. + pub bib_style: String, + /// Mail merge data source and preview. + pub merge: crate::cmd::mailings::MergeState, + /// Requests from commands to the UI (open a dialog, scroll…), drained by the front end. + pub ui_requests: Vec, + /// View › Window: the other WordCraft windows and Side by Side (#322). + pub windows: crate::cmd::view::Windows, + /// Read Aloud player (Review › Speech). + pub read_aloud: crate::speech::ReadAloud, + /// Preferences the front end saves between runs. + pub prefs: Prefs, + /// The system region (#838): the paper a new blank document starts on, the default + /// measurement unit and the decimal separator. The front end sets it from the platform (the + /// browser's locale on the web); headless sessions keep `en-US`. A region the user chose + /// ([`Prefs::region`]) wins: read it with [`Session::region`]. + pub system_region: wordcraft_geom::Region, + /// Custom keyboard shortcuts (`tools.customizeKeyboard`); the front end saves them too. + pub keymap: crate::KeyMap, + /// Custom ribbon and Quick Access Toolbar (`tools.customizeRibbon`); the front end saves it too. + pub ribbon: crate::RibbonLayout, + /// The language new blank documents (and opened plain text) are written in, a BCP 47 tag; + /// `None` is WordCraft's default, US English. The front end sets it from the interface + /// language with [`Session::set_editing_language`] (a Polish interface writes Polish). + pub editing_lang: Option, + /// Editing inside an equation: which one and the caret in it. + pub math: Option, + /// Equations are typed in LaTeX rather than the linear format. + pub math_latex: bool, + /// Text typed into equations is normal (non-math) text. + pub math_normal_text: bool, + /// The password Word documents are saved with (File › Info › Protect Document › Encrypt with + /// Password, `file.encrypt`); `None` saves them unencrypted. Kept from a password-protected + /// file that was opened, cleared when the document is replaced. + pub password: Option, + /// The recovery worker every open document's copy is kept through (desktop hosts only). + #[cfg(not(target_arch = "wasm32"))] + pub(crate) recovery: Option, + /// The active document's recovery copy. + #[cfg(not(target_arch = "wasm32"))] + pub(crate) recovery_slot: crate::recovery::Slot, + /// More pictures and shapes selected along with the one the selection holds (Shift+click), + /// for Group. Cleared by any edit or selection change other than adding to it. + pub also_selected: Vec, + /// The member of the selected Drawing Canvas picked on its own (#2637), which Shape Format, + /// Delete, Copy and the frame act on; see [`crate::cmd::canvas::selected_member`]. Cleared by + /// any selection change, caret movement or undo. + pub member: Option, + /// Column (block) selection, if one was made (see [`Session::column_segments`]). + pub column: Option, + /// Column selection mode (Ctrl+Shift+F8): caret movement extends the block. + pub column_mode: bool, + /// A selection widened to whole table cells or rows, and where it really runs (see + /// [`Session::sel_ends`]). + pub(crate) widened: Option, + /// Where the last edits happened, oldest first (at most [`EDIT_MARKS`]), for Go Back + /// (Shift+F5). Positions aren't mapped through later edits; they're clamped when used. + edit_marks: Vec, + /// How many Go Back presses since the last edit (which place comes next). + go_back_step: usize, + /// The open documents other than the active one, in no particular order. + others: Vec, + /// Every open document's id, the active one's too, in the order they were opened. + order: Vec, + /// Untitled documents made so far, for the next one's name (`Document3`). + untitled_count: u32, +} + +impl Session { + /// File name of the persisted custom dictionary inside the settings folder. + pub const USER_DICT_FILE: &str = "custom-dictionary.txt"; +} diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index 425ec98697..74fbcae28a 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -1,689 +1,689 @@ -//! Several open documents in one session. The active document lives in the session's own fields, -//! where every command works on it; the others wait as [`DocState`] values and are swapped in, -//! field by field, when they become active. Swapping moves the fields: nothing is cloned, laid -//! out again or thrown away, so a host may switch documents as often as it likes. - -use std::path::{Path as FsPath, PathBuf}; -use std::sync::Arc; -use std::sync::atomic::{AtomicU64, Ordering}; - -use serde::Serialize; -use serde_json::json; -use wordcraft_doc::props::CharProps; -use wordcraft_doc::{Document, Pos, StoryRef}; -use wordcraft_layout::LayoutCache; - -use super::{ColumnBlock, FindState, LayoutSlots, MathEdit, Selection, Session, TypedWord, Undo, ViewState, Widened, doc_bib_style}; -use crate::CmdError; - -/// Document ids are unique in the process, so an id an agent or a window holds never names -/// another document, even across sessions. -static NEXT_DOCUMENT_ID: AtomicU64 = AtomicU64::new(1); - -pub(super) fn next_document_id() -> u64 { - NEXT_DOCUMENT_ID.fetch_add(1, Ordering::Relaxed) -} - -/// The id the next document will get: every document made after this call has an id at least -/// this big. -pub(super) fn document_id_mark() -> u64 { - NEXT_DOCUMENT_ID.load(Ordering::Relaxed) -} - -/// An untitled document's name: `Document1`, `Document2`… -pub(super) fn untitled_name(n: u32) -> String { - format!("Document{}", n.max(1)) -} - -/// An open document that isn't the active one: every per-document field of [`Session`], waiting -/// to be swapped back in. The session's app-wide state (clipboard, preferences, macros, Quick -/// Parts, the custom dictionary, Read Aloud…) is not here. -pub struct DocState { - pub(super) doc: Document, - pub(super) sel: Selection, - pub(super) view: ViewState, - pub(super) pending: Option, - pub(super) path: Option, - pub(super) file_name: Option, - pub(super) dirty: bool, - pub(super) pending_move: Option, - pub(super) find: FindState, - pub(super) goal_x: Option, - pub(super) page_hint: usize, - pub(super) new_header_parts: Vec, - pub(super) hf_return: Option, - pub(super) history: Vec>, - pub(super) undo_evicted: u64, - pub(super) redo: Vec>, - pub(super) typing_open: bool, - pub(super) join_next: bool, - pub(super) equation_join_next: bool, - pub(super) ime_text_join: Option, - pub(super) join_anchor: bool, - pub(super) autocap: Option, - pub(super) rev: u64, - pub(super) document_id: u64, - pub(super) untitled: u32, - pub(super) made_rev: u64, - pub(super) ime_body_epoch: u64, - pub(super) cache: LayoutCache, - pub(super) layout: LayoutSlots, - pub(super) typing: Option, - pub(super) originals: std::collections::HashMap, - pub(super) md_source: Option, - pub(super) md_file: crate::cmd::md_source::FileText, - pub(super) picture_looks: std::collections::HashMap, - pub(super) versions: Vec<(String, String, Document)>, - pub(super) autosave: bool, - pub(super) bib_style: String, - pub(super) merge: crate::cmd::mailings::MergeState, - pub(super) math: Option, - pub(super) password: Option, - #[cfg(not(target_arch = "wasm32"))] - pub(super) recovery_slot: crate::recovery::Slot, - pub(super) also_selected: Vec, - pub(super) column: Option, - pub(super) column_mode: bool, - pub(super) widened: Option, - pub(super) edit_marks: Vec, - pub(super) go_back_step: usize, -} - -impl DocState { - /// A fresh document state: caret at the start, no history, not dirty, a new id. - pub(super) fn new(doc: Document, untitled: u32, view: ViewState) -> Self { - let start = doc.start_of(StoryRef::Body); - let bib_style = doc_bib_style(&doc); - DocState { - doc, - sel: Selection::caret(start), - view, - pending: None, - path: None, - file_name: None, - dirty: false, - pending_move: None, - find: FindState::default(), - goal_x: None, - page_hint: 0, - new_header_parts: Vec::new(), - hf_return: None, - history: Vec::new(), - undo_evicted: 0, - redo: Vec::new(), - typing_open: false, - join_next: false, - equation_join_next: false, - ime_text_join: None, - join_anchor: false, - autocap: None, - rev: 1, - document_id: next_document_id(), - untitled, - made_rev: 1, - ime_body_epoch: 0, - cache: LayoutCache::new(), - layout: Default::default(), - typing: None, - originals: Default::default(), - md_source: None, - md_file: Default::default(), - picture_looks: Default::default(), - versions: Vec::new(), - autosave: true, - bib_style, - merge: Default::default(), - math: None, - password: None, - #[cfg(not(target_arch = "wasm32"))] - recovery_slot: Default::default(), - also_selected: Vec::new(), - column: None, - column_mode: false, - widened: None, - edit_marks: Vec::new(), - go_back_step: 0, - } - } - - /// The document's id ([`Session::document_id`] while it is active). - pub fn id(&self) -> u64 { - self.document_id - } - - fn info(&self) -> DocInfo { - DocInfo { - id: self.document_id, - title: title(self.path.as_deref(), self.file_name.as_deref(), &self.doc, self.untitled), - path: self.path.clone(), - dirty: self.dirty, - active: false, - } - } -} - -/// One open document, as [`Session::documents`] lists it. -#[derive(Clone, Debug, PartialEq, Eq, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct DocInfo { - /// The document's id: unique in the process, kept while the document is open. - pub id: u64, - /// The name the title bar shows: the file's name without its extension, else the name given - /// with Rename, else the document's Title property, else `Document1`, `Document2`… (English; - /// a front end names untitled documents in its own language). - pub title: String, - pub path: Option, - /// Changed since it was opened or last saved. - pub dirty: bool, - /// The active document, the one commands work on. - pub active: bool, -} - -fn title(path: Option<&FsPath>, file_name: Option<&str>, doc: &Document, untitled: u32) -> String { - match (path, file_name) { - (Some(p), _) => p.file_stem().map_or_else(|| p.to_string_lossy().to_string(), |s| s.to_string_lossy().to_string()), - (None, Some(name)) => name.to_string(), - (None, None) if !doc.core.title.is_empty() => doc.core.title.clone(), - (None, None) => untitled_name(untitled), - } -} - -impl Session { - /// Swap the active document's state with `d`'s: `d` then holds the document that was active. - fn swap_doc(&mut self, d: &mut DocState) { - crate::cmd::md_source::apply(self); - use std::mem::swap; - // Every field is listed, so a new one doesn't build until someone decides whether each - // open document has its own (then it goes in `DocState` and is swapped below) or the - // session shares it. - let Session { - // Per document. - doc: _, - sel: _, - view: _, - pending: _, - path: _, - file_name: _, - dirty: _, - pending_move: _, - find: _, - goal_x: _, - page_hint: _, - new_header_parts: _, - hf_return: _, - history: _, - undo_evicted: _, - redo: _, - typing_open: _, - join_next: _, - equation_join_next: _, - ime_text_join: _, - join_anchor: _, - autocap: _, - rev: _, - document_id: _, - untitled: _, - made_rev: _, - ime_body_epoch: _, - cache: _, - layout: _, - typing: _, - originals: _, - md_source: _, - md_file: _, - picture_looks: _, - versions: _, - autosave: _, - bib_style: _, - merge: _, - math: _, - password: _, - #[cfg(not(target_arch = "wasm32"))] - recovery_slot: _, - also_selected: _, - column: _, - column_mode: _, - widened: _, - edit_marks: _, - go_back_step: _, - // Shared by every open document. - // Names the document it belongs to, so it never applies to another one. - review_style: _, - user_dict_path: _, - clipboard: _, - clipboard_text: _, - clipboard_html: _, - clipboard_rtf: _, - clip_history: _, - registry: _, - author: _, - initials: _, - painter: _, - // What the drawn window knows of the others: its front end sets it as it draws. - windows: _, - status: _, - last_command: _, - recording: _, - macros: _, - building_blocks: _, - ui_requests: _, - read_aloud: _, - prefs: _, - system_region: _, - keymap: _, - ribbon: _, - editing_lang: _, - math_latex: _, - math_normal_text: _, - last_highlight: _, - // Keyed by document id and revision, so one cache serves every document. - protect_facts: _, - // The input method talks to the window, whichever document it shows; the IME - // context is keyed by `document_id`. - ime_state_revision: _, - ime_state_seen: _, - ime_edit_outcome: _, - #[cfg(any(test, feature = "test-support"))] - ime_state_exhausted: _, - pending_ime_application_change: _, - ime_origin: _, - // A picked Drawing Canvas member is cleared by a switch, not kept per document. - member: _, - others: _, - order: _, - untitled_count: _, - #[cfg(not(target_arch = "wasm32"))] - recovery: _, - } = self; - let DocState { - doc, - sel, - view, - pending, - path, - file_name, - dirty, - pending_move, - find, - goal_x, - page_hint, - new_header_parts, - hf_return, - history, - undo_evicted, - redo, - typing_open, - join_next, - equation_join_next, - ime_text_join, - join_anchor, - autocap, - rev, - document_id, - untitled, - made_rev, - ime_body_epoch, - cache, - layout, - typing, - originals, - md_source, - md_file, - picture_looks, - versions, - autosave, - bib_style, - merge, - math, - password, - #[cfg(not(target_arch = "wasm32"))] - recovery_slot, - also_selected, - column, - column_mode, - widened, - edit_marks, - go_back_step, - } = d; - swap(&mut self.doc, doc); - swap(&mut self.sel, sel); - swap(&mut self.view, view); - swap(&mut self.pending, pending); - swap(&mut self.path, path); - swap(&mut self.file_name, file_name); - swap(&mut self.dirty, dirty); - swap(&mut self.pending_move, pending_move); - swap(&mut self.find, find); - swap(&mut self.goal_x, goal_x); - swap(&mut self.page_hint, page_hint); - swap(&mut self.new_header_parts, new_header_parts); - swap(&mut self.hf_return, hf_return); - swap(&mut self.history, history); - swap(&mut self.undo_evicted, undo_evicted); - swap(&mut self.redo, redo); - swap(&mut self.typing_open, typing_open); - swap(&mut self.join_next, join_next); - swap(&mut self.equation_join_next, equation_join_next); - swap(&mut self.ime_text_join, ime_text_join); - swap(&mut self.join_anchor, join_anchor); - swap(&mut self.autocap, autocap); - swap(&mut self.rev, rev); - swap(&mut self.document_id, document_id); - swap(&mut self.untitled, untitled); - swap(&mut self.made_rev, made_rev); - swap(&mut self.ime_body_epoch, ime_body_epoch); - swap(&mut self.cache, cache); - swap(&mut self.layout, layout); - swap(&mut self.typing, typing); - swap(&mut self.originals, originals); - swap(&mut self.md_source, md_source); - swap(&mut self.md_file, md_file); - swap(&mut self.picture_looks, picture_looks); - swap(&mut self.versions, versions); - swap(&mut self.autosave, autosave); - swap(&mut self.bib_style, bib_style); - swap(&mut self.merge, merge); - swap(&mut self.math, math); - swap(&mut self.password, password); - #[cfg(not(target_arch = "wasm32"))] - swap(&mut self.recovery_slot, recovery_slot); - swap(&mut self.also_selected, also_selected); - swap(&mut self.column, column); - swap(&mut self.column_mode, column_mode); - swap(&mut self.widened, widened); - swap(&mut self.edit_marks, edit_marks); - swap(&mut self.go_back_step, go_back_step); - // A picked Drawing Canvas member (#2637) belongs to the document left behind. - self.member = None; - } - - /// Make document `id` active without anything else a switch by the user does. - pub(super) fn switch_to(&mut self, id: u64) -> Result<(), CmdError> { - if id == self.document_id { - return Ok(()); - } - crate::cmd::md_source::apply(self); - let at = self.others.iter().position(|d| d.document_id == id).ok_or_else(|| unknown(id))?; - let mut d = self.others.swap_remove(at); - self.swap_doc(&mut d); - self.view.follow_prefs(&self.prefs); - self.others.push(d); - Ok(()) - } - - /// Take parked document `id` out of the session; false when it isn't parked here. - fn remove_parked(&mut self, id: u64) -> bool { - let Some(at) = self.others.iter().position(|d| d.document_id == id) else { return false }; - let d = self.others.swap_remove(at); - self.order.retain(|d| *d != id); - self.forget_recovery_copy(&d); - true - } - - /// Whether document `id` is open in this session (active or not). - pub fn is_open(&self, id: u64) -> bool { - id == self.document_id || self.others.iter().any(|d| d.document_id == id) - } - - /// Whether the open document `id` has changes that aren't written to a file yet: the - /// active document's own flag, or a background document's. - pub fn document_dirty(&self, id: u64) -> bool { - if id == self.document_id { self.dirty } else { self.others.iter().any(|d| d.document_id == id && d.dirty) } - } - - /// Every open document in the order they were opened, the active one included. - pub fn documents(&self) -> Vec { - self.order - .iter() - .filter_map(|&id| { - if id == self.document_id { - Some(DocInfo { - id, - title: title(self.path.as_deref(), self.file_name.as_deref(), &self.doc, self.untitled), - path: self.path.clone(), - dirty: self.dirty, - active: true, - }) - } else { - self.others.iter().find(|d| d.document_id == id).map(DocState::info) - } - }) - .collect() - } - - /// Make document `id` the active one, the one commands work on (nothing happens when it - /// already is). A Read Aloud reading of another document stops. An id that isn't open is an - /// error. - pub fn activate(&mut self, id: u64) -> Result<(), CmdError> { - self.switch_to(id)?; - self.stop_reading_elsewhere(); - Ok(()) - } - - /// Make document `id` the active one, as [`Session::activate`] does, but a Read Aloud - /// reading of another document carries on: for a front end that shows each document in a - /// window of its own, and makes each window's document active to draw it. An id that isn't - /// open is an error. - pub fn switch_document(&mut self, id: u64) -> Result<(), CmdError> { - self.switch_to(id) - } - - /// Open `doc` as another document and make it active; the one that was active stays open. - /// The new document starts as a replaced one does ([`Session::set_document`]): caret at the - /// start, no undo history, not dirty, no path, an untitled name (`Document2`), and the view - /// the active document had. Returns its id. - pub fn add_document(&mut self, doc: Document) -> u64 { - self.untitled_count = self.untitled_count.saturating_add(1); - self.add(doc, self.untitled_count) - } - - /// [`Session::add_document`] for a document opened from a file: it doesn't use up an - /// untitled name. - pub(crate) fn add_opened_document(&mut self, doc: Document) -> u64 { - self.add(doc, 0) - } - - /// Adds `doc` to the session and makes it active, parking the previously active document - /// and applying any pending Markdown Source text first. - fn add(&mut self, mut doc: Document, untitled: u32) -> u64 { - crate::cmd::md_source::apply(self); - doc.ensure_nonempty(); - let mut view = self.view.clone(); - view.outline.collapsed.clear(); - view.follow_prefs(&self.prefs); - let mut d = DocState::new(doc, untitled, view); - let id = d.document_id; - self.order.push(id); - self.swap_doc(&mut d); - self.others.push(d); - self.stop_reading_elsewhere(); - id - } - - /// A closed document's recovery copy goes with it, as its unsaved changes do. - fn forget_recovery_copy(&mut self, d: &DocState) { - #[cfg(not(target_arch = "wasm32"))] - if let (Some(r), Some(id)) = (&mut self.recovery, &d.recovery_slot.id) { - r.close(id.clone()); - } - #[cfg(target_arch = "wasm32")] - let _ = d; - } - - /// What recovery reads from every open document, the active one first, with each - /// document's slot to update. Nothing is cloned or laid out. - #[cfg(not(target_arch = "wasm32"))] - pub(crate) fn recovery_docs(&mut self) -> impl Iterator> { - let active = crate::recovery::DocRecovery { - id: self.document_id, - wants_copy: self.dirty && self.password.is_none(), - rev: self.rev, - doc: &self.doc, - path: self.path.as_deref(), - slot: &mut self.recovery_slot, - }; - std::iter::once(active).chain(self.others.iter_mut().map(|d| crate::recovery::DocRecovery { - id: d.document_id, - wants_copy: d.dirty && d.password.is_none(), - rev: d.rev, - doc: &d.doc, - path: d.path.as_deref(), - slot: &mut d.recovery_slot, - })) - } - - /// Every open document's recovery slot, with whether it should have a current copy and - /// its revision. - #[cfg(not(target_arch = "wasm32"))] - pub(crate) fn recovery_slots(&self) -> impl Iterator { - std::iter::once((self.dirty && self.password.is_none(), self.rev, &self.recovery_slot)) - .chain(self.others.iter().map(|d| (d.dirty && d.password.is_none(), d.rev, &d.recovery_slot))) - } - - /// Close document `id` without asking: its unsaved changes are lost (callers ask first), - /// and so is its recovery copy. - /// Closing the active document makes the one opened before it active (else the one after). - /// Closing the last open document leaves a new blank one, so a session always has an - /// active document; what a window does then is the front end's choice. An id that isn't - /// open is an error. - pub fn close_document(&mut self, id: u64) -> Result<(), CmdError> { - if id != self.document_id { - if !self.remove_parked(id) { - return Err(unknown(id)); - } - return Ok(()); - } - let at = self.order.iter().position(|d| *d == id); - let next = at.and_then(|i| i.checked_sub(1).and_then(|p| self.order.get(p)).or_else(|| self.order.get(i.saturating_add(1)))).copied(); - match next { - Some(n) => { - self.switch_to(n)?; - self.remove_parked(id); - } - None => { - let mut doc = crate::cmd::file::normal_document(self); - doc.ensure_nonempty(); - self.untitled_count = self.untitled_count.saturating_add(1); - let mut view = self.view.clone(); - view.outline.collapsed.clear(); - let mut fresh = DocState::new(doc, self.untitled_count, view); - self.order = vec![fresh.document_id]; - self.swap_doc(&mut fresh); - self.forget_recovery_copy(&fresh); - } - } - self.stop_reading_elsewhere(); - Ok(()) - } - - /// The active document stands in for no document at all (a Start screen's, or what is left - /// after closing the last one): an untouched blank document gives back its untitled name - /// when it is the newest, so the first document a person sees is `Document1`, the next - /// `Document2`. - pub fn release_untitled_name(&mut self) { - if self.untitled != 0 && self.untitled == self.untitled_count && self.is_untouched_blank() { - self.untitled_count = self.untitled_count.saturating_sub(1); - self.untitled = 0; - } - } - - /// Whether the active document is a blank one nobody has used: no file and no name, never - /// changed, nothing to undo or redo and no text. Opening a file replaces such a document - /// rather than opening beside it. - pub fn is_untouched_blank(&self) -> bool { - self.used_because().is_none() - } - - /// Why the active document doesn't count as an untouched blank one, if it doesn't. - pub(crate) fn used_because(&self) -> Option<&'static str> { - if self.path.is_some() { - Some("it has a file") - } else if self.file_name.is_some() { - Some("it has a name") - } else if self.dirty { - Some("it has unsaved changes") - } else if !self.history.is_empty() || !self.redo.is_empty() { - Some("it has undo or redo steps") - } else if self.rev != self.made_rev { - Some("it changed since it was made") - } else if self.doc.paragraph_count() > 1 || !self.doc.plain_text(StoryRef::Body).trim().is_empty() { - Some("it has text") - } else { - None - } - } - - /// Run `f` with document `id` active, then make the document that was active before active - /// again (unless `f` closed it). For work addressed to a document other than the one a - /// window shows: an agent's command, a result that arrives later. Read Aloud carries on. - /// UI requests `f` makes (`ui_requests`) carry `"document": id` when that isn't the - /// document that was active. An id that isn't open is an error and `f` doesn't run. - pub fn with_document(&mut self, id: u64, f: impl FnOnce(&mut Session) -> R) -> Result { - let home = self.document_id; - self.switch_to(id)?; - // The queue drops its oldest entries when full, so `f`'s requests are told apart by - // collecting them on their own rather than by position. - let earlier = std::mem::take(&mut self.ui_requests); - let r = f(self); - let made = std::mem::replace(&mut self.ui_requests, earlier); - for mut q in made { - if id != home - && let Some(o) = q.as_object_mut() - { - o.entry("document").or_insert(json!(id)); - } - self.push_ui_request(q); - } - if self.document_id != home && self.is_open(home) { - self.switch_to(home)?; - } - Ok(r) - } - - /// The open document saved at `path`, if any: the same file reached by another spelling - /// (`./a.docx`, a symbolic link) counts on desktop. - pub fn find_open(&self, path: &FsPath) -> Option { - let wanted = crate::io::canonical(path); - let same = |p: &FsPath| p == path || wanted.is_some() && crate::io::canonical(p) == wanted; - self.order.iter().copied().find(|&id| { - if id == self.document_id { - self.path.as_deref().is_some_and(same) - } else { - self.others.iter().find(|d| d.document_id == id).and_then(|d| d.path.as_deref()).is_some_and(same) - } - }) - } - - /// Stop a Read Aloud reading of a document that isn't the active one (it highlights its - /// sentences in the document shown). - fn stop_reading_elsewhere(&mut self) { - if self.read_aloud.document.is_some_and(|d| d != self.document_id) && self.read_aloud.state() != crate::speech::State::Stopped { - self.read_aloud.stop(); - } - } - - /// Take the undo step `run` made for a command back out of the document it started in, when - /// the command ended up in another document (it opened or made one) and left the first - /// document's text as it was: opening a document is no edit to undo there. - pub(super) fn put_back_parked(&mut self, id: u64, before: Option) { - let Some((doc, sel, history, redo, typing_open, evicted, column)) = before else { return }; - if !self.is_open(id) { - return; - } - let _ = self.with_document(id, |s| { - if s.doc != doc { - return; - } - s.sel = sel; - s.history = history; - s.redo = redo; - s.typing_open = typing_open; - s.undo_evicted = evicted; - s.column = column; - }); - } -} - -fn unknown(id: u64) -> CmdError { - CmdError::Params(format!("no open document has id {id}")) -} +//! Several open documents in one session. The active document lives in the session's own fields, +//! where every command works on it; the others wait as [`DocState`] values and are swapped in, +//! field by field, when they become active. Swapping moves the fields: nothing is cloned, laid +//! out again or thrown away, so a host may switch documents as often as it likes. + +use std::path::{Path as FsPath, PathBuf}; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; + +use serde::Serialize; +use serde_json::json; +use wordcraft_doc::props::CharProps; +use wordcraft_doc::{Document, Pos, StoryRef}; +use wordcraft_layout::LayoutCache; + +use super::{ColumnBlock, FindState, LayoutSlots, MathEdit, Selection, Session, TypedWord, Undo, ViewState, Widened, doc_bib_style}; +use crate::CmdError; + +/// Document ids are unique in the process, so an id an agent or a window holds never names +/// another document, even across sessions. +static NEXT_DOCUMENT_ID: AtomicU64 = AtomicU64::new(1); + +pub(super) fn next_document_id() -> u64 { + NEXT_DOCUMENT_ID.fetch_add(1, Ordering::Relaxed) +} + +/// The id the next document will get: every document made after this call has an id at least +/// this big. +pub(super) fn document_id_mark() -> u64 { + NEXT_DOCUMENT_ID.load(Ordering::Relaxed) +} + +/// An untitled document's name: `Document1`, `Document2`… +pub(super) fn untitled_name(n: u32) -> String { + format!("Document{}", n.max(1)) +} + +/// An open document that isn't the active one: every per-document field of [`Session`], waiting +/// to be swapped back in. The session's app-wide state (clipboard, preferences, macros, Quick +/// Parts, the custom dictionary, Read Aloud…) is not here. +pub struct DocState { + pub(super) doc: Document, + pub(super) sel: Selection, + pub(super) view: ViewState, + pub(super) pending: Option, + pub(super) path: Option, + pub(super) file_name: Option, + pub(super) dirty: bool, + pub(super) pending_move: Option, + pub(super) find: FindState, + pub(super) goal_x: Option, + pub(super) page_hint: usize, + pub(super) new_header_parts: Vec, + pub(super) hf_return: Option, + pub(super) history: Vec>, + pub(super) undo_evicted: u64, + pub(super) redo: Vec>, + pub(super) typing_open: bool, + pub(super) join_next: bool, + pub(super) equation_join_next: bool, + pub(super) ime_text_join: Option, + pub(super) join_anchor: bool, + pub(super) autocap: Option, + pub(super) rev: u64, + pub(super) document_id: u64, + pub(super) untitled: u32, + pub(super) made_rev: u64, + pub(super) ime_body_epoch: u64, + pub(super) cache: LayoutCache, + pub(super) layout: LayoutSlots, + pub(super) typing: Option, + pub(super) originals: std::collections::HashMap, + pub(super) md_source: Option, + pub(super) md_file: crate::cmd::md_source::FileText, + pub(super) picture_looks: std::collections::HashMap, + pub(super) versions: Vec<(String, String, Document)>, + pub(super) autosave: bool, + pub(super) bib_style: String, + pub(super) merge: crate::cmd::mailings::MergeState, + pub(super) math: Option, + pub(super) password: Option, + #[cfg(not(target_arch = "wasm32"))] + pub(super) recovery_slot: crate::recovery::Slot, + pub(super) also_selected: Vec, + pub(super) column: Option, + pub(super) column_mode: bool, + pub(super) widened: Option, + pub(super) edit_marks: Vec, + pub(super) go_back_step: usize, +} + +impl DocState { + /// A fresh document state: caret at the start, no history, not dirty, a new id. + pub(super) fn new(doc: Document, untitled: u32, view: ViewState) -> Self { + let start = doc.start_of(StoryRef::Body); + let bib_style = doc_bib_style(&doc); + DocState { + doc, + sel: Selection::caret(start), + view, + pending: None, + path: None, + file_name: None, + dirty: false, + pending_move: None, + find: FindState::default(), + goal_x: None, + page_hint: 0, + new_header_parts: Vec::new(), + hf_return: None, + history: Vec::new(), + undo_evicted: 0, + redo: Vec::new(), + typing_open: false, + join_next: false, + equation_join_next: false, + ime_text_join: None, + join_anchor: false, + autocap: None, + rev: 1, + document_id: next_document_id(), + untitled, + made_rev: 1, + ime_body_epoch: 0, + cache: LayoutCache::new(), + layout: Default::default(), + typing: None, + originals: Default::default(), + md_source: None, + md_file: Default::default(), + picture_looks: Default::default(), + versions: Vec::new(), + autosave: true, + bib_style, + merge: Default::default(), + math: None, + password: None, + #[cfg(not(target_arch = "wasm32"))] + recovery_slot: Default::default(), + also_selected: Vec::new(), + column: None, + column_mode: false, + widened: None, + edit_marks: Vec::new(), + go_back_step: 0, + } + } + + /// The document's id ([`Session::document_id`] while it is active). + pub fn id(&self) -> u64 { + self.document_id + } + + fn info(&self) -> DocInfo { + DocInfo { + id: self.document_id, + title: title(self.path.as_deref(), self.file_name.as_deref(), &self.doc, self.untitled), + path: self.path.clone(), + dirty: self.dirty, + active: false, + } + } +} + +/// One open document, as [`Session::documents`] lists it. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DocInfo { + /// The document's id: unique in the process, kept while the document is open. + pub id: u64, + /// The name the title bar shows: the file's name without its extension, else the name given + /// with Rename, else the document's Title property, else `Document1`, `Document2`… (English; + /// a front end names untitled documents in its own language). + pub title: String, + pub path: Option, + /// Changed since it was opened or last saved. + pub dirty: bool, + /// The active document, the one commands work on. + pub active: bool, +} + +fn title(path: Option<&FsPath>, file_name: Option<&str>, doc: &Document, untitled: u32) -> String { + match (path, file_name) { + (Some(p), _) => p.file_stem().map_or_else(|| p.to_string_lossy().to_string(), |s| s.to_string_lossy().to_string()), + (None, Some(name)) => name.to_string(), + (None, None) if !doc.core.title.is_empty() => doc.core.title.clone(), + (None, None) => untitled_name(untitled), + } +} + +impl Session { + /// Swap the active document's state with `d`'s: `d` then holds the document that was active. + fn swap_doc(&mut self, d: &mut DocState) { + crate::cmd::md_source::apply(self); + use std::mem::swap; + // Every field is listed, so a new one doesn't build until someone decides whether each + // open document has its own (then it goes in `DocState` and is swapped below) or the + // session shares it. + let Session { + // Per document. + doc: _, + sel: _, + view: _, + pending: _, + path: _, + file_name: _, + dirty: _, + pending_move: _, + find: _, + goal_x: _, + page_hint: _, + new_header_parts: _, + hf_return: _, + history: _, + undo_evicted: _, + redo: _, + typing_open: _, + join_next: _, + equation_join_next: _, + ime_text_join: _, + join_anchor: _, + autocap: _, + rev: _, + document_id: _, + untitled: _, + made_rev: _, + ime_body_epoch: _, + cache: _, + layout: _, + typing: _, + originals: _, + md_source: _, + md_file: _, + picture_looks: _, + versions: _, + autosave: _, + bib_style: _, + merge: _, + math: _, + password: _, + #[cfg(not(target_arch = "wasm32"))] + recovery_slot: _, + also_selected: _, + column: _, + column_mode: _, + widened: _, + edit_marks: _, + go_back_step: _, + // Shared by every open document. + // Names the document it belongs to, so it never applies to another one. + review_style: _, + user_dict_path: _, + clipboard: _, + clipboard_text: _, + clipboard_html: _, + clipboard_rtf: _, + clip_history: _, + registry: _, + author: _, + initials: _, + painter: _, + // What the drawn window knows of the others: its front end sets it as it draws. + windows: _, + status: _, + last_command: _, + recording: _, + macros: _, + building_blocks: _, + ui_requests: _, + read_aloud: _, + prefs: _, + system_region: _, + keymap: _, + ribbon: _, + editing_lang: _, + math_latex: _, + math_normal_text: _, + last_highlight: _, + // Keyed by document id and revision, so one cache serves every document. + protect_facts: _, + // The input method talks to the window, whichever document it shows; the IME + // context is keyed by `document_id`. + ime_state_revision: _, + ime_state_seen: _, + ime_edit_outcome: _, + #[cfg(any(test, feature = "test-support"))] + ime_state_exhausted: _, + pending_ime_application_change: _, + ime_origin: _, + // A picked Drawing Canvas member is cleared by a switch, not kept per document. + member: _, + others: _, + order: _, + untitled_count: _, + #[cfg(not(target_arch = "wasm32"))] + recovery: _, + } = self; + let DocState { + doc, + sel, + view, + pending, + path, + file_name, + dirty, + pending_move, + find, + goal_x, + page_hint, + new_header_parts, + hf_return, + history, + undo_evicted, + redo, + typing_open, + join_next, + equation_join_next, + ime_text_join, + join_anchor, + autocap, + rev, + document_id, + untitled, + made_rev, + ime_body_epoch, + cache, + layout, + typing, + originals, + md_source, + md_file, + picture_looks, + versions, + autosave, + bib_style, + merge, + math, + password, + #[cfg(not(target_arch = "wasm32"))] + recovery_slot, + also_selected, + column, + column_mode, + widened, + edit_marks, + go_back_step, + } = d; + swap(&mut self.doc, doc); + swap(&mut self.sel, sel); + swap(&mut self.view, view); + swap(&mut self.pending, pending); + swap(&mut self.path, path); + swap(&mut self.file_name, file_name); + swap(&mut self.dirty, dirty); + swap(&mut self.pending_move, pending_move); + swap(&mut self.find, find); + swap(&mut self.goal_x, goal_x); + swap(&mut self.page_hint, page_hint); + swap(&mut self.new_header_parts, new_header_parts); + swap(&mut self.hf_return, hf_return); + swap(&mut self.history, history); + swap(&mut self.undo_evicted, undo_evicted); + swap(&mut self.redo, redo); + swap(&mut self.typing_open, typing_open); + swap(&mut self.join_next, join_next); + swap(&mut self.equation_join_next, equation_join_next); + swap(&mut self.ime_text_join, ime_text_join); + swap(&mut self.join_anchor, join_anchor); + swap(&mut self.autocap, autocap); + swap(&mut self.rev, rev); + swap(&mut self.document_id, document_id); + swap(&mut self.untitled, untitled); + swap(&mut self.made_rev, made_rev); + swap(&mut self.ime_body_epoch, ime_body_epoch); + swap(&mut self.cache, cache); + swap(&mut self.layout, layout); + swap(&mut self.typing, typing); + swap(&mut self.originals, originals); + swap(&mut self.md_source, md_source); + swap(&mut self.md_file, md_file); + swap(&mut self.picture_looks, picture_looks); + swap(&mut self.versions, versions); + swap(&mut self.autosave, autosave); + swap(&mut self.bib_style, bib_style); + swap(&mut self.merge, merge); + swap(&mut self.math, math); + swap(&mut self.password, password); + #[cfg(not(target_arch = "wasm32"))] + swap(&mut self.recovery_slot, recovery_slot); + swap(&mut self.also_selected, also_selected); + swap(&mut self.column, column); + swap(&mut self.column_mode, column_mode); + swap(&mut self.widened, widened); + swap(&mut self.edit_marks, edit_marks); + swap(&mut self.go_back_step, go_back_step); + // A picked Drawing Canvas member (#2637) belongs to the document left behind. + self.member = None; + } + + /// Make document `id` active without anything else a switch by the user does. + pub(super) fn switch_to(&mut self, id: u64) -> Result<(), CmdError> { + if id == self.document_id { + return Ok(()); + } + crate::cmd::md_source::apply(self); + let at = self.others.iter().position(|d| d.document_id == id).ok_or_else(|| unknown(id))?; + let mut d = self.others.swap_remove(at); + self.swap_doc(&mut d); + self.view.follow_prefs(&self.prefs); + self.others.push(d); + Ok(()) + } + + /// Take parked document `id` out of the session; false when it isn't parked here. + fn remove_parked(&mut self, id: u64) -> bool { + let Some(at) = self.others.iter().position(|d| d.document_id == id) else { return false }; + let d = self.others.swap_remove(at); + self.order.retain(|d| *d != id); + self.forget_recovery_copy(&d); + true + } + + /// Whether document `id` is open in this session (active or not). + pub fn is_open(&self, id: u64) -> bool { + id == self.document_id || self.others.iter().any(|d| d.document_id == id) + } + + /// Whether the open document `id` has changes that aren't written to a file yet: the + /// active document's own flag, or a background document's. + pub fn document_dirty(&self, id: u64) -> bool { + if id == self.document_id { self.dirty } else { self.others.iter().any(|d| d.document_id == id && d.dirty) } + } + + /// Every open document in the order they were opened, the active one included. + pub fn documents(&self) -> Vec { + self.order + .iter() + .filter_map(|&id| { + if id == self.document_id { + Some(DocInfo { + id, + title: title(self.path.as_deref(), self.file_name.as_deref(), &self.doc, self.untitled), + path: self.path.clone(), + dirty: self.dirty, + active: true, + }) + } else { + self.others.iter().find(|d| d.document_id == id).map(DocState::info) + } + }) + .collect() + } + + /// Make document `id` the active one, the one commands work on (nothing happens when it + /// already is). A Read Aloud reading of another document stops. An id that isn't open is an + /// error. + pub fn activate(&mut self, id: u64) -> Result<(), CmdError> { + self.switch_to(id)?; + self.stop_reading_elsewhere(); + Ok(()) + } + + /// Make document `id` the active one, as [`Session::activate`] does, but a Read Aloud + /// reading of another document carries on: for a front end that shows each document in a + /// window of its own, and makes each window's document active to draw it. An id that isn't + /// open is an error. + pub fn switch_document(&mut self, id: u64) -> Result<(), CmdError> { + self.switch_to(id) + } + + /// Open `doc` as another document and make it active; the one that was active stays open. + /// The new document starts as a replaced one does ([`Session::set_document`]): caret at the + /// start, no undo history, not dirty, no path, an untitled name (`Document2`), and the view + /// the active document had. Returns its id. + pub fn add_document(&mut self, doc: Document) -> u64 { + self.untitled_count = self.untitled_count.saturating_add(1); + self.add(doc, self.untitled_count) + } + + /// [`Session::add_document`] for a document opened from a file: it doesn't use up an + /// untitled name. + pub(crate) fn add_opened_document(&mut self, doc: Document) -> u64 { + self.add(doc, 0) + } + + /// Adds `doc` to the session and makes it active, parking the previously active document + /// and applying any pending Markdown Source text first. + fn add(&mut self, mut doc: Document, untitled: u32) -> u64 { + crate::cmd::md_source::apply(self); + doc.ensure_nonempty(); + let mut view = self.view.clone(); + view.outline.collapsed.clear(); + view.follow_prefs(&self.prefs); + let mut d = DocState::new(doc, untitled, view); + let id = d.document_id; + self.order.push(id); + self.swap_doc(&mut d); + self.others.push(d); + self.stop_reading_elsewhere(); + id + } + + /// A closed document's recovery copy goes with it, as its unsaved changes do. + fn forget_recovery_copy(&mut self, d: &DocState) { + #[cfg(not(target_arch = "wasm32"))] + if let (Some(r), Some(id)) = (&mut self.recovery, &d.recovery_slot.id) { + r.close(id.clone()); + } + #[cfg(target_arch = "wasm32")] + let _ = d; + } + + /// What recovery reads from every open document, the active one first, with each + /// document's slot to update. Nothing is cloned or laid out. + #[cfg(not(target_arch = "wasm32"))] + pub(crate) fn recovery_docs(&mut self) -> impl Iterator> { + let active = crate::recovery::DocRecovery { + id: self.document_id, + wants_copy: self.dirty && self.password.is_none(), + rev: self.rev, + doc: &self.doc, + path: self.path.as_deref(), + slot: &mut self.recovery_slot, + }; + std::iter::once(active).chain(self.others.iter_mut().map(|d| crate::recovery::DocRecovery { + id: d.document_id, + wants_copy: d.dirty && d.password.is_none(), + rev: d.rev, + doc: &d.doc, + path: d.path.as_deref(), + slot: &mut d.recovery_slot, + })) + } + + /// Every open document's recovery slot, with whether it should have a current copy and + /// its revision. + #[cfg(not(target_arch = "wasm32"))] + pub(crate) fn recovery_slots(&self) -> impl Iterator { + std::iter::once((self.dirty && self.password.is_none(), self.rev, &self.recovery_slot)) + .chain(self.others.iter().map(|d| (d.dirty && d.password.is_none(), d.rev, &d.recovery_slot))) + } + + /// Close document `id` without asking: its unsaved changes are lost (callers ask first), + /// and so is its recovery copy. + /// Closing the active document makes the one opened before it active (else the one after). + /// Closing the last open document leaves a new blank one, so a session always has an + /// active document; what a window does then is the front end's choice. An id that isn't + /// open is an error. + pub fn close_document(&mut self, id: u64) -> Result<(), CmdError> { + if id != self.document_id { + if !self.remove_parked(id) { + return Err(unknown(id)); + } + return Ok(()); + } + let at = self.order.iter().position(|d| *d == id); + let next = at.and_then(|i| i.checked_sub(1).and_then(|p| self.order.get(p)).or_else(|| self.order.get(i.saturating_add(1)))).copied(); + match next { + Some(n) => { + self.switch_to(n)?; + self.remove_parked(id); + } + None => { + let mut doc = crate::cmd::file::normal_document(self); + doc.ensure_nonempty(); + self.untitled_count = self.untitled_count.saturating_add(1); + let mut view = self.view.clone(); + view.outline.collapsed.clear(); + let mut fresh = DocState::new(doc, self.untitled_count, view); + self.order = vec![fresh.document_id]; + self.swap_doc(&mut fresh); + self.forget_recovery_copy(&fresh); + } + } + self.stop_reading_elsewhere(); + Ok(()) + } + + /// The active document stands in for no document at all (a Start screen's, or what is left + /// after closing the last one): an untouched blank document gives back its untitled name + /// when it is the newest, so the first document a person sees is `Document1`, the next + /// `Document2`. + pub fn release_untitled_name(&mut self) { + if self.untitled != 0 && self.untitled == self.untitled_count && self.is_untouched_blank() { + self.untitled_count = self.untitled_count.saturating_sub(1); + self.untitled = 0; + } + } + + /// Whether the active document is a blank one nobody has used: no file and no name, never + /// changed, nothing to undo or redo and no text. Opening a file replaces such a document + /// rather than opening beside it. + pub fn is_untouched_blank(&self) -> bool { + self.used_because().is_none() + } + + /// Why the active document doesn't count as an untouched blank one, if it doesn't. + pub(crate) fn used_because(&self) -> Option<&'static str> { + if self.path.is_some() { + Some("it has a file") + } else if self.file_name.is_some() { + Some("it has a name") + } else if self.dirty { + Some("it has unsaved changes") + } else if !self.history.is_empty() || !self.redo.is_empty() { + Some("it has undo or redo steps") + } else if self.rev != self.made_rev { + Some("it changed since it was made") + } else if self.doc.paragraph_count() > 1 || !self.doc.plain_text(StoryRef::Body).trim().is_empty() { + Some("it has text") + } else { + None + } + } + + /// Run `f` with document `id` active, then make the document that was active before active + /// again (unless `f` closed it). For work addressed to a document other than the one a + /// window shows: an agent's command, a result that arrives later. Read Aloud carries on. + /// UI requests `f` makes (`ui_requests`) carry `"document": id` when that isn't the + /// document that was active. An id that isn't open is an error and `f` doesn't run. + pub fn with_document(&mut self, id: u64, f: impl FnOnce(&mut Session) -> R) -> Result { + let home = self.document_id; + self.switch_to(id)?; + // The queue drops its oldest entries when full, so `f`'s requests are told apart by + // collecting them on their own rather than by position. + let earlier = std::mem::take(&mut self.ui_requests); + let r = f(self); + let made = std::mem::replace(&mut self.ui_requests, earlier); + for mut q in made { + if id != home + && let Some(o) = q.as_object_mut() + { + o.entry("document").or_insert(json!(id)); + } + self.push_ui_request(q); + } + if self.document_id != home && self.is_open(home) { + self.switch_to(home)?; + } + Ok(r) + } + + /// The open document saved at `path`, if any: the same file reached by another spelling + /// (`./a.docx`, a symbolic link) counts on desktop. + pub fn find_open(&self, path: &FsPath) -> Option { + let wanted = crate::io::canonical(path); + let same = |p: &FsPath| p == path || wanted.is_some() && crate::io::canonical(p) == wanted; + self.order.iter().copied().find(|&id| { + if id == self.document_id { + self.path.as_deref().is_some_and(same) + } else { + self.others.iter().find(|d| d.document_id == id).and_then(|d| d.path.as_deref()).is_some_and(same) + } + }) + } + + /// Stop a Read Aloud reading of a document that isn't the active one (it highlights its + /// sentences in the document shown). + fn stop_reading_elsewhere(&mut self) { + if self.read_aloud.document.is_some_and(|d| d != self.document_id) && self.read_aloud.state() != crate::speech::State::Stopped { + self.read_aloud.stop(); + } + } + + /// Take the undo step `run` made for a command back out of the document it started in, when + /// the command ended up in another document (it opened or made one) and left the first + /// document's text as it was: opening a document is no edit to undo there. + pub(super) fn put_back_parked(&mut self, id: u64, before: Option) { + let Some((doc, sel, history, redo, typing_open, evicted, column)) = before else { return }; + if !self.is_open(id) { + return; + } + let _ = self.with_document(id, |s| { + if s.doc != doc { + return; + } + s.sel = sel; + s.history = history; + s.redo = redo; + s.typing_open = typing_open; + s.undo_evicted = evicted; + s.column = column; + }); + } +} + +fn unknown(id: u64) -> CmdError { + CmdError::Params(format!("no open document has id {id}")) +} diff --git a/crates/engine/src/session/history.rs b/crates/engine/src/session/history.rs index 10664d160f..be15c2cd77 100644 --- a/crates/engine/src/session/history.rs +++ b/crates/engine/src/session/history.rs @@ -1,263 +1,263 @@ -use super::doc_state::{MAX_UNDO, Selection, UNDO_LIST_MAX, Undo, next_math_session}; -use super::{Arc, Document, Session}; - -impl Session { - /// Snapshot for undo before a change. Returns whether it took a step: typing that is - /// already in an open step is not a new one. - pub fn checkpoint(&mut self, label: &str) -> bool { - if label == "Typing" && self.typing_open { - return false; - } - self.typing_open = label == "Typing"; - self.history.push(Arc::new(Undo { - label: label.to_string(), - doc: self.doc.clone(), - sel: self.sel.clone(), - pending: self.pending.clone(), - column: self.column.clone(), - })); - if self.history.len() > MAX_UNDO { - self.history.remove(0); - self.undo_evicted += 1; - } - self.redo.clear(); - // The newest step is what a joined command merges into. - self.join_anchor = true; - true - } - - /// Take back the newest undo step: a command that joins an earlier step of its own. - pub(crate) fn drop_last_undo_step(&mut self) { - self.history.pop(); - } - - /// Record `doc`/`sel` as their own undo step after the fact and close the typing group, so - /// Undo right after an automatic change (AutoFormat, AutoCorrect) reverts only that change. - pub fn push_undo(&mut self, label: &str, doc: Document, sel: Selection) { - self.history.push(Arc::new(Undo { label: label.to_string(), doc, sel, pending: self.pending.clone(), column: None })); - if self.history.len() > MAX_UNDO { - self.history.remove(0); - self.undo_evicted += 1; - } - self.typing_open = false; - self.join_anchor = true; - } - - /// Make the next mutating command part of the previous undo step instead of a new one, so a - /// drag that runs a command every frame is a single Undo. Call it on every frame of the drag - /// except the first; it is consumed by the next `run`. - pub fn join_next_undo(&mut self) { - self.join_next = true; - } - - /// Make the next equation typing command (`equation.type` or `equation.backspace`) join the - /// previous equation deletion's undo step. Unlike [`Self::join_next_undo`], the promise is - /// not generic: any other command consumes and discards it, so unrelated edits get their own - /// checkpoint. A deletion-only IME request therefore cannot leak a join into the next edit. - pub fn join_next_equation_edit(&mut self) { - self.equation_join_next = true; - } - - /// Discard an equation replacement join that received no replacement (for example an empty - /// commit), so it cannot join a later composition's typing. - pub fn clear_equation_join(&mut self) { - self.equation_join_next = false; - } - - /// Make the next `text.insert` at the current selection join a successful body - /// `DeleteSurrounding`'s undo step. Unlike [`Self::join_next_undo`], the promise is scoped to - /// that exact replacement: any other command consumes and discards it, so an intervening - /// ordinary edit gets its own checkpoint. - pub fn join_next_ime_text_replacement(&mut self) { - self.ime_text_join = Some(self.sel.clone()); - } - - /// Discard a body replacement join that received no replacement (an empty or refused - /// commit, a new request or a refusal), so it cannot join a later composition. - pub fn clear_ime_text_join(&mut self) { - self.ime_text_join = None; - } - - /// Discard every pending IME replacement join. A refused surrounding request supersedes the - /// composition it interrupts, including any promise from an earlier accepted request. - pub fn clear_ime_joins(&mut self) { - self.equation_join_next = false; - self.ime_text_join = None; - } - - /// Stamp the document's save metadata (modified time, last modified by, revision; created - /// when it has none) the way Word does on every save. `file.save` calls it, and so does any - /// front end that writes the bytes itself (the web build's download, #262). Like Word, the - /// document also keeps the zoom it is shown at (#995). - pub fn stamp_save(&mut self) { - self.doc.settings.zoom = crate::cmd::view::saved_zoom(&self.view); - let core = &mut self.doc.core; - core.modified = crate::cmd::now_iso(); - if core.created.is_empty() { - core.created = core.modified.clone(); - } - core.last_modified_by = self.author.clone(); - core.revision = core.revision.saturating_add(1); - } - - /// Whether typing is still collecting into one undo step (no other command since). - pub fn typing_open(&self) -> bool { - self.typing_open - } - - /// Reopen the typing group when the last undo step is typing, so the keys typed next join - /// it: after an input method's rewrite was joined to it (`join_next_undo` closes the group). - pub fn continue_typing(&mut self) { - if self.undo_label() == Some("Typing") { - self.typing_open = true; - } - } - - /// Close an open typing group (caret moved, other command). - pub fn close_typing(&mut self) { - self.typing_open = false; - } - - pub fn can_undo(&self) -> bool { - !self.history.is_empty() - } - - pub fn can_redo(&self) -> bool { - !self.redo.is_empty() - } - - pub fn undo_label(&self) -> Option<&str> { - self.history.last().map(|u| u.label.as_str()) - } - - pub fn redo_label(&self) -> Option<&str> { - self.redo.last().map(|u| u.label.as_str()) - } - - /// Undo steps pushed so far, evicted ones included: only grows while steps are added. - pub(crate) fn undo_steps_so_far(&self) -> u64 { - self.history.len() as u64 + self.undo_evicted - } - - /// The number of undo steps. - pub fn undo_depth(&self) -> usize { - self.history.len() - } - - /// Where the undo history stands, for [`Session::merge_undo_since`]. - pub fn undo_mark(&self) -> u64 { - (self.history.len() as u64).saturating_add(self.undo_evicted) - } - - /// Makes the steps recorded since `mark` one undo step, the first: a drag that applied its - /// value as it went (a slider in a pane) undoes in one go. - pub fn merge_undo_since(&mut self, mark: u64) { - let keep = mark.saturating_sub(self.undo_evicted).saturating_add(1); - self.history.truncate(usize::try_from(keep).unwrap_or(usize::MAX)); - } - - pub fn undo_labels(&self) -> Vec { - self.history.iter().rev().map(|u| u.label.clone()).collect() - } - - /// The Undo button's list (#701): the undo steps' labels (English, as commands name them), - /// newest first, at most [`UNDO_LIST_MAX`]. - pub fn undo_history(&self) -> Vec<&str> { - self.history.iter().rev().take(UNDO_LIST_MAX).map(|u| u.label.as_str()).collect() - } - - /// Undo the newest `count` steps in one go, as picking an entry in the Undo list does (#701). - /// More than the undo depth undoes everything; a step that would take the document's - /// protection away stops it (only Stop Protection lifts it). Returns the steps undone. - pub fn undo_steps(&mut self, count: usize) -> usize { - let mut done = 0; - while done < count && !self.step_lifts_protection(false) && self.undo() { - done += 1; - } - done - } - - /// Undoes the newest step: closes typing, records an AutoCorrect exception when the undone step - /// was its automatic capital, swaps the document, selection, pending formatting and column with - /// the redo snapshot, replaces the body identity, refreshes equation editing and touches layout. - pub fn undo(&mut self) -> bool { - self.typing_open = false; - // Undoing the automatic capitalisation's own step makes its word an exception; any - // other Undo forgets the capital. - let steps = self.history.len() as u64 + self.undo_evicted; - if let Some(c) = self.autocap.take().filter(|c| c.depth == steps) { - c.add_exceptions(&mut self.prefs.autocorrect); - } - let Some(u) = self.history.pop().map(Arc::unwrap_or_clone) else { return false }; - let cur = Undo { - label: u.label.clone(), - doc: std::mem::replace(&mut self.doc, u.doc), - sel: std::mem::replace(&mut self.sel, u.sel), - pending: std::mem::replace(&mut self.pending, u.pending), - column: std::mem::replace(&mut self.column, u.column), - }; - self.redo.push(Arc::new(cur)); - // Undo swaps the whole body back: a composition belongs to the text it showed. - self.note_body_replaced(); - self.refresh_math_session(); - self.touch(); - true - } - - /// Would Undo (or Redo) take the document's protection away? Only Stop Protection lifts it. - pub fn step_lifts_protection(&self, redo: bool) -> bool { - let next = if redo { self.redo.last() } else { self.history.last() }; - next.is_some_and(|u| crate::cmd::protect::protection_lifted(&self.doc, &u.doc)) - } - - /// Redoes the newest undone step by swapping the snapshots back, replacing the body identity, - /// refreshing equation editing and touching layout. - pub fn redo(&mut self) -> bool { - self.typing_open = false; - let Some(u) = self.redo.pop().map(Arc::unwrap_or_clone) else { return false }; - let cur = Undo { - label: u.label.clone(), - doc: std::mem::replace(&mut self.doc, u.doc), - sel: std::mem::replace(&mut self.sel, u.sel), - pending: std::mem::replace(&mut self.pending, u.pending), - column: std::mem::replace(&mut self.column, u.column), - }; - self.history.push(Arc::new(cur)); - self.note_body_replaced(); - self.refresh_math_session(); - self.touch(); - true - } - - /// An equation's editing session cannot ride across Undo/Redo: the equation tree, or the - /// object at that offset, may be a different one now. A new session token starts a new IME - /// context; if tokens are exhausted, the equation is left (fail closed). - fn refresh_math_session(&mut self) { - if self.math.is_none() { - return; - } - match next_math_session() { - Some(session) => { - if let Some(m) = self.math.as_mut() { - m.session = session; - } - } - None => self.math = None, - } - } - - /// Drop history (after open / new). - pub fn reset_history(&mut self) { - self.autocap = None; - self.history.clear(); - self.redo.clear(); - self.typing_open = false; - self.join_anchor = false; - } - - /// The version snapshots belong to the document they were taken from: keeping the old ones - /// would let a restore put the previous document's content into this one (#1327 follow-up). - pub fn clear_versions(&mut self) { - self.versions.clear(); - } -} +use super::doc_state::{MAX_UNDO, Selection, UNDO_LIST_MAX, Undo, next_math_session}; +use super::{Arc, Document, Session}; + +impl Session { + /// Snapshot for undo before a change. Returns whether it took a step: typing that is + /// already in an open step is not a new one. + pub fn checkpoint(&mut self, label: &str) -> bool { + if label == "Typing" && self.typing_open { + return false; + } + self.typing_open = label == "Typing"; + self.history.push(Arc::new(Undo { + label: label.to_string(), + doc: self.doc.clone(), + sel: self.sel.clone(), + pending: self.pending.clone(), + column: self.column.clone(), + })); + if self.history.len() > MAX_UNDO { + self.history.remove(0); + self.undo_evicted += 1; + } + self.redo.clear(); + // The newest step is what a joined command merges into. + self.join_anchor = true; + true + } + + /// Take back the newest undo step: a command that joins an earlier step of its own. + pub(crate) fn drop_last_undo_step(&mut self) { + self.history.pop(); + } + + /// Record `doc`/`sel` as their own undo step after the fact and close the typing group, so + /// Undo right after an automatic change (AutoFormat, AutoCorrect) reverts only that change. + pub fn push_undo(&mut self, label: &str, doc: Document, sel: Selection) { + self.history.push(Arc::new(Undo { label: label.to_string(), doc, sel, pending: self.pending.clone(), column: None })); + if self.history.len() > MAX_UNDO { + self.history.remove(0); + self.undo_evicted += 1; + } + self.typing_open = false; + self.join_anchor = true; + } + + /// Make the next mutating command part of the previous undo step instead of a new one, so a + /// drag that runs a command every frame is a single Undo. Call it on every frame of the drag + /// except the first; it is consumed by the next `run`. + pub fn join_next_undo(&mut self) { + self.join_next = true; + } + + /// Make the next equation typing command (`equation.type` or `equation.backspace`) join the + /// previous equation deletion's undo step. Unlike [`Self::join_next_undo`], the promise is + /// not generic: any other command consumes and discards it, so unrelated edits get their own + /// checkpoint. A deletion-only IME request therefore cannot leak a join into the next edit. + pub fn join_next_equation_edit(&mut self) { + self.equation_join_next = true; + } + + /// Discard an equation replacement join that received no replacement (for example an empty + /// commit), so it cannot join a later composition's typing. + pub fn clear_equation_join(&mut self) { + self.equation_join_next = false; + } + + /// Make the next `text.insert` at the current selection join a successful body + /// `DeleteSurrounding`'s undo step. Unlike [`Self::join_next_undo`], the promise is scoped to + /// that exact replacement: any other command consumes and discards it, so an intervening + /// ordinary edit gets its own checkpoint. + pub fn join_next_ime_text_replacement(&mut self) { + self.ime_text_join = Some(self.sel.clone()); + } + + /// Discard a body replacement join that received no replacement (an empty or refused + /// commit, a new request or a refusal), so it cannot join a later composition. + pub fn clear_ime_text_join(&mut self) { + self.ime_text_join = None; + } + + /// Discard every pending IME replacement join. A refused surrounding request supersedes the + /// composition it interrupts, including any promise from an earlier accepted request. + pub fn clear_ime_joins(&mut self) { + self.equation_join_next = false; + self.ime_text_join = None; + } + + /// Stamp the document's save metadata (modified time, last modified by, revision; created + /// when it has none) the way Word does on every save. `file.save` calls it, and so does any + /// front end that writes the bytes itself (the web build's download, #262). Like Word, the + /// document also keeps the zoom it is shown at (#995). + pub fn stamp_save(&mut self) { + self.doc.settings.zoom = crate::cmd::view::saved_zoom(&self.view); + let core = &mut self.doc.core; + core.modified = crate::cmd::now_iso(); + if core.created.is_empty() { + core.created = core.modified.clone(); + } + core.last_modified_by = self.author.clone(); + core.revision = core.revision.saturating_add(1); + } + + /// Whether typing is still collecting into one undo step (no other command since). + pub fn typing_open(&self) -> bool { + self.typing_open + } + + /// Reopen the typing group when the last undo step is typing, so the keys typed next join + /// it: after an input method's rewrite was joined to it (`join_next_undo` closes the group). + pub fn continue_typing(&mut self) { + if self.undo_label() == Some("Typing") { + self.typing_open = true; + } + } + + /// Close an open typing group (caret moved, other command). + pub fn close_typing(&mut self) { + self.typing_open = false; + } + + pub fn can_undo(&self) -> bool { + !self.history.is_empty() + } + + pub fn can_redo(&self) -> bool { + !self.redo.is_empty() + } + + pub fn undo_label(&self) -> Option<&str> { + self.history.last().map(|u| u.label.as_str()) + } + + pub fn redo_label(&self) -> Option<&str> { + self.redo.last().map(|u| u.label.as_str()) + } + + /// Undo steps pushed so far, evicted ones included: only grows while steps are added. + pub(crate) fn undo_steps_so_far(&self) -> u64 { + self.history.len() as u64 + self.undo_evicted + } + + /// The number of undo steps. + pub fn undo_depth(&self) -> usize { + self.history.len() + } + + /// Where the undo history stands, for [`Session::merge_undo_since`]. + pub fn undo_mark(&self) -> u64 { + (self.history.len() as u64).saturating_add(self.undo_evicted) + } + + /// Makes the steps recorded since `mark` one undo step, the first: a drag that applied its + /// value as it went (a slider in a pane) undoes in one go. + pub fn merge_undo_since(&mut self, mark: u64) { + let keep = mark.saturating_sub(self.undo_evicted).saturating_add(1); + self.history.truncate(usize::try_from(keep).unwrap_or(usize::MAX)); + } + + pub fn undo_labels(&self) -> Vec { + self.history.iter().rev().map(|u| u.label.clone()).collect() + } + + /// The Undo button's list (#701): the undo steps' labels (English, as commands name them), + /// newest first, at most [`UNDO_LIST_MAX`]. + pub fn undo_history(&self) -> Vec<&str> { + self.history.iter().rev().take(UNDO_LIST_MAX).map(|u| u.label.as_str()).collect() + } + + /// Undo the newest `count` steps in one go, as picking an entry in the Undo list does (#701). + /// More than the undo depth undoes everything; a step that would take the document's + /// protection away stops it (only Stop Protection lifts it). Returns the steps undone. + pub fn undo_steps(&mut self, count: usize) -> usize { + let mut done = 0; + while done < count && !self.step_lifts_protection(false) && self.undo() { + done += 1; + } + done + } + + /// Undoes the newest step: closes typing, records an AutoCorrect exception when the undone step + /// was its automatic capital, swaps the document, selection, pending formatting and column with + /// the redo snapshot, replaces the body identity, refreshes equation editing and touches layout. + pub fn undo(&mut self) -> bool { + self.typing_open = false; + // Undoing the automatic capitalisation's own step makes its word an exception; any + // other Undo forgets the capital. + let steps = self.history.len() as u64 + self.undo_evicted; + if let Some(c) = self.autocap.take().filter(|c| c.depth == steps) { + c.add_exceptions(&mut self.prefs.autocorrect); + } + let Some(u) = self.history.pop().map(Arc::unwrap_or_clone) else { return false }; + let cur = Undo { + label: u.label.clone(), + doc: std::mem::replace(&mut self.doc, u.doc), + sel: std::mem::replace(&mut self.sel, u.sel), + pending: std::mem::replace(&mut self.pending, u.pending), + column: std::mem::replace(&mut self.column, u.column), + }; + self.redo.push(Arc::new(cur)); + // Undo swaps the whole body back: a composition belongs to the text it showed. + self.note_body_replaced(); + self.refresh_math_session(); + self.touch(); + true + } + + /// Would Undo (or Redo) take the document's protection away? Only Stop Protection lifts it. + pub fn step_lifts_protection(&self, redo: bool) -> bool { + let next = if redo { self.redo.last() } else { self.history.last() }; + next.is_some_and(|u| crate::cmd::protect::protection_lifted(&self.doc, &u.doc)) + } + + /// Redoes the newest undone step by swapping the snapshots back, replacing the body identity, + /// refreshing equation editing and touching layout. + pub fn redo(&mut self) -> bool { + self.typing_open = false; + let Some(u) = self.redo.pop().map(Arc::unwrap_or_clone) else { return false }; + let cur = Undo { + label: u.label.clone(), + doc: std::mem::replace(&mut self.doc, u.doc), + sel: std::mem::replace(&mut self.sel, u.sel), + pending: std::mem::replace(&mut self.pending, u.pending), + column: std::mem::replace(&mut self.column, u.column), + }; + self.history.push(Arc::new(cur)); + self.note_body_replaced(); + self.refresh_math_session(); + self.touch(); + true + } + + /// An equation's editing session cannot ride across Undo/Redo: the equation tree, or the + /// object at that offset, may be a different one now. A new session token starts a new IME + /// context; if tokens are exhausted, the equation is left (fail closed). + fn refresh_math_session(&mut self) { + if self.math.is_none() { + return; + } + match next_math_session() { + Some(session) => { + if let Some(m) = self.math.as_mut() { + m.session = session; + } + } + None => self.math = None, + } + } + + /// Drop history (after open / new). + pub fn reset_history(&mut self) { + self.autocap = None; + self.history.clear(); + self.redo.clear(); + self.typing_open = false; + self.join_anchor = false; + } + + /// The version snapshots belong to the document they were taken from: keeping the old ones + /// would let a restore put the previous document's content into this one (#1327 follow-up). + pub fn clear_versions(&mut self) { + self.versions.clear(); + } +} diff --git a/crates/engine/src/session/ime.rs b/crates/engine/src/session/ime.rs index ede6dea2e9..19fccad95e 100644 --- a/crates/engine/src/session/ime.rs +++ b/crates/engine/src/session/ime.rs @@ -1,85 +1,85 @@ -use super::ime_state::{ImeCaretState, next_ime_state_revision}; -use super::{CmdError, ImeEditOutcome, Session, Value}; - -impl Session { - /// The current command's explicit IME effect classification. - pub fn ime_edit_outcome(&self) -> ImeEditOutcome { - self.ime_edit_outcome - } - - /// Set the effect classification from a command handler. [`Session::run`] fills - /// `selection_changed` and enforces the content/state invariant after the command. - pub(crate) fn set_ime_edit_outcome(&mut self, outcome: ImeEditOutcome) { - self.ime_edit_outcome = outcome; - } - - /// Take the pending application-origin change signal. The UI owns one session, so a simple - /// bool is enough; it deliberately survives later commands until drained. - pub fn take_pending_ime_application_change(&mut self) -> bool { - std::mem::take(&mut self.pending_ime_application_change) - } - - /// Simulate a globally exhausted IME revision allocator in this session (test support). - /// - /// This only ever makes receipt classification fail closed. It exists so front-end producer - /// tests can exercise the real `ime_stamp` path instead of hand-building an `Unavailable` - /// stamp; production builds do not compile it. - #[cfg(any(test, feature = "test-support"))] - #[doc(hidden)] - pub fn exhaust_ime_state_revision(&mut self) { - self.ime_state_exhausted = true; - self.ime_state_revision = None; - } - - /// The checked, process-monotonic revision for IME receipts with unavailable surrounding - /// text. Selection/caret motion allocates on the next call; `None` means exhausted. - pub fn ime_state_revision(&mut self) -> Option { - self.sync_ime_caret_state(); - self.ime_state_revision - } - - /// Returns the caret identity used for IME state revisions: the equation session and position - /// while editing, otherwise the body selection. - pub(super) fn current_ime_caret_state(&self) -> ImeCaretState { - match &self.math { - Some(m) => ImeCaretState::Equation { session: m.session, pos: m.pos.clone() }, - None => ImeCaretState::Body(self.sel.clone()), - } - } - - /// Allocate the next IME state revision, or store `None` when the allocator is exhausted. - /// Test support can pin exhaustion for this session so a front-end producer test sees the - /// real `ime_stamp`/receipt path fail closed. - pub(super) fn reset_ime_state_revision(&mut self) { - #[cfg(any(test, feature = "test-support"))] - if self.ime_state_exhausted { - self.ime_state_revision = None; - return; - } - // v5: allocation failure must store `None`, never retain a stale revision. - self.ime_state_revision = next_ime_state_revision(); - } - - /// Allocates a new revision only when the body selection or the equation session and position - /// differs from the last observed state. - fn sync_ime_caret_state(&mut self) { - let state = self.current_ime_caret_state(); - if self.ime_state_seen.as_ref() != Some(&state) { - self.ime_state_seen = Some(state); - self.reset_ime_state_revision(); - } - } - - /// Run one command as the input method's own transaction. - /// - /// Commands run inside this scope are IME-origin: a successful command run *outside* this - /// scope sets the pending application-change signal, but nothing inside it does. Nested commands inherit - /// the origin. The previous origin is restored on every return, including unknown, disabled or - /// refused commands. - pub fn run_ime(&mut self, id: &str, params: &Value) -> Result { - let previous = std::mem::replace(&mut self.ime_origin, true); - let result = self.run(id, params); - self.ime_origin = previous; - result - } -} +use super::ime_state::{ImeCaretState, next_ime_state_revision}; +use super::{CmdError, ImeEditOutcome, Session, Value}; + +impl Session { + /// The current command's explicit IME effect classification. + pub fn ime_edit_outcome(&self) -> ImeEditOutcome { + self.ime_edit_outcome + } + + /// Set the effect classification from a command handler. [`Session::run`] fills + /// `selection_changed` and enforces the content/state invariant after the command. + pub(crate) fn set_ime_edit_outcome(&mut self, outcome: ImeEditOutcome) { + self.ime_edit_outcome = outcome; + } + + /// Take the pending application-origin change signal. The UI owns one session, so a simple + /// bool is enough; it deliberately survives later commands until drained. + pub fn take_pending_ime_application_change(&mut self) -> bool { + std::mem::take(&mut self.pending_ime_application_change) + } + + /// Simulate a globally exhausted IME revision allocator in this session (test support). + /// + /// This only ever makes receipt classification fail closed. It exists so front-end producer + /// tests can exercise the real `ime_stamp` path instead of hand-building an `Unavailable` + /// stamp; production builds do not compile it. + #[cfg(any(test, feature = "test-support"))] + #[doc(hidden)] + pub fn exhaust_ime_state_revision(&mut self) { + self.ime_state_exhausted = true; + self.ime_state_revision = None; + } + + /// The checked, process-monotonic revision for IME receipts with unavailable surrounding + /// text. Selection/caret motion allocates on the next call; `None` means exhausted. + pub fn ime_state_revision(&mut self) -> Option { + self.sync_ime_caret_state(); + self.ime_state_revision + } + + /// Returns the caret identity used for IME state revisions: the equation session and position + /// while editing, otherwise the body selection. + pub(super) fn current_ime_caret_state(&self) -> ImeCaretState { + match &self.math { + Some(m) => ImeCaretState::Equation { session: m.session, pos: m.pos.clone() }, + None => ImeCaretState::Body(self.sel.clone()), + } + } + + /// Allocate the next IME state revision, or store `None` when the allocator is exhausted. + /// Test support can pin exhaustion for this session so a front-end producer test sees the + /// real `ime_stamp`/receipt path fail closed. + pub(super) fn reset_ime_state_revision(&mut self) { + #[cfg(any(test, feature = "test-support"))] + if self.ime_state_exhausted { + self.ime_state_revision = None; + return; + } + // v5: allocation failure must store `None`, never retain a stale revision. + self.ime_state_revision = next_ime_state_revision(); + } + + /// Allocates a new revision only when the body selection or the equation session and position + /// differs from the last observed state. + fn sync_ime_caret_state(&mut self) { + let state = self.current_ime_caret_state(); + if self.ime_state_seen.as_ref() != Some(&state) { + self.ime_state_seen = Some(state); + self.reset_ime_state_revision(); + } + } + + /// Run one command as the input method's own transaction. + /// + /// Commands run inside this scope are IME-origin: a successful command run *outside* this + /// scope sets the pending application-change signal, but nothing inside it does. Nested commands inherit + /// the origin. The previous origin is restored on every return, including unknown, disabled or + /// refused commands. + pub fn run_ime(&mut self, id: &str, params: &Value) -> Result { + let previous = std::mem::replace(&mut self.ime_origin, true); + let result = self.run(id, params); + self.ime_origin = previous; + result + } +} diff --git a/crates/engine/src/session/layout.rs b/crates/engine/src/session/layout.rs index 823f751894..2f773e308f 100644 --- a/crates/engine/src/session/layout.rs +++ b/crates/engine/src/session/layout.rs @@ -1,218 +1,218 @@ -use super::doc_state::{Selection, TypedWord, typed_range}; -use super::locale::SYSTEM_LOCALE; -use super::snapshot_types::LayoutEntry; -use super::view::DRAFT_WIDTH; -use super::{Arc, DocLayout, Document, LayoutCache, LayoutOptions, Session, StoryRef, Value, ViewMode}; - -impl Session { - /// The current layout (recomputed when the document or view changed). - pub fn layout(&mut self) -> Arc { - let typing = self.typing_word(); - self.layout_with(typing) - } - - /// [`Session::layout`] with `typing` as the word being typed. - fn layout_with(&mut self, typing: Option) -> Arc { - let mode = if self.view.read_mode { ViewMode::Read } else { self.view.mode }; - let width = match mode { - ViewMode::Read => self.view.read_width, - ViewMode::Web => self.view.web_width, - _ => DRAFT_WIDTH, - }; - let height = if self.view.read_mode { self.view.read_height } else { 0.0 }; - let spacing = if self.view.read_mode { self.view.read_line_spacing.map(f32::to_bits) } else { None }; - // Deletions in balloons depend on the view (the Comments pane, Read Mode, side by side), so - // a view change that moves them in or out of the text lays it out again. - let hide_deleted = !self.view.show_markup; - let deleted = (hide_deleted, self.prefs.markup.hidden_deletions(), self.revisions_in_balloons()); - let outline = (mode == ViewMode::Outline).then(|| self.view.outline.view()); - let shown = (self.view.marks || self.prefs.marks.hidden, self.view.field_codes); - // A dictionary or Language pack installed, removed or edited (in this document's window or - // any other) proofs it again, open or parked. - let rev = (self.rev, wordcraft_proof::dictionary_generation()); - let (proofing, numeral) = (self.view.proofing, self.view.numeral); - let fits = |e: &Option| { - e.as_ref().is_some_and(|(r, w, h, m, sp, _, pf, o, hd, n, t, sh)| { - *r == rev - && (*w == width || mode == ViewMode::Print) - && *h == height - && *m == mode - && *sp == spacing - && *pf == proofing - && *n == numeral - && *hd == deleted - && *o == outline - && *t == typing - && *sh == shown - }) - }; - if !fits(&self.layout[0]) && fits(&self.layout[1]) { - self.layout.swap(0, 1); - } - if fits(&self.layout[0]) - && let Some(e) = &self.layout[0] - { - return e.5.clone(); - } - let (fn_name, fn_path) = self.resolved_filenames(); - let (filename, filepath): (Option>, Option>) = (Some(Arc::from(fn_name.as_str())), Some(Arc::from(fn_path.as_str()))); - let opts = LayoutOptions { - view: mode, - web_width: width, - read_height: height, - read_line_spacing: spacing.map(f32::from_bits), - show_hidden: shown.0, - hide_deleted, - hide_marked: deleted.1, - deletions_in_balloons: deleted.2, - proofing: self.view.proofing, - numeral: self.view.numeral.resolve(SYSTEM_LOCALE.get().map(String::as_str)), - comment_balloons: false, - field_codes: shown.1, - outline: outline.clone(), - proof: wordcraft_layout::ProofOptions { grammar: self.prefs.mark_grammar, spelling: self.prefs.spelling() }, - typing_word: typing, - filename: filename.clone(), - filepath: filepath.clone(), - }; - let l = Arc::new(wordcraft_layout::layout(&self.doc, &mut self.cache, &opts)); - let [newest, _] = std::mem::take(&mut self.layout); - self.layout = - [Some((rev, width, height, mode, spacing, l.clone(), self.view.proofing, outline, deleted, self.view.numeral, typing, shown)), newest]; - l - } - - /// The recorded word while the caret and the word are as typing left them; else drops it. - fn typing_word(&mut self) -> Option { - let t = self.typing.as_ref()?; - let p = if self.sel.is_collapsed() && self.sel.focus == t.at { self.doc.para_at(&t.at) } else { None }; - match p { - Some(p) if typed_range(&p.text, t.at.off) == Some((t.start, t.end)) => { - Some(wordcraft_layout::TypingWord { rev: p.rev, start: t.start, end: t.end }) - } - _ => { - self.typing = None; - None - } - } - } - - /// After a successful command: a `text.*` edit at the caret records the word being typed; - /// any other caret move ends it. - pub(super) fn note_typing(&mut self, id: &str, before: Option<&Document>, sel_before: &Selection) { - let at = &self.sel.focus; - let edited = || before.is_some_and(|d| d.para_at(at).map(|p| &p.text) != self.doc.para_at(at).map(|p| &p.text)); - if id.starts_with("text.") && edited() { - self.typing = self.word_being_typed(); - } else if self.sel != *sel_before { - self.typing = None; - } - } - - /// The word the collapsed caret ends or sits in, as [`TypedWord`]. - fn word_being_typed(&self) -> Option { - if !self.sel.is_collapsed() { - return None; - } - let at = self.sel.focus.clone(); - let (start, end) = typed_range(&self.doc.para_at(&at)?.text, at.off)?; - Some(TypedWord { at, start, end }) - } - - /// The page as command `id` would leave it, for a live preview (Insert › Table's grid, #3931): - /// the command runs on a copy of the document, with this caret and view, and the copy is laid - /// out. The copy comes back with its layout, which is drawn from it: the layout's paragraph - /// paths name the copy's paragraphs, not this document's, and so is the copy's selection, - /// which is where the caret would be. The document, the selection, the undo history and Repeat - /// are untouched. `None` when the command fails or isn't allowed here. - pub fn preview_layout(&mut self, id: &str, params: &Value) -> Option<(Document, Selection, Arc)> { - // The word being typed keeps going without a squiggle on the preview: the command moves - // the copy's caret off it and may split its paragraph, so it is found again by its place. - let typing = self.typing_word(); - let at = self.sel.focus.clone(); - let mut copy = Session::with_registry(self.doc.clone(), self.registry.clone()); - copy.sel = self.sel.clone(); - copy.column = self.column.clone(); - copy.widened = self.widened.clone(); - copy.view = self.view.clone(); - copy.prefs = self.prefs.clone(); - copy.path = self.path.clone(); - copy.file_name = self.file_name.clone(); - copy.author = self.author.clone(); - copy.last_highlight = self.last_highlight; - copy.system_region = self.system_region.clone(); - copy.editing_lang = self.editing_lang.clone(); - // The copy shares the paragraph layouts, so only what the command changed is laid out. - std::mem::swap(&mut copy.cache, &mut self.cache); - let layout = copy.run(id, params).is_ok().then(|| { - let typing = typing.and_then(|t| { - let p = copy.doc.para_at(&at)?; - (t.end <= p.len()).then_some(wordcraft_layout::TypingWord { rev: p.rev, ..t }) - }); - copy.layout_with(typing) - }); - std::mem::swap(&mut copy.cache, &mut self.cache); - layout.map(|l| (copy.doc, copy.sel, l)) - } - - /// A layout for output (PDF, images, print): no proofing marks, print view. - pub fn export_layout(&self) -> Arc { - self.export_layout_with(false) - } - - /// [`Session::export_layout`]; with `markup` (Print Markup) each page also has the markup - /// area with the comments in balloons. Fields Word fills in for output show their fresh - /// results ([`crate::cmd::references::output_fields`]). - pub fn export_layout_with(&self, markup: bool) -> Arc { - let out = crate::cmd::references::output_fields(&self.doc); - self.export_layout_of(out.as_ref().unwrap_or(&self.doc), markup) - } - - /// [`Session::export_layout_with`] of `doc`, this document as it is output. - pub(crate) fn export_layout_of(&self, doc: &Document, markup: bool) -> Arc { - let (fn_name, fn_path) = self.resolved_filenames(); - let opts = LayoutOptions { - view: ViewMode::Print, - comment_balloons: markup && self.prefs.markup.comments, - numeral: self.view.numeral.resolve(SYSTEM_LOCALE.get().map(String::as_str)), - filename: Some(Arc::from(fn_name.as_str())), - filepath: Some(Arc::from(fn_path.as_str())), - ..Default::default() - }; - Arc::new(wordcraft_layout::layout(doc, &mut LayoutCache::new(), &opts)) - } - - /// Invalidate the cached layout (fonts changed etc.). - pub fn relayout(&mut self) { - self.layout = Default::default(); - self.cache.clear(); - } - - /// Set the language new documents are written in ([`Session::editing_lang`]; `None` or a - /// malformed tag: US English). A document nobody has touched yet — untitled, unchanged, no - /// undo steps, no text — switches too, so the blank document a session starts with follows - /// the saved proofing default, or the interface language when none is saved. Nothing else - /// changes and no undo step is added. - pub fn set_editing_language(&mut self, tag: Option<&str>) { - let tag = self - .prefs - .default_language - .as_deref() - .and_then(wordcraft_proof::lang::normalize_tag) - .or_else(|| tag.and_then(wordcraft_proof::lang::normalize_tag)); - if self.editing_lang == tag { - return; - } - self.editing_lang = tag; - let untouched = self.path.is_none() && !self.dirty && self.history.is_empty() && self.redo.is_empty(); - if untouched && self.doc.plain_text(StoryRef::Body).trim().is_empty() && self.doc.paragraph_count() <= 1 { - Arc::make_mut(&mut self.doc.styles).default_chr.lang = Some(self.editing_lang.clone().unwrap_or_else(|| "en-US".into())); - self.rev = self.rev.wrapping_add(1); - // Not an edit: the document is as untouched as it was. - if self.made_rev == self.rev.wrapping_sub(1) { - self.made_rev = self.rev; - } - self.relayout(); - } - } -} +use super::doc_state::{Selection, TypedWord, typed_range}; +use super::locale::SYSTEM_LOCALE; +use super::snapshot_types::LayoutEntry; +use super::view::DRAFT_WIDTH; +use super::{Arc, DocLayout, Document, LayoutCache, LayoutOptions, Session, StoryRef, Value, ViewMode}; + +impl Session { + /// The current layout (recomputed when the document or view changed). + pub fn layout(&mut self) -> Arc { + let typing = self.typing_word(); + self.layout_with(typing) + } + + /// [`Session::layout`] with `typing` as the word being typed. + fn layout_with(&mut self, typing: Option) -> Arc { + let mode = if self.view.read_mode { ViewMode::Read } else { self.view.mode }; + let width = match mode { + ViewMode::Read => self.view.read_width, + ViewMode::Web => self.view.web_width, + _ => DRAFT_WIDTH, + }; + let height = if self.view.read_mode { self.view.read_height } else { 0.0 }; + let spacing = if self.view.read_mode { self.view.read_line_spacing.map(f32::to_bits) } else { None }; + // Deletions in balloons depend on the view (the Comments pane, Read Mode, side by side), so + // a view change that moves them in or out of the text lays it out again. + let hide_deleted = !self.view.show_markup; + let deleted = (hide_deleted, self.prefs.markup.hidden_deletions(), self.revisions_in_balloons()); + let outline = (mode == ViewMode::Outline).then(|| self.view.outline.view()); + let shown = (self.view.marks || self.prefs.marks.hidden, self.view.field_codes); + // A dictionary or Language pack installed, removed or edited (in this document's window or + // any other) proofs it again, open or parked. + let rev = (self.rev, wordcraft_proof::dictionary_generation()); + let (proofing, numeral) = (self.view.proofing, self.view.numeral); + let fits = |e: &Option| { + e.as_ref().is_some_and(|(r, w, h, m, sp, _, pf, o, hd, n, t, sh)| { + *r == rev + && (*w == width || mode == ViewMode::Print) + && *h == height + && *m == mode + && *sp == spacing + && *pf == proofing + && *n == numeral + && *hd == deleted + && *o == outline + && *t == typing + && *sh == shown + }) + }; + if !fits(&self.layout[0]) && fits(&self.layout[1]) { + self.layout.swap(0, 1); + } + if fits(&self.layout[0]) + && let Some(e) = &self.layout[0] + { + return e.5.clone(); + } + let (fn_name, fn_path) = self.resolved_filenames(); + let (filename, filepath): (Option>, Option>) = (Some(Arc::from(fn_name.as_str())), Some(Arc::from(fn_path.as_str()))); + let opts = LayoutOptions { + view: mode, + web_width: width, + read_height: height, + read_line_spacing: spacing.map(f32::from_bits), + show_hidden: shown.0, + hide_deleted, + hide_marked: deleted.1, + deletions_in_balloons: deleted.2, + proofing: self.view.proofing, + numeral: self.view.numeral.resolve(SYSTEM_LOCALE.get().map(String::as_str)), + comment_balloons: false, + field_codes: shown.1, + outline: outline.clone(), + proof: wordcraft_layout::ProofOptions { grammar: self.prefs.mark_grammar, spelling: self.prefs.spelling() }, + typing_word: typing, + filename: filename.clone(), + filepath: filepath.clone(), + }; + let l = Arc::new(wordcraft_layout::layout(&self.doc, &mut self.cache, &opts)); + let [newest, _] = std::mem::take(&mut self.layout); + self.layout = + [Some((rev, width, height, mode, spacing, l.clone(), self.view.proofing, outline, deleted, self.view.numeral, typing, shown)), newest]; + l + } + + /// The recorded word while the caret and the word are as typing left them; else drops it. + fn typing_word(&mut self) -> Option { + let t = self.typing.as_ref()?; + let p = if self.sel.is_collapsed() && self.sel.focus == t.at { self.doc.para_at(&t.at) } else { None }; + match p { + Some(p) if typed_range(&p.text, t.at.off) == Some((t.start, t.end)) => { + Some(wordcraft_layout::TypingWord { rev: p.rev, start: t.start, end: t.end }) + } + _ => { + self.typing = None; + None + } + } + } + + /// After a successful command: a `text.*` edit at the caret records the word being typed; + /// any other caret move ends it. + pub(super) fn note_typing(&mut self, id: &str, before: Option<&Document>, sel_before: &Selection) { + let at = &self.sel.focus; + let edited = || before.is_some_and(|d| d.para_at(at).map(|p| &p.text) != self.doc.para_at(at).map(|p| &p.text)); + if id.starts_with("text.") && edited() { + self.typing = self.word_being_typed(); + } else if self.sel != *sel_before { + self.typing = None; + } + } + + /// The word the collapsed caret ends or sits in, as [`TypedWord`]. + fn word_being_typed(&self) -> Option { + if !self.sel.is_collapsed() { + return None; + } + let at = self.sel.focus.clone(); + let (start, end) = typed_range(&self.doc.para_at(&at)?.text, at.off)?; + Some(TypedWord { at, start, end }) + } + + /// The page as command `id` would leave it, for a live preview (Insert › Table's grid, #3931): + /// the command runs on a copy of the document, with this caret and view, and the copy is laid + /// out. The copy comes back with its layout, which is drawn from it: the layout's paragraph + /// paths name the copy's paragraphs, not this document's, and so is the copy's selection, + /// which is where the caret would be. The document, the selection, the undo history and Repeat + /// are untouched. `None` when the command fails or isn't allowed here. + pub fn preview_layout(&mut self, id: &str, params: &Value) -> Option<(Document, Selection, Arc)> { + // The word being typed keeps going without a squiggle on the preview: the command moves + // the copy's caret off it and may split its paragraph, so it is found again by its place. + let typing = self.typing_word(); + let at = self.sel.focus.clone(); + let mut copy = Session::with_registry(self.doc.clone(), self.registry.clone()); + copy.sel = self.sel.clone(); + copy.column = self.column.clone(); + copy.widened = self.widened.clone(); + copy.view = self.view.clone(); + copy.prefs = self.prefs.clone(); + copy.path = self.path.clone(); + copy.file_name = self.file_name.clone(); + copy.author = self.author.clone(); + copy.last_highlight = self.last_highlight; + copy.system_region = self.system_region.clone(); + copy.editing_lang = self.editing_lang.clone(); + // The copy shares the paragraph layouts, so only what the command changed is laid out. + std::mem::swap(&mut copy.cache, &mut self.cache); + let layout = copy.run(id, params).is_ok().then(|| { + let typing = typing.and_then(|t| { + let p = copy.doc.para_at(&at)?; + (t.end <= p.len()).then_some(wordcraft_layout::TypingWord { rev: p.rev, ..t }) + }); + copy.layout_with(typing) + }); + std::mem::swap(&mut copy.cache, &mut self.cache); + layout.map(|l| (copy.doc, copy.sel, l)) + } + + /// A layout for output (PDF, images, print): no proofing marks, print view. + pub fn export_layout(&self) -> Arc { + self.export_layout_with(false) + } + + /// [`Session::export_layout`]; with `markup` (Print Markup) each page also has the markup + /// area with the comments in balloons. Fields Word fills in for output show their fresh + /// results ([`crate::cmd::references::output_fields`]). + pub fn export_layout_with(&self, markup: bool) -> Arc { + let out = crate::cmd::references::output_fields(&self.doc); + self.export_layout_of(out.as_ref().unwrap_or(&self.doc), markup) + } + + /// [`Session::export_layout_with`] of `doc`, this document as it is output. + pub(crate) fn export_layout_of(&self, doc: &Document, markup: bool) -> Arc { + let (fn_name, fn_path) = self.resolved_filenames(); + let opts = LayoutOptions { + view: ViewMode::Print, + comment_balloons: markup && self.prefs.markup.comments, + numeral: self.view.numeral.resolve(SYSTEM_LOCALE.get().map(String::as_str)), + filename: Some(Arc::from(fn_name.as_str())), + filepath: Some(Arc::from(fn_path.as_str())), + ..Default::default() + }; + Arc::new(wordcraft_layout::layout(doc, &mut LayoutCache::new(), &opts)) + } + + /// Invalidate the cached layout (fonts changed etc.). + pub fn relayout(&mut self) { + self.layout = Default::default(); + self.cache.clear(); + } + + /// Set the language new documents are written in ([`Session::editing_lang`]; `None` or a + /// malformed tag: US English). A document nobody has touched yet — untitled, unchanged, no + /// undo steps, no text — switches too, so the blank document a session starts with follows + /// the saved proofing default, or the interface language when none is saved. Nothing else + /// changes and no undo step is added. + pub fn set_editing_language(&mut self, tag: Option<&str>) { + let tag = self + .prefs + .default_language + .as_deref() + .and_then(wordcraft_proof::lang::normalize_tag) + .or_else(|| tag.and_then(wordcraft_proof::lang::normalize_tag)); + if self.editing_lang == tag { + return; + } + self.editing_lang = tag; + let untouched = self.path.is_none() && !self.dirty && self.history.is_empty() && self.redo.is_empty(); + if untouched && self.doc.plain_text(StoryRef::Body).trim().is_empty() && self.doc.paragraph_count() <= 1 { + Arc::make_mut(&mut self.doc.styles).default_chr.lang = Some(self.editing_lang.clone().unwrap_or_else(|| "en-US".into())); + self.rev = self.rev.wrapping_add(1); + // Not an edit: the document is as untouched as it was. + if self.made_rev == self.rev.wrapping_sub(1) { + self.made_rev = self.rev; + } + self.relayout(); + } + } +} diff --git a/crates/engine/src/session/lifecycle.rs b/crates/engine/src/session/lifecycle.rs index c80293e9f6..9e818a5155 100644 --- a/crates/engine/src/session/lifecycle.rs +++ b/crates/engine/src/session/lifecycle.rs @@ -1,181 +1,181 @@ -use super::doc_state::MAX_UI_REQUESTS; -use super::prefs::Prefs; -use super::view::ViewState; -use super::{Arc, DocState, Document, ImeEditOutcome, Registry, Session, Value}; - -impl Session { - /// Queue a request for the front end. A headless session never drains the queue, so only - /// the newest [`MAX_UI_REQUESTS`] are kept. - pub fn push_ui_request(&mut self, req: Value) { - if self.ui_requests.len() >= MAX_UI_REQUESTS { - self.ui_requests.remove(0); - } - self.ui_requests.push(req); - } - - /// Save the custom dictionary through [`Session::user_dict_path`], if the host wired one up, - /// with `removed` (words just taken out) left out of the merge with the file. A failed save - /// keeps the words in memory and never fails the command (#40), as does a file outside the - /// automation roots in force: it is read, then replaced beside itself (#3737). - pub(crate) fn persist_user_dictionary(&self, removed: &[String]) { - let Some(path) = self.user_dict_path.as_deref() else { return }; - let name = path.to_string_lossy(); - if let Err(e) = crate::file_access::check_read(&name) - .and_then(|()| crate::file_access::check_write(&name)) - .and_then(|()| wordcraft_proof::save_user_dictionary(path, removed).map_err(|e| e.to_string())) - { - log::warn!("custom dictionary: save failed: {e}"); - } - } - - /// Start an editing session at the document beginning with empty history and - /// default view, preferences and a yellow highlighter independent of document undo. - pub fn new(doc: Document) -> Self { - Self::with_registry(doc, Arc::new(crate::cmd::registry())) - } - - /// Initialise session defaults while sharing the immutable command registry with previews. - pub(super) fn with_registry(doc: Document, registry: Arc) -> Self { - let DocState { - doc, - sel, - view, - pending, - path, - file_name, - dirty, - pending_move, - find, - goal_x, - page_hint, - new_header_parts, - hf_return, - history, - undo_evicted, - redo, - typing_open, - join_next, - equation_join_next, - ime_text_join, - join_anchor, - autocap, - rev, - document_id, - untitled, - made_rev, - ime_body_epoch, - cache, - layout, - typing, - originals, - md_source, - md_file, - picture_looks, - versions, - autosave, - bib_style, - merge, - math, - password, - #[cfg(not(target_arch = "wasm32"))] - recovery_slot, - also_selected, - column, - column_mode, - widened, - edit_marks, - go_back_step, - } = DocState::new(doc, 1, ViewState::default()); - Session { - doc, - sel, - view, - pending, - last_highlight: wordcraft_doc::props::Highlight::Yellow, - path, - file_name, - user_dict_path: None, - dirty, - clipboard: None, - pending_move, - clipboard_text: String::new(), - clipboard_html: String::new(), - clipboard_rtf: String::new(), - clip_history: Default::default(), - find, - goal_x, - page_hint, - registry, - author: "WordCraft User".into(), - initials: String::new(), - painter: None, - status: String::new(), - new_header_parts, - hf_return, - review_style: None, - history, - undo_evicted, - redo, - typing_open, - join_next, - equation_join_next, - ime_text_join, - join_anchor, - autocap, - rev, - document_id, - untitled, - made_rev, - protect_facts: std::sync::Mutex::new(None), - ime_body_epoch, - ime_state_revision: None, - ime_state_seen: None, - ime_edit_outcome: ImeEditOutcome::default(), - #[cfg(any(test, feature = "test-support"))] - ime_state_exhausted: false, - pending_ime_application_change: false, - ime_origin: false, - cache, - layout, - typing, - originals, - md_source, - md_file, - picture_looks, - last_command: None, - recording: None, - macros: Default::default(), - versions, - building_blocks: Default::default(), - autosave, - bib_style, - merge, - ui_requests: Vec::new(), - math, - math_latex: false, - math_normal_text: false, - also_selected, - member: None, - prefs: Prefs::default(), - system_region: Default::default(), - keymap: crate::KeyMap::default(), - ribbon: crate::RibbonLayout::default(), - editing_lang: None, - read_aloud: Default::default(), - windows: Default::default(), - password, - #[cfg(not(target_arch = "wasm32"))] - recovery: None, - #[cfg(not(target_arch = "wasm32"))] - recovery_slot, - column, - column_mode, - widened, - edit_marks, - go_back_step, - others: Vec::new(), - order: vec![document_id], - untitled_count: 1, - } - } -} +use super::doc_state::MAX_UI_REQUESTS; +use super::prefs::Prefs; +use super::view::ViewState; +use super::{Arc, DocState, Document, ImeEditOutcome, Registry, Session, Value}; + +impl Session { + /// Queue a request for the front end. A headless session never drains the queue, so only + /// the newest [`MAX_UI_REQUESTS`] are kept. + pub fn push_ui_request(&mut self, req: Value) { + if self.ui_requests.len() >= MAX_UI_REQUESTS { + self.ui_requests.remove(0); + } + self.ui_requests.push(req); + } + + /// Save the custom dictionary through [`Session::user_dict_path`], if the host wired one up, + /// with `removed` (words just taken out) left out of the merge with the file. A failed save + /// keeps the words in memory and never fails the command (#40), as does a file outside the + /// automation roots in force: it is read, then replaced beside itself (#3737). + pub(crate) fn persist_user_dictionary(&self, removed: &[String]) { + let Some(path) = self.user_dict_path.as_deref() else { return }; + let name = path.to_string_lossy(); + if let Err(e) = crate::file_access::check_read(&name) + .and_then(|()| crate::file_access::check_write(&name)) + .and_then(|()| wordcraft_proof::save_user_dictionary(path, removed).map_err(|e| e.to_string())) + { + log::warn!("custom dictionary: save failed: {e}"); + } + } + + /// Start an editing session at the document beginning with empty history and + /// default view, preferences and a yellow highlighter independent of document undo. + pub fn new(doc: Document) -> Self { + Self::with_registry(doc, Arc::new(crate::cmd::registry())) + } + + /// Initialise session defaults while sharing the immutable command registry with previews. + pub(super) fn with_registry(doc: Document, registry: Arc) -> Self { + let DocState { + doc, + sel, + view, + pending, + path, + file_name, + dirty, + pending_move, + find, + goal_x, + page_hint, + new_header_parts, + hf_return, + history, + undo_evicted, + redo, + typing_open, + join_next, + equation_join_next, + ime_text_join, + join_anchor, + autocap, + rev, + document_id, + untitled, + made_rev, + ime_body_epoch, + cache, + layout, + typing, + originals, + md_source, + md_file, + picture_looks, + versions, + autosave, + bib_style, + merge, + math, + password, + #[cfg(not(target_arch = "wasm32"))] + recovery_slot, + also_selected, + column, + column_mode, + widened, + edit_marks, + go_back_step, + } = DocState::new(doc, 1, ViewState::default()); + Session { + doc, + sel, + view, + pending, + last_highlight: wordcraft_doc::props::Highlight::Yellow, + path, + file_name, + user_dict_path: None, + dirty, + clipboard: None, + pending_move, + clipboard_text: String::new(), + clipboard_html: String::new(), + clipboard_rtf: String::new(), + clip_history: Default::default(), + find, + goal_x, + page_hint, + registry, + author: "WordCraft User".into(), + initials: String::new(), + painter: None, + status: String::new(), + new_header_parts, + hf_return, + review_style: None, + history, + undo_evicted, + redo, + typing_open, + join_next, + equation_join_next, + ime_text_join, + join_anchor, + autocap, + rev, + document_id, + untitled, + made_rev, + protect_facts: std::sync::Mutex::new(None), + ime_body_epoch, + ime_state_revision: None, + ime_state_seen: None, + ime_edit_outcome: ImeEditOutcome::default(), + #[cfg(any(test, feature = "test-support"))] + ime_state_exhausted: false, + pending_ime_application_change: false, + ime_origin: false, + cache, + layout, + typing, + originals, + md_source, + md_file, + picture_looks, + last_command: None, + recording: None, + macros: Default::default(), + versions, + building_blocks: Default::default(), + autosave, + bib_style, + merge, + ui_requests: Vec::new(), + math, + math_latex: false, + math_normal_text: false, + also_selected, + member: None, + prefs: Prefs::default(), + system_region: Default::default(), + keymap: crate::KeyMap::default(), + ribbon: crate::RibbonLayout::default(), + editing_lang: None, + read_aloud: Default::default(), + windows: Default::default(), + password, + #[cfg(not(target_arch = "wasm32"))] + recovery: None, + #[cfg(not(target_arch = "wasm32"))] + recovery_slot, + column, + column_mode, + widened, + edit_marks, + go_back_step, + others: Vec::new(), + order: vec![document_id], + untitled_count: 1, + } + } +} diff --git a/crates/engine/src/session/query.rs b/crates/engine/src/session/query.rs index 3c731e15a3..fdd1c503a7 100644 --- a/crates/engine/src/session/query.rs +++ b/crates/engine/src/session/query.rs @@ -1,302 +1,302 @@ -use super::doc_state::{CellRange, EDIT_MARKS, Selection}; -use super::{CharProps, Document, PartKind, Path, Pos, Session, StoryRef, ViewMode, untitled_name}; - -impl Session { - /// Remember the caret as the place of an edit, for Go Back. Edits in the same paragraph as - /// the last one (typing on) update that place rather than adding one. - pub(super) fn note_edit(&mut self) { - let at = self.sel.focus.clone(); - match self.edit_marks.last_mut() { - Some(last) if last.story == at.story && last.path == at.path => *last = at, - _ => { - self.edit_marks.push(at); - if self.edit_marks.len() > EDIT_MARKS { - self.edit_marks.remove(0); - } - } - } - self.go_back_step = 0; - } - - /// Go Back (Shift+F5): the next of the last edit places, most recent first, cycling; a place - /// the caret is already at is skipped. `None` before any edit. - pub fn go_back(&mut self) -> Option { - let n = self.edit_marks.len(); - for _ in 0..n { - let i = n - 1 - self.go_back_step % n; - self.go_back_step = self.go_back_step.wrapping_add(1); - let Some(p) = self.edit_marks.get(i) else { continue }; - let p = self.doc.clamp(p); - if p != self.sel.focus || n == 1 { - return Some(p); - } - } - self.edit_marks.last().map(|p| self.doc.clamp(p)) - } - - /// The line pieces of the current column selection, if there is one (the selection hasn't - /// moved since the block was made). - pub fn column_segments(&self) -> Option<&[(Pos, Pos)]> { - // Cells that are one run of the text are an ordinary stream selection. - self.column.as_ref().filter(|c| c.sel == self.sel && !c.cells.as_ref().is_some_and(|r| r.stream)).map(|c| c.segments.as_slice()) - } - - /// The whole table cells selected, if the selection is that (see [`CellRange`]). - pub fn cell_range(&self) -> Option<&CellRange> { - self.column.as_ref().filter(|c| c.sel == self.sel).and_then(|c| c.cells.as_ref()) - } - - /// The selection as it was made: where a drag, Shift+click or Shift+arrows started and where - /// the caret went, even when Word's table rule widened it to whole cells or rows (`sel` holds - /// those). - pub fn sel_ends(&self) -> &Selection { - self.widened.as_ref().filter(|w| w.sel == self.sel).map_or(&self.sel, |w| &w.ends) - } - - pub fn selects_whole_story(&self) -> bool { - let st = self.sel.focus.story; - let (a, b) = self.sel.ordered(); - a == self.doc.start_of(st) && b == self.doc.end_of(st) - } - - /// Whether the caret is in a header or footer. - pub fn in_header_footer(&self) -> bool { - let StoryRef::Part(id) = self.sel.focus.story else { return false }; - self.doc.parts.get(&id).is_some_and(|p| matches!(p.kind, PartKind::Header | PartKind::Footer)) - } - - /// Document revision (bumped by every change). - pub fn rev(&self) -> u64 { - self.rev - } - - /// Which document is open (see `document_id`): lets a caller tell an edit from a replacement. - pub fn document_id(&self) -> u64 { - self.document_id - } - - /// How many times the body was replaced under this [`Self::document_id`]. - /// - /// Front ends use it as the body's IME replacement epoch and to tell a body swap from an - /// ordinary edit; it advances for `document.setText`, a version restore, a compare and - /// Undo/Redo, never for typing. - pub fn ime_body_epoch(&self) -> u64 { - self.ime_body_epoch - } - - /// Note that a command replaced the document or its whole body under this document id. - /// - /// The counter fails closed: if it were ever exhausted (unreachable in practice), the - /// document owner id advances too, so a cached IME context still cannot be reused. - pub fn note_body_replaced(&mut self) { - self.ime_body_epoch = match self.ime_body_epoch.checked_add(1) { - Some(epoch) => epoch, - None => { - self.document_id = self.document_id.saturating_add(1); - u64::MAX - } - }; - } - - pub fn touch(&mut self) { - self.rev = self.rev.wrapping_add(1); - self.dirty = true; - } - - /// Reading Highlight ranges for the current document (searched again after edits); empty when it's off. - pub fn find_highlights(&mut self) -> &[(Pos, Pos)] { - if !self.find.highlight { - return &[]; - } - if self.find.highlights.as_ref().is_none_or(|(r, _)| *r != self.rev) { - let found = crate::cmd::edit::search(self, StoryRef::Body).unwrap_or_default(); - self.find.highlights = Some((self.rev, found)); - } - self.find.highlights.as_ref().map(|(_, v)| v.as_slice()).unwrap_or(&[]) - } - - /// Whether the markup area beside the page can show balloons: markup shown in Print Layout - /// (not Read Mode or pages side by side), comments not listed in the Comments pane instead, - /// and Track Changes Options not set to show every revision inline. - pub fn balloons_shown(&self) -> bool { - let v = &self.view; - v.show_markup - && !v.comments_pane - && !v.read_mode - && !v.multi_page - && v.mode == ViewMode::Print - && self.prefs.markup.balloons != wordcraft_layout::display::BalloonMode::Inline - } - - /// Show revisions in balloons, with the balloons on screen: tracked deletions in the body leave - /// the text for balloons in the markup area. Where no balloons can be shown they stay inline, - /// as in Word; so do deletions in headers, footers, notes and text boxes, which get no balloons. - pub fn revisions_in_balloons(&self) -> bool { - let m = &self.prefs.markup; - self.balloons_shown() && m.balloons == wordcraft_layout::display::BalloonMode::Revisions && m.insertions_deletions - } - - /// Initials for new comments: the ones set in File › Options, else the user name's. - pub fn user_initials(&self) -> String { - let set = self.initials.trim(); - if set.is_empty() { self.author.split_whitespace().filter_map(|w| w.chars().next()).collect() } else { set.to_string() } - } - - /// The document's file name and full path for fields (`FILENAME`) and layout context. - /// Returns `(filename, filepath)`. Unsaved documents with no explicit file name default - /// consistently to their name, `("Document1", "Document1")` for the first. Document metadata title is never used - /// as a filename fallback (#3449). - pub fn resolved_filenames(&self) -> (String, String) { - if let Some(path) = &self.path { - let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string_lossy().to_string()); - let full = path.to_string_lossy().to_string(); - (name, full) - } else if let Some(name) = &self.file_name { - (name.clone(), name.clone()) - } else { - let name = untitled_name(self.untitled); - (name.clone(), name) - } - } - - /// The region in effect (#838): the one the user chose ([`Prefs::region`]), else the - /// system's. - pub fn region(&self) -> wordcraft_geom::Region { - match self.prefs.region.as_deref() { - Some(tag) => wordcraft_geom::Region::from_tag(tag), - None => self.system_region.clone(), - } - } - - /// The English of the region in effect (`en-GB` for a British region), or `None` when the - /// region's language isn't English, it names no territory, or it is the United States (US - /// English is the default anyway). An English interface writes new documents in it, as Word - /// does on a British or Canadian system (#1045). - pub fn region_english(&self) -> Option { - let r = self.region(); - (r.language == "en" && !r.territory.is_empty() && r.territory != "US") - .then(|| format!("en-{}", r.territory)) - .and_then(|t| wordcraft_proof::lang::normalize_tag(&t)) - } - - /// The Spanish of the region in effect (`es-MX` on a Mexican system, `es-UY` on a Uruguayan - /// one; the ISO 639-2 `spa` counts as Spanish), or Spain's (`es-ES`) when the region's - /// language isn't Spanish or it names no territory. A Spanish interface writes new documents in it, so they are proofed in the - /// writer's Spanish from the first word (#3360). - pub fn region_spanish(&self) -> String { - let r = self.region(); - (matches!(r.language.as_str(), "es" | "spa") && !r.territory.is_empty()) - .then(|| format!("es-{}", r.territory)) - .and_then(|t| wordcraft_proof::lang::normalize_tag(&t)) - .unwrap_or_else(|| "es-ES".into()) - } - - /// The unit rulers and dialogs show lengths in: the user's choice, else the region's. - pub fn units(&self) -> wordcraft_geom::Unit { - self.prefs.units.unwrap_or_else(|| self.region().units()) - } - - /// `pt` in [`Session::units`], written the region's way (`2,54 cm` in Poland). - pub fn format_length(&self, pt: f32) -> String { - self.region().format(self.units(), pt) - } - - /// A new blank document (the Normal template) on the region's paper: Letter in the US and - /// other Letter regions, A4 elsewhere (#838). Margins stay 2.54 cm (1") all round. - pub fn blank_document(&self) -> Document { - blank_document(&self.region()) - } - - /// The paragraph path the caret is in. - pub fn caret_path(&self) -> &Path { - &self.sel.focus.path - } - - pub fn story(&self) -> StoryRef { - self.sel.focus.story - } - - /// Formatting typing would use at the caret: the caret's, except run direction. New text - /// takes its direction from its own letters, as on screen, so Latin typed or inserted into - /// reopened Hebrew isn't saved as right-to-left; the DOCX writer marks right-to-left pieces - /// itself (#1543). The bidi language stays (it only applies to complex-script text). - /// Over a selection, as in Word, typing takes the first selected character's formatting, - /// whichever way the selection was made (#2879). - pub fn typing_props(&self) -> CharProps { - let (a, _) = self.sel.ordered(); - let first = match self.doc.para_at(&a) { - Some(p) if self.pending.is_none() && !self.sel.is_collapsed() && a.off < p.len() => Some(p.props_of_char(a.off)), - _ => None, - }; - let mut c = match first { - Some(props) => { - // The selection is replaced, so the new text isn't part of a link or a note number. - let mut out = if props.link.is_some() { props.unlinked() } else { props.clone() }; - if matches!(out.style.as_deref(), Some("FootnoteReference" | "EndnoteReference")) { - out.style = None; - } - out - } - None => self.caret_props(), - }; - c.rtl = None; - c - } - - /// The caret's formatting, as Reveal Formatting shows it. - pub fn caret_props(&self) -> CharProps { - if let Some(p) = &self.pending { - return p.clone(); - } - let f = &self.sel.focus; - let Some(p) = self.doc.para_at(f) else { return CharProps::default() }; - let props = p.props_at(f.off); - // As in Word, text typed at either end of a hyperlink isn't part of it; only typing - // inside the link extends it. - let inside_link = props.link.is_some() && f.off > 0 && f.off < p.len() && p.props_of_char(f.off).link == props.link; - // Text typed next to a symbol is in the text's font, not the symbol's (`w:sym`). - let mut out = (if props.link.is_some() && !inside_link { props.unlinked() } else { props.clone() }).without_symbol(); - // A footnote or endnote number is raised by its character style; text typed after it is body text. - if matches!(out.style.as_deref(), Some("FootnoteReference" | "EndnoteReference")) { - out.style = None; - } - out - } - - /// Words in the document, as the status bar shows them (see [`Prefs::count_notes`]). - pub fn word_count(&self) -> usize { - if self.prefs.count_notes { self.doc.word_count_including_notes() } else { self.doc.word_count() } - } - - /// The stories the Word Count dialog and the status bar count: the body plus notes and text - /// boxes when [`Prefs::count_notes`] is on ([`Document::counted_stories`]). - pub fn counted_stories(&self) -> Vec { - if self.prefs.count_notes { self.doc.counted_stories() } else { vec![StoryRef::Body] } - } - - /// The whole document's text the counts cover (#2797): the stories [`Session::counted_stories`] - /// names, joined with paragraph breaks, as the Word Count dialog counts them. - pub fn counted_text(&self) -> String { - self.counted_stories().iter().map(|st| self.doc.plain_text(*st)).collect::>().join("\n") - } - - /// Selected plain text. - pub fn selected_text(&self) -> String { - // A column block or whole cells that aren't one run of the text: each piece's own text, - // a line each, so the status bar and Word Count count those cells only. - if let Some(segs) = self.column_segments() { - return segs.iter().map(|(a, b)| self.doc.copy_range(a.min(b), a.max(b)).plain_text()).collect::>().join("\n"); - } - let (a, b) = self.sel.ordered(); - self.doc.copy_range(&a, &b).plain_text() - } -} - -/// A new blank document on `region`'s paper (see [`Session::blank_document`]). -pub fn blank_document(region: &wordcraft_geom::Region) -> Document { - let mut d = Document::new(); - let (_, w, h) = region.paper(); - d.last_section.page_w = w; - d.last_section.page_h = h; - d -} +use super::doc_state::{CellRange, EDIT_MARKS, Selection}; +use super::{CharProps, Document, PartKind, Path, Pos, Session, StoryRef, ViewMode, untitled_name}; + +impl Session { + /// Remember the caret as the place of an edit, for Go Back. Edits in the same paragraph as + /// the last one (typing on) update that place rather than adding one. + pub(super) fn note_edit(&mut self) { + let at = self.sel.focus.clone(); + match self.edit_marks.last_mut() { + Some(last) if last.story == at.story && last.path == at.path => *last = at, + _ => { + self.edit_marks.push(at); + if self.edit_marks.len() > EDIT_MARKS { + self.edit_marks.remove(0); + } + } + } + self.go_back_step = 0; + } + + /// Go Back (Shift+F5): the next of the last edit places, most recent first, cycling; a place + /// the caret is already at is skipped. `None` before any edit. + pub fn go_back(&mut self) -> Option { + let n = self.edit_marks.len(); + for _ in 0..n { + let i = n - 1 - self.go_back_step % n; + self.go_back_step = self.go_back_step.wrapping_add(1); + let Some(p) = self.edit_marks.get(i) else { continue }; + let p = self.doc.clamp(p); + if p != self.sel.focus || n == 1 { + return Some(p); + } + } + self.edit_marks.last().map(|p| self.doc.clamp(p)) + } + + /// The line pieces of the current column selection, if there is one (the selection hasn't + /// moved since the block was made). + pub fn column_segments(&self) -> Option<&[(Pos, Pos)]> { + // Cells that are one run of the text are an ordinary stream selection. + self.column.as_ref().filter(|c| c.sel == self.sel && !c.cells.as_ref().is_some_and(|r| r.stream)).map(|c| c.segments.as_slice()) + } + + /// The whole table cells selected, if the selection is that (see [`CellRange`]). + pub fn cell_range(&self) -> Option<&CellRange> { + self.column.as_ref().filter(|c| c.sel == self.sel).and_then(|c| c.cells.as_ref()) + } + + /// The selection as it was made: where a drag, Shift+click or Shift+arrows started and where + /// the caret went, even when Word's table rule widened it to whole cells or rows (`sel` holds + /// those). + pub fn sel_ends(&self) -> &Selection { + self.widened.as_ref().filter(|w| w.sel == self.sel).map_or(&self.sel, |w| &w.ends) + } + + pub fn selects_whole_story(&self) -> bool { + let st = self.sel.focus.story; + let (a, b) = self.sel.ordered(); + a == self.doc.start_of(st) && b == self.doc.end_of(st) + } + + /// Whether the caret is in a header or footer. + pub fn in_header_footer(&self) -> bool { + let StoryRef::Part(id) = self.sel.focus.story else { return false }; + self.doc.parts.get(&id).is_some_and(|p| matches!(p.kind, PartKind::Header | PartKind::Footer)) + } + + /// Document revision (bumped by every change). + pub fn rev(&self) -> u64 { + self.rev + } + + /// Which document is open (see `document_id`): lets a caller tell an edit from a replacement. + pub fn document_id(&self) -> u64 { + self.document_id + } + + /// How many times the body was replaced under this [`Self::document_id`]. + /// + /// Front ends use it as the body's IME replacement epoch and to tell a body swap from an + /// ordinary edit; it advances for `document.setText`, a version restore, a compare and + /// Undo/Redo, never for typing. + pub fn ime_body_epoch(&self) -> u64 { + self.ime_body_epoch + } + + /// Note that a command replaced the document or its whole body under this document id. + /// + /// The counter fails closed: if it were ever exhausted (unreachable in practice), the + /// document owner id advances too, so a cached IME context still cannot be reused. + pub fn note_body_replaced(&mut self) { + self.ime_body_epoch = match self.ime_body_epoch.checked_add(1) { + Some(epoch) => epoch, + None => { + self.document_id = self.document_id.saturating_add(1); + u64::MAX + } + }; + } + + pub fn touch(&mut self) { + self.rev = self.rev.wrapping_add(1); + self.dirty = true; + } + + /// Reading Highlight ranges for the current document (searched again after edits); empty when it's off. + pub fn find_highlights(&mut self) -> &[(Pos, Pos)] { + if !self.find.highlight { + return &[]; + } + if self.find.highlights.as_ref().is_none_or(|(r, _)| *r != self.rev) { + let found = crate::cmd::edit::search(self, StoryRef::Body).unwrap_or_default(); + self.find.highlights = Some((self.rev, found)); + } + self.find.highlights.as_ref().map(|(_, v)| v.as_slice()).unwrap_or(&[]) + } + + /// Whether the markup area beside the page can show balloons: markup shown in Print Layout + /// (not Read Mode or pages side by side), comments not listed in the Comments pane instead, + /// and Track Changes Options not set to show every revision inline. + pub fn balloons_shown(&self) -> bool { + let v = &self.view; + v.show_markup + && !v.comments_pane + && !v.read_mode + && !v.multi_page + && v.mode == ViewMode::Print + && self.prefs.markup.balloons != wordcraft_layout::display::BalloonMode::Inline + } + + /// Show revisions in balloons, with the balloons on screen: tracked deletions in the body leave + /// the text for balloons in the markup area. Where no balloons can be shown they stay inline, + /// as in Word; so do deletions in headers, footers, notes and text boxes, which get no balloons. + pub fn revisions_in_balloons(&self) -> bool { + let m = &self.prefs.markup; + self.balloons_shown() && m.balloons == wordcraft_layout::display::BalloonMode::Revisions && m.insertions_deletions + } + + /// Initials for new comments: the ones set in File › Options, else the user name's. + pub fn user_initials(&self) -> String { + let set = self.initials.trim(); + if set.is_empty() { self.author.split_whitespace().filter_map(|w| w.chars().next()).collect() } else { set.to_string() } + } + + /// The document's file name and full path for fields (`FILENAME`) and layout context. + /// Returns `(filename, filepath)`. Unsaved documents with no explicit file name default + /// consistently to their name, `("Document1", "Document1")` for the first. Document metadata title is never used + /// as a filename fallback (#3449). + pub fn resolved_filenames(&self) -> (String, String) { + if let Some(path) = &self.path { + let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string_lossy().to_string()); + let full = path.to_string_lossy().to_string(); + (name, full) + } else if let Some(name) = &self.file_name { + (name.clone(), name.clone()) + } else { + let name = untitled_name(self.untitled); + (name.clone(), name) + } + } + + /// The region in effect (#838): the one the user chose ([`Prefs::region`]), else the + /// system's. + pub fn region(&self) -> wordcraft_geom::Region { + match self.prefs.region.as_deref() { + Some(tag) => wordcraft_geom::Region::from_tag(tag), + None => self.system_region.clone(), + } + } + + /// The English of the region in effect (`en-GB` for a British region), or `None` when the + /// region's language isn't English, it names no territory, or it is the United States (US + /// English is the default anyway). An English interface writes new documents in it, as Word + /// does on a British or Canadian system (#1045). + pub fn region_english(&self) -> Option { + let r = self.region(); + (r.language == "en" && !r.territory.is_empty() && r.territory != "US") + .then(|| format!("en-{}", r.territory)) + .and_then(|t| wordcraft_proof::lang::normalize_tag(&t)) + } + + /// The Spanish of the region in effect (`es-MX` on a Mexican system, `es-UY` on a Uruguayan + /// one; the ISO 639-2 `spa` counts as Spanish), or Spain's (`es-ES`) when the region's + /// language isn't Spanish or it names no territory. A Spanish interface writes new documents in it, so they are proofed in the + /// writer's Spanish from the first word (#3360). + pub fn region_spanish(&self) -> String { + let r = self.region(); + (matches!(r.language.as_str(), "es" | "spa") && !r.territory.is_empty()) + .then(|| format!("es-{}", r.territory)) + .and_then(|t| wordcraft_proof::lang::normalize_tag(&t)) + .unwrap_or_else(|| "es-ES".into()) + } + + /// The unit rulers and dialogs show lengths in: the user's choice, else the region's. + pub fn units(&self) -> wordcraft_geom::Unit { + self.prefs.units.unwrap_or_else(|| self.region().units()) + } + + /// `pt` in [`Session::units`], written the region's way (`2,54 cm` in Poland). + pub fn format_length(&self, pt: f32) -> String { + self.region().format(self.units(), pt) + } + + /// A new blank document (the Normal template) on the region's paper: Letter in the US and + /// other Letter regions, A4 elsewhere (#838). Margins stay 2.54 cm (1") all round. + pub fn blank_document(&self) -> Document { + blank_document(&self.region()) + } + + /// The paragraph path the caret is in. + pub fn caret_path(&self) -> &Path { + &self.sel.focus.path + } + + pub fn story(&self) -> StoryRef { + self.sel.focus.story + } + + /// Formatting typing would use at the caret: the caret's, except run direction. New text + /// takes its direction from its own letters, as on screen, so Latin typed or inserted into + /// reopened Hebrew isn't saved as right-to-left; the DOCX writer marks right-to-left pieces + /// itself (#1543). The bidi language stays (it only applies to complex-script text). + /// Over a selection, as in Word, typing takes the first selected character's formatting, + /// whichever way the selection was made (#2879). + pub fn typing_props(&self) -> CharProps { + let (a, _) = self.sel.ordered(); + let first = match self.doc.para_at(&a) { + Some(p) if self.pending.is_none() && !self.sel.is_collapsed() && a.off < p.len() => Some(p.props_of_char(a.off)), + _ => None, + }; + let mut c = match first { + Some(props) => { + // The selection is replaced, so the new text isn't part of a link or a note number. + let mut out = if props.link.is_some() { props.unlinked() } else { props.clone() }; + if matches!(out.style.as_deref(), Some("FootnoteReference" | "EndnoteReference")) { + out.style = None; + } + out + } + None => self.caret_props(), + }; + c.rtl = None; + c + } + + /// The caret's formatting, as Reveal Formatting shows it. + pub fn caret_props(&self) -> CharProps { + if let Some(p) = &self.pending { + return p.clone(); + } + let f = &self.sel.focus; + let Some(p) = self.doc.para_at(f) else { return CharProps::default() }; + let props = p.props_at(f.off); + // As in Word, text typed at either end of a hyperlink isn't part of it; only typing + // inside the link extends it. + let inside_link = props.link.is_some() && f.off > 0 && f.off < p.len() && p.props_of_char(f.off).link == props.link; + // Text typed next to a symbol is in the text's font, not the symbol's (`w:sym`). + let mut out = (if props.link.is_some() && !inside_link { props.unlinked() } else { props.clone() }).without_symbol(); + // A footnote or endnote number is raised by its character style; text typed after it is body text. + if matches!(out.style.as_deref(), Some("FootnoteReference" | "EndnoteReference")) { + out.style = None; + } + out + } + + /// Words in the document, as the status bar shows them (see [`Prefs::count_notes`]). + pub fn word_count(&self) -> usize { + if self.prefs.count_notes { self.doc.word_count_including_notes() } else { self.doc.word_count() } + } + + /// The stories the Word Count dialog and the status bar count: the body plus notes and text + /// boxes when [`Prefs::count_notes`] is on ([`Document::counted_stories`]). + pub fn counted_stories(&self) -> Vec { + if self.prefs.count_notes { self.doc.counted_stories() } else { vec![StoryRef::Body] } + } + + /// The whole document's text the counts cover (#2797): the stories [`Session::counted_stories`] + /// names, joined with paragraph breaks, as the Word Count dialog counts them. + pub fn counted_text(&self) -> String { + self.counted_stories().iter().map(|st| self.doc.plain_text(*st)).collect::>().join("\n") + } + + /// Selected plain text. + pub fn selected_text(&self) -> String { + // A column block or whole cells that aren't one run of the text: each piece's own text, + // a line each, so the status bar and Word Count count those cells only. + if let Some(segs) = self.column_segments() { + return segs.iter().map(|(a, b)| self.doc.copy_range(a.min(b), a.max(b)).plain_text()).collect::>().join("\n"); + } + let (a, b) = self.sel.ordered(); + self.doc.copy_range(&a, &b).plain_text() + } +} + +/// A new blank document on `region`'s paper (see [`Session::blank_document`]). +pub fn blank_document(region: &wordcraft_geom::Region) -> Document { + let mut d = Document::new(); + let (_, w, h) = region.paper(); + d.last_section.page_w = w; + d.last_section.page_h = h; + d +} diff --git a/crates/engine/src/session/run.rs b/crates/engine/src/session/run.rs index 5d39d50762..948cdc4d4c 100644 --- a/crates/engine/src/session/run.rs +++ b/crates/engine/src/session/run.rs @@ -1,481 +1,481 @@ -use super::doc_state::{ColumnBlock, Selection}; -use super::ime_state::ImeCaretState; -use super::snapshot_types::RunSnapshot; -use super::{CharProps, CmdError, Document, Fragment, ImeEditOutcome, Pos, Session, StoryRef, Value, document_id_mark}; -use crate::CommandSpec; - -struct SuccessContext<'a> { - id: &'a str, - spec: &'a CommandSpec, - before_doc: &'a Option, - pending_before: &'a Option>, - document_id_before: u64, - replaces_document: bool, - dirty_before: Option, - rev_restore: Option, - pushed: bool, - mutates: bool, - ime_caret_before: ImeCaretState, - rev_before: u64, - sel_before: &'a Selection, -} - -struct ElsewhereContext { - home: u64, - mark: u64, - before_doc: Option, - blocks_before: Option>, - default_language_before: Option, - pending_application_change_before: bool, -} - -impl Session { - fn leave_math_for_command(&mut self, id: &str) { - if self.math.is_some() - && (id.starts_with("text.") || id.starts_with("caret.") || id.starts_with("select.") || id.starts_with("edit.paste")) - && let Some(m) = self.math.take() - { - let off = m.at.off + wordcraft_doc::para::OBJ.len_utf8(); - self.sel = Selection::caret(Pos { off, ..m.at }); - } - } - - /// Whether the command changed nothing: the document, the selection, the formatting - /// waiting at the caret and the column selection are as they were before it ran. - /// - /// A command that changes nothing is not an edit: it adds no undo step, marks no - /// unsaved changes, forces no relayout and leaves the redo stack alone (#23). - fn unchanged(&self, doc: &Document, sel: &Selection, column: &Option, pending: &Option) -> bool { - doc.same_content(&self.doc) - && *sel == self.sel - && *column == self.column - && *pending == self.pending - && !self.md_source.as_ref().is_some_and(|src| src.is_pending()) - } - - /// A navigated style change is for the Accept or Reject that follows: a command that edits - /// or moves the selection forgets it. - fn forget_review_style(&mut self, id: &str, mutates: bool) { - if (mutates && !matches!(id, "review.accept" | "review.reject")) || id.starts_with("caret.") || id.starts_with("select.") { - self.review_style = None; - } - } - - /// Run a command by id with JSON params (the single entry point for the UI, CLI, MCP and - /// control channel). Mutating commands are undoable; a failed command leaves the document as - /// it was; a panic inside a command becomes an error. - pub fn run(&mut self, id: &str, params: &Value) -> Result { - if self.md_source.is_some() - && let Some(mutates) = self.registry.get(id).map(|spec| spec.mutates) - { - crate::cmd::md_source::before_command(self, id, mutates)?; - } - self.run_command(id, params) - } - - /// Dispatches `id` through the command registry with transactional rollback on failure, - /// tracking history, undo checkpoints, and IME outcomes. - fn run_command(&mut self, id: &str, params: &Value) -> Result { - // The document the command starts in, and the first id a document it opens can have. - let home = self.document_id; - let mark = document_id_mark(); - // Every dispatch starts from an unclassified outcome. Early returns (unknown, disabled, - // protected) leave it at the default. - self.ime_edit_outcome = ImeEditOutcome::default(); - // A rolled-back transaction must not leave a successful application-change signal from a - // nested command that ran before the failure. - let pending_application_change_before = self.pending_ime_application_change; - let join = std::mem::take(&mut self.join_next); - // The equation replacement promise is not a generic undo join: only the command that - // actually types into the equation may consume it. Any other command discards it. - let equation_join = std::mem::take(&mut self.equation_join_next); - // The body replacement promise is likewise scoped: only the `text.insert` at the - // selection the deletion left may consume it. - let ime_text_join = self.ime_text_join.take(); - let reg = self.registry.clone(); - let Some(spec) = reg.get(id) else { return Err(CmdError::Unknown(id.to_string())) }; - self.forget_review_style(id, spec.mutates); - let join = join - || (equation_join && matches!(id, "equation.type" | "equation.backspace")) - || (id == "text.insert" && ime_text_join.as_ref().is_some_and(|sel| sel == &self.sel)); - if let Some(why) = (spec.enabled)(self) { - return Err(CmdError::Disabled(format!("{id}: {why}"))); - } - // Restrict Editing: refused outright, or allowed as long as it only edits the exceptions. - let guard = if spec.mutates { crate::cmd::protect::check_before(self, id, params)? } else { false }; - // Quick Parts aren't part of the document, so a refused edit puts them back itself (the Spike). - let blocks_before = guard.then(|| self.building_blocks.clone()); - if spec.mutates && self.doc.settings.protection.as_deref() == Some("trackedChanges") { - self.doc.settings.track_changes = true; - } - // Macro recording and Repeat. - let record = !matches!(id, "tools.recordMacro" | "tools.macros" | "edit.undo" | "edit.redo" | "edit.repeat") - && (spec.mutates || id.starts_with("caret.") || id.starts_with("select.")); - // A password never lands in a recorded macro or Repeat. - let kept = || { - let mut p = params.clone(); - if let Some(m) = p.as_object_mut() { - m.remove("password"); - } - p - }; - if record && let Some((_, steps)) = self.recording.as_mut() { - steps.push((id.to_string(), kept())); - } - if spec.mutates && !matches!(id, "edit.undo" | "edit.redo" | "edit.repeat") { - self.last_command = Some((id.to_string(), kept())); - } - // Typing, caret movement and selection outside the equation commands leave the equation. - self.leave_math_for_command(id); - // The undo stacks are snapshotted whole, not as a length: commands run nested commands, - // whose checkpoints, undos and redos (and evictions at the limit) must be put back too. - let ime_epoch_before = spec.mutates.then_some(self.ime_body_epoch); - // Commands may leave an equation (a whole-body replacement) even after they fail; put - // the editing session back with the document. - let math_before = spec.mutates.then(|| self.math.clone()); - let before_doc: Option = if spec.mutates { - Some(( - self.doc.clone(), - self.sel.clone(), - self.history.clone(), - self.redo.clone(), - self.typing_open, - self.undo_evicted, - self.column.clone(), - )) - } else { - None - }; - // The formatting waiting at the caret (⌘B with nothing selected): an undo step restores - // it, so a command that only changes it is an edit. - let pending_before = spec.mutates.then(|| self.pending.clone()); - // Which document is open. A command that replaces the document (a mail merge) opens a - // new one, with a new undo history: it is not an edit of this document, whatever the - // new document holds. - let document_id_before = self.document_id; - // Whether the document has unsaved changes, and the revision its layout is keyed on: a - // command that changes nothing leaves both as they were, and a failed one leaves no - // trace. - let dirty_before = spec.mutates.then_some(self.dirty); - let rev_restore = spec.mutates.then_some(self.rev); - // These mutating commands can replace the open document and discard its saved versions. - // Keep the versions until the command and the protected-region post-check have both - // succeeded; rejected commands must leave the old document and its snapshots together. - // `mailings.finish` with a path saves the merged copy there and leaves the open - // document alone, so it replaces nothing. - let replaces_document = matches!(id, "review.compare" | "review.combine" | "mailings.envelopes" | "mailings.labels") - || (id == "mailings.finish" && crate::p::str(params, "path").is_none()); - let versions_before = replaces_document.then(|| self.versions.clone()); - let identity_before = replaces_document.then(|| { - ( - self.path.clone(), - self.file_name.clone(), - self.document_id, - self.password.clone(), - self.dirty, - self.bib_style.clone(), - self.merge.preview, - ) - }); - let typing = spec.id == "text.insert" || spec.id == "equation.type"; - if spec.mutates && !typing { - self.typing_open = false; - } - // Whether this command pushed an undo step (a no-op command's step is dropped below, - // leaving its undo group without one). - let mut pushed = false; - if spec.mutates && spec.id != "view.markdownSourceText" { - let label = if typing { "Typing" } else { spec.label }; - // A joined command merges into its group's step; a group whose first command - // changed nothing has none, so the next command in it starts one (#4001). - if !join || self.history.is_empty() || !self.join_anchor { - pushed = self.checkpoint(label); - } - } else if !spec.id.starts_with("caret.") && !spec.id.starts_with("view.") { - // Non-mutating commands other than caret movement keep the typing group. - } else if spec.id.starts_with("caret.") { - self.typing_open = false; - } - // Only the paste right after a tracked cut makes it a move. - if (spec.mutates || matches!(id, "edit.undo" | "edit.undoTo" | "edit.redo")) && !matches!(id, "edit.cut" | "edit.paste" | "edit.pasteSpecial") - { - self.pending_move = None; - } - let sel_before = self.sel.clone(); - // A rejected command must not persist a new proofing default, including a rejection - // by the protected-region check after its handler has succeeded. Undo stays document-only. - let default_language_before = self.prefs.default_language.clone(); - let run = spec.run; - let mutates = spec.mutates; - let ime_caret_before = self.current_ime_caret_state(); - let rev_before = self.rev; - let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| match crate::cmd::canvas::member_command(self, id, params) { - Some(r) => r, - None => crate::cmd::column::dispatch(self, id, mutates, run, params), - })); - let mut result = match result { - Ok(r) => r, - Err(p) => { - let msg = p.downcast_ref::<&str>().map(ToString::to_string).or_else(|| p.downcast_ref::().cloned()).unwrap_or_default(); - Err(CmdError::Failed(format!("{id} panicked: {msg}"))) - } - }; - if self.document_id != home || (result.is_err() && self.order.iter().any(|d| *d >= mark)) { - return self.finish_elsewhere( - ElsewhereContext { home, mark, before_doc, blocks_before, default_language_before, pending_application_change_before }, - result, - ); - } - // Restrict Editing: outside the exceptions nothing may change. - if result.is_ok() - && guard - && let Some((before, ..)) = &before_doc - && !crate::cmd::protect::outside_unchanged(before, &self.doc, &self.author) - { - result = Err(CmdError::Disabled(format!("{id}: only the editable regions of this protected document can change"))); - } - match &result { - Ok(_) => self.finish_success(SuccessContext { - id, - spec, - before_doc: &before_doc, - pending_before: &pending_before, - document_id_before, - replaces_document, - dirty_before, - rev_restore, - pushed, - mutates, - ime_caret_before, - rev_before, - sel_before: &sel_before, - }), - Err(e) => { - self.ime_edit_outcome = ImeEditOutcome::default(); - self.pending_ime_application_change = pending_application_change_before; - self.autocap = None; - self.prefs.default_language = default_language_before; - if let Some(epoch) = ime_epoch_before { - self.ime_body_epoch = epoch; - } - if let Some(math) = math_before { - self.math = math; - } - if let Some(versions) = versions_before { - self.versions = versions; - } - if let Some((path, file_name, document_id, password, dirty, bib_style, merge_preview)) = identity_before { - self.path = path; - self.file_name = file_name; - self.document_id = document_id; - self.password = password; - self.dirty = dirty; - self.bib_style = bib_style; - self.merge.preview = merge_preview; - } - if let Some((d, s, h, r, t, ev, c)) = before_doc { - self.doc = d; - self.sel = s; - self.history = h; - self.redo = r; - self.typing_open = t; - // The steps the command pushed out are back, so they no longer count as gone. - self.undo_evicted = ev; - self.column = c; - // Its step is gone with them: its undo group has none to merge into. - if pushed { - self.join_anchor = false; - } - } - if let Some(dirty) = dirty_before { - self.dirty = dirty; - } - if let Some(rev) = rev_restore { - self.restore_rev(rev); - } - if let Some(b) = blocks_before { - self.building_blocks = b; - } - self.status = e.to_string(); - } - } - result - } - - fn finish_success(&mut self, context: SuccessContext<'_>) { - let SuccessContext { - id, - spec, - before_doc, - pending_before, - document_id_before, - replaces_document, - dirty_before, - rev_restore, - pushed, - mutates, - ime_caret_before, - rev_before, - sel_before, - } = context; - let mut no_op = false; - if spec.mutates { - // Formatting changed while tracking becomes a formatting revision. - if let Some((before, ..)) = before_doc { - crate::cmd::fmt_revisions::record(self, id, before); - } - self.doc.ensure_nonempty(); - self.doc.prune_text_boxes(); - // Whether the command left the document, the selection and the formatting - // waiting at the caret as they were. A command that replaces the document — - // a mail merge, a comparison — is not an edit of this one, whatever the new - // document holds: `replaces_document` names the commands that may replace - // it, and `document_id` catches any other that does (through - // `set_document`, which starts a new history the old one must not overwrite). - let unchanged = |s: &Session| match (before_doc, pending_before) { - (Some((doc, sel, _, _, _, _, column)), Some(pending)) => { - !replaces_document && s.document_id == document_id_before && s.unchanged(doc, sel, column, pending) - } - _ => false, - }; - let changed = !unchanged(self); - if changed { - // The layout the connectors are rerouted against is the new one. - self.touch(); - } - // Glued connectors follow the shapes they join. - let rerouted = crate::cmd::connectors::reroutes_after(id) && crate::cmd::connectors::reroute(self); - if rerouted { - self.touch(); - } - // A command that changed nothing is not an edit (#23): it adds no undo - // step, marks no unsaved changes, forces no relayout and leaves the redo - // stack alone. What it left behind is compared with what it started from, - // after the reroute: a reroute that moved a connector is an edit of its own. - no_op = !changed && !rerouted && unchanged(self); - if no_op { - // The step the dispatch pushed is not an edit: put the stacks back as - // they were, so the redo stack survives and no step is evicted. The - // group it belonged to is left without a step of its own, so the next - // command in it starts one. - if let Some((_, _, history, redo, typing_open, evicted, _)) = before_doc { - self.history = history.clone(); - self.redo = redo.clone(); - self.typing_open = *typing_open; - self.undo_evicted = *evicted; - } - if let Some(dirty) = dirty_before { - self.dirty = dirty; - } - if let Some(rev) = rev_restore { - let touched = self.rev; - self.restore_rev(rev); - crate::cmd::draw::rekey_lasso(self, touched, rev); - } - if pushed { - self.join_anchor = false; - } - } - } - self.clamp_selection(); - let mut ime_outcome = self.ime_edit_outcome; - if ime_outcome.content_changed { - ime_outcome.state_changed = true; - } - // An unclassified mutating command is conservatively a state change. It can make - // a frame Other, but it can never mint a receipt because `classified` is false. - // A command that changed nothing is not an edit (#23), so it is not an IME - // state change either: otherwise Backspace at the document start would still - // signal an application change and move the settled IME revision. - if !ime_outcome.classified && mutates && !no_op { - ime_outcome.state_changed = true; - } - ime_outcome.selection_changed = self.current_ime_caret_state() != ime_caret_before; - self.ime_edit_outcome = ime_outcome; - if ime_outcome.state_changed { - let caret = self.current_ime_caret_state(); - self.ime_state_seen = Some(caret); - self.reset_ime_state_revision(); - } - if !self.ime_origin && !no_op && (ime_outcome.content_changed || ime_outcome.state_changed || (!ime_outcome.classified && mutates)) { - self.pending_ime_application_change = true; - } - if sel_before.focus.story == StoryRef::Body && self.in_header_footer() { - self.hf_return = Some(sel_before.clone()); - } - // A selection command that moves the insertion point ends the typing group, like - // caret movement: typing, Select, typing are two undo steps (#419). - if !spec.mutates && self.sel != *sel_before { - self.typing_open = false; - } - if spec.mutates && !no_op && !matches!(id, "edit.undo" | "edit.redo") { - self.note_edit(); - } - // Outline view: collapsed headings follow edits; the caret stays where it shows. - crate::cmd::outline::after_command(self, sel_before, self.rev != rev_before); - // Extra selected objects last until something else is edited or selected; formatting - // them keeps them selected, as in Word. - if !matches!(id, "select.addObject" | "arrange.ungroup" | "shape.fill" | "shape.effects") && (spec.mutates || self.sel != *sel_before) { - self.also_selected.clear(); - } - // A canvas member stays picked while its canvas does and nothing else is selected. - if id != "select.member" - && (self.sel != *sel_before - || id.starts_with("select.") - || id.starts_with("caret.") - || matches!(id, "edit.undo" | "edit.undoTo" | "edit.redo")) - { - self.member = None; - } - self.note_typing(id, before_doc.as_ref().map(|b| &b.0), sel_before); - } - - /// The end of [`Session::run`] for a command that ended up in another document than it - /// started in (it opened, made, switched to or closed one) or that failed after opening one. - /// The checks that compare the document before and after the command don't apply. - /// Succeeded: the first document loses the undo step made for the command when its text is - /// as it was (opening a document is no edit to undo there). Failed: the documents the command opened close again and the first document is - /// active and as it was. - fn finish_elsewhere(&mut self, context: ElsewhereContext, result: Result) -> Result { - let ElsewhereContext { home, mark, before_doc, blocks_before, default_language_before, pending_application_change_before } = context; - // Another document is a state change the input method has to see, never an IME edit. - self.ime_edit_outcome = ImeEditOutcome::default(); - match &result { - Ok(_) => { - self.put_back_parked(home, before_doc); - self.clamp_selection(); - self.ime_state_seen = Some(self.current_ime_caret_state()); - self.reset_ime_state_revision(); - if !self.ime_origin { - self.pending_ime_application_change = true; - } - } - Err(e) => { - self.pending_ime_application_change = pending_application_change_before; - self.autocap = None; - self.prefs.default_language = default_language_before; - if self.is_open(home) { - let _ = self.switch_to(home); - let opened: Vec = self.order.iter().copied().filter(|d| *d >= mark && *d != home).collect(); - for d in opened { - let _ = self.close_document(d); - } - if let Some((d, s, h, r, t, ev, c)) = before_doc { - self.doc = d; - self.sel = s; - self.history = h; - self.redo = r; - self.typing_open = t; - self.undo_evicted = ev; - self.column = c; - } - } - if let Some(b) = blocks_before { - self.building_blocks = b; - } - self.status = e.to_string(); - } - } - result - } -} +use super::doc_state::{ColumnBlock, Selection}; +use super::ime_state::ImeCaretState; +use super::snapshot_types::RunSnapshot; +use super::{CharProps, CmdError, Document, Fragment, ImeEditOutcome, Pos, Session, StoryRef, Value, document_id_mark}; +use crate::CommandSpec; + +struct SuccessContext<'a> { + id: &'a str, + spec: &'a CommandSpec, + before_doc: &'a Option, + pending_before: &'a Option>, + document_id_before: u64, + replaces_document: bool, + dirty_before: Option, + rev_restore: Option, + pushed: bool, + mutates: bool, + ime_caret_before: ImeCaretState, + rev_before: u64, + sel_before: &'a Selection, +} + +struct ElsewhereContext { + home: u64, + mark: u64, + before_doc: Option, + blocks_before: Option>, + default_language_before: Option, + pending_application_change_before: bool, +} + +impl Session { + fn leave_math_for_command(&mut self, id: &str) { + if self.math.is_some() + && (id.starts_with("text.") || id.starts_with("caret.") || id.starts_with("select.") || id.starts_with("edit.paste")) + && let Some(m) = self.math.take() + { + let off = m.at.off + wordcraft_doc::para::OBJ.len_utf8(); + self.sel = Selection::caret(Pos { off, ..m.at }); + } + } + + /// Whether the command changed nothing: the document, the selection, the formatting + /// waiting at the caret and the column selection are as they were before it ran. + /// + /// A command that changes nothing is not an edit: it adds no undo step, marks no + /// unsaved changes, forces no relayout and leaves the redo stack alone (#23). + fn unchanged(&self, doc: &Document, sel: &Selection, column: &Option, pending: &Option) -> bool { + doc.same_content(&self.doc) + && *sel == self.sel + && *column == self.column + && *pending == self.pending + && !self.md_source.as_ref().is_some_and(|src| src.is_pending()) + } + + /// A navigated style change is for the Accept or Reject that follows: a command that edits + /// or moves the selection forgets it. + fn forget_review_style(&mut self, id: &str, mutates: bool) { + if (mutates && !matches!(id, "review.accept" | "review.reject")) || id.starts_with("caret.") || id.starts_with("select.") { + self.review_style = None; + } + } + + /// Run a command by id with JSON params (the single entry point for the UI, CLI, MCP and + /// control channel). Mutating commands are undoable; a failed command leaves the document as + /// it was; a panic inside a command becomes an error. + pub fn run(&mut self, id: &str, params: &Value) -> Result { + if self.md_source.is_some() + && let Some(mutates) = self.registry.get(id).map(|spec| spec.mutates) + { + crate::cmd::md_source::before_command(self, id, mutates)?; + } + self.run_command(id, params) + } + + /// Dispatches `id` through the command registry with transactional rollback on failure, + /// tracking history, undo checkpoints, and IME outcomes. + fn run_command(&mut self, id: &str, params: &Value) -> Result { + // The document the command starts in, and the first id a document it opens can have. + let home = self.document_id; + let mark = document_id_mark(); + // Every dispatch starts from an unclassified outcome. Early returns (unknown, disabled, + // protected) leave it at the default. + self.ime_edit_outcome = ImeEditOutcome::default(); + // A rolled-back transaction must not leave a successful application-change signal from a + // nested command that ran before the failure. + let pending_application_change_before = self.pending_ime_application_change; + let join = std::mem::take(&mut self.join_next); + // The equation replacement promise is not a generic undo join: only the command that + // actually types into the equation may consume it. Any other command discards it. + let equation_join = std::mem::take(&mut self.equation_join_next); + // The body replacement promise is likewise scoped: only the `text.insert` at the + // selection the deletion left may consume it. + let ime_text_join = self.ime_text_join.take(); + let reg = self.registry.clone(); + let Some(spec) = reg.get(id) else { return Err(CmdError::Unknown(id.to_string())) }; + self.forget_review_style(id, spec.mutates); + let join = join + || (equation_join && matches!(id, "equation.type" | "equation.backspace")) + || (id == "text.insert" && ime_text_join.as_ref().is_some_and(|sel| sel == &self.sel)); + if let Some(why) = (spec.enabled)(self) { + return Err(CmdError::Disabled(format!("{id}: {why}"))); + } + // Restrict Editing: refused outright, or allowed as long as it only edits the exceptions. + let guard = if spec.mutates { crate::cmd::protect::check_before(self, id, params)? } else { false }; + // Quick Parts aren't part of the document, so a refused edit puts them back itself (the Spike). + let blocks_before = guard.then(|| self.building_blocks.clone()); + if spec.mutates && self.doc.settings.protection.as_deref() == Some("trackedChanges") { + self.doc.settings.track_changes = true; + } + // Macro recording and Repeat. + let record = !matches!(id, "tools.recordMacro" | "tools.macros" | "edit.undo" | "edit.redo" | "edit.repeat") + && (spec.mutates || id.starts_with("caret.") || id.starts_with("select.")); + // A password never lands in a recorded macro or Repeat. + let kept = || { + let mut p = params.clone(); + if let Some(m) = p.as_object_mut() { + m.remove("password"); + } + p + }; + if record && let Some((_, steps)) = self.recording.as_mut() { + steps.push((id.to_string(), kept())); + } + if spec.mutates && !matches!(id, "edit.undo" | "edit.redo" | "edit.repeat") { + self.last_command = Some((id.to_string(), kept())); + } + // Typing, caret movement and selection outside the equation commands leave the equation. + self.leave_math_for_command(id); + // The undo stacks are snapshotted whole, not as a length: commands run nested commands, + // whose checkpoints, undos and redos (and evictions at the limit) must be put back too. + let ime_epoch_before = spec.mutates.then_some(self.ime_body_epoch); + // Commands may leave an equation (a whole-body replacement) even after they fail; put + // the editing session back with the document. + let math_before = spec.mutates.then(|| self.math.clone()); + let before_doc: Option = if spec.mutates { + Some(( + self.doc.clone(), + self.sel.clone(), + self.history.clone(), + self.redo.clone(), + self.typing_open, + self.undo_evicted, + self.column.clone(), + )) + } else { + None + }; + // The formatting waiting at the caret (⌘B with nothing selected): an undo step restores + // it, so a command that only changes it is an edit. + let pending_before = spec.mutates.then(|| self.pending.clone()); + // Which document is open. A command that replaces the document (a mail merge) opens a + // new one, with a new undo history: it is not an edit of this document, whatever the + // new document holds. + let document_id_before = self.document_id; + // Whether the document has unsaved changes, and the revision its layout is keyed on: a + // command that changes nothing leaves both as they were, and a failed one leaves no + // trace. + let dirty_before = spec.mutates.then_some(self.dirty); + let rev_restore = spec.mutates.then_some(self.rev); + // These mutating commands can replace the open document and discard its saved versions. + // Keep the versions until the command and the protected-region post-check have both + // succeeded; rejected commands must leave the old document and its snapshots together. + // `mailings.finish` with a path saves the merged copy there and leaves the open + // document alone, so it replaces nothing. + let replaces_document = matches!(id, "review.compare" | "review.combine" | "mailings.envelopes" | "mailings.labels") + || (id == "mailings.finish" && crate::p::str(params, "path").is_none()); + let versions_before = replaces_document.then(|| self.versions.clone()); + let identity_before = replaces_document.then(|| { + ( + self.path.clone(), + self.file_name.clone(), + self.document_id, + self.password.clone(), + self.dirty, + self.bib_style.clone(), + self.merge.preview, + ) + }); + let typing = spec.id == "text.insert" || spec.id == "equation.type"; + if spec.mutates && !typing { + self.typing_open = false; + } + // Whether this command pushed an undo step (a no-op command's step is dropped below, + // leaving its undo group without one). + let mut pushed = false; + if spec.mutates && spec.id != "view.markdownSourceText" { + let label = if typing { "Typing" } else { spec.label }; + // A joined command merges into its group's step; a group whose first command + // changed nothing has none, so the next command in it starts one (#4001). + if !join || self.history.is_empty() || !self.join_anchor { + pushed = self.checkpoint(label); + } + } else if !spec.id.starts_with("caret.") && !spec.id.starts_with("view.") { + // Non-mutating commands other than caret movement keep the typing group. + } else if spec.id.starts_with("caret.") { + self.typing_open = false; + } + // Only the paste right after a tracked cut makes it a move. + if (spec.mutates || matches!(id, "edit.undo" | "edit.undoTo" | "edit.redo")) && !matches!(id, "edit.cut" | "edit.paste" | "edit.pasteSpecial") + { + self.pending_move = None; + } + let sel_before = self.sel.clone(); + // A rejected command must not persist a new proofing default, including a rejection + // by the protected-region check after its handler has succeeded. Undo stays document-only. + let default_language_before = self.prefs.default_language.clone(); + let run = spec.run; + let mutates = spec.mutates; + let ime_caret_before = self.current_ime_caret_state(); + let rev_before = self.rev; + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| match crate::cmd::canvas::member_command(self, id, params) { + Some(r) => r, + None => crate::cmd::column::dispatch(self, id, mutates, run, params), + })); + let mut result = match result { + Ok(r) => r, + Err(p) => { + let msg = p.downcast_ref::<&str>().map(ToString::to_string).or_else(|| p.downcast_ref::().cloned()).unwrap_or_default(); + Err(CmdError::Failed(format!("{id} panicked: {msg}"))) + } + }; + if self.document_id != home || (result.is_err() && self.order.iter().any(|d| *d >= mark)) { + return self.finish_elsewhere( + ElsewhereContext { home, mark, before_doc, blocks_before, default_language_before, pending_application_change_before }, + result, + ); + } + // Restrict Editing: outside the exceptions nothing may change. + if result.is_ok() + && guard + && let Some((before, ..)) = &before_doc + && !crate::cmd::protect::outside_unchanged(before, &self.doc, &self.author) + { + result = Err(CmdError::Disabled(format!("{id}: only the editable regions of this protected document can change"))); + } + match &result { + Ok(_) => self.finish_success(SuccessContext { + id, + spec, + before_doc: &before_doc, + pending_before: &pending_before, + document_id_before, + replaces_document, + dirty_before, + rev_restore, + pushed, + mutates, + ime_caret_before, + rev_before, + sel_before: &sel_before, + }), + Err(e) => { + self.ime_edit_outcome = ImeEditOutcome::default(); + self.pending_ime_application_change = pending_application_change_before; + self.autocap = None; + self.prefs.default_language = default_language_before; + if let Some(epoch) = ime_epoch_before { + self.ime_body_epoch = epoch; + } + if let Some(math) = math_before { + self.math = math; + } + if let Some(versions) = versions_before { + self.versions = versions; + } + if let Some((path, file_name, document_id, password, dirty, bib_style, merge_preview)) = identity_before { + self.path = path; + self.file_name = file_name; + self.document_id = document_id; + self.password = password; + self.dirty = dirty; + self.bib_style = bib_style; + self.merge.preview = merge_preview; + } + if let Some((d, s, h, r, t, ev, c)) = before_doc { + self.doc = d; + self.sel = s; + self.history = h; + self.redo = r; + self.typing_open = t; + // The steps the command pushed out are back, so they no longer count as gone. + self.undo_evicted = ev; + self.column = c; + // Its step is gone with them: its undo group has none to merge into. + if pushed { + self.join_anchor = false; + } + } + if let Some(dirty) = dirty_before { + self.dirty = dirty; + } + if let Some(rev) = rev_restore { + self.restore_rev(rev); + } + if let Some(b) = blocks_before { + self.building_blocks = b; + } + self.status = e.to_string(); + } + } + result + } + + fn finish_success(&mut self, context: SuccessContext<'_>) { + let SuccessContext { + id, + spec, + before_doc, + pending_before, + document_id_before, + replaces_document, + dirty_before, + rev_restore, + pushed, + mutates, + ime_caret_before, + rev_before, + sel_before, + } = context; + let mut no_op = false; + if spec.mutates { + // Formatting changed while tracking becomes a formatting revision. + if let Some((before, ..)) = before_doc { + crate::cmd::fmt_revisions::record(self, id, before); + } + self.doc.ensure_nonempty(); + self.doc.prune_text_boxes(); + // Whether the command left the document, the selection and the formatting + // waiting at the caret as they were. A command that replaces the document — + // a mail merge, a comparison — is not an edit of this one, whatever the new + // document holds: `replaces_document` names the commands that may replace + // it, and `document_id` catches any other that does (through + // `set_document`, which starts a new history the old one must not overwrite). + let unchanged = |s: &Session| match (before_doc, pending_before) { + (Some((doc, sel, _, _, _, _, column)), Some(pending)) => { + !replaces_document && s.document_id == document_id_before && s.unchanged(doc, sel, column, pending) + } + _ => false, + }; + let changed = !unchanged(self); + if changed { + // The layout the connectors are rerouted against is the new one. + self.touch(); + } + // Glued connectors follow the shapes they join. + let rerouted = crate::cmd::connectors::reroutes_after(id) && crate::cmd::connectors::reroute(self); + if rerouted { + self.touch(); + } + // A command that changed nothing is not an edit (#23): it adds no undo + // step, marks no unsaved changes, forces no relayout and leaves the redo + // stack alone. What it left behind is compared with what it started from, + // after the reroute: a reroute that moved a connector is an edit of its own. + no_op = !changed && !rerouted && unchanged(self); + if no_op { + // The step the dispatch pushed is not an edit: put the stacks back as + // they were, so the redo stack survives and no step is evicted. The + // group it belonged to is left without a step of its own, so the next + // command in it starts one. + if let Some((_, _, history, redo, typing_open, evicted, _)) = before_doc { + self.history = history.clone(); + self.redo = redo.clone(); + self.typing_open = *typing_open; + self.undo_evicted = *evicted; + } + if let Some(dirty) = dirty_before { + self.dirty = dirty; + } + if let Some(rev) = rev_restore { + let touched = self.rev; + self.restore_rev(rev); + crate::cmd::draw::rekey_lasso(self, touched, rev); + } + if pushed { + self.join_anchor = false; + } + } + } + self.clamp_selection(); + let mut ime_outcome = self.ime_edit_outcome; + if ime_outcome.content_changed { + ime_outcome.state_changed = true; + } + // An unclassified mutating command is conservatively a state change. It can make + // a frame Other, but it can never mint a receipt because `classified` is false. + // A command that changed nothing is not an edit (#23), so it is not an IME + // state change either: otherwise Backspace at the document start would still + // signal an application change and move the settled IME revision. + if !ime_outcome.classified && mutates && !no_op { + ime_outcome.state_changed = true; + } + ime_outcome.selection_changed = self.current_ime_caret_state() != ime_caret_before; + self.ime_edit_outcome = ime_outcome; + if ime_outcome.state_changed { + let caret = self.current_ime_caret_state(); + self.ime_state_seen = Some(caret); + self.reset_ime_state_revision(); + } + if !self.ime_origin && !no_op && (ime_outcome.content_changed || ime_outcome.state_changed || (!ime_outcome.classified && mutates)) { + self.pending_ime_application_change = true; + } + if sel_before.focus.story == StoryRef::Body && self.in_header_footer() { + self.hf_return = Some(sel_before.clone()); + } + // A selection command that moves the insertion point ends the typing group, like + // caret movement: typing, Select, typing are two undo steps (#419). + if !spec.mutates && self.sel != *sel_before { + self.typing_open = false; + } + if spec.mutates && !no_op && !matches!(id, "edit.undo" | "edit.redo") { + self.note_edit(); + } + // Outline view: collapsed headings follow edits; the caret stays where it shows. + crate::cmd::outline::after_command(self, sel_before, self.rev != rev_before); + // Extra selected objects last until something else is edited or selected; formatting + // them keeps them selected, as in Word. + if !matches!(id, "select.addObject" | "arrange.ungroup" | "shape.fill" | "shape.effects") && (spec.mutates || self.sel != *sel_before) { + self.also_selected.clear(); + } + // A canvas member stays picked while its canvas does and nothing else is selected. + if id != "select.member" + && (self.sel != *sel_before + || id.starts_with("select.") + || id.starts_with("caret.") + || matches!(id, "edit.undo" | "edit.undoTo" | "edit.redo")) + { + self.member = None; + } + self.note_typing(id, before_doc.as_ref().map(|b| &b.0), sel_before); + } + + /// The end of [`Session::run`] for a command that ended up in another document than it + /// started in (it opened, made, switched to or closed one) or that failed after opening one. + /// The checks that compare the document before and after the command don't apply. + /// Succeeded: the first document loses the undo step made for the command when its text is + /// as it was (opening a document is no edit to undo there). Failed: the documents the command opened close again and the first document is + /// active and as it was. + fn finish_elsewhere(&mut self, context: ElsewhereContext, result: Result) -> Result { + let ElsewhereContext { home, mark, before_doc, blocks_before, default_language_before, pending_application_change_before } = context; + // Another document is a state change the input method has to see, never an IME edit. + self.ime_edit_outcome = ImeEditOutcome::default(); + match &result { + Ok(_) => { + self.put_back_parked(home, before_doc); + self.clamp_selection(); + self.ime_state_seen = Some(self.current_ime_caret_state()); + self.reset_ime_state_revision(); + if !self.ime_origin { + self.pending_ime_application_change = true; + } + } + Err(e) => { + self.pending_ime_application_change = pending_application_change_before; + self.autocap = None; + self.prefs.default_language = default_language_before; + if self.is_open(home) { + let _ = self.switch_to(home); + let opened: Vec = self.order.iter().copied().filter(|d| *d >= mark && *d != home).collect(); + for d in opened { + let _ = self.close_document(d); + } + if let Some((d, s, h, r, t, ev, c)) = before_doc { + self.doc = d; + self.sel = s; + self.history = h; + self.redo = r; + self.typing_open = t; + self.undo_evicted = ev; + self.column = c; + } + } + if let Some(b) = blocks_before { + self.building_blocks = b; + } + self.status = e.to_string(); + } + } + result + } +} diff --git a/crates/engine/src/session/snapshot.rs b/crates/engine/src/session/snapshot.rs index 90209edc57..311cb3ea92 100644 --- a/crates/engine/src/session/snapshot.rs +++ b/crates/engine/src/session/snapshot.rs @@ -1,302 +1,302 @@ -use super::doc_state::{MAX_UNDO, Selection, doc_bib_style}; -use super::snapshot_types::{EditSnapshot, SNAPSHOT_HEAD}; -use super::{Document, Session, StoryRef, next_document_id}; - -impl Session { - /// Replace the document (open/new). - pub fn set_document(&mut self, doc: Document) { - self.autocap = None; - let replaced = self.document_id; - self.md_source = None; - self.md_file = Default::default(); - self.document_id = next_document_id(); - if let Some(at) = self.order.iter_mut().find(|d| **d == replaced) { - *at = self.document_id; - } - self.note_body_replaced(); - self.bib_style = doc_bib_style(&doc); - self.doc = doc; - self.doc.ensure_nonempty(); - self.password = None; - self.file_name = None; - // The old document's equation is gone: its editing session cannot carry over. - self.math = None; - self.sel = Selection::caret(self.doc.start_of(StoryRef::Body)); - self.pending = None; - self.typing = None; - self.also_selected.clear(); - self.member = None; - self.column = None; - self.column_mode = false; - self.widened = None; - self.edit_marks.clear(); - self.new_header_parts.clear(); - self.hf_return = None; - self.go_back_step = 0; - self.view.outline.collapsed.clear(); - self.view.follow_prefs(&self.prefs); - // A copy the replaced document kept stays in the recovery list. - #[cfg(not(target_arch = "wasm32"))] - { - self.recovery_slot = Default::default(); - } - self.reset_history(); - self.clear_versions(); - self.touch(); - self.dirty = false; - self.made_rev = self.rev; - self.relayout(); - } - - /// A snapshot to put back with [`Session::restore`], for a caller that runs a command and - /// may want to take it back without a trace (cheap: the document's blocks are shared). - /// - /// Covered: `doc`, `sel`, the undo stack (its length, and its oldest steps when it is near - /// the limit), `redo`, the open typing group, the pending `join_next_undo`, - /// `join_next_equation_edit` and body replacement promises, whether the typing group has a - /// step of its own, and `dirty`. `rev`, - /// `cache` and `layout` are derived (`restore` bumps `rev`). The other fields are not - /// covered: a caller that lets the command change them (the view, the find state, the - /// clipboard, macros, the file path…) saves and puts them back itself. - /// - /// `restore` is exact when the commands in between only add undo steps (every mutating - /// command adds at most one; up to [`SNAPSHOT_HEAD`] may push old steps out of a full - /// stack). Taking steps off the stack (`edit.undo`), clearing it (`file.open` into - /// a blank document) or switching documents in between is not covered. - /// - /// The destructuring lists every field, so a new field does not build until someone decides - /// whether a snapshot must cover it. - pub fn edit_snapshot(&self) -> EditSnapshot { - let Session { - doc, - sel, - view: _, - pending: _, - last_highlight: _, - path: _, - file_name: _, - // The custom dictionary file, like the file path: host wiring, not an edit. - user_dict_path: _, - dirty, - clipboard: _, - // Like the clipboard: what the next paste can make a move of. - pending_move: _, - clipboard_text: _, - clipboard_html: _, - clipboard_rtf: _, - clip_history: _, - find: _, - goal_x: _, - page_hint: _, - registry: _, - author: _, - initials: _, - painter: _, - status: _, - // Headers just created to edit: closing checks they still exist and are empty. - new_header_parts: _, - hf_return: _, - // Which navigated style change Accept/Reject acts on: it checks the selection itself. - review_style: _, - history, - undo_evicted, - redo, - typing_open, - join_next, - equation_join_next, - ime_text_join, - join_anchor, - autocap: _, - rev: _, - protect_facts: _, - cache: _, - layout: _, - // The word being typed, like `column`: valid only while `sel` is where typing left it. - typing: _, - originals: _, - md_source: _, - md_file: _, - picture_looks: _, - last_command: _, - recording: _, - macros: _, - versions: _, - building_blocks: _, - autosave: _, - bib_style: _, - merge: _, - ui_requests: _, - document_id: _, - // The untitled name and when the document was made: not edits. - untitled: _, - made_rev: _, - // The other open documents: a snapshot covers the active one. - others: _, - order: _, - untitled_count: _, - ime_body_epoch, - // The IME revision and its caret token are process-global evidence, not document - // state: a rollback must not reuse a revision. - ime_state_revision: _, - ime_state_seen: _, - ime_edit_outcome: _, - #[cfg(any(test, feature = "test-support"))] - ime_state_exhausted: _, - pending_ime_application_change: _, - ime_origin: _, - read_aloud: _, - // The other windows, like `view`: not part of the document or its history. - windows: _, - prefs: _, - system_region: _, - keymap: _, - ribbon: _, - // A preference for new documents, like `prefs`: not part of this document. - editing_lang: _, - // Equation editing mode, like `view`: not part of the document or its history. - math: _, - math_latex: _, - math_normal_text: _, - // The save password, like the file path: not an edit to the document. - password: _, - #[cfg(not(target_arch = "wasm32"))] - recovery: _, - #[cfg(not(target_arch = "wasm32"))] - recovery_slot: _, - also_selected: _, - member: _, - // Column selection and a widened selection's ends, like `sel`'s shape: valid only while - // `sel` matches them. - column: _, - column_mode: _, - widened: _, - // Go Back's places, like the caret history: not part of the document. - edit_marks: _, - go_back_step: _, - } = self; - let head = if history.len() + SNAPSHOT_HEAD > MAX_UNDO { history.iter().take(SNAPSHOT_HEAD).cloned().collect() } else { Vec::new() }; - EditSnapshot { - doc: doc.clone(), - sel: sel.clone(), - history_len: history.len(), - evicted: *undo_evicted, - head, - redo: redo.clone(), - typing_open: *typing_open, - join_next: *join_next, - equation_join_next: *equation_join_next, - ime_text_join: ime_text_join.clone(), - join_anchor: *join_anchor, - dirty: *dirty, - ime_body_epoch: *ime_body_epoch, - } - } - - /// Put a snapshot back: the commands run since leave no trace in the document, the - /// selection or undo/redo (undo steps the limit pushed out meanwhile come back). The layout - /// is recomputed. - pub fn restore(&mut self, snap: EditSnapshot) { - let EditSnapshot { - doc, - sel, - history_len, - evicted, - head, - redo, - typing_open, - join_next, - equation_join_next, - ime_text_join, - join_anchor, - dirty, - ime_body_epoch, - } = snap; - let gone = usize::try_from(self.undo_evicted.saturating_sub(evicted)).unwrap_or(usize::MAX); - if gone > 0 { - let back = gone.min(head.len()); - self.history.splice(0..0, head.into_iter().take(back)); - } - self.undo_evicted = evicted; - self.autocap = None; - self.doc = doc; - self.sel = sel; - self.also_selected.clear(); - self.member = None; - self.history.truncate(history_len); - self.redo = redo; - self.typing_open = typing_open; - self.join_next = join_next; - self.equation_join_next = equation_join_next; - self.ime_text_join = ime_text_join; - self.ime_body_epoch = ime_body_epoch; - self.join_anchor = join_anchor; - self.touch(); - self.dirty = dirty; - } - - /// Make the selection valid for the current document. - pub fn clamp_selection(&mut self) { - self.sel.anchor = self.doc.clamp(&self.sel.anchor); - self.sel.focus = self.doc.clamp(&self.sel.focus); - // The equation being edited must still be there (undo, edits elsewhere). - if let Some(m) = &self.math { - let eq = self.doc.para_at(&m.at).and_then(|p| match p.object_at(m.at.off) { - Some(wordcraft_doc::InlineObject::Equation { math, .. }) => Some(wordcraft_doc::math_edit::clamp(&math.nodes, &m.pos)), - _ => None, - }); - match eq { - Some(pos) => { - if let Some(m) = self.math.as_mut() { - m.pos = pos; - } - } - None => self.math = None, - } - } - } - - /// Why command `id` can't run now, for greying it out: its own `enabled` check, then what - /// Restrict Editing refuses outright. `None` when it can run (or doesn't exist). - pub fn why_disabled(&self, id: &str) -> Option<&'static str> { - let spec = self.registry.get(id)?; - (spec.enabled)(self) - .or_else(|| crate::cmd::md_source::refusal(self, id, spec.mutates)) - .or_else(|| if spec.mutates { crate::cmd::protect::greyed_out(self, id, || self.protect_facts()) } else { None }) - } - - /// Put the revision back to `rev` after a command that changed nothing or failed. What the - /// command cached at the revision it touched to is dropped: that number comes round again - /// with the next edit, for a different document. - pub(super) fn restore_rev(&mut self, rev: u64) { - if self.rev == rev { - return; - } - self.rev = rev; - for slot in &mut self.layout { - if slot.as_ref().is_some_and(|l| l.0.0 != rev) { - *slot = None; - } - } - if self.find.highlights.as_ref().is_some_and(|(r, _)| *r != rev) { - self.find.highlights = None; - } - let facts = self.protect_facts.get_mut().unwrap_or_else(std::sync::PoisonError::into_inner); - if facts.as_ref().is_some_and(|((_, r, _), _)| *r != rev) { - *facts = None; - } - } - - /// [`crate::cmd::protect::Facts`] for the document as it is now, cached per revision. - fn protect_facts(&self) -> crate::cmd::protect::Facts { - let key = (self.document_id, self.rev, self.author.clone()); - let mut cache = self.protect_facts.lock().unwrap_or_else(std::sync::PoisonError::into_inner); - if let Some((k, f)) = cache.as_ref() - && *k == key - { - return *f; - } - let f = crate::cmd::protect::Facts::of(self); - *cache = Some((key, f)); - f - } -} +use super::doc_state::{MAX_UNDO, Selection, doc_bib_style}; +use super::snapshot_types::{EditSnapshot, SNAPSHOT_HEAD}; +use super::{Document, Session, StoryRef, next_document_id}; + +impl Session { + /// Replace the document (open/new). + pub fn set_document(&mut self, doc: Document) { + self.autocap = None; + let replaced = self.document_id; + self.md_source = None; + self.md_file = Default::default(); + self.document_id = next_document_id(); + if let Some(at) = self.order.iter_mut().find(|d| **d == replaced) { + *at = self.document_id; + } + self.note_body_replaced(); + self.bib_style = doc_bib_style(&doc); + self.doc = doc; + self.doc.ensure_nonempty(); + self.password = None; + self.file_name = None; + // The old document's equation is gone: its editing session cannot carry over. + self.math = None; + self.sel = Selection::caret(self.doc.start_of(StoryRef::Body)); + self.pending = None; + self.typing = None; + self.also_selected.clear(); + self.member = None; + self.column = None; + self.column_mode = false; + self.widened = None; + self.edit_marks.clear(); + self.new_header_parts.clear(); + self.hf_return = None; + self.go_back_step = 0; + self.view.outline.collapsed.clear(); + self.view.follow_prefs(&self.prefs); + // A copy the replaced document kept stays in the recovery list. + #[cfg(not(target_arch = "wasm32"))] + { + self.recovery_slot = Default::default(); + } + self.reset_history(); + self.clear_versions(); + self.touch(); + self.dirty = false; + self.made_rev = self.rev; + self.relayout(); + } + + /// A snapshot to put back with [`Session::restore`], for a caller that runs a command and + /// may want to take it back without a trace (cheap: the document's blocks are shared). + /// + /// Covered: `doc`, `sel`, the undo stack (its length, and its oldest steps when it is near + /// the limit), `redo`, the open typing group, the pending `join_next_undo`, + /// `join_next_equation_edit` and body replacement promises, whether the typing group has a + /// step of its own, and `dirty`. `rev`, + /// `cache` and `layout` are derived (`restore` bumps `rev`). The other fields are not + /// covered: a caller that lets the command change them (the view, the find state, the + /// clipboard, macros, the file path…) saves and puts them back itself. + /// + /// `restore` is exact when the commands in between only add undo steps (every mutating + /// command adds at most one; up to [`SNAPSHOT_HEAD`] may push old steps out of a full + /// stack). Taking steps off the stack (`edit.undo`), clearing it (`file.open` into + /// a blank document) or switching documents in between is not covered. + /// + /// The destructuring lists every field, so a new field does not build until someone decides + /// whether a snapshot must cover it. + pub fn edit_snapshot(&self) -> EditSnapshot { + let Session { + doc, + sel, + view: _, + pending: _, + last_highlight: _, + path: _, + file_name: _, + // The custom dictionary file, like the file path: host wiring, not an edit. + user_dict_path: _, + dirty, + clipboard: _, + // Like the clipboard: what the next paste can make a move of. + pending_move: _, + clipboard_text: _, + clipboard_html: _, + clipboard_rtf: _, + clip_history: _, + find: _, + goal_x: _, + page_hint: _, + registry: _, + author: _, + initials: _, + painter: _, + status: _, + // Headers just created to edit: closing checks they still exist and are empty. + new_header_parts: _, + hf_return: _, + // Which navigated style change Accept/Reject acts on: it checks the selection itself. + review_style: _, + history, + undo_evicted, + redo, + typing_open, + join_next, + equation_join_next, + ime_text_join, + join_anchor, + autocap: _, + rev: _, + protect_facts: _, + cache: _, + layout: _, + // The word being typed, like `column`: valid only while `sel` is where typing left it. + typing: _, + originals: _, + md_source: _, + md_file: _, + picture_looks: _, + last_command: _, + recording: _, + macros: _, + versions: _, + building_blocks: _, + autosave: _, + bib_style: _, + merge: _, + ui_requests: _, + document_id: _, + // The untitled name and when the document was made: not edits. + untitled: _, + made_rev: _, + // The other open documents: a snapshot covers the active one. + others: _, + order: _, + untitled_count: _, + ime_body_epoch, + // The IME revision and its caret token are process-global evidence, not document + // state: a rollback must not reuse a revision. + ime_state_revision: _, + ime_state_seen: _, + ime_edit_outcome: _, + #[cfg(any(test, feature = "test-support"))] + ime_state_exhausted: _, + pending_ime_application_change: _, + ime_origin: _, + read_aloud: _, + // The other windows, like `view`: not part of the document or its history. + windows: _, + prefs: _, + system_region: _, + keymap: _, + ribbon: _, + // A preference for new documents, like `prefs`: not part of this document. + editing_lang: _, + // Equation editing mode, like `view`: not part of the document or its history. + math: _, + math_latex: _, + math_normal_text: _, + // The save password, like the file path: not an edit to the document. + password: _, + #[cfg(not(target_arch = "wasm32"))] + recovery: _, + #[cfg(not(target_arch = "wasm32"))] + recovery_slot: _, + also_selected: _, + member: _, + // Column selection and a widened selection's ends, like `sel`'s shape: valid only while + // `sel` matches them. + column: _, + column_mode: _, + widened: _, + // Go Back's places, like the caret history: not part of the document. + edit_marks: _, + go_back_step: _, + } = self; + let head = if history.len() + SNAPSHOT_HEAD > MAX_UNDO { history.iter().take(SNAPSHOT_HEAD).cloned().collect() } else { Vec::new() }; + EditSnapshot { + doc: doc.clone(), + sel: sel.clone(), + history_len: history.len(), + evicted: *undo_evicted, + head, + redo: redo.clone(), + typing_open: *typing_open, + join_next: *join_next, + equation_join_next: *equation_join_next, + ime_text_join: ime_text_join.clone(), + join_anchor: *join_anchor, + dirty: *dirty, + ime_body_epoch: *ime_body_epoch, + } + } + + /// Put a snapshot back: the commands run since leave no trace in the document, the + /// selection or undo/redo (undo steps the limit pushed out meanwhile come back). The layout + /// is recomputed. + pub fn restore(&mut self, snap: EditSnapshot) { + let EditSnapshot { + doc, + sel, + history_len, + evicted, + head, + redo, + typing_open, + join_next, + equation_join_next, + ime_text_join, + join_anchor, + dirty, + ime_body_epoch, + } = snap; + let gone = usize::try_from(self.undo_evicted.saturating_sub(evicted)).unwrap_or(usize::MAX); + if gone > 0 { + let back = gone.min(head.len()); + self.history.splice(0..0, head.into_iter().take(back)); + } + self.undo_evicted = evicted; + self.autocap = None; + self.doc = doc; + self.sel = sel; + self.also_selected.clear(); + self.member = None; + self.history.truncate(history_len); + self.redo = redo; + self.typing_open = typing_open; + self.join_next = join_next; + self.equation_join_next = equation_join_next; + self.ime_text_join = ime_text_join; + self.ime_body_epoch = ime_body_epoch; + self.join_anchor = join_anchor; + self.touch(); + self.dirty = dirty; + } + + /// Make the selection valid for the current document. + pub fn clamp_selection(&mut self) { + self.sel.anchor = self.doc.clamp(&self.sel.anchor); + self.sel.focus = self.doc.clamp(&self.sel.focus); + // The equation being edited must still be there (undo, edits elsewhere). + if let Some(m) = &self.math { + let eq = self.doc.para_at(&m.at).and_then(|p| match p.object_at(m.at.off) { + Some(wordcraft_doc::InlineObject::Equation { math, .. }) => Some(wordcraft_doc::math_edit::clamp(&math.nodes, &m.pos)), + _ => None, + }); + match eq { + Some(pos) => { + if let Some(m) = self.math.as_mut() { + m.pos = pos; + } + } + None => self.math = None, + } + } + } + + /// Why command `id` can't run now, for greying it out: its own `enabled` check, then what + /// Restrict Editing refuses outright. `None` when it can run (or doesn't exist). + pub fn why_disabled(&self, id: &str) -> Option<&'static str> { + let spec = self.registry.get(id)?; + (spec.enabled)(self) + .or_else(|| crate::cmd::md_source::refusal(self, id, spec.mutates)) + .or_else(|| if spec.mutates { crate::cmd::protect::greyed_out(self, id, || self.protect_facts()) } else { None }) + } + + /// Put the revision back to `rev` after a command that changed nothing or failed. What the + /// command cached at the revision it touched to is dropped: that number comes round again + /// with the next edit, for a different document. + pub(super) fn restore_rev(&mut self, rev: u64) { + if self.rev == rev { + return; + } + self.rev = rev; + for slot in &mut self.layout { + if slot.as_ref().is_some_and(|l| l.0.0 != rev) { + *slot = None; + } + } + if self.find.highlights.as_ref().is_some_and(|(r, _)| *r != rev) { + self.find.highlights = None; + } + let facts = self.protect_facts.get_mut().unwrap_or_else(std::sync::PoisonError::into_inner); + if facts.as_ref().is_some_and(|((_, r, _), _)| *r != rev) { + *facts = None; + } + } + + /// [`crate::cmd::protect::Facts`] for the document as it is now, cached per revision. + fn protect_facts(&self) -> crate::cmd::protect::Facts { + let key = (self.document_id, self.rev, self.author.clone()); + let mut cache = self.protect_facts.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some((k, f)) = cache.as_ref() + && *k == key + { + return *f; + } + let f = crate::cmd::protect::Facts::of(self); + *cache = Some((key, f)); + f + } +} diff --git a/crates/engine/src/session/views.rs b/crates/engine/src/session/views.rs index 6442df1b65..9be8596052 100644 --- a/crates/engine/src/session/views.rs +++ b/crates/engine/src/session/views.rs @@ -1,170 +1,170 @@ -//! Several windows on one document. The document, its undo history, dirty flag, path and save -//! state are shared; each window has its own [`WindowView`]: where its caret is and how it shows -//! the document. The session holds one view per document (the drawn window's, for the active -//! document); a front end keeps the other windows' views and moves them in and out with -//! [`Session::take_view`] and [`Session::put_view`] as it draws each window. - -use wordcraft_doc::props::CharProps; -use wordcraft_doc::{Pos, StoryRef}; - -use super::{ColumnBlock, MathEdit, Selection, Session, TypedWord, ViewState, Widened, next_math_session}; - -/// One window's view of a document: its selection and caret state, and how it shows the page -/// (zoom, view mode, panes, ruler…). Positions in it may be stale after edits made through -/// another window; [`Session::put_view`] makes them valid again. -pub struct WindowView { - sel: Selection, - view: ViewState, - pending: Option, - goal_x: Option, - page_hint: usize, - hf_return: Option, - typing_open: bool, - join_next: bool, - equation_join_next: bool, - ime_text_join: Option, - typing: Option, - math: Option, - also_selected: Vec, - column: Option, - column_mode: bool, - widened: Option, - /// The document and its revision when the view was taken out. - document_id: u64, - rev: u64, -} - -impl WindowView { - /// The document this view is of. - pub fn document_id(&self) -> u64 { - self.document_id - } - - /// The view's selection. - pub fn selection(&self) -> &Selection { - &self.sel - } - - /// How the view shows the document. - pub fn view(&self) -> &ViewState { - &self.view - } -} - -impl Session { - /// Take the active document's view out of the session, for a window that stops being drawn. - /// The session is left with a caret at the start of the document and the default view - /// until [`Session::put_view`] puts a view back. - pub fn take_view(&mut self) -> WindowView { - use std::mem::{replace, take}; - let start = self.doc.start_of(StoryRef::Body); - WindowView { - sel: replace(&mut self.sel, Selection::caret(start)), - view: take(&mut self.view), - pending: self.pending.take(), - goal_x: self.goal_x.take(), - page_hint: take(&mut self.page_hint), - hf_return: self.hf_return.take(), - typing_open: take(&mut self.typing_open), - join_next: take(&mut self.join_next), - equation_join_next: take(&mut self.equation_join_next), - ime_text_join: self.ime_text_join.take(), - typing: self.typing.take(), - math: self.math.take(), - also_selected: take(&mut self.also_selected), - column: self.column.take(), - column_mode: take(&mut self.column_mode), - widened: self.widened.take(), - document_id: self.document_id, - rev: self.rev, - } - } - - /// A copy of the active document's view, for a new window on it (View › New Window). - pub fn copy_view(&self) -> WindowView { - WindowView { - sel: self.sel.clone(), - view: self.view.clone(), - pending: self.pending.clone(), - goal_x: self.goal_x, - page_hint: self.page_hint, - hf_return: self.hf_return.clone(), - // The new window hasn't typed: its first key starts an undo step of its own. - typing_open: false, - join_next: false, - equation_join_next: false, - ime_text_join: None, - typing: None, - // An equation editing session is an input method's owner: the new window's is its own. - math: self.math.clone().and_then(|m| Some(MathEdit { session: next_math_session()?, ..m })), - also_selected: self.also_selected.clone(), - column: self.column.clone(), - column_mode: self.column_mode, - widened: self.widened.clone(), - document_id: self.document_id, - rev: self.rev, - } - } - - /// Make `v` the active document's view. When the document changed since `v` was taken - /// (another window edited it, or `v` is of another document), its positions are clamped into - /// the document, what only made sense for the text it was taken from (a column block, a - /// widened table selection, more selected objects, the word being typed) is dropped, and the - /// window's typing no longer joins its last undo step. - pub fn put_view(&mut self, v: WindowView) { - let WindowView { - sel, - view, - pending, - goal_x, - page_hint, - hf_return, - typing_open, - join_next, - equation_join_next, - ime_text_join, - typing, - math, - also_selected, - column, - column_mode, - widened, - document_id, - rev, - } = v; - let current = document_id == self.document_id && rev == self.rev; - self.sel = sel; - self.view = view; - self.view.follow_prefs(&self.prefs); - self.pending = pending; - self.goal_x = goal_x; - self.page_hint = page_hint; - self.column_mode = column_mode; - self.math = math; - // A picked Drawing Canvas member (#2637) is not part of a view's saved selection. - self.member = None; - if current { - self.hf_return = hf_return; - self.typing_open = typing_open; - self.join_next = join_next; - self.equation_join_next = equation_join_next; - self.ime_text_join = ime_text_join; - self.typing = typing; - self.also_selected = also_selected; - self.column = column; - self.widened = widened; - return; - } - self.hf_return = hf_return.map(|s| Selection { anchor: self.doc.clamp(&s.anchor), focus: self.doc.clamp(&s.focus) }); - self.typing_open = false; - self.join_next = false; - self.equation_join_next = false; - self.ime_text_join = None; - self.typing = None; - self.also_selected = Vec::new(); - self.column = None; - self.widened = None; - self.goal_x = None; - self.clamp_selection(); - } -} +//! Several windows on one document. The document, its undo history, dirty flag, path and save +//! state are shared; each window has its own [`WindowView`]: where its caret is and how it shows +//! the document. The session holds one view per document (the drawn window's, for the active +//! document); a front end keeps the other windows' views and moves them in and out with +//! [`Session::take_view`] and [`Session::put_view`] as it draws each window. + +use wordcraft_doc::props::CharProps; +use wordcraft_doc::{Pos, StoryRef}; + +use super::{ColumnBlock, MathEdit, Selection, Session, TypedWord, ViewState, Widened, next_math_session}; + +/// One window's view of a document: its selection and caret state, and how it shows the page +/// (zoom, view mode, panes, ruler…). Positions in it may be stale after edits made through +/// another window; [`Session::put_view`] makes them valid again. +pub struct WindowView { + sel: Selection, + view: ViewState, + pending: Option, + goal_x: Option, + page_hint: usize, + hf_return: Option, + typing_open: bool, + join_next: bool, + equation_join_next: bool, + ime_text_join: Option, + typing: Option, + math: Option, + also_selected: Vec, + column: Option, + column_mode: bool, + widened: Option, + /// The document and its revision when the view was taken out. + document_id: u64, + rev: u64, +} + +impl WindowView { + /// The document this view is of. + pub fn document_id(&self) -> u64 { + self.document_id + } + + /// The view's selection. + pub fn selection(&self) -> &Selection { + &self.sel + } + + /// How the view shows the document. + pub fn view(&self) -> &ViewState { + &self.view + } +} + +impl Session { + /// Take the active document's view out of the session, for a window that stops being drawn. + /// The session is left with a caret at the start of the document and the default view + /// until [`Session::put_view`] puts a view back. + pub fn take_view(&mut self) -> WindowView { + use std::mem::{replace, take}; + let start = self.doc.start_of(StoryRef::Body); + WindowView { + sel: replace(&mut self.sel, Selection::caret(start)), + view: take(&mut self.view), + pending: self.pending.take(), + goal_x: self.goal_x.take(), + page_hint: take(&mut self.page_hint), + hf_return: self.hf_return.take(), + typing_open: take(&mut self.typing_open), + join_next: take(&mut self.join_next), + equation_join_next: take(&mut self.equation_join_next), + ime_text_join: self.ime_text_join.take(), + typing: self.typing.take(), + math: self.math.take(), + also_selected: take(&mut self.also_selected), + column: self.column.take(), + column_mode: take(&mut self.column_mode), + widened: self.widened.take(), + document_id: self.document_id, + rev: self.rev, + } + } + + /// A copy of the active document's view, for a new window on it (View › New Window). + pub fn copy_view(&self) -> WindowView { + WindowView { + sel: self.sel.clone(), + view: self.view.clone(), + pending: self.pending.clone(), + goal_x: self.goal_x, + page_hint: self.page_hint, + hf_return: self.hf_return.clone(), + // The new window hasn't typed: its first key starts an undo step of its own. + typing_open: false, + join_next: false, + equation_join_next: false, + ime_text_join: None, + typing: None, + // An equation editing session is an input method's owner: the new window's is its own. + math: self.math.clone().and_then(|m| Some(MathEdit { session: next_math_session()?, ..m })), + also_selected: self.also_selected.clone(), + column: self.column.clone(), + column_mode: self.column_mode, + widened: self.widened.clone(), + document_id: self.document_id, + rev: self.rev, + } + } + + /// Make `v` the active document's view. When the document changed since `v` was taken + /// (another window edited it, or `v` is of another document), its positions are clamped into + /// the document, what only made sense for the text it was taken from (a column block, a + /// widened table selection, more selected objects, the word being typed) is dropped, and the + /// window's typing no longer joins its last undo step. + pub fn put_view(&mut self, v: WindowView) { + let WindowView { + sel, + view, + pending, + goal_x, + page_hint, + hf_return, + typing_open, + join_next, + equation_join_next, + ime_text_join, + typing, + math, + also_selected, + column, + column_mode, + widened, + document_id, + rev, + } = v; + let current = document_id == self.document_id && rev == self.rev; + self.sel = sel; + self.view = view; + self.view.follow_prefs(&self.prefs); + self.pending = pending; + self.goal_x = goal_x; + self.page_hint = page_hint; + self.column_mode = column_mode; + self.math = math; + // A picked Drawing Canvas member (#2637) is not part of a view's saved selection. + self.member = None; + if current { + self.hf_return = hf_return; + self.typing_open = typing_open; + self.join_next = join_next; + self.equation_join_next = equation_join_next; + self.ime_text_join = ime_text_join; + self.typing = typing; + self.also_selected = also_selected; + self.column = column; + self.widened = widened; + return; + } + self.hf_return = hf_return.map(|s| Selection { anchor: self.doc.clamp(&s.anchor), focus: self.doc.clamp(&s.focus) }); + self.typing_open = false; + self.join_next = false; + self.equation_join_next = false; + self.ime_text_join = None; + self.typing = None; + self.also_selected = Vec::new(); + self.column = None; + self.widened = None; + self.goal_x = None; + self.clamp_selection(); + } +} diff --git a/crates/engine/src/snapshot.rs b/crates/engine/src/snapshot.rs index 1e10420b00..dcd60a38da 100644 --- a/crates/engine/src/snapshot.rs +++ b/crates/engine/src/snapshot.rs @@ -1,80 +1,80 @@ -use crate::doc_state::{ColumnBlock, Selection, Undo}; -use std::sync::Arc; - -use wordcraft_doc::Document; -use wordcraft_layout::{DocLayout, NumeralMode, ViewMode}; - -/// What [`Session::restore`] needs to take commands back exactly: the document, the selection, -/// the undo and redo stacks (also the oldest undo steps the undo limit pushes out meanwhile), the -/// open typing group, the pending -/// [`Session::join_next_undo`]/[`Session::join_next_equation_edit`]/body IME replacement promises, -/// whether the group has a step of its own, and the dirty flag. See -/// [`Session::edit_snapshot`]. -pub struct EditSnapshot { - pub(super) doc: Document, - pub(super) sel: Selection, - pub(super) history_len: usize, - /// `Session::undo_evicted` when the snapshot was taken. - pub(super) evicted: u64, - /// The oldest undo steps, kept only when the stack is near its limit (the commands that - /// follow may push them out); at most [`SNAPSHOT_HEAD`]. - pub(super) head: Vec>, - pub(super) redo: Vec>, - pub(super) typing_open: bool, - pub(super) join_next: bool, - pub(super) equation_join_next: bool, - /// The body `DeleteSurrounding` replacement promise then, with the exact caret it expects. - pub(super) ime_text_join: Option, - pub(super) join_anchor: bool, - pub(super) dirty: bool, - /// The body's replacement epoch then: a previewed replacement that is rolled back is not a - /// replacement after all. - pub(super) ime_body_epoch: u64, -} - -/// Undo steps that the commands run between [`Session::edit_snapshot`] and [`Session::restore`] -/// may push out of a full stack and still come back. -pub(super) const SNAPSHOT_HEAD: usize = 4; - -impl EditSnapshot { - /// The document when the snapshot was taken. - pub fn doc(&self) -> &Document { - &self.doc - } - /// The dirty flag then. - pub fn dirty(&self) -> bool { - self.dirty - } - /// The number of undo steps then. - pub fn undo_depth(&self) -> usize { - self.history_len - } -} - -/// What `run` keeps to take a failed mutating command back: the document, the selection, the -/// undo and redo stacks, the open typing group, the undo steps dropped so far and the column -/// block. -pub(super) type RunSnapshot = (Document, Selection, Vec>, Vec>, bool, u64, Option); - -/// A layout made and what it was made for: ((revision, proofing dictionaries' generation), width, read height, view, read line -/// spacing, layout, proofing, outline, (deletions left out, deletions marked Hidden, body -/// deletions in balloons), numeral display, the word being typed, (hidden text shown, field -/// codes shown)). -pub(super) type LayoutEntry = ( - (u64, u64), - f32, - f32, - ViewMode, - Option, - Arc, - bool, - Option, - (bool, wordcraft_layout::HideDeleted, bool), - NumeralMode, - Option, - (bool, bool), -); - -/// The layouts last made, newest first. Two, so two windows on one document with different -/// views ([`WindowView`]) each find theirs as they draw in turn. -pub(super) type LayoutSlots = [Option; 2]; +use crate::doc_state::{ColumnBlock, Selection, Undo}; +use std::sync::Arc; + +use wordcraft_doc::Document; +use wordcraft_layout::{DocLayout, NumeralMode, ViewMode}; + +/// What [`Session::restore`] needs to take commands back exactly: the document, the selection, +/// the undo and redo stacks (also the oldest undo steps the undo limit pushes out meanwhile), the +/// open typing group, the pending +/// [`Session::join_next_undo`]/[`Session::join_next_equation_edit`]/body IME replacement promises, +/// whether the group has a step of its own, and the dirty flag. See +/// [`Session::edit_snapshot`]. +pub struct EditSnapshot { + pub(super) doc: Document, + pub(super) sel: Selection, + pub(super) history_len: usize, + /// `Session::undo_evicted` when the snapshot was taken. + pub(super) evicted: u64, + /// The oldest undo steps, kept only when the stack is near its limit (the commands that + /// follow may push them out); at most [`SNAPSHOT_HEAD`]. + pub(super) head: Vec>, + pub(super) redo: Vec>, + pub(super) typing_open: bool, + pub(super) join_next: bool, + pub(super) equation_join_next: bool, + /// The body `DeleteSurrounding` replacement promise then, with the exact caret it expects. + pub(super) ime_text_join: Option, + pub(super) join_anchor: bool, + pub(super) dirty: bool, + /// The body's replacement epoch then: a previewed replacement that is rolled back is not a + /// replacement after all. + pub(super) ime_body_epoch: u64, +} + +/// Undo steps that the commands run between [`Session::edit_snapshot`] and [`Session::restore`] +/// may push out of a full stack and still come back. +pub(super) const SNAPSHOT_HEAD: usize = 4; + +impl EditSnapshot { + /// The document when the snapshot was taken. + pub fn doc(&self) -> &Document { + &self.doc + } + /// The dirty flag then. + pub fn dirty(&self) -> bool { + self.dirty + } + /// The number of undo steps then. + pub fn undo_depth(&self) -> usize { + self.history_len + } +} + +/// What `run` keeps to take a failed mutating command back: the document, the selection, the +/// undo and redo stacks, the open typing group, the undo steps dropped so far and the column +/// block. +pub(super) type RunSnapshot = (Document, Selection, Vec>, Vec>, bool, u64, Option); + +/// A layout made and what it was made for: ((revision, proofing dictionaries' generation), width, read height, view, read line +/// spacing, layout, proofing, outline, (deletions left out, deletions marked Hidden, body +/// deletions in balloons), numeral display, the word being typed, (hidden text shown, field +/// codes shown)). +pub(super) type LayoutEntry = ( + (u64, u64), + f32, + f32, + ViewMode, + Option, + Arc, + bool, + Option, + (bool, wordcraft_layout::HideDeleted, bool), + NumeralMode, + Option, + (bool, bool), +); + +/// The layouts last made, newest first. Two, so two windows on one document with different +/// views ([`WindowView`]) each find theirs as they draw in turn. +pub(super) type LayoutSlots = [Option; 2]; diff --git a/crates/engine/src/speech.rs b/crates/engine/src/speech.rs index d4d2c26645..233b13c5c1 100644 --- a/crates/engine/src/speech.rs +++ b/crates/engine/src/speech.rs @@ -1,1082 +1,1082 @@ -//! Read Aloud: the document as a queue of sentences that the system's speech program speaks one -//! at a time, so reading can be paused, skipped forwards and backwards, and sped up. -//! -//! Citation-manager fields can be left out: Zotero, Mendeley and EndNote citations and -//! bibliographies (range fields with an `ADDIN …` code) and Word's own `CITATION` and -//! `BIBLIOGRAPHY` fields. The citation's text simply isn't in the queue, so every citation -//! style is skipped the same way: "(Smith et al., 2020)", "[3]" or a superscript number. -//! -//! Each sentence is spoken in its own language: the run's language tag, or else the script of its -//! letters (Cyrillic, Greek, Arabic, Hebrew, CJK, Thai…), so a Russian sentence gets a Russian voice -//! where one is installed instead of being spelled out letter by letter. The system's own voice -//! stays whenever it already speaks the language (English text on an English system keeps the -//! voice the user chose), and without a voice for the language the default voice speaks. Speech -//! Dispatcher is the exception: it can't be asked for its default language, so it is told the -//! language of every sentence. -//! -//! The speech itself runs on a worker thread (desktop only): it starts `say` (macOS), `espeak-ng` -//! / `espeak` (Linux, BSD) or the Windows speech synthesizer (PowerShell) for the current sentence, -//! or talks to Speech Dispatcher over its socket when neither espeak is installed, and moves on when -//! the sentence ends. Commands only change the shared state and wake the worker, which stops the -//! running sentence when the state changed under it. - -use std::sync::{Arc, Condvar, Mutex, MutexGuard, PoisonError}; - -use serde_json::{Value, json}; -use wordcraft_doc::para::{COLUMN_BREAK, LINE_BREAK, OBJ, PAGE_BREAK}; -use wordcraft_doc::{Document, InlineObject, Pos}; - -/// Longest sentence handed to the speech program (longer ones are split at a space). -const MAX_SENTENCE: usize = 600; -/// Longest utterance, even with no space to split at (a long URL, a run of letters): it is cut -/// there, mid-word. -const MAX_UTTERANCE: usize = 2 * MAX_SENTENCE; -/// Most sentences in one queue. -const MAX_SENTENCES: usize = 20_000; -/// Speed limits (1.0 = the normal speed). -pub const MIN_RATE: f32 = 0.5; -pub const MAX_RATE: f32 = 3.0; -/// Words per minute at speed 1.0 (`say` and `espeak` both default to about this). -#[cfg(not(target_arch = "wasm32"))] -const BASE_WPM: f32 = 180.0; - -/// Words that end with a full stop without ending the sentence. -const ABBREVIATIONS: &[&str] = &[ - "al", "e.g", "i.e", "eg", "ie", "cf", "vs", "etc", "fig", "figs", "eq", "eqs", "no", "nos", "p", "pp", "vol", "vols", "ch", "chap", "sec", "tab", - "ref", "refs", "dr", "mr", "mrs", "ms", "prof", "st", "approx", "ca", "resp", "ed", "eds", "suppl", "jr", "sr", "inc", "ltd", "co", "dept", - "est", "min", "max", "viz", "ibid", -]; - -/// Is this field code a citation or a bibliography (Zotero, Mendeley, EndNote, Word)? -pub fn is_citation_code(instr: &str) -> bool { - let code = instr.trim_start(); - let word = code.split_whitespace().next().unwrap_or("").to_ascii_uppercase(); - if word == "CITATION" || word == "BIBLIOGRAPHY" { - return true; - } - if word != "ADDIN" { - return false; - } - let rest = code.get(5..).unwrap_or("").trim_start().to_ascii_uppercase(); - ["ZOTERO_ITEM", "ZOTERO_BIBL", "CSL_CITATION", "CSL_BIBLIOGRAPHY", "MENDELEY", "EN.CITE", "EN.REFLIST"].iter().any(|k| rest.starts_with(k)) -} - -/// One sentence to speak: its text and where it is in the document. -#[derive(Clone, Debug, PartialEq)] -pub struct Utterance { - pub text: String, - pub start: Pos, - pub end: Pos, - /// The language to speak it in (a BCP 47 tag), or `None` for the system's default voice. - pub lang: Option, -} - -impl Utterance { - pub fn to_json(&self) -> Value { - json!({"text": self.text, "start": crate::cmd::pos_json(&self.start), "end": crate::cmd::pos_json(&self.end)}) - } -} - -/// The sentences from `from` (to `to`, if given) in `from`'s story, in reading order. -/// With `skip_citations`, citation and bibliography fields are left out. At most -/// [`MAX_SENTENCES`]; when that cuts the text short a warning is logged, so the stop is not silent. -pub fn sentences(doc: &Document, from: &Pos, to: Option<&Pos>, skip_citations: bool) -> Vec { - let story = from.story; - // Spans to leave out: whole range fields, both markers included. - let skipped = Spans::new(if skip_citations { - doc.field_ranges(story).into_iter().filter(|r| is_citation_code(&r.instr)).map(|r| (r.start, r.end)).collect() - } else { - Vec::new() - }); - let in_skipped = |p: &Pos| skipped.contains(p); - let mut out = Vec::new(); - for path in doc.para_paths(story) { - if path < from.path { - continue; - } - if let Some(to) = to - && path > to.path - { - break; - } - let Some(para) = doc.para(story, &path) else { continue }; - // The spoken text, and for each char the paragraph offset it came from. - let mut text = String::new(); - let mut map: Vec<(usize, usize)> = Vec::new(); // (start, end) offsets in the paragraph - let mut k = 0; - for (off, c) in para.text.char_indices() { - let end = off + c.len_utf8(); - let obj = if c == OBJ { - k += 1; - para.objects.get(k - 1) - } else { - None - }; - if (path == from.path && off < from.off) || to.is_some_and(|t| path == t.path && off >= t.off) { - continue; - } - let pos = Pos::new(story, path.clone(), off); - if in_skipped(&pos) { - continue; - } - match obj { - Some(InlineObject::Field { instr, .. }) if skip_citations && is_citation_code(instr) => {} - Some(o) => { - for ch in o.plain_text().chars() { - text.push(ch); - map.push((off, end)); - } - } - None if c == OBJ => {} - None => { - let ch = if matches!(c, LINE_BREAK | PAGE_BREAK | COLUMN_BREAK | '\t') { ' ' } else { c }; - text.push(ch); - map.push((off, end)); - } - } - } - // Sliced per sentence; skipping from the start each time was quadratic in the paragraph. - let text_chars: Vec = text.chars().collect(); - for (a, b) in split_sentences(&text) { - if out.len() >= MAX_SENTENCES { - log::warn!( - "Read Aloud: the queue holds at most {MAX_SENTENCES} sentences; the rest of the text is not read (start again from where it stops)" - ); - return out; - } - let spoken = tidy(&text_chars.get(a..b).unwrap_or(&[]).iter().collect::()); - if !spoken.chars().any(char::is_alphanumeric) { - continue; - } - let (Some(first), Some(last)) = (map.get(a), map.get(b.saturating_sub(1))) else { continue }; - // The language of the sentence's first character, through styles and the defaults. - let run = para.props_of_char(first.0); - let explicit = doc.styles.resolve_char(para.props.style.as_deref(), run).lang; - let lang = speech_language(&spoken, explicit.as_deref(), run.lang_bidi.as_deref()); - out.push(Utterance { text: spoken, start: Pos::new(story, path.clone(), first.0), end: Pos::new(story, path.clone(), last.1), lang }); - } - } - out -} - -/// Spans of the document (both ends included), sorted and merged so a position is looked up by -/// binary search: checking every character against every citation was quadratic in a document -/// with many of them. -struct Spans(Vec<(Pos, Pos)>); - -impl Spans { - fn new(mut spans: Vec<(Pos, Pos)>) -> Self { - spans.retain(|(a, b)| a <= b); - spans.sort(); - let mut merged: Vec<(Pos, Pos)> = Vec::with_capacity(spans.len()); - for (a, b) in spans { - match merged.last_mut() { - Some(last) if a <= last.1 => { - if b > last.1 { - last.1 = b; - } - } - _ => merged.push((a, b)), - } - } - Spans(merged) - } - - /// Is `p` inside one of the spans? - fn contains(&self, p: &Pos) -> bool { - let after = self.0.partition_point(|(a, _)| a <= p); - after.checked_sub(1).and_then(|i| self.0.get(i)).is_some_and(|(_, b)| p <= b) - } -} - -/// The language of `text` from its script, as a BCP 47 primary tag: Cyrillic is Russian (Ukrainian -/// with one of its letters `іїєґ`), Greek, Arabic, Hebrew, Thai and Hangul map to `el`, `ar`, `he`, `th` -/// and `ko`, kana to Japanese, Han letters alone to Chinese. `None` for Latin text (the script -/// doesn't say which language) and text without letters. Mixed text goes to its largest non-Latin -/// script once that script has a real share of the letters (a fifth), even when Latin letters -/// outnumber it ("Я открыл Microsoft Word and PowerPoint"): the default voice would spell those -/// letters out, while a Russian voice reads the English words well enough. Ties go to the script -/// listed first. -pub fn detect_language(text: &str) -> Option<&'static str> { - // Letters per script: Cyrillic, Greek, Arabic, Hebrew, Thai, Hangul, kana, Han, Latin and others. - let mut n = [0usize; 10]; - let mut ukrainian = false; - for c in text.chars().filter(|c| c.is_alphabetic()) { - let i = match c { - '\u{0400}'..='\u{052F}' | '\u{1C80}'..='\u{1C8F}' | '\u{2DE0}'..='\u{2DFF}' | '\u{A640}'..='\u{A69F}' => { - ukrainian |= matches!(c, 'і' | 'ї' | 'є' | 'ґ' | 'І' | 'Ї' | 'Є' | 'Ґ'); - 0 - } - '\u{0370}'..='\u{03FF}' | '\u{1F00}'..='\u{1FFF}' => 1, - '\u{0600}'..='\u{06FF}' | '\u{0750}'..='\u{077F}' | '\u{08A0}'..='\u{08FF}' | '\u{FB50}'..='\u{FDFF}' | '\u{FE70}'..='\u{FEFF}' => 2, - '\u{0590}'..='\u{05FF}' | '\u{FB1D}'..='\u{FB4F}' => 3, - '\u{0E00}'..='\u{0E7F}' => 4, - '\u{1100}'..='\u{11FF}' | '\u{3130}'..='\u{318F}' | '\u{AC00}'..='\u{D7AF}' => 5, - '\u{3040}'..='\u{30FF}' | '\u{31F0}'..='\u{31FF}' => 6, - '\u{3400}'..='\u{4DBF}' | '\u{4E00}'..='\u{9FFF}' | '\u{F900}'..='\u{FAFF}' | '\u{20000}'..='\u{2FFFF}' => 7, - _ => 8, - }; - if let Some(k) = n.get_mut(i) { - *k += 1; - } - } - // Japanese text mixes kana and Han letters; any kana makes it Japanese. - if n[6] > 0 { - n[6] += n[7]; - n[7] = 0; - } - // The first script with the most letters (`max_by_key` would keep the last of a tie). - let (best, count) = n.iter().take(8).copied().enumerate().fold((0, 0), |b, (i, k)| if k > b.1 { (i, k) } else { b }); - let total: usize = n.iter().sum(); - if count == 0 || count.saturating_mul(5) < total { - return None; - } - Some(match best { - 0 if ukrainian => "uk", - 0 => "ru", - 1 => "el", - 2 => "ar", - 3 => "he", - 4 => "th", - 5 => "ko", - 6 => "ja", - _ => "zh", - }) -} - -/// A language tag that is safe to hand to a speech program or put in a script: letters, digits -/// and hyphens only, starting with a letter, at most 12 characters (a tag from a document is -/// hostile input). -fn plain_tag(tag: &str) -> Option<&str> { - let t = tag.trim(); - (!t.is_empty() && t.len() <= 12 && t.starts_with(|c: char| c.is_ascii_alphabetic()) && t.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')) - .then_some(t) -} - -/// The script group a language is written in, for the languages [`detect_language`] can tell -/// apart by script (the primary subtag in, the detected tag out). -fn script_language(primary: &str) -> Option<&'static str> { - Some(match primary { - "ru" | "uk" | "be" | "bg" | "sr" | "mk" | "kk" | "ky" | "mn" | "tg" => "ru", - "el" => "el", - "ar" | "fa" | "ur" | "ps" | "ku" | "sd" | "ug" => "ar", - "he" | "yi" => "he", - "th" => "th", - "ko" => "ko", - "ja" => "ja", - "zh" => "zh", - _ => return None, - }) -} - -/// The language to speak `text` in: the document's `explicit` tag when it fits the text, else the -/// language of its script, else `None` (the default voice). When the text is in a script -/// [`detect_language`] recognises, a tag written in another script (the `en-US` Word writes on -/// every run, on Cyrillic text) gives way to the script, or the voice would spell the letters out; -/// right-to-left text tries the run's complex-script tag (`w:bidi`, e.g. `fa-IR`) first, and Han -/// letters alone keep a Japanese or Korean tag. Latin text keeps any tag, as in Word, which reads -/// text in its proofing language: English words in a run tagged `ru-RU` get the Russian voice. -pub fn speech_language(text: &str, explicit: Option<&str>, bidi: Option<&str>) -> Option { - let explicit = explicit.and_then(plain_tag); - let Some(d) = detect_language(text) else { return explicit.map(str::to_string) }; - let fits = |tag: &&str| { - let primary = tag.split('-').next().unwrap_or("").to_ascii_lowercase(); - let script = script_language(&primary); - script == script_language(d) || (d == "zh" && matches!(script, Some("ja" | "ko"))) - }; - let bidi = bidi.and_then(plain_tag).filter(|_| matches!(d, "ar" | "he")); - Some(bidi.into_iter().chain(explicit).find(fits).unwrap_or(d).to_string()) -} - -/// The primary language subtag of a plain tag, lowercased (`ru-RU` gives `ru`). `None` for tags -/// without a two- or three-letter language, such as Word's `x-none`. -#[cfg(any(unix, windows, test))] -fn primary_tag(tag: &str) -> Option { - let t = plain_tag(tag)?; - t.split('-').next().filter(|p| (2..=3).contains(&p.len()) && p.chars().all(|c| c.is_ascii_alphabetic())).map(str::to_ascii_lowercase) -} - -/// The language to ask the speech program for, or `None` to keep its default voice: only when -/// `lang` isn't the language the default voice speaks (`default`, a primary subtag) already. -#[cfg(any(unix, test))] -fn voice_language(lang: Option<&str>, default: &str) -> Option { - lang.and_then(primary_tag).filter(|p| !p.eq_ignore_ascii_case(default)) -} - -/// The arguments of `espeak-ng`/`espeak`: the speed, the voice for `lang` unless it's English -/// (espeak's default voice), and `--stdin`. -#[cfg(any(all(unix, not(target_os = "macos")), test))] -fn espeak_args(wpm: &str, lang: Option<&str>) -> Vec { - let mut a = vec!["-s".to_string(), wpm.to_string()]; - if let Some(p) = voice_language(lang, "en") { - a.push("-v".into()); - a.push(p); - } - a.push("--stdin".into()); - a -} - -/// The voices in `say -v '?'`'s `listing`: each line is `Name ll_RR # sample sentence`, and -/// names may contain spaces. Gives (name, lowercased `ll_rr` code) pairs. -#[cfg(any(target_os = "macos", test))] -fn mac_voices(listing: &str) -> impl Iterator + '_ { - listing.lines().filter_map(|line| { - let line = line.split('#').next().unwrap_or(""); - let words: Vec<&str> = line.split_whitespace().collect(); - let at = words.iter().skip(1).position(|w| { - let mut p = w.split('_'); - p.next().is_some_and(|l| (2..=3).contains(&l.len()) && l.chars().all(|c| c.is_ascii_lowercase())) - && p.next().is_some_and(|r| !r.is_empty()) - })? + 1; - Some((words.get(..at)?.join(" "), words.get(at)?.to_ascii_lowercase())) - }) -} - -/// macOS's novelty voices (sound effects and singing), never chosen to read a document. -#[cfg(any(target_os = "macos", test))] -const MAC_NOVELTY_VOICES: &[&str] = &[ - "Albert", - "Bad News", - "Bahh", - "Bells", - "Boing", - "Bubbles", - "Cellos", - "Deranged", - "Good News", - "Hysterical", - "Jester", - "Organ", - "Pipe Organ", - "Superstar", - "Trinoids", - "Whisper", - "Wobble", - "Zarvox", -]; - -/// The voice `say` should use for `lang`, or `None` to keep the system voice: `None` when the -/// system voice already speaks the language (`default`, a primary subtag) or no voice does, else -/// the first voice in `listing` for the language, one of the same region first, never a novelty voice. -#[cfg(any(target_os = "macos", test))] -fn mac_voice(lang: &str, listing: &str, default: &str) -> Option { - let primary = voice_language(Some(lang), default)?; - let tag = plain_tag(lang)?.replace('-', "_").to_ascii_lowercase(); - let mut first = None; - for (name, code) in mac_voices(listing) { - if code.split('_').next() != Some(primary.as_str()) || MAC_NOVELTY_VOICES.contains(&name.as_str()) { - continue; - } - if code == tag { - return Some(name); - } - first.get_or_insert(name); - } - first -} - -/// The language (primary subtag) of the system voice: the `selected` voice's in `listing`, else -/// the `locale`'s (`ru_RU`, `ru_RU.UTF-8`), else English. -#[cfg(any(target_os = "macos", test))] -fn mac_default_language(selected: Option<&str>, listing: &str, locale: Option<&str>) -> String { - let voice = selected - .and_then(|v| mac_voices(listing).find(|(name, _)| name == v.trim())) - .and_then(|(_, code)| code.split('_').next().map(str::to_string)); - let locale = || locale.and_then(|l| l.split(['_', '-', '.', '@']).next()).and_then(primary_tag); - voice.or_else(locale).unwrap_or_else(|| "en".into()) -} - -/// The language of the voice `say` uses by default (asked once). -#[cfg(target_os = "macos")] -fn mac_default_voice_language() -> &'static str { - static LANG: std::sync::OnceLock = std::sync::OnceLock::new(); - LANG.get_or_init(|| { - let read = |args: &[&str]| { - std::process::Command::new("defaults") - .args(args) - .output() - .ok() - .filter(|o| o.status.success()) - .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) - }; - let voice = read(&["read", "com.apple.speech.voice.prefs", "SelectedVoiceName"]); - let locale = read(&["read", "-g", "AppleLocale"]).or_else(|| std::env::var("LANG").ok()); - mac_default_language(voice.as_deref(), mac_voice_listing(), locale.as_deref()) - }) -} - -#[cfg(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")))] -mod ssip; - -/// What Read Aloud says when the system has none of the speech programs it knows. -#[cfg(not(target_arch = "wasm32"))] -const MISSING_PROGRAM: &str = if cfg!(all(unix, not(target_os = "macos"))) { - "Read Aloud needs a speech program: install espeak-ng or Speech Dispatcher from your distribution's packages" -} else { - "Read Aloud couldn't find the system's speech program" -}; - -/// Read Aloud's speed (1 = normal) as Speech Dispatcher's rate, -100 to 100 with 0 the voice's -/// normal speed: the slowest speed is -50 and the fastest 100. -#[cfg(any(all(unix, not(target_os = "macos")), test))] -fn spd_rate(rate: f32) -> i32 { - let rate = if rate.is_finite() { rate.clamp(MIN_RATE, MAX_RATE) } else { 1.0 }; - let scaled = if rate >= 1.0 { (rate - 1.0) / (MAX_RATE - 1.0) * 100.0 } else { (rate - 1.0) * 100.0 }; - scaled.round().clamp(-100.0, 100.0) as i32 -} - -/// Say one sentence through Speech Dispatcher ([`ssip`]), in the sentence's language whenever it -/// has one, region included (its voices are per region: `en-GB`, `pt-BR`). Its default language is -/// its own setting (`en-US` unless configured), which SSIP can't ask for, so English is named too. -/// `None` when Speech Dispatcher isn't installed. -#[cfg(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")))] -fn speak_dispatcher(text: &str, lang: Option<&str>, rate: f32, changed: &dyn Fn() -> bool) -> Option { - let stream = match ssip::connect(changed) { - ssip::Connection::Open(s) => s, - ssip::Connection::Missing => return None, - ssip::Connection::Interrupted => return Some(Spoken::Interrupted), - ssip::Connection::Unreachable(why) => return Some(Spoken::Failed(format!("Read Aloud couldn't reach Speech Dispatcher: {why}"))), - }; - let lang = dispatcher_language(lang); - let req = ssip::Request { text, rate: spd_rate(rate), lang: lang.as_deref() }; - Some(dispatcher_spoken(ssip::converse(&stream, &req, changed))) -} - -/// How a sentence Speech Dispatcher handled ended, for the worker. -#[cfg(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")))] -fn dispatcher_spoken(outcome: std::io::Result) -> Spoken { - match outcome { - Ok(ssip::Outcome::Done) => Spoken::Done, - Ok(ssip::Outcome::Interrupted) => Spoken::Interrupted, - Ok(ssip::Outcome::Halted) => Spoken::Halted(HALTED_BY_OTHER.into()), - Ok(ssip::Outcome::NotStarted) => Spoken::Halted(NOT_STARTED.into()), - Err(e) => Spoken::Failed(format!("Read Aloud couldn't use Speech Dispatcher: {e}")), - } -} - -/// Why reading paused when another program cancelled or paused the sentence. -#[cfg(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")))] -const HALTED_BY_OTHER: &str = - "Speech Dispatcher kept stopping the sentence for another program (a screen reader, or a stop for every program): press Play to go on"; - -/// Why reading paused when Speech Dispatcher never began the sentence. -#[cfg(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")))] -const NOT_STARTED: &str = - "Speech Dispatcher didn't start the sentence (other speech may be queued before it, or its speech module may be stuck): press Play to try again"; - -#[cfg(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")))] -thread_local! { - /// Neither espeak command was found during this reading (the worker thread lives as long as - /// one reading): the next sentences go straight to Speech Dispatcher. - static NO_ESPEAK: std::cell::Cell = const { std::cell::Cell::new(false) }; -} - -/// The language tag Speech Dispatcher is given for a sentence in `lang`: its language and region, -/// as SSIP's `SET LANGUAGE` takes them (`sr-Latn-RS` gives `sr-RS`, `zh-Hant-TW` gives `zh-TW`), -/// when it names a language (Word's `x-none` doesn't). -#[cfg(any(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")), test))] -fn dispatcher_language(lang: Option<&str>) -> Option { - let tag = lang.and_then(plain_tag)?; - let primary = primary_tag(tag)?; - let region = tag - .split('-') - .skip(1) - .take(2) - .find(|t| (t.len() == 2 && t.chars().all(|c| c.is_ascii_alphabetic())) || (t.len() == 3 && t.chars().all(|c| c.is_ascii_digit()))); - Some(match region { - Some(r) => format!("{primary}-{}", r.to_ascii_uppercase()), - None => primary, - }) -} - -/// Should `speak` say the sentence again with the default voice? When it gave the program a voice -/// and the program then exited with a failure: espeak-ng does that, silently, for a voice it -/// doesn't know, and a missing voice must never mean silence. `exited_ok` is `None` when the -/// program didn't exit on its own (stopped, or never started). -#[cfg(any(not(target_arch = "wasm32"), test))] -fn retry_without_voice(gave_voice: bool, exited_ok: Option) -> bool { - gave_voice && exited_ok == Some(false) -} - -/// The names `say` can voice, as `say -v '?'` lists them (asked once). -#[cfg(target_os = "macos")] -fn mac_voice_listing() -> &'static str { - static LISTING: std::sync::OnceLock = std::sync::OnceLock::new(); - LISTING.get_or_init(|| { - std::process::Command::new("say").args(["-v", "?"]).output().map(|o| String::from_utf8_lossy(&o.stdout).into_owned()).unwrap_or_default() - }) -} - -/// Sentence spans in `text` as char index ranges, without surrounding whitespace. -fn split_sentences(text: &str) -> Vec<(usize, usize)> { - let chars: Vec = text.chars().collect(); - let mut out = Vec::new(); - let mut start = 0; - let mut i = 0; - while i < chars.len() { - let c = chars.get(i).copied().unwrap_or(' '); - let mut end = None; - if matches!(c, '.' | '!' | '?' | '…' | '。' | '!' | '?') { - // Include closing quotes and brackets. - let mut j = i + 1; - while chars.get(j).is_some_and(|c| matches!(c, '"' | '\'' | '”' | '’' | ')' | ']' | '»' | '.' | '!' | '?')) { - j += 1; - } - let at_end = j >= chars.len(); - let space_after = chars.get(j).is_some_and(|c| c.is_whitespace()); - if (at_end || space_after) && (c != '.' || !is_abbreviation(&chars, start, i)) { - end = Some(j); - } - i = j.max(i + 1); - } else { - i += 1; - } - // Very long sentences are split at a space so skipping stays fine-grained. - if end.is_none() && i - start >= MAX_SENTENCE && chars.get(i).is_some_and(|c| c.is_whitespace()) { - end = Some(i); - } - if let Some(e) = end { - push_trimmed(&chars, start, e, &mut out); - start = e; - } - } - push_trimmed(&chars, start, chars.len(), &mut out); - out -} - -fn push_trimmed(chars: &[char], mut a: usize, mut b: usize, out: &mut Vec<(usize, usize)>) { - while a < b && chars.get(a).is_some_and(|c| c.is_whitespace()) { - a += 1; - } - while b > a && chars.get(b - 1).is_some_and(|c| c.is_whitespace()) { - b -= 1; - } - // No space to split at (a long URL, a run of letters or full stops): cut the span hard. - while b - a > MAX_UTTERANCE { - out.push((a, a + MAX_UTTERANCE)); - a += MAX_UTTERANCE; - while a < b && chars.get(a).is_some_and(|c| c.is_whitespace()) { - a += 1; - } - } - if a < b { - out.push((a, b)); - } -} - -/// Does the full stop at `dot` end an abbreviation ("et al.", "Fig.", "J. Smith") rather than a sentence? -fn is_abbreviation(chars: &[char], start: usize, dot: usize) -> bool { - let mut w = dot; - while w > start && chars.get(w - 1).is_some_and(|c| c.is_alphanumeric() || *c == '.') { - w -= 1; - } - let word: String = chars.get(w..dot).unwrap_or(&[]).iter().collect::().to_lowercase(); - if word.is_empty() { - return false; - } - // An initial: "J. Smith", "A. B. Jones". - let letters: Vec = word.chars().filter(|c| *c != '.').collect(); - if letters.len() == 1 && letters.first().is_some_and(|c| c.is_alphabetic()) { - return true; - } - ABBREVIATIONS.contains(&word.as_str()) -} - -/// Collapse whitespace and tidy what removed citations leave behind: "( )", "[]", " ." → ".". -/// One pass, linear in `s` (#3680): a closing bracket right after its opening one (or after it -/// and a space) removes the pair, so nested empty brackets "( [ ] )" go too. -fn tidy(s: &str) -> String { - let mut t = String::with_capacity(s.len()); - for c in s.chars() { - if c.is_whitespace() { - if !t.is_empty() && !t.ends_with(' ') { - t.push(' '); - } - continue; - } - let open = match c { - ')' => Some('('), - ']' => Some('['), - _ => None, - }; - if let Some(open) = open { - let body = t.strip_suffix(' ').unwrap_or(&t); - if let Some(rest) = body.strip_suffix(open) { - let keep = rest.len(); - t.truncate(keep); - continue; - } - } - if matches!(c, '.' | ',' | ';' | ':') && t.ends_with(' ') { - t.pop(); - } - t.push(c); - } - if t.ends_with(' ') { - t.pop(); - } - t -} - -/// Reading state. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum State { - Stopped, - Playing, - Paused, -} - -impl State { - pub fn name(self) -> &'static str { - match self { - State::Stopped => "stopped", - State::Playing => "playing", - State::Paused => "paused", - } - } -} - -/// How sentences are spoken. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum Backend { - /// The system's speech program. - System, - /// Silent; a sentence lasts until something interrupts it (tests, `WORDCRAFT_SPEECH=off`). - Hold, - /// Silent; every sentence finishes at once (tests). - Instant, -} - -struct Shared { - queue: Vec, - index: usize, - state: State, - rate: f32, - /// Bumped on every change that should stop the sentence being spoken. - generation: u64, - error: Option, - /// A worker thread is running (cleared under the lock as it exits). - #[cfg_attr(target_arch = "wasm32", allow(dead_code))] - running: bool, - /// Bumped when a reading starts: a worker thread can outlive a reading (a pause, then a new - /// Read Aloud), and what it learnt about the system's speech programs is per reading. - #[cfg_attr(target_arch = "wasm32", allow(dead_code))] - readings: u64, -} - -/// The Read Aloud player. -pub struct ReadAloud { - shared: Arc<(Mutex, Condvar)>, - pub backend: Backend, - /// Leave citations and bibliographies out of the next reading. - pub skip_citations: bool, - /// The player window is open (it stays open after the last sentence until closed). - pub open: bool, - /// The document being read ([`crate::Session::document_id`]): the sentences' positions are - /// in it. - pub document: Option, -} - -impl Default for ReadAloud { - fn default() -> Self { - let off = cfg!(test) || std::env::var("WORDCRAFT_SPEECH").is_ok_and(|v| v == "off"); - ReadAloud { - shared: Arc::new(( - Mutex::new(Shared { - queue: Vec::new(), - index: 0, - state: State::Stopped, - rate: 1.0, - generation: 0, - error: None, - running: false, - readings: 0, - }), - Condvar::new(), - )), - backend: if off { Backend::Hold } else { Backend::System }, - skip_citations: true, - open: false, - document: None, - } - } -} - -fn lock(m: &Mutex) -> MutexGuard<'_, Shared> { - m.lock().unwrap_or_else(PoisonError::into_inner) -} - -impl ReadAloud { - /// Change the shared state, stop the current sentence and wake the worker. - fn change(&mut self, f: impl FnOnce(&mut Shared)) { - { - let (m, cv) = &*self.shared; - let mut s = lock(m); - f(&mut s); - s.generation = s.generation.wrapping_add(1); - cv.notify_all(); - } - self.ensure_worker(); - } - - /// Start reading `queue` from its first sentence. - pub fn start(&mut self, queue: Vec) { - self.open = true; - self.change(|s| { - s.state = if queue.is_empty() { State::Stopped } else { State::Playing }; - s.queue = queue; - s.index = 0; - s.error = None; - s.readings = s.readings.wrapping_add(1); - }); - } - - /// Pause, or carry on (a finished reading starts again from the top). - pub fn play_pause(&mut self) { - self.change(|s| { - s.state = match s.state { - State::Playing => State::Paused, - _ if s.queue.is_empty() => State::Stopped, - _ => { - if s.index >= s.queue.len() { - s.index = 0; - } - s.error = None; - State::Playing - } - }; - }); - } - - /// Move by `delta` sentences; a stopped player starts playing there. - pub fn skip(&mut self, delta: i64) { - self.change(|s| { - let last = s.queue.len().saturating_sub(1) as i64; - let at = (s.index.min(s.queue.len()) as i64).saturating_add(delta).clamp(0, last.max(0)); - s.index = at as usize; - if s.state == State::Stopped && !s.queue.is_empty() { - s.state = State::Playing; - } - }); - } - - /// Set the speed (1.0 = normal); the current sentence restarts at the new speed. - pub fn set_rate(&mut self, rate: f32) { - let r = if rate.is_finite() { rate.clamp(MIN_RATE, MAX_RATE) } else { 1.0 }; - let playing = self.state() == State::Playing; - if playing { - self.change(|s| s.rate = r); - } else { - lock(&self.shared.0).rate = r; - } - } - - /// Stop reading and close the player. - pub fn stop(&mut self) { - self.open = false; - self.change(|s| s.state = State::Stopped); - } - - pub fn state(&self) -> State { - lock(&self.shared.0).state - } - pub fn rate(&self) -> f32 { - lock(&self.shared.0).rate - } - /// The sentence being read (or paused on). - pub fn current(&self) -> Option { - let s = lock(&self.shared.0); - if s.state == State::Stopped { None } else { s.queue.get(s.index).cloned() } - } - /// (index, number of sentences). - pub fn progress(&self) -> (usize, usize) { - let s = lock(&self.shared.0); - (s.index, s.queue.len()) - } - /// The last speech error (no speech program…), if any. - pub fn error(&self) -> Option { - lock(&self.shared.0).error.clone() - } - - pub fn status(&self) -> Value { - let s = lock(&self.shared.0); - let current = if s.state == State::Stopped { None } else { s.queue.get(s.index) }; - json!({ - "state": s.state.name(), - "open": self.open, - "index": s.index, - "count": s.queue.len(), - "rate": s.rate, - "skipCitations": self.skip_citations, - "current": current.map(Utterance::to_json), - "error": s.error, - }) - } - - #[cfg(target_arch = "wasm32")] - fn ensure_worker(&mut self) { - let (m, _) = &*self.shared; - let mut s = lock(m); - if s.state == State::Playing && self.backend == Backend::System { - s.state = State::Stopped; - s.error = Some("Read Aloud isn't available in the browser".into()); - } - } - - #[cfg(not(target_arch = "wasm32"))] - fn ensure_worker(&mut self) { - let mut s = lock(&self.shared.0); - if s.state != State::Playing || s.running { - return; - } - s.running = true; - let shared = Arc::clone(&self.shared); - let backend = self.backend; - if let Err(e) = std::thread::Builder::new().name("read-aloud".into()).spawn(move || worker(&shared, backend)) { - s.running = false; - s.state = State::Stopped; - s.error = Some(format!("couldn't start Read Aloud: {e}")); - } - } -} - -impl Drop for ReadAloud { - fn drop(&mut self) { - let (m, cv) = &*self.shared; - let mut s = lock(m); - s.state = State::Stopped; - s.generation = s.generation.wrapping_add(1); - cv.notify_all(); - } -} - -/// How a sentence ended. -#[cfg(not(target_arch = "wasm32"))] -enum Spoken { - Done, - Interrupted, - /// Another program stopped the sentence: reading pauses on it, saying why. - #[cfg_attr(not(all(unix, not(target_os = "macos"))), allow(dead_code))] - Halted(String), - Failed(String), -} - -#[cfg(not(target_arch = "wasm32"))] -fn worker(shared: &(Mutex, Condvar), backend: Backend) { - let (m, cv) = shared; - let mut reading = None; - loop { - let (text, lang, generation, rate, readings) = { - let mut s = lock(m); - while s.state == State::Paused { - s = cv.wait(s).unwrap_or_else(PoisonError::into_inner); - } - if s.state == State::Stopped { - s.running = false; - return; - } - match s.queue.get(s.index) { - Some(u) => (u.text.clone(), u.lang.clone(), s.generation, s.rate, s.readings), - None => { - s.state = State::Stopped; - s.running = false; - return; - } - } - }; - // A new reading asks the system afresh which speech programs it has. - if reading != Some(readings) { - reading = Some(readings); - #[cfg(all(unix, not(target_os = "macos")))] - NO_ESPEAK.set(false); - } - let changed = || lock(m).generation != generation; - let outcome = match backend { - Backend::System => speak(&text, lang.as_deref(), rate, &changed), - Backend::Instant => Spoken::Done, - Backend::Hold => { - let mut s = lock(m); - while s.generation == generation { - s = cv.wait(s).unwrap_or_else(PoisonError::into_inner); - } - Spoken::Interrupted - } - }; - let mut s = lock(m); - if s.generation != generation { - continue; - } - match outcome { - Spoken::Done => { - s.index += 1; - if s.index >= s.queue.len() { - s.state = State::Stopped; - s.running = false; - return; - } - } - Spoken::Interrupted => {} - Spoken::Halted(why) => { - s.state = State::Paused; - s.error = Some(why); - } - Spoken::Failed(e) => { - s.state = State::Stopped; - s.error = Some(e); - s.running = false; - return; - } - } - } -} - -/// Windows PowerShell by its full path, `\System32\WindowsPowerShell\v1.0\powershell.exe` -/// (`C:\Windows` when `SystemRoot` is unset), rather than whichever `powershell` the program search -/// finds first. -#[cfg(any(target_os = "windows", test))] -fn windows_powershell(system_root: Option) -> std::path::PathBuf { - let root = system_root.filter(|r| !r.is_empty()).unwrap_or_else(|| "C:\\Windows".into()); - std::path::Path::new(&root).join("System32").join("WindowsPowerShell").join("v1.0").join("powershell.exe") -} - -/// Win32 `CREATE_NO_WINDOW` process creation flag: a console program starts without a console window. -#[cfg(any(target_os = "windows", test))] -const CREATE_NO_WINDOW: u32 = 0x0800_0000; - -/// How Read Aloud starts Windows PowerShell to speak at `rate`: the program, its arguments and -/// its process creation flags. `speak` builds its `Command` from exactly this; std can't read -/// creation flags back from a `Command`, so the tests check this value instead. -#[cfg(any(target_os = "windows", test))] -struct WindowsSpeech { - program: std::path::PathBuf, - args: [String; 4], - creation_flags: u32, -} - -#[cfg(any(target_os = "windows", test))] -fn windows_speech(system_root: Option, rate: f32, lang: Option<&str>) -> WindowsSpeech { - // SAPI rate: -10 (a third of normal speed) … 10 (three times). - let r = ((rate.ln() / 3f32.ln()) * 10.0).round().clamp(-10.0, 10.0) as i64; - // The voice of the sentence's language, when one is installed (the default voice would spell - // out Cyrillic, Greek… letters one by one), but only when the user's default voice doesn't - // speak that language already, in any region. The tag is `primary_tag`-clean, so it can't end - // the quote. - let voice = lang.and_then(primary_tag).map_or_else(String::new, |p| { - format!( - "if ($s.Voice.Culture.TwoLetterISOLanguageName -ne '{p}') {{ $v = $s.GetInstalledVoices() | Where-Object {{ $_.Enabled -and $_.VoiceInfo.Culture.TwoLetterISOLanguageName -eq '{p}' }} | Select-Object -First 1; if ($v) {{ $s.SelectVoice($v.VoiceInfo.Name) }} }}; " - ) - }); - // The sentence arrives on stdin as UTF-8 bytes; `[Console]::In` would decode them with the - // console's code page (Cyrillic became `?` and the voice read single letters), so read the raw - // stream and decode it as UTF-8. The text itself is never part of the script. - let script = format!( - "Add-Type -AssemblyName System.Speech; $s = New-Object System.Speech.Synthesis.SpeechSynthesizer; $s.Rate = {r}; {voice}$m = New-Object System.IO.MemoryStream; [Console]::OpenStandardInput().CopyTo($m); $s.Speak([System.Text.Encoding]::UTF8.GetString($m.ToArray()))" - ); - WindowsSpeech { - program: windows_powershell(system_root), - args: ["-NoProfile".into(), "-NonInteractive".into(), "-Command".into(), script], - // PowerShell is a console program: without this, every sentence flashes a console window - // over the document (#1122). - creation_flags: CREATE_NO_WINDOW, - } -} - -/// Speak one sentence with the system's speech program, stopping early when `changed()`. When -/// the program fails with the sentence's voice, the default voice says it instead. -#[cfg(not(target_arch = "wasm32"))] -fn speak(text: &str, lang: Option<&str>, rate: f32, changed: &dyn Fn() -> bool) -> Spoken { - let (spoken, gave_voice, exited_ok) = speak_with(text, lang, rate, changed); - if retry_without_voice(gave_voice, exited_ok) { - return speak_with(text, None, rate, changed).0; - } - spoken -} - -/// [`speak`] in `lang`'s voice once: how it ended, whether the program was given a voice, and -/// whether it exited successfully (`None` when it didn't exit on its own). -#[cfg(not(target_arch = "wasm32"))] -fn speak_with(text: &str, lang: Option<&str>, rate: f32, changed: &dyn Fn() -> bool) -> (Spoken, bool, Option) { - use std::io::Write; - use std::process::{Command, Stdio}; - let wpm = ((BASE_WPM * rate).round() as i64).to_string(); - // The text goes in on stdin so it can never be taken for an option. Each try is a program - // and whether it was given a voice. - let mut tries: Vec<(Command, bool)> = Vec::new(); - #[cfg(target_os = "macos")] - { - let mut c = Command::new("say"); - c.args(["-r", &wpm]); - let voice = lang.and_then(|l| mac_voice(l, mac_voice_listing(), mac_default_voice_language())); - if let Some(voice) = &voice { - c.args(["-v", voice.as_str()]); - } - tries.push((c, voice.is_some())); - } - #[cfg(target_os = "windows")] - { - let w = windows_speech(std::env::var_os("SystemRoot"), rate, lang); - let mut c = Command::new(&w.program); - c.args(&w.args); - std::os::windows::process::CommandExt::creation_flags(&mut c, w.creation_flags); - tries.push((c, lang.and_then(primary_tag).is_some())); - } - #[cfg(all(unix, not(target_os = "macos")))] - { - let programs: &[&str] = if NO_ESPEAK.get() { &[] } else { &["espeak-ng", "espeak"] }; - for prog in programs { - let args = espeak_args(&wpm, lang); - let voiced = args.iter().any(|a| a == "-v"); - let mut c = Command::new(prog); - c.args(args); - tries.push((c, voiced)); - } - } - let _ = (&wpm, lang); - let mut child = None; - let mut not_found = true; - let mut last_err = String::from("no speech program on this system"); - for (mut c, voiced) in tries { - match c.stdin(Stdio::piped()).stdout(Stdio::null()).stderr(Stdio::null()).spawn() { - Ok(ch) => { - child = Some((ch, voiced)); - break; - } - Err(e) => { - not_found &= e.kind() == std::io::ErrorKind::NotFound; - last_err = e.to_string(); - } - } - } - let Some((mut child, voiced)) = child else { - // Speech Dispatcher, which many desktops install without the espeak command. - #[cfg(all(unix, not(target_os = "macos")))] - if not_found { - NO_ESPEAK.set(true); - if let Some(spoken) = speak_dispatcher(text, lang, rate, changed) { - return (spoken, false, None); - } - } - let message = if not_found { MISSING_PROGRAM.to_string() } else { format!("Read Aloud couldn't start the speech program: {last_err}") }; - return (Spoken::Failed(message), false, None); - }; - if let Some(mut stdin) = child.stdin.take() { - let _ = stdin.write_all(text.as_bytes()); - // Dropping stdin closes it, which tells the program the text is complete. - } - loop { - match child.try_wait() { - Ok(Some(status)) => return (Spoken::Done, voiced, Some(status.success())), - Ok(None) => {} - Err(e) => return (Spoken::Failed(e.to_string()), voiced, None), - } - if changed() { - let _ = child.kill(); - let _ = child.wait(); - return (Spoken::Interrupted, voiced, None); - } - std::thread::sleep(std::time::Duration::from_millis(30)); - } -} - -#[cfg(test)] -mod tests; +//! Read Aloud: the document as a queue of sentences that the system's speech program speaks one +//! at a time, so reading can be paused, skipped forwards and backwards, and sped up. +//! +//! Citation-manager fields can be left out: Zotero, Mendeley and EndNote citations and +//! bibliographies (range fields with an `ADDIN …` code) and Word's own `CITATION` and +//! `BIBLIOGRAPHY` fields. The citation's text simply isn't in the queue, so every citation +//! style is skipped the same way: "(Smith et al., 2020)", "[3]" or a superscript number. +//! +//! Each sentence is spoken in its own language: the run's language tag, or else the script of its +//! letters (Cyrillic, Greek, Arabic, Hebrew, CJK, Thai…), so a Russian sentence gets a Russian voice +//! where one is installed instead of being spelled out letter by letter. The system's own voice +//! stays whenever it already speaks the language (English text on an English system keeps the +//! voice the user chose), and without a voice for the language the default voice speaks. Speech +//! Dispatcher is the exception: it can't be asked for its default language, so it is told the +//! language of every sentence. +//! +//! The speech itself runs on a worker thread (desktop only): it starts `say` (macOS), `espeak-ng` +//! / `espeak` (Linux, BSD) or the Windows speech synthesizer (PowerShell) for the current sentence, +//! or talks to Speech Dispatcher over its socket when neither espeak is installed, and moves on when +//! the sentence ends. Commands only change the shared state and wake the worker, which stops the +//! running sentence when the state changed under it. + +use std::sync::{Arc, Condvar, Mutex, MutexGuard, PoisonError}; + +use serde_json::{Value, json}; +use wordcraft_doc::para::{COLUMN_BREAK, LINE_BREAK, OBJ, PAGE_BREAK}; +use wordcraft_doc::{Document, InlineObject, Pos}; + +/// Longest sentence handed to the speech program (longer ones are split at a space). +const MAX_SENTENCE: usize = 600; +/// Longest utterance, even with no space to split at (a long URL, a run of letters): it is cut +/// there, mid-word. +const MAX_UTTERANCE: usize = 2 * MAX_SENTENCE; +/// Most sentences in one queue. +const MAX_SENTENCES: usize = 20_000; +/// Speed limits (1.0 = the normal speed). +pub const MIN_RATE: f32 = 0.5; +pub const MAX_RATE: f32 = 3.0; +/// Words per minute at speed 1.0 (`say` and `espeak` both default to about this). +#[cfg(not(target_arch = "wasm32"))] +const BASE_WPM: f32 = 180.0; + +/// Words that end with a full stop without ending the sentence. +const ABBREVIATIONS: &[&str] = &[ + "al", "e.g", "i.e", "eg", "ie", "cf", "vs", "etc", "fig", "figs", "eq", "eqs", "no", "nos", "p", "pp", "vol", "vols", "ch", "chap", "sec", "tab", + "ref", "refs", "dr", "mr", "mrs", "ms", "prof", "st", "approx", "ca", "resp", "ed", "eds", "suppl", "jr", "sr", "inc", "ltd", "co", "dept", + "est", "min", "max", "viz", "ibid", +]; + +/// Is this field code a citation or a bibliography (Zotero, Mendeley, EndNote, Word)? +pub fn is_citation_code(instr: &str) -> bool { + let code = instr.trim_start(); + let word = code.split_whitespace().next().unwrap_or("").to_ascii_uppercase(); + if word == "CITATION" || word == "BIBLIOGRAPHY" { + return true; + } + if word != "ADDIN" { + return false; + } + let rest = code.get(5..).unwrap_or("").trim_start().to_ascii_uppercase(); + ["ZOTERO_ITEM", "ZOTERO_BIBL", "CSL_CITATION", "CSL_BIBLIOGRAPHY", "MENDELEY", "EN.CITE", "EN.REFLIST"].iter().any(|k| rest.starts_with(k)) +} + +/// One sentence to speak: its text and where it is in the document. +#[derive(Clone, Debug, PartialEq)] +pub struct Utterance { + pub text: String, + pub start: Pos, + pub end: Pos, + /// The language to speak it in (a BCP 47 tag), or `None` for the system's default voice. + pub lang: Option, +} + +impl Utterance { + pub fn to_json(&self) -> Value { + json!({"text": self.text, "start": crate::cmd::pos_json(&self.start), "end": crate::cmd::pos_json(&self.end)}) + } +} + +/// The sentences from `from` (to `to`, if given) in `from`'s story, in reading order. +/// With `skip_citations`, citation and bibliography fields are left out. At most +/// [`MAX_SENTENCES`]; when that cuts the text short a warning is logged, so the stop is not silent. +pub fn sentences(doc: &Document, from: &Pos, to: Option<&Pos>, skip_citations: bool) -> Vec { + let story = from.story; + // Spans to leave out: whole range fields, both markers included. + let skipped = Spans::new(if skip_citations { + doc.field_ranges(story).into_iter().filter(|r| is_citation_code(&r.instr)).map(|r| (r.start, r.end)).collect() + } else { + Vec::new() + }); + let in_skipped = |p: &Pos| skipped.contains(p); + let mut out = Vec::new(); + for path in doc.para_paths(story) { + if path < from.path { + continue; + } + if let Some(to) = to + && path > to.path + { + break; + } + let Some(para) = doc.para(story, &path) else { continue }; + // The spoken text, and for each char the paragraph offset it came from. + let mut text = String::new(); + let mut map: Vec<(usize, usize)> = Vec::new(); // (start, end) offsets in the paragraph + let mut k = 0; + for (off, c) in para.text.char_indices() { + let end = off + c.len_utf8(); + let obj = if c == OBJ { + k += 1; + para.objects.get(k - 1) + } else { + None + }; + if (path == from.path && off < from.off) || to.is_some_and(|t| path == t.path && off >= t.off) { + continue; + } + let pos = Pos::new(story, path.clone(), off); + if in_skipped(&pos) { + continue; + } + match obj { + Some(InlineObject::Field { instr, .. }) if skip_citations && is_citation_code(instr) => {} + Some(o) => { + for ch in o.plain_text().chars() { + text.push(ch); + map.push((off, end)); + } + } + None if c == OBJ => {} + None => { + let ch = if matches!(c, LINE_BREAK | PAGE_BREAK | COLUMN_BREAK | '\t') { ' ' } else { c }; + text.push(ch); + map.push((off, end)); + } + } + } + // Sliced per sentence; skipping from the start each time was quadratic in the paragraph. + let text_chars: Vec = text.chars().collect(); + for (a, b) in split_sentences(&text) { + if out.len() >= MAX_SENTENCES { + log::warn!( + "Read Aloud: the queue holds at most {MAX_SENTENCES} sentences; the rest of the text is not read (start again from where it stops)" + ); + return out; + } + let spoken = tidy(&text_chars.get(a..b).unwrap_or(&[]).iter().collect::()); + if !spoken.chars().any(char::is_alphanumeric) { + continue; + } + let (Some(first), Some(last)) = (map.get(a), map.get(b.saturating_sub(1))) else { continue }; + // The language of the sentence's first character, through styles and the defaults. + let run = para.props_of_char(first.0); + let explicit = doc.styles.resolve_char(para.props.style.as_deref(), run).lang; + let lang = speech_language(&spoken, explicit.as_deref(), run.lang_bidi.as_deref()); + out.push(Utterance { text: spoken, start: Pos::new(story, path.clone(), first.0), end: Pos::new(story, path.clone(), last.1), lang }); + } + } + out +} + +/// Spans of the document (both ends included), sorted and merged so a position is looked up by +/// binary search: checking every character against every citation was quadratic in a document +/// with many of them. +struct Spans(Vec<(Pos, Pos)>); + +impl Spans { + fn new(mut spans: Vec<(Pos, Pos)>) -> Self { + spans.retain(|(a, b)| a <= b); + spans.sort(); + let mut merged: Vec<(Pos, Pos)> = Vec::with_capacity(spans.len()); + for (a, b) in spans { + match merged.last_mut() { + Some(last) if a <= last.1 => { + if b > last.1 { + last.1 = b; + } + } + _ => merged.push((a, b)), + } + } + Spans(merged) + } + + /// Is `p` inside one of the spans? + fn contains(&self, p: &Pos) -> bool { + let after = self.0.partition_point(|(a, _)| a <= p); + after.checked_sub(1).and_then(|i| self.0.get(i)).is_some_and(|(_, b)| p <= b) + } +} + +/// The language of `text` from its script, as a BCP 47 primary tag: Cyrillic is Russian (Ukrainian +/// with one of its letters `іїєґ`), Greek, Arabic, Hebrew, Thai and Hangul map to `el`, `ar`, `he`, `th` +/// and `ko`, kana to Japanese, Han letters alone to Chinese. `None` for Latin text (the script +/// doesn't say which language) and text without letters. Mixed text goes to its largest non-Latin +/// script once that script has a real share of the letters (a fifth), even when Latin letters +/// outnumber it ("Я открыл Microsoft Word and PowerPoint"): the default voice would spell those +/// letters out, while a Russian voice reads the English words well enough. Ties go to the script +/// listed first. +pub fn detect_language(text: &str) -> Option<&'static str> { + // Letters per script: Cyrillic, Greek, Arabic, Hebrew, Thai, Hangul, kana, Han, Latin and others. + let mut n = [0usize; 10]; + let mut ukrainian = false; + for c in text.chars().filter(|c| c.is_alphabetic()) { + let i = match c { + '\u{0400}'..='\u{052F}' | '\u{1C80}'..='\u{1C8F}' | '\u{2DE0}'..='\u{2DFF}' | '\u{A640}'..='\u{A69F}' => { + ukrainian |= matches!(c, 'і' | 'ї' | 'є' | 'ґ' | 'І' | 'Ї' | 'Є' | 'Ґ'); + 0 + } + '\u{0370}'..='\u{03FF}' | '\u{1F00}'..='\u{1FFF}' => 1, + '\u{0600}'..='\u{06FF}' | '\u{0750}'..='\u{077F}' | '\u{08A0}'..='\u{08FF}' | '\u{FB50}'..='\u{FDFF}' | '\u{FE70}'..='\u{FEFF}' => 2, + '\u{0590}'..='\u{05FF}' | '\u{FB1D}'..='\u{FB4F}' => 3, + '\u{0E00}'..='\u{0E7F}' => 4, + '\u{1100}'..='\u{11FF}' | '\u{3130}'..='\u{318F}' | '\u{AC00}'..='\u{D7AF}' => 5, + '\u{3040}'..='\u{30FF}' | '\u{31F0}'..='\u{31FF}' => 6, + '\u{3400}'..='\u{4DBF}' | '\u{4E00}'..='\u{9FFF}' | '\u{F900}'..='\u{FAFF}' | '\u{20000}'..='\u{2FFFF}' => 7, + _ => 8, + }; + if let Some(k) = n.get_mut(i) { + *k += 1; + } + } + // Japanese text mixes kana and Han letters; any kana makes it Japanese. + if n[6] > 0 { + n[6] += n[7]; + n[7] = 0; + } + // The first script with the most letters (`max_by_key` would keep the last of a tie). + let (best, count) = n.iter().take(8).copied().enumerate().fold((0, 0), |b, (i, k)| if k > b.1 { (i, k) } else { b }); + let total: usize = n.iter().sum(); + if count == 0 || count.saturating_mul(5) < total { + return None; + } + Some(match best { + 0 if ukrainian => "uk", + 0 => "ru", + 1 => "el", + 2 => "ar", + 3 => "he", + 4 => "th", + 5 => "ko", + 6 => "ja", + _ => "zh", + }) +} + +/// A language tag that is safe to hand to a speech program or put in a script: letters, digits +/// and hyphens only, starting with a letter, at most 12 characters (a tag from a document is +/// hostile input). +fn plain_tag(tag: &str) -> Option<&str> { + let t = tag.trim(); + (!t.is_empty() && t.len() <= 12 && t.starts_with(|c: char| c.is_ascii_alphabetic()) && t.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')) + .then_some(t) +} + +/// The script group a language is written in, for the languages [`detect_language`] can tell +/// apart by script (the primary subtag in, the detected tag out). +fn script_language(primary: &str) -> Option<&'static str> { + Some(match primary { + "ru" | "uk" | "be" | "bg" | "sr" | "mk" | "kk" | "ky" | "mn" | "tg" => "ru", + "el" => "el", + "ar" | "fa" | "ur" | "ps" | "ku" | "sd" | "ug" => "ar", + "he" | "yi" => "he", + "th" => "th", + "ko" => "ko", + "ja" => "ja", + "zh" => "zh", + _ => return None, + }) +} + +/// The language to speak `text` in: the document's `explicit` tag when it fits the text, else the +/// language of its script, else `None` (the default voice). When the text is in a script +/// [`detect_language`] recognises, a tag written in another script (the `en-US` Word writes on +/// every run, on Cyrillic text) gives way to the script, or the voice would spell the letters out; +/// right-to-left text tries the run's complex-script tag (`w:bidi`, e.g. `fa-IR`) first, and Han +/// letters alone keep a Japanese or Korean tag. Latin text keeps any tag, as in Word, which reads +/// text in its proofing language: English words in a run tagged `ru-RU` get the Russian voice. +pub fn speech_language(text: &str, explicit: Option<&str>, bidi: Option<&str>) -> Option { + let explicit = explicit.and_then(plain_tag); + let Some(d) = detect_language(text) else { return explicit.map(str::to_string) }; + let fits = |tag: &&str| { + let primary = tag.split('-').next().unwrap_or("").to_ascii_lowercase(); + let script = script_language(&primary); + script == script_language(d) || (d == "zh" && matches!(script, Some("ja" | "ko"))) + }; + let bidi = bidi.and_then(plain_tag).filter(|_| matches!(d, "ar" | "he")); + Some(bidi.into_iter().chain(explicit).find(fits).unwrap_or(d).to_string()) +} + +/// The primary language subtag of a plain tag, lowercased (`ru-RU` gives `ru`). `None` for tags +/// without a two- or three-letter language, such as Word's `x-none`. +#[cfg(any(unix, windows, test))] +fn primary_tag(tag: &str) -> Option { + let t = plain_tag(tag)?; + t.split('-').next().filter(|p| (2..=3).contains(&p.len()) && p.chars().all(|c| c.is_ascii_alphabetic())).map(str::to_ascii_lowercase) +} + +/// The language to ask the speech program for, or `None` to keep its default voice: only when +/// `lang` isn't the language the default voice speaks (`default`, a primary subtag) already. +#[cfg(any(unix, test))] +fn voice_language(lang: Option<&str>, default: &str) -> Option { + lang.and_then(primary_tag).filter(|p| !p.eq_ignore_ascii_case(default)) +} + +/// The arguments of `espeak-ng`/`espeak`: the speed, the voice for `lang` unless it's English +/// (espeak's default voice), and `--stdin`. +#[cfg(any(all(unix, not(target_os = "macos")), test))] +fn espeak_args(wpm: &str, lang: Option<&str>) -> Vec { + let mut a = vec!["-s".to_string(), wpm.to_string()]; + if let Some(p) = voice_language(lang, "en") { + a.push("-v".into()); + a.push(p); + } + a.push("--stdin".into()); + a +} + +/// The voices in `say -v '?'`'s `listing`: each line is `Name ll_RR # sample sentence`, and +/// names may contain spaces. Gives (name, lowercased `ll_rr` code) pairs. +#[cfg(any(target_os = "macos", test))] +fn mac_voices(listing: &str) -> impl Iterator + '_ { + listing.lines().filter_map(|line| { + let line = line.split('#').next().unwrap_or(""); + let words: Vec<&str> = line.split_whitespace().collect(); + let at = words.iter().skip(1).position(|w| { + let mut p = w.split('_'); + p.next().is_some_and(|l| (2..=3).contains(&l.len()) && l.chars().all(|c| c.is_ascii_lowercase())) + && p.next().is_some_and(|r| !r.is_empty()) + })? + 1; + Some((words.get(..at)?.join(" "), words.get(at)?.to_ascii_lowercase())) + }) +} + +/// macOS's novelty voices (sound effects and singing), never chosen to read a document. +#[cfg(any(target_os = "macos", test))] +const MAC_NOVELTY_VOICES: &[&str] = &[ + "Albert", + "Bad News", + "Bahh", + "Bells", + "Boing", + "Bubbles", + "Cellos", + "Deranged", + "Good News", + "Hysterical", + "Jester", + "Organ", + "Pipe Organ", + "Superstar", + "Trinoids", + "Whisper", + "Wobble", + "Zarvox", +]; + +/// The voice `say` should use for `lang`, or `None` to keep the system voice: `None` when the +/// system voice already speaks the language (`default`, a primary subtag) or no voice does, else +/// the first voice in `listing` for the language, one of the same region first, never a novelty voice. +#[cfg(any(target_os = "macos", test))] +fn mac_voice(lang: &str, listing: &str, default: &str) -> Option { + let primary = voice_language(Some(lang), default)?; + let tag = plain_tag(lang)?.replace('-', "_").to_ascii_lowercase(); + let mut first = None; + for (name, code) in mac_voices(listing) { + if code.split('_').next() != Some(primary.as_str()) || MAC_NOVELTY_VOICES.contains(&name.as_str()) { + continue; + } + if code == tag { + return Some(name); + } + first.get_or_insert(name); + } + first +} + +/// The language (primary subtag) of the system voice: the `selected` voice's in `listing`, else +/// the `locale`'s (`ru_RU`, `ru_RU.UTF-8`), else English. +#[cfg(any(target_os = "macos", test))] +fn mac_default_language(selected: Option<&str>, listing: &str, locale: Option<&str>) -> String { + let voice = selected + .and_then(|v| mac_voices(listing).find(|(name, _)| name == v.trim())) + .and_then(|(_, code)| code.split('_').next().map(str::to_string)); + let locale = || locale.and_then(|l| l.split(['_', '-', '.', '@']).next()).and_then(primary_tag); + voice.or_else(locale).unwrap_or_else(|| "en".into()) +} + +/// The language of the voice `say` uses by default (asked once). +#[cfg(target_os = "macos")] +fn mac_default_voice_language() -> &'static str { + static LANG: std::sync::OnceLock = std::sync::OnceLock::new(); + LANG.get_or_init(|| { + let read = |args: &[&str]| { + std::process::Command::new("defaults") + .args(args) + .output() + .ok() + .filter(|o| o.status.success()) + .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) + }; + let voice = read(&["read", "com.apple.speech.voice.prefs", "SelectedVoiceName"]); + let locale = read(&["read", "-g", "AppleLocale"]).or_else(|| std::env::var("LANG").ok()); + mac_default_language(voice.as_deref(), mac_voice_listing(), locale.as_deref()) + }) +} + +#[cfg(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")))] +mod ssip; + +/// What Read Aloud says when the system has none of the speech programs it knows. +#[cfg(not(target_arch = "wasm32"))] +const MISSING_PROGRAM: &str = if cfg!(all(unix, not(target_os = "macos"))) { + "Read Aloud needs a speech program: install espeak-ng or Speech Dispatcher from your distribution's packages" +} else { + "Read Aloud couldn't find the system's speech program" +}; + +/// Read Aloud's speed (1 = normal) as Speech Dispatcher's rate, -100 to 100 with 0 the voice's +/// normal speed: the slowest speed is -50 and the fastest 100. +#[cfg(any(all(unix, not(target_os = "macos")), test))] +fn spd_rate(rate: f32) -> i32 { + let rate = if rate.is_finite() { rate.clamp(MIN_RATE, MAX_RATE) } else { 1.0 }; + let scaled = if rate >= 1.0 { (rate - 1.0) / (MAX_RATE - 1.0) * 100.0 } else { (rate - 1.0) * 100.0 }; + scaled.round().clamp(-100.0, 100.0) as i32 +} + +/// Say one sentence through Speech Dispatcher ([`ssip`]), in the sentence's language whenever it +/// has one, region included (its voices are per region: `en-GB`, `pt-BR`). Its default language is +/// its own setting (`en-US` unless configured), which SSIP can't ask for, so English is named too. +/// `None` when Speech Dispatcher isn't installed. +#[cfg(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")))] +fn speak_dispatcher(text: &str, lang: Option<&str>, rate: f32, changed: &dyn Fn() -> bool) -> Option { + let stream = match ssip::connect(changed) { + ssip::Connection::Open(s) => s, + ssip::Connection::Missing => return None, + ssip::Connection::Interrupted => return Some(Spoken::Interrupted), + ssip::Connection::Unreachable(why) => return Some(Spoken::Failed(format!("Read Aloud couldn't reach Speech Dispatcher: {why}"))), + }; + let lang = dispatcher_language(lang); + let req = ssip::Request { text, rate: spd_rate(rate), lang: lang.as_deref() }; + Some(dispatcher_spoken(ssip::converse(&stream, &req, changed))) +} + +/// How a sentence Speech Dispatcher handled ended, for the worker. +#[cfg(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")))] +fn dispatcher_spoken(outcome: std::io::Result) -> Spoken { + match outcome { + Ok(ssip::Outcome::Done) => Spoken::Done, + Ok(ssip::Outcome::Interrupted) => Spoken::Interrupted, + Ok(ssip::Outcome::Halted) => Spoken::Halted(HALTED_BY_OTHER.into()), + Ok(ssip::Outcome::NotStarted) => Spoken::Halted(NOT_STARTED.into()), + Err(e) => Spoken::Failed(format!("Read Aloud couldn't use Speech Dispatcher: {e}")), + } +} + +/// Why reading paused when another program cancelled or paused the sentence. +#[cfg(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")))] +const HALTED_BY_OTHER: &str = + "Speech Dispatcher kept stopping the sentence for another program (a screen reader, or a stop for every program): press Play to go on"; + +/// Why reading paused when Speech Dispatcher never began the sentence. +#[cfg(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")))] +const NOT_STARTED: &str = + "Speech Dispatcher didn't start the sentence (other speech may be queued before it, or its speech module may be stuck): press Play to try again"; + +#[cfg(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")))] +thread_local! { + /// Neither espeak command was found during this reading (the worker thread lives as long as + /// one reading): the next sentences go straight to Speech Dispatcher. + static NO_ESPEAK: std::cell::Cell = const { std::cell::Cell::new(false) }; +} + +/// The language tag Speech Dispatcher is given for a sentence in `lang`: its language and region, +/// as SSIP's `SET LANGUAGE` takes them (`sr-Latn-RS` gives `sr-RS`, `zh-Hant-TW` gives `zh-TW`), +/// when it names a language (Word's `x-none` doesn't). +#[cfg(any(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")), test))] +fn dispatcher_language(lang: Option<&str>) -> Option { + let tag = lang.and_then(plain_tag)?; + let primary = primary_tag(tag)?; + let region = tag + .split('-') + .skip(1) + .take(2) + .find(|t| (t.len() == 2 && t.chars().all(|c| c.is_ascii_alphabetic())) || (t.len() == 3 && t.chars().all(|c| c.is_ascii_digit()))); + Some(match region { + Some(r) => format!("{primary}-{}", r.to_ascii_uppercase()), + None => primary, + }) +} + +/// Should `speak` say the sentence again with the default voice? When it gave the program a voice +/// and the program then exited with a failure: espeak-ng does that, silently, for a voice it +/// doesn't know, and a missing voice must never mean silence. `exited_ok` is `None` when the +/// program didn't exit on its own (stopped, or never started). +#[cfg(any(not(target_arch = "wasm32"), test))] +fn retry_without_voice(gave_voice: bool, exited_ok: Option) -> bool { + gave_voice && exited_ok == Some(false) +} + +/// The names `say` can voice, as `say -v '?'` lists them (asked once). +#[cfg(target_os = "macos")] +fn mac_voice_listing() -> &'static str { + static LISTING: std::sync::OnceLock = std::sync::OnceLock::new(); + LISTING.get_or_init(|| { + std::process::Command::new("say").args(["-v", "?"]).output().map(|o| String::from_utf8_lossy(&o.stdout).into_owned()).unwrap_or_default() + }) +} + +/// Sentence spans in `text` as char index ranges, without surrounding whitespace. +fn split_sentences(text: &str) -> Vec<(usize, usize)> { + let chars: Vec = text.chars().collect(); + let mut out = Vec::new(); + let mut start = 0; + let mut i = 0; + while i < chars.len() { + let c = chars.get(i).copied().unwrap_or(' '); + let mut end = None; + if matches!(c, '.' | '!' | '?' | '…' | '。' | '!' | '?') { + // Include closing quotes and brackets. + let mut j = i + 1; + while chars.get(j).is_some_and(|c| matches!(c, '"' | '\'' | '”' | '’' | ')' | ']' | '»' | '.' | '!' | '?')) { + j += 1; + } + let at_end = j >= chars.len(); + let space_after = chars.get(j).is_some_and(|c| c.is_whitespace()); + if (at_end || space_after) && (c != '.' || !is_abbreviation(&chars, start, i)) { + end = Some(j); + } + i = j.max(i + 1); + } else { + i += 1; + } + // Very long sentences are split at a space so skipping stays fine-grained. + if end.is_none() && i - start >= MAX_SENTENCE && chars.get(i).is_some_and(|c| c.is_whitespace()) { + end = Some(i); + } + if let Some(e) = end { + push_trimmed(&chars, start, e, &mut out); + start = e; + } + } + push_trimmed(&chars, start, chars.len(), &mut out); + out +} + +fn push_trimmed(chars: &[char], mut a: usize, mut b: usize, out: &mut Vec<(usize, usize)>) { + while a < b && chars.get(a).is_some_and(|c| c.is_whitespace()) { + a += 1; + } + while b > a && chars.get(b - 1).is_some_and(|c| c.is_whitespace()) { + b -= 1; + } + // No space to split at (a long URL, a run of letters or full stops): cut the span hard. + while b - a > MAX_UTTERANCE { + out.push((a, a + MAX_UTTERANCE)); + a += MAX_UTTERANCE; + while a < b && chars.get(a).is_some_and(|c| c.is_whitespace()) { + a += 1; + } + } + if a < b { + out.push((a, b)); + } +} + +/// Does the full stop at `dot` end an abbreviation ("et al.", "Fig.", "J. Smith") rather than a sentence? +fn is_abbreviation(chars: &[char], start: usize, dot: usize) -> bool { + let mut w = dot; + while w > start && chars.get(w - 1).is_some_and(|c| c.is_alphanumeric() || *c == '.') { + w -= 1; + } + let word: String = chars.get(w..dot).unwrap_or(&[]).iter().collect::().to_lowercase(); + if word.is_empty() { + return false; + } + // An initial: "J. Smith", "A. B. Jones". + let letters: Vec = word.chars().filter(|c| *c != '.').collect(); + if letters.len() == 1 && letters.first().is_some_and(|c| c.is_alphabetic()) { + return true; + } + ABBREVIATIONS.contains(&word.as_str()) +} + +/// Collapse whitespace and tidy what removed citations leave behind: "( )", "[]", " ." → ".". +/// One pass, linear in `s` (#3680): a closing bracket right after its opening one (or after it +/// and a space) removes the pair, so nested empty brackets "( [ ] )" go too. +fn tidy(s: &str) -> String { + let mut t = String::with_capacity(s.len()); + for c in s.chars() { + if c.is_whitespace() { + if !t.is_empty() && !t.ends_with(' ') { + t.push(' '); + } + continue; + } + let open = match c { + ')' => Some('('), + ']' => Some('['), + _ => None, + }; + if let Some(open) = open { + let body = t.strip_suffix(' ').unwrap_or(&t); + if let Some(rest) = body.strip_suffix(open) { + let keep = rest.len(); + t.truncate(keep); + continue; + } + } + if matches!(c, '.' | ',' | ';' | ':') && t.ends_with(' ') { + t.pop(); + } + t.push(c); + } + if t.ends_with(' ') { + t.pop(); + } + t +} + +/// Reading state. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum State { + Stopped, + Playing, + Paused, +} + +impl State { + pub fn name(self) -> &'static str { + match self { + State::Stopped => "stopped", + State::Playing => "playing", + State::Paused => "paused", + } + } +} + +/// How sentences are spoken. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Backend { + /// The system's speech program. + System, + /// Silent; a sentence lasts until something interrupts it (tests, `WORDCRAFT_SPEECH=off`). + Hold, + /// Silent; every sentence finishes at once (tests). + Instant, +} + +struct Shared { + queue: Vec, + index: usize, + state: State, + rate: f32, + /// Bumped on every change that should stop the sentence being spoken. + generation: u64, + error: Option, + /// A worker thread is running (cleared under the lock as it exits). + #[cfg_attr(target_arch = "wasm32", allow(dead_code))] + running: bool, + /// Bumped when a reading starts: a worker thread can outlive a reading (a pause, then a new + /// Read Aloud), and what it learnt about the system's speech programs is per reading. + #[cfg_attr(target_arch = "wasm32", allow(dead_code))] + readings: u64, +} + +/// The Read Aloud player. +pub struct ReadAloud { + shared: Arc<(Mutex, Condvar)>, + pub backend: Backend, + /// Leave citations and bibliographies out of the next reading. + pub skip_citations: bool, + /// The player window is open (it stays open after the last sentence until closed). + pub open: bool, + /// The document being read ([`crate::Session::document_id`]): the sentences' positions are + /// in it. + pub document: Option, +} + +impl Default for ReadAloud { + fn default() -> Self { + let off = cfg!(test) || std::env::var("WORDCRAFT_SPEECH").is_ok_and(|v| v == "off"); + ReadAloud { + shared: Arc::new(( + Mutex::new(Shared { + queue: Vec::new(), + index: 0, + state: State::Stopped, + rate: 1.0, + generation: 0, + error: None, + running: false, + readings: 0, + }), + Condvar::new(), + )), + backend: if off { Backend::Hold } else { Backend::System }, + skip_citations: true, + open: false, + document: None, + } + } +} + +fn lock(m: &Mutex) -> MutexGuard<'_, Shared> { + m.lock().unwrap_or_else(PoisonError::into_inner) +} + +impl ReadAloud { + /// Change the shared state, stop the current sentence and wake the worker. + fn change(&mut self, f: impl FnOnce(&mut Shared)) { + { + let (m, cv) = &*self.shared; + let mut s = lock(m); + f(&mut s); + s.generation = s.generation.wrapping_add(1); + cv.notify_all(); + } + self.ensure_worker(); + } + + /// Start reading `queue` from its first sentence. + pub fn start(&mut self, queue: Vec) { + self.open = true; + self.change(|s| { + s.state = if queue.is_empty() { State::Stopped } else { State::Playing }; + s.queue = queue; + s.index = 0; + s.error = None; + s.readings = s.readings.wrapping_add(1); + }); + } + + /// Pause, or carry on (a finished reading starts again from the top). + pub fn play_pause(&mut self) { + self.change(|s| { + s.state = match s.state { + State::Playing => State::Paused, + _ if s.queue.is_empty() => State::Stopped, + _ => { + if s.index >= s.queue.len() { + s.index = 0; + } + s.error = None; + State::Playing + } + }; + }); + } + + /// Move by `delta` sentences; a stopped player starts playing there. + pub fn skip(&mut self, delta: i64) { + self.change(|s| { + let last = s.queue.len().saturating_sub(1) as i64; + let at = (s.index.min(s.queue.len()) as i64).saturating_add(delta).clamp(0, last.max(0)); + s.index = at as usize; + if s.state == State::Stopped && !s.queue.is_empty() { + s.state = State::Playing; + } + }); + } + + /// Set the speed (1.0 = normal); the current sentence restarts at the new speed. + pub fn set_rate(&mut self, rate: f32) { + let r = if rate.is_finite() { rate.clamp(MIN_RATE, MAX_RATE) } else { 1.0 }; + let playing = self.state() == State::Playing; + if playing { + self.change(|s| s.rate = r); + } else { + lock(&self.shared.0).rate = r; + } + } + + /// Stop reading and close the player. + pub fn stop(&mut self) { + self.open = false; + self.change(|s| s.state = State::Stopped); + } + + pub fn state(&self) -> State { + lock(&self.shared.0).state + } + pub fn rate(&self) -> f32 { + lock(&self.shared.0).rate + } + /// The sentence being read (or paused on). + pub fn current(&self) -> Option { + let s = lock(&self.shared.0); + if s.state == State::Stopped { None } else { s.queue.get(s.index).cloned() } + } + /// (index, number of sentences). + pub fn progress(&self) -> (usize, usize) { + let s = lock(&self.shared.0); + (s.index, s.queue.len()) + } + /// The last speech error (no speech program…), if any. + pub fn error(&self) -> Option { + lock(&self.shared.0).error.clone() + } + + pub fn status(&self) -> Value { + let s = lock(&self.shared.0); + let current = if s.state == State::Stopped { None } else { s.queue.get(s.index) }; + json!({ + "state": s.state.name(), + "open": self.open, + "index": s.index, + "count": s.queue.len(), + "rate": s.rate, + "skipCitations": self.skip_citations, + "current": current.map(Utterance::to_json), + "error": s.error, + }) + } + + #[cfg(target_arch = "wasm32")] + fn ensure_worker(&mut self) { + let (m, _) = &*self.shared; + let mut s = lock(m); + if s.state == State::Playing && self.backend == Backend::System { + s.state = State::Stopped; + s.error = Some("Read Aloud isn't available in the browser".into()); + } + } + + #[cfg(not(target_arch = "wasm32"))] + fn ensure_worker(&mut self) { + let mut s = lock(&self.shared.0); + if s.state != State::Playing || s.running { + return; + } + s.running = true; + let shared = Arc::clone(&self.shared); + let backend = self.backend; + if let Err(e) = std::thread::Builder::new().name("read-aloud".into()).spawn(move || worker(&shared, backend)) { + s.running = false; + s.state = State::Stopped; + s.error = Some(format!("couldn't start Read Aloud: {e}")); + } + } +} + +impl Drop for ReadAloud { + fn drop(&mut self) { + let (m, cv) = &*self.shared; + let mut s = lock(m); + s.state = State::Stopped; + s.generation = s.generation.wrapping_add(1); + cv.notify_all(); + } +} + +/// How a sentence ended. +#[cfg(not(target_arch = "wasm32"))] +enum Spoken { + Done, + Interrupted, + /// Another program stopped the sentence: reading pauses on it, saying why. + #[cfg_attr(not(all(unix, not(target_os = "macos"))), allow(dead_code))] + Halted(String), + Failed(String), +} + +#[cfg(not(target_arch = "wasm32"))] +fn worker(shared: &(Mutex, Condvar), backend: Backend) { + let (m, cv) = shared; + let mut reading = None; + loop { + let (text, lang, generation, rate, readings) = { + let mut s = lock(m); + while s.state == State::Paused { + s = cv.wait(s).unwrap_or_else(PoisonError::into_inner); + } + if s.state == State::Stopped { + s.running = false; + return; + } + match s.queue.get(s.index) { + Some(u) => (u.text.clone(), u.lang.clone(), s.generation, s.rate, s.readings), + None => { + s.state = State::Stopped; + s.running = false; + return; + } + } + }; + // A new reading asks the system afresh which speech programs it has. + if reading != Some(readings) { + reading = Some(readings); + #[cfg(all(unix, not(target_os = "macos")))] + NO_ESPEAK.set(false); + } + let changed = || lock(m).generation != generation; + let outcome = match backend { + Backend::System => speak(&text, lang.as_deref(), rate, &changed), + Backend::Instant => Spoken::Done, + Backend::Hold => { + let mut s = lock(m); + while s.generation == generation { + s = cv.wait(s).unwrap_or_else(PoisonError::into_inner); + } + Spoken::Interrupted + } + }; + let mut s = lock(m); + if s.generation != generation { + continue; + } + match outcome { + Spoken::Done => { + s.index += 1; + if s.index >= s.queue.len() { + s.state = State::Stopped; + s.running = false; + return; + } + } + Spoken::Interrupted => {} + Spoken::Halted(why) => { + s.state = State::Paused; + s.error = Some(why); + } + Spoken::Failed(e) => { + s.state = State::Stopped; + s.error = Some(e); + s.running = false; + return; + } + } + } +} + +/// Windows PowerShell by its full path, `\System32\WindowsPowerShell\v1.0\powershell.exe` +/// (`C:\Windows` when `SystemRoot` is unset), rather than whichever `powershell` the program search +/// finds first. +#[cfg(any(target_os = "windows", test))] +fn windows_powershell(system_root: Option) -> std::path::PathBuf { + let root = system_root.filter(|r| !r.is_empty()).unwrap_or_else(|| "C:\\Windows".into()); + std::path::Path::new(&root).join("System32").join("WindowsPowerShell").join("v1.0").join("powershell.exe") +} + +/// Win32 `CREATE_NO_WINDOW` process creation flag: a console program starts without a console window. +#[cfg(any(target_os = "windows", test))] +const CREATE_NO_WINDOW: u32 = 0x0800_0000; + +/// How Read Aloud starts Windows PowerShell to speak at `rate`: the program, its arguments and +/// its process creation flags. `speak` builds its `Command` from exactly this; std can't read +/// creation flags back from a `Command`, so the tests check this value instead. +#[cfg(any(target_os = "windows", test))] +struct WindowsSpeech { + program: std::path::PathBuf, + args: [String; 4], + creation_flags: u32, +} + +#[cfg(any(target_os = "windows", test))] +fn windows_speech(system_root: Option, rate: f32, lang: Option<&str>) -> WindowsSpeech { + // SAPI rate: -10 (a third of normal speed) … 10 (three times). + let r = ((rate.ln() / 3f32.ln()) * 10.0).round().clamp(-10.0, 10.0) as i64; + // The voice of the sentence's language, when one is installed (the default voice would spell + // out Cyrillic, Greek… letters one by one), but only when the user's default voice doesn't + // speak that language already, in any region. The tag is `primary_tag`-clean, so it can't end + // the quote. + let voice = lang.and_then(primary_tag).map_or_else(String::new, |p| { + format!( + "if ($s.Voice.Culture.TwoLetterISOLanguageName -ne '{p}') {{ $v = $s.GetInstalledVoices() | Where-Object {{ $_.Enabled -and $_.VoiceInfo.Culture.TwoLetterISOLanguageName -eq '{p}' }} | Select-Object -First 1; if ($v) {{ $s.SelectVoice($v.VoiceInfo.Name) }} }}; " + ) + }); + // The sentence arrives on stdin as UTF-8 bytes; `[Console]::In` would decode them with the + // console's code page (Cyrillic became `?` and the voice read single letters), so read the raw + // stream and decode it as UTF-8. The text itself is never part of the script. + let script = format!( + "Add-Type -AssemblyName System.Speech; $s = New-Object System.Speech.Synthesis.SpeechSynthesizer; $s.Rate = {r}; {voice}$m = New-Object System.IO.MemoryStream; [Console]::OpenStandardInput().CopyTo($m); $s.Speak([System.Text.Encoding]::UTF8.GetString($m.ToArray()))" + ); + WindowsSpeech { + program: windows_powershell(system_root), + args: ["-NoProfile".into(), "-NonInteractive".into(), "-Command".into(), script], + // PowerShell is a console program: without this, every sentence flashes a console window + // over the document (#1122). + creation_flags: CREATE_NO_WINDOW, + } +} + +/// Speak one sentence with the system's speech program, stopping early when `changed()`. When +/// the program fails with the sentence's voice, the default voice says it instead. +#[cfg(not(target_arch = "wasm32"))] +fn speak(text: &str, lang: Option<&str>, rate: f32, changed: &dyn Fn() -> bool) -> Spoken { + let (spoken, gave_voice, exited_ok) = speak_with(text, lang, rate, changed); + if retry_without_voice(gave_voice, exited_ok) { + return speak_with(text, None, rate, changed).0; + } + spoken +} + +/// [`speak`] in `lang`'s voice once: how it ended, whether the program was given a voice, and +/// whether it exited successfully (`None` when it didn't exit on its own). +#[cfg(not(target_arch = "wasm32"))] +fn speak_with(text: &str, lang: Option<&str>, rate: f32, changed: &dyn Fn() -> bool) -> (Spoken, bool, Option) { + use std::io::Write; + use std::process::{Command, Stdio}; + let wpm = ((BASE_WPM * rate).round() as i64).to_string(); + // The text goes in on stdin so it can never be taken for an option. Each try is a program + // and whether it was given a voice. + let mut tries: Vec<(Command, bool)> = Vec::new(); + #[cfg(target_os = "macos")] + { + let mut c = Command::new("say"); + c.args(["-r", &wpm]); + let voice = lang.and_then(|l| mac_voice(l, mac_voice_listing(), mac_default_voice_language())); + if let Some(voice) = &voice { + c.args(["-v", voice.as_str()]); + } + tries.push((c, voice.is_some())); + } + #[cfg(target_os = "windows")] + { + let w = windows_speech(std::env::var_os("SystemRoot"), rate, lang); + let mut c = Command::new(&w.program); + c.args(&w.args); + std::os::windows::process::CommandExt::creation_flags(&mut c, w.creation_flags); + tries.push((c, lang.and_then(primary_tag).is_some())); + } + #[cfg(all(unix, not(target_os = "macos")))] + { + let programs: &[&str] = if NO_ESPEAK.get() { &[] } else { &["espeak-ng", "espeak"] }; + for prog in programs { + let args = espeak_args(&wpm, lang); + let voiced = args.iter().any(|a| a == "-v"); + let mut c = Command::new(prog); + c.args(args); + tries.push((c, voiced)); + } + } + let _ = (&wpm, lang); + let mut child = None; + let mut not_found = true; + let mut last_err = String::from("no speech program on this system"); + for (mut c, voiced) in tries { + match c.stdin(Stdio::piped()).stdout(Stdio::null()).stderr(Stdio::null()).spawn() { + Ok(ch) => { + child = Some((ch, voiced)); + break; + } + Err(e) => { + not_found &= e.kind() == std::io::ErrorKind::NotFound; + last_err = e.to_string(); + } + } + } + let Some((mut child, voiced)) = child else { + // Speech Dispatcher, which many desktops install without the espeak command. + #[cfg(all(unix, not(target_os = "macos")))] + if not_found { + NO_ESPEAK.set(true); + if let Some(spoken) = speak_dispatcher(text, lang, rate, changed) { + return (spoken, false, None); + } + } + let message = if not_found { MISSING_PROGRAM.to_string() } else { format!("Read Aloud couldn't start the speech program: {last_err}") }; + return (Spoken::Failed(message), false, None); + }; + if let Some(mut stdin) = child.stdin.take() { + let _ = stdin.write_all(text.as_bytes()); + // Dropping stdin closes it, which tells the program the text is complete. + } + loop { + match child.try_wait() { + Ok(Some(status)) => return (Spoken::Done, voiced, Some(status.success())), + Ok(None) => {} + Err(e) => return (Spoken::Failed(e.to_string()), voiced, None), + } + if changed() { + let _ = child.kill(); + let _ = child.wait(); + return (Spoken::Interrupted, voiced, None); + } + std::thread::sleep(std::time::Duration::from_millis(30)); + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/engine/src/speech/ssip.rs b/crates/engine/src/speech/ssip.rs index a0cf365495..979b144453 100644 --- a/crates/engine/src/speech/ssip.rs +++ b/crates/engine/src/speech/ssip.rs @@ -1,927 +1,927 @@ -//! Speech Dispatcher through its own protocol (SSIP) on the user's socket, for systems that have -//! Speech Dispatcher but not the `espeak-ng` command. -//! -//! Talking SSIP directly, instead of starting `spd-say`, keeps two promises `spd-say` can't: the -//! sentence is always data (pipe mode runs a line starting with `!-!` as an SSIP command, and a -//! document must never send commands), and stopping cancels only WordCraft's own speech -//! (`CANCEL self`), where `spd-say -C` would silence every client, a screen reader included. -//! -//! One connection speaks one sentence: it names the client, asks for the begin, end, cancel and -//! pause events, sets the rate and language, sends the text, and waits for the sentence's end -//! event. A sentence another client's message cancels (a screen reader's announcement) is sent -//! again after a pause, twice at most; a sentence cancelled once more or paused by another -//! client halts reading on it ([`Outcome::Halted`]), and so does one that never began -//! ([`Outcome::NotStarted`]). - -use std::io::{ErrorKind, Read, Write}; -use std::net::{TcpStream, ToSocketAddrs}; -use std::os::unix::net::UnixStream; -use std::path::PathBuf; -use std::time::{Duration, Instant}; - -/// How often a wait for the end event looks at whether reading moved on. -const POLL: Duration = Duration::from_millis(30); -/// Longest wait for a reply to a command (the server answers at once; this only bounds a stuck one). -const REPLY_TIMEOUT: Duration = if cfg!(test) { Duration::from_secs(1) } else { Duration::from_secs(5) }; -/// Longest reply line kept (a server sends short lines; this bounds a broken one). -const MAX_LINE: usize = 4096; -/// Speech Dispatcher's TCP port when `SPEECHD_ADDRESS` names none. -const DEFAULT_PORT: u16 = 6560; -/// How long a freshly started server gets to open its socket (it loads its speech modules first). -const START_WAIT: Duration = Duration::from_secs(5); -/// Longest wait for a TCP connection to one address. -const CONNECT_TIMEOUT: Duration = Duration::from_secs(1); -/// The end of a sentence is awaited this long after it began, plus [`SENTENCE_PER_CHAR`] for each -/// character: far more than the slowest reading takes, so only a stuck server reaches it. -const SENTENCE_BASE: Duration = if cfg!(test) { Duration::from_secs(1) } else { Duration::from_secs(30) }; -const SENTENCE_PER_CHAR: Duration = Duration::from_millis(400); -/// Longest wait for a queued sentence to begin (behind other clients' speech, or paused before it -/// began, which sends no pause event). -const QUEUE_WAIT: Duration = if cfg!(test) { Duration::from_secs(1) } else { Duration::from_secs(120) }; -/// The pauses before a sentence another client cancelled is sent again: the other client's -/// message gets time to end, and the server holds ours back while a higher-priority one speaks. -const RESEND_AFTER: [Duration; 2] = - if cfg!(test) { [Duration::from_millis(50), Duration::from_millis(50)] } else { [Duration::from_secs(1), Duration::from_secs(3)] }; - -/// What to say and how. -pub(super) struct Request<'a> { - pub text: &'a str, - /// Speech Dispatcher's rate, -100 to 100. - pub rate: i32, - /// A plain language tag (`hu`, `pt-BR`); anything else is left out. - pub lang: Option<&'a str>, -} - -/// How a sentence ended. -#[derive(Debug, PartialEq, Eq)] -pub(super) enum Outcome { - /// Spoken to the end. - Done, - /// Reading moved on: the sentence was cancelled. - Interrupted, - /// Another client stopped it: cancelled it again after it was sent again, or paused it. - /// Reading halts on it instead of fighting the other client. - Halted, - /// The sentence never began: queued too long, behind other clients' speech, paused before it - /// began (which sends no event), or with a speech module that doesn't start. Reading halts on it. - NotStarted, -} - -/// Where Speech Dispatcher listens. -#[derive(Debug, PartialEq, Eq)] -pub(super) enum Address { - Unix(PathBuf), - /// Host and port. - Inet(String, u16), -} - -/// The server's address, found the way Speech Dispatcher's own client library finds it: -/// `SPEECHD_ADDRESS` when set (`unix_socket[:path]`, `inet_socket[:host[:port]]`), else the socket -/// `speech-dispatcher/speechd.sock` in the runtime folder, else in the cache folder -/// (`XDG_CACHE_HOME`, else `~/.cache`). `None` for an address method it doesn't know, a port that -/// isn't a number, or no folder to look in. -pub(super) fn address(spec: Option<&str>, runtime_dir: Option, cache_home: Option, home: Option) -> Option
{ - let default_socket = || { - let set = |p: Option| p.filter(|p| !p.as_os_str().is_empty()); - set(runtime_dir) - .or_else(|| set(cache_home)) - .or_else(|| set(home).map(|h| h.join(".cache"))) - .map(|d| d.join("speech-dispatcher").join("speechd.sock")) - }; - let Some(spec) = spec.map(str::trim).filter(|a| !a.is_empty()) else { return default_socket().map(Address::Unix) }; - let (method, rest) = spec.split_once(':').unwrap_or((spec, "")); - match method { - "unix_socket" => Some(Address::Unix(if rest.is_empty() { default_socket()? } else { PathBuf::from(rest) })), - "inet_socket" => { - let mut p = rest.split(':'); - let host = p.next().filter(|h| !h.is_empty()).unwrap_or("127.0.0.1"); - let port = p.next().filter(|p| !p.is_empty()).map_or(Some(DEFAULT_PORT), |p| p.parse().ok())?; - Some(Address::Inet(host.to_string(), port)) - } - _ => None, - } -} - -/// Is `host` this machine (the only place a server may be started)? -fn is_local(host: &str) -> bool { - host.eq_ignore_ascii_case("localhost") || host.parse::().is_ok_and(|ip| ip.is_loopback()) -} - -/// A connection to Speech Dispatcher. -pub(super) enum Stream { - Unix(UnixStream), - Tcp(TcpStream), -} - -impl Stream { - fn try_clone(&self) -> std::io::Result { - Ok(match self { - Stream::Unix(s) => Stream::Unix(s.try_clone()?), - Stream::Tcp(s) => Stream::Tcp(s.try_clone()?), - }) - } - - /// Reads give up after [`POLL`] (so a wait can look at whether reading moved on), writes after - /// [`REPLY_TIMEOUT`]. - fn set_timeouts(&self) -> std::io::Result<()> { - match self { - Stream::Unix(s) => { - s.set_read_timeout(Some(POLL))?; - s.set_write_timeout(Some(REPLY_TIMEOUT)) - } - Stream::Tcp(s) => { - s.set_read_timeout(Some(POLL))?; - s.set_write_timeout(Some(REPLY_TIMEOUT)) - } - } - } -} - -impl Read for Stream { - fn read(&mut self, buf: &mut [u8]) -> std::io::Result { - match self { - Stream::Unix(s) => s.read(buf), - Stream::Tcp(s) => s.read(buf), - } - } -} - -impl Write for Stream { - fn write(&mut self, buf: &[u8]) -> std::io::Result { - match self { - Stream::Unix(s) => s.write(buf), - Stream::Tcp(s) => s.write(buf), - } - } - fn flush(&mut self) -> std::io::Result<()> { - match self { - Stream::Unix(s) => s.flush(), - Stream::Tcp(s) => s.flush(), - } - } -} - -/// Connect to `address`; between the addresses a host name resolves to, give up once `changed()`. -fn open(address: &Address, changed: &dyn Fn() -> bool) -> std::io::Result { - match address { - Address::Unix(path) => UnixStream::connect(path).map(Stream::Unix), - Address::Inet(host, port) => { - let mut last = std::io::Error::from(ErrorKind::NotFound); - for a in (host.as_str(), *port).to_socket_addrs()? { - if changed() { - return Err(moved_on()); - } - match TcpStream::connect_timeout(&a, CONNECT_TIMEOUT) { - Ok(s) => return Ok(Stream::Tcp(s)), - Err(e) => last = e, - } - } - Err(last) - } - } -} - -/// How [`connect`] went. -pub(super) enum Connection { - Open(Stream), - /// Speech Dispatcher isn't installed. - Missing, - /// It is installed but can't be reached: why. - Unreachable(String), - /// Reading moved on while the server was starting. - Interrupted, -} - -/// Connect to the user's Speech Dispatcher, starting it once if it isn't running (as `spd-say` -/// does), unless the address is another machine's. -pub(super) fn connect(changed: &dyn Fn() -> bool) -> Connection { - let env = |k: &str| std::env::var_os(k).map(PathBuf::from); - let spec = std::env::var("SPEECHD_ADDRESS").ok().filter(|s| !s.trim().is_empty()); - let Some(address) = address(spec.as_deref(), env("XDG_RUNTIME_DIR"), env("XDG_CACHE_HOME"), env("HOME")) else { - return if spec.is_some() { Connection::Unreachable("SPEECHD_ADDRESS isn't an address it understands".into()) } else { Connection::Missing }; - }; - match open(&address, changed) { - Ok(s) => return Connection::Open(s), - Err(e) if e.kind() == ErrorKind::Interrupted => return Connection::Interrupted, - Err(_) => {} - } - let mut start = std::process::Command::new("speech-dispatcher"); - start.arg("--spawn"); - // A server started for an address of its own must open it there. - match (&address, spec.is_some()) { - (_, false) => {} - (Address::Unix(path), true) => { - start.args(["--communication-method", "unix_socket", "--socket-path"]).arg(path); - } - (Address::Inet(host, port), true) if is_local(host) => { - start.args(["--communication-method", "inet_socket", "--port", &port.to_string()]); - } - (Address::Inet(..), true) => return Connection::Unreachable("nothing answers at SPEECHD_ADDRESS".into()), - } - // `--spawn` exits once it has forked the server; the wait for it is still bounded and gives way - // to Stop, so a start that hangs can't keep the reading thread (and Read Aloud with it) busy. - let until = Instant::now() + START_WAIT; - match start_server(&mut start, until, changed) { - Started::Exited(true) => {} - Started::Exited(false) => { - return Connection::Unreachable("it isn't running and didn't start (its configuration may turn starting on demand off)".into()); - } - Started::Missing => return Connection::Missing, - Started::Failed(e) => return Connection::Unreachable(format!("it couldn't be started: {e}")), - Started::Interrupted => return Connection::Interrupted, - Started::TimedOut => { - return open(&address, changed).map_or_else(|_| Connection::Unreachable("starting it didn't finish in time".into()), Connection::Open); - } - } - // The server opens its socket a moment after it starts. - loop { - if changed() { - return Connection::Interrupted; - } - if let Ok(s) = open(&address, changed) { - return Connection::Open(s); - } - if Instant::now() >= until { - return Connection::Unreachable("it started but doesn't answer".into()); - } - std::thread::sleep(Duration::from_millis(50)); - } -} - -/// How running the server's start command went. -#[derive(Debug)] -enum Started { - /// It exited, successfully or not. - Exited(bool), - /// The command isn't installed. - Missing, - /// It couldn't be run or waited for. - Failed(std::io::Error), - /// Reading moved on before it exited; it was stopped. - Interrupted, - /// It was still running at the deadline; it was stopped. - TimedOut, -} - -/// Run `command` (output discarded) and wait for it to exit, at most until `until` and only while -/// reading stays put; a command still running then is stopped and reaped. -fn start_server(command: &mut std::process::Command, until: Instant, changed: &dyn Fn() -> bool) -> Started { - use std::process::Stdio; - let mut child = match command.stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null()).spawn() { - Ok(child) => child, - Err(e) if e.kind() == ErrorKind::NotFound => return Started::Missing, - Err(e) => return Started::Failed(e), - }; - let stopped = loop { - match child.try_wait() { - Ok(Some(status)) => return Started::Exited(status.success()), - Ok(None) if changed() => break Started::Interrupted, - Ok(None) if Instant::now() >= until => break Started::TimedOut, - Ok(None) => std::thread::sleep(Duration::from_millis(20)), - Err(e) => break Started::Failed(e), - } - }; - let _ = child.kill(); - let _ = child.wait(); - stopped -} - -/// The sentence as SSIP message data: one line (a line break is a space when read aloud anyway), -/// a leading `.` doubled so it can't end the message, and the terminating `.` line. -pub(super) fn message_data(text: &str) -> String { - let line: String = text.chars().map(|c| if c == '\r' || c == '\n' { ' ' } else { c }).collect(); - let dot = if line.starts_with('.') { "." } else { "" }; - format!("{dot}{line}\r\n.\r\n") -} - -/// Reads reply lines. Bytes wait in `pending` until their line is complete, so a character split -/// across reads stays whole, and a line longer than [`MAX_LINE`] is an error, never a growing buffer. -struct Lines { - reader: R, - pending: Vec, -} - -impl Lines { - fn new(reader: R) -> Self { - Lines { reader, pending: Vec::new() } - } - - /// The next complete line without its line ending, or `Ok(None)` when none is complete after - /// one read (a timeout, or part of a line): the caller looks at its deadline and whether reading - /// moved on between calls, however slowly the bytes trickle in. - fn next(&mut self) -> std::io::Result> { - if let Some(line) = self.complete_line() { - return Ok(Some(line)); - } - if self.pending.len() > MAX_LINE { - return Err(ErrorKind::InvalidData.into()); - } - let mut buf = [0u8; 512]; - match self.reader.read(&mut buf) { - Ok(0) => Err(ErrorKind::UnexpectedEof.into()), - Ok(n) => { - self.pending.extend_from_slice(buf.get(..n).unwrap_or_default()); - Ok(self.complete_line()) - } - Err(e) if matches!(e.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut | ErrorKind::Interrupted) => Ok(None), - Err(e) => Err(e), - } - } - - /// The first complete line in `pending`, taken out of it. - fn complete_line(&mut self) -> Option { - let end = self.pending.iter().position(|&b| b == b'\n')?; - let line: Vec = self.pending.drain(..=end).collect(); - Some(String::from_utf8_lossy(&line).trim_end_matches(['\r', '\n']).to_string()) - } -} - -/// The three-digit code of a final reply line (`225 OK MESSAGE QUEUED`); `None` for a -/// continuation line (`225-12`). -fn final_code(line: &str) -> Option { - let code = line.get(..3)?.parse().ok()?; - (line.as_bytes().get(3) == Some(&b' ') || line.len() == 3).then_some(code) -} - -/// A `2xx` reply, or an error naming the code. -fn ok(code: u16) -> std::io::Result<()> { - if (200..300).contains(&code) { Ok(()) } else { Err(std::io::Error::other(format!("Speech Dispatcher refused a command ({code})"))) } -} - -/// The error a conversation stops with when reading moved on. -fn moved_on() -> std::io::Error { - std::io::Error::new(ErrorKind::Interrupted, "reading moved on") -} - -/// The begin (701), end (702), cancel (703) and pause (704) events. -const BEGIN: u16 = 701; -const END: u16 = 702; -const CANCEL: u16 = 703; -const PAUSE: u16 = 704; - -/// One conversation with the server. -struct Conversation<'a, W, R> { - w: W, - lines: Lines, - /// The last end, cancel or pause event, until it's taken. - event: Option, - /// The message just queued began. - begun: bool, - /// Whether reading moved on (checked between lines and at every read timeout). - changed: &'a dyn Fn() -> bool, -} - -impl Conversation<'_, W, R> { - /// The reply to the command just sent: its final code. An end, cancel or pause event that arrives - /// meanwhile is kept. Gives up after [`REPLY_TIMEOUT`], also when the server keeps sending - /// other lines, and as soon as reading moves on. - fn reply(&mut self) -> std::io::Result { - let start = Instant::now(); - loop { - if let Some(line) = self.lines.next()? { - match final_code(&line) { - Some(BEGIN) => self.begun = true, - Some(code @ (END | CANCEL | PAUSE)) => self.event = Some(code), - Some(code) if (700..800).contains(&code) => {} - Some(code) => return Ok(code), - None => {} - } - } - if start.elapsed() > REPLY_TIMEOUT { - return Err(ErrorKind::TimedOut.into()); - } - if (self.changed)() { - return Err(moved_on()); - } - } - } - - /// Send one command line and read its reply code. - fn command(&mut self, cmd: &str) -> std::io::Result { - self.w.write_all(cmd.as_bytes())?; - self.w.write_all(b"\r\n")?; - self.reply() - } - - /// What became of the message just queued: its end, cancel or pause event, or - /// [`Fate::NotStarted`] when it didn't begin within [`QUEUE_WAIT`]. Once it began, its end is - /// waited for until `limit` has passed (a stuck server: an error). An error as soon as reading - /// moves on. - fn fate(&mut self, limit: Duration) -> std::io::Result { - let queued = Instant::now(); - let mut began = self.begun.then(Instant::now); - loop { - match self.event.take() { - Some(END) => return Ok(Fate::End), - Some(CANCEL) => return Ok(Fate::Cancelled), - Some(_) => return Ok(Fate::Paused), - None => {} - } - if (self.changed)() { - return Err(moved_on()); - } - match began { - None if queued.elapsed() > QUEUE_WAIT => return Ok(Fate::NotStarted), - Some(t) if t.elapsed() > limit => return Err(std::io::Error::new(ErrorKind::TimedOut, "the sentence didn't end")), - _ => {} - } - if let Some(line) = self.lines.next()? { - match final_code(&line) { - Some(BEGIN) => { - began.get_or_insert_with(Instant::now); - } - Some(code @ (END | CANCEL | PAUSE)) => self.event = Some(code), - _ => {} - } - } - } - } - - /// Wait `pause`, reading what the server sends meanwhile; an error as soon as reading moves on. - fn wait(&mut self, pause: Duration) -> std::io::Result<()> { - let start = Instant::now(); - while start.elapsed() < pause { - if (self.changed)() { - return Err(moved_on()); - } - self.lines.next()?; - } - Ok(()) - } - - /// Set up the client, say the sentence, and wait for it to end. Only [`converse`] cancels our - /// own speech, and it stops waiting first, so a cancel or pause event comes from another - /// client. A cancel is most often another client's message, a screen reader's announcement: - /// the sentence is sent again after a pause ([`RESEND_AFTER`]), when the announcement has - /// ended or the server holds ours back until it has. Cancelled again after that, paused, or - /// never begun, the sentence halts reading. - fn speak(&mut self, req: &Request<'_>) -> std::io::Result { - ok(self.command("SET SELF CLIENT_NAME user:wordcraft:read-aloud")?)?; - ok(self.command("SET SELF NOTIFICATION BEGIN on")?)?; - ok(self.command("SET SELF NOTIFICATION END on")?)?; - ok(self.command("SET SELF NOTIFICATION CANCEL on")?)?; - ok(self.command("SET SELF NOTIFICATION PAUSE on")?)?; - ok(self.command(&format!("SET SELF RATE {}", req.rate))?)?; - // A language the server doesn't know leaves its default: no error. The tag is checked - // again here, at the edge of the protocol: it goes on a command line. - if let Some(lang) = req.lang.and_then(super::plain_tag) { - self.command(&format!("SET SELF LANGUAGE {lang}"))?; - } - let chars = u32::try_from(req.text.chars().count()).unwrap_or(u32::MAX); - let limit = SENTENCE_BASE.saturating_add(SENTENCE_PER_CHAR.saturating_mul(chars)); - let mut resends = RESEND_AFTER.iter(); - loop { - self.event = None; - self.begun = false; - ok(self.command("SPEAK")?)?; - self.w.write_all(message_data(req.text).as_bytes())?; - ok(self.reply()?)?; - match self.fate(limit)? { - Fate::End => return Ok(Outcome::Done), - Fate::Cancelled => match resends.next() { - Some(pause) => self.wait(*pause)?, - None => return Ok(Outcome::Halted), - }, - Fate::Paused => return Ok(Outcome::Halted), - Fate::NotStarted => return Ok(Outcome::NotStarted), - } - } - } -} - -/// What became of a queued message. -enum Fate { - End, - Cancelled, - Paused, - NotStarted, -} - -/// The last words after a sentence that didn't finish. -const CLOSE: &[u8] = b"\r\n.\r\nCANCEL self\r\nQUIT\r\n"; - -/// Speak `req` over `stream`, cancelling it when `changed()`. -pub(super) fn converse(stream: &Stream, req: &Request<'_>, changed: &dyn Fn() -> bool) -> std::io::Result { - stream.set_timeouts()?; - let mut c = Conversation { w: stream.try_clone()?, lines: Lines::new(stream.try_clone()?), event: None, begun: false, changed }; - let result = c.speak(req); - // The last words wait for no answer: a server that stopped answering mustn't hold up Stop. - // After anything but a finished sentence our message may still be queued, or the server may - // be reading message data: an empty line and `.` end a message being sent (outside one they - // are two refused commands), and `CANCEL self` drops ours. - let done = matches!(result, Ok(Outcome::Done)); - let _ = c.w.write_all(if done { b"QUIT\r\n" } else { CLOSE }); - match result { - Err(e) if e.kind() == ErrorKind::Interrupted => Ok(Outcome::Interrupted), - r => r, - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::io::{BufRead, BufReader}; - - #[test] - fn the_address_follows_the_environment() { - let unix = |p: &str| Some(Address::Unix(PathBuf::from(p))); - let run = || Some(PathBuf::from("/run/user/1")); - assert_eq!(address(Some("unix_socket:/tmp/s.sock"), None, None, None), unix("/tmp/s.sock")); - assert_eq!(address(None, run(), None, None), unix("/run/user/1/speech-dispatcher/speechd.sock")); - // `unix_socket` alone is the default socket, as in Speech Dispatcher's client library. - assert_eq!(address(Some("unix_socket"), run(), None, None), unix("/run/user/1/speech-dispatcher/speechd.sock")); - assert_eq!(address(Some("unix_socket:"), None, None, Some("/home/u".into())), unix("/home/u/.cache/speech-dispatcher/speechd.sock")); - // Without a runtime folder: the cache folder, `XDG_CACHE_HOME` first. - assert_eq!(address(Some(" "), None, None, Some("/home/u".into())), unix("/home/u/.cache/speech-dispatcher/speechd.sock")); - assert_eq!(address(None, Some("".into()), Some("/c".into()), Some("/home/u".into())), unix("/c/speech-dispatcher/speechd.sock")); - // A TCP address, with the default host and port filled in. - assert_eq!(address(Some("inet_socket:localhost:6561"), run(), None, None), Some(Address::Inet("localhost".into(), 6561))); - assert_eq!(address(Some("inet_socket"), None, None, None), Some(Address::Inet("127.0.0.1".into(), DEFAULT_PORT))); - assert_eq!(address(Some("inet_socket:host"), None, None, None), Some(Address::Inet("host".into(), DEFAULT_PORT))); - assert_eq!(address(Some("inet_socket:host:port"), None, None, None), None); - assert_eq!(address(Some("inet_socket:host:99999"), None, None, None), None); - assert_eq!(address(Some("carrier_pigeon:x"), run(), None, None), None); - assert_eq!(address(None, None, None, None), None); - assert!(is_local("localhost") && is_local("127.0.0.1") && is_local("::1")); - assert!(!is_local("example.org") && !is_local("192.0.2.1")); - } - - #[test] - fn document_text_is_always_message_data() { - // `spd-say -e` would run this line as an SSIP command; here it's spoken text. - assert_eq!(message_data("!-!SET SELF RATE 50"), "!-!SET SELF RATE 50\r\n.\r\n"); - // A line of a single `.` would end the message early, and a line break would start a - // new line that could be one. - assert_eq!(message_data(".\r\nQUIT"), ".. QUIT\r\n.\r\n"); - assert_eq!(message_data("a\n.\nb"), "a . b\r\n.\r\n"); - assert_eq!(final_code("225 OK MESSAGE QUEUED"), Some(225)); - assert_eq!(final_code("225-17"), None); - assert_eq!(final_code("x"), None); - } - - /// Hands out the given reads one by one, then reports a timeout. - struct Reads(std::collections::VecDeque>); - - impl Read for Reads { - fn read(&mut self, buf: &mut [u8]) -> std::io::Result { - match self.0.pop_front() { - Some(chunk) if !chunk.is_empty() => { - let n = chunk.len().min(buf.len()); - buf[..n].copy_from_slice(&chunk[..n]); - if n < chunk.len() { - self.0.push_front(chunk[n..].to_vec()); - } - Ok(n) - } - _ => Err(ErrorKind::WouldBlock.into()), - } - } - } - - #[test] - fn a_character_split_by_a_read_timeout_stays_whole() { - // `é` is C3 A9; the read times out (the empty read) between its two bytes. - let mut lines = Lines::new(Reads(vec![b"249-Caf\xC3".to_vec(), Vec::new(), b"\xA9\tfr\tnone\r\n702 END\r\n".to_vec()].into())); - assert_eq!(lines.next().unwrap(), None, "half a line"); - assert_eq!(lines.next().unwrap(), None, "the timeout"); - assert_eq!(lines.next().unwrap().as_deref(), Some("249-Café\tfr\tnone")); - assert_eq!(lines.next().unwrap().as_deref(), Some("702 END")); - assert_eq!(lines.next().unwrap(), None); - } - - #[test] - fn a_line_without_an_end_is_an_error_not_a_growing_buffer() { - // A peer that never stops sending and never ends the line. - let mut lines = Lines::new(std::io::repeat(b'x')); - let mut calls = 0; - let err = loop { - calls += 1; - assert!(calls < 100, "never gave up"); - match lines.next() { - Ok(None) => {} - Ok(Some(l)) => panic!("{l}"), - Err(e) => break e, - } - }; - assert_eq!(err.kind(), ErrorKind::InvalidData); - assert!(lines.pending.len() <= MAX_LINE + 512); - } - - #[test] - fn a_trickle_of_bytes_hands_control_back_after_each_read() { - // One byte per read and never a line end: every call returns, so the caller can look at - // its deadline and at whether reading moved on. - let mut lines = Lines::new(Reads(vec![b"2".to_vec(), b"0".to_vec(), b"0".to_vec(), b" OK\r\n".to_vec()].into())); - assert_eq!(lines.next().unwrap(), None); - assert_eq!(lines.next().unwrap(), None); - assert_eq!(lines.next().unwrap(), None); - assert_eq!(lines.next().unwrap().as_deref(), Some("200 OK")); - } - - /// How the stand-in server ends a message. - #[derive(Clone, Copy)] - enum Ending { - /// The end event. - End, - /// Nothing until `CANCEL self`. - Hold, - /// Cancelled by another client every time. - CancelledByOther, - /// Cancelled by another client the first time only. - CancelledOnce, - /// Queued and never begun (paused before it began: no event at all). - NeverBegins, - /// Paused by another client (`PAUSE all`). - PausedByOther, - } - - /// A Speech Dispatcher stand-in: answers every command with `2xx` and ends each message as - /// `ending` says. Returns the lines it received. - fn server(mut s: UnixStream, ending: Ending) -> std::thread::JoinHandle> { - std::thread::spawn(move || { - let mut got = Vec::new(); - let mut r = BufReader::new(s.try_clone().unwrap()); - let mut in_data = false; - let mut messages = 0; - loop { - let mut line = String::new(); - if r.read_line(&mut line).unwrap_or(0) == 0 { - return got; - } - let line = line.trim_end_matches(['\r', '\n']).to_string(); - got.push(line.clone()); - let reply: &[u8] = if in_data { - if line != "." { - continue; - } - in_data = false; - messages += 1; - match ending { - Ending::CancelledOnce if messages == 1 => { - b"225-1\r\n225 OK MESSAGE QUEUED\r\n701 BEGIN\r\n703-1\r\n703-7\r\n703 CANCELED\r\n" - } - Ending::CancelledOnce => b"225-1\r\n225 OK MESSAGE QUEUED\r\n701 BEGIN\r\n702-1\r\n702-7\r\n702 END\r\n", - Ending::NeverBegins => b"225-1\r\n225 OK MESSAGE QUEUED\r\n", - Ending::CancelledByOther => b"225-1\r\n225 OK MESSAGE QUEUED\r\n701 BEGIN\r\n703-1\r\n703-7\r\n703 CANCELED\r\n", - Ending::PausedByOther => b"225-1\r\n225 OK MESSAGE QUEUED\r\n701 BEGIN\r\n704-1\r\n704-7\r\n704 PAUSED\r\n", - Ending::End => b"225-1\r\n225 OK MESSAGE QUEUED\r\n702-1\r\n702-7\r\n702 END\r\n", - Ending::Hold => b"225-1\r\n225 OK MESSAGE QUEUED\r\n701 BEGIN\r\n", - } - } else if line == "SPEAK" { - in_data = true; - b"230 OK RECEIVING DATA\r\n" - } else if line == "CANCEL self" { - b"703-1\r\n703-7\r\n703 CANCELED\r\n210 OK CANCELLED\r\n" - } else if line == "QUIT" { - let _ = s.write_all(b"231 HAPPY HACKING\r\n"); - return got; - } else { - b"200 OK\r\n" - }; - // The client doesn't wait for the answers to its last words. - let _ = s.write_all(reply); - } - }) - } - - fn speaks(got: &[String]) -> usize { - got.iter().filter(|l| *l == "SPEAK").count() - } - - #[test] - fn a_sentence_is_spoken_to_its_end() { - let (client, srv) = UnixStream::pair().unwrap(); - let h = server(srv, Ending::End); - let req = Request { text: "!-!QUIT", rate: 50, lang: Some("pt-BR") }; - assert_eq!(converse(&Stream::Unix(client), &req, &|| false).unwrap(), Outcome::Done); - let got = h.join().unwrap(); - assert!(got.contains(&"SET SELF RATE 50".to_string()), "{got:?}"); - // The region stays: Speech Dispatcher's voices are per region (`en-GB`, `pt-BR`). - assert!(got.contains(&"SET SELF LANGUAGE pt-BR".to_string()), "{got:?}"); - let at = got.iter().position(|l| l == "SPEAK").unwrap(); - assert_eq!(got[at + 1..at + 3], ["!-!QUIT".to_string(), ".".to_string()], "the text went as data"); - assert_eq!(speaks(&got), 1); - assert!(!got.iter().any(|l| l.starts_with("CANCEL")), "nothing cancelled"); - assert_eq!(got.last().map(String::as_str), Some("QUIT")); - } - - #[test] - fn a_sentence_a_screen_reader_cuts_off_is_said_again_after_a_pause() { - // One announcement: the sentence is said again once it has had time to end. - let (client, srv) = UnixStream::pair().unwrap(); - let h = server(srv, Ending::CancelledOnce); - let req = Request { text: "Interrupted by a screen reader.", rate: 0, lang: None }; - let start = Instant::now(); - assert_eq!(converse(&Stream::Unix(client), &req, &|| false).unwrap(), Outcome::Done); - assert!(start.elapsed() >= RESEND_AFTER[0], "it waited before saying it again"); - let got = h.join().unwrap(); - assert_eq!(speaks(&got), 2, "{got:?}"); - assert!(got.contains(&"SET SELF NOTIFICATION BEGIN on".to_string()), "{got:?}"); - assert!(!got.iter().any(|l| l.starts_with("CANCEL")), "{got:?}"); - } - - #[test] - fn a_sentence_another_client_keeps_stopping_halts_reading() { - // Cancelled every time (another client stopping everything, a failing module): two - // tries more, then reading halts, never moving on silently. A pause halts at once. - for (ending, sent) in [(Ending::CancelledByOther, 1 + RESEND_AFTER.len()), (Ending::PausedByOther, 1)] { - let (client, srv) = UnixStream::pair().unwrap(); - let h = server(srv, ending); - let req = Request { text: "Interrupted by a screen reader.", rate: 0, lang: None }; - assert_eq!(converse(&Stream::Unix(client), &req, &|| false).unwrap(), Outcome::Halted); - let got = h.join().unwrap(); - assert_eq!(speaks(&got), sent, "{got:?}"); - assert!(got.contains(&"SET SELF NOTIFICATION PAUSE on".to_string()), "{got:?}"); - // Only our own message is dropped. - assert!(got.contains(&"CANCEL self".to_string()) && !got.iter().any(|l| l.eq_ignore_ascii_case("CANCEL all")), "{got:?}"); - } - } - - #[test] - fn a_withheld_end_times_out_and_the_next_sentence_plays() { - // The server accepts the sentence, begins it, stays connected and never reports its end: - // the wait gives up with an error, drops the message (`CANCEL self`), and closes. - let (client, srv) = UnixStream::pair().unwrap(); - let h = server(srv, Ending::Hold); - let req = Request { text: "Withheld.", rate: 0, lang: None }; - let start = Instant::now(); - let err = converse(&Stream::Unix(client), &req, &|| false).unwrap_err(); - assert_eq!(err.kind(), ErrorKind::TimedOut, "{err}"); - assert!(start.elapsed() < SENTENCE_BASE + SENTENCE_PER_CHAR * 9 + REPLY_TIMEOUT * 2, "{:?}", start.elapsed()); - let got = h.join().unwrap(); - assert_eq!(speaks(&got), 1); - assert!(got.contains(&"CANCEL self".to_string()) && got.last().map(String::as_str) == Some("QUIT"), "{got:?}"); - // Playback recovers: the next sentence, on a new connection as every sentence gets, - // is spoken to its end. - let (client, srv) = UnixStream::pair().unwrap(); - let h = server(srv, Ending::End); - assert_eq!(converse(&Stream::Unix(client), &Request { text: "Next.", rate: 0, lang: None }, &|| false).unwrap(), Outcome::Done); - assert_eq!(speaks(&h.join().unwrap()), 1); - } - - #[test] - fn a_sentence_that_never_begins_halts_reading_instead_of_failing() { - // `PAUSE all` before our message began sends no event: the time queued has its own - // bound, and reaching it halts reading (Play carries on) rather than stopping with an error. - let (client, srv) = UnixStream::pair().unwrap(); - let h = server(srv, Ending::NeverBegins); - let req = Request { text: "Queued behind another client.", rate: 0, lang: None }; - let start = Instant::now(); - assert_eq!(converse(&Stream::Unix(client), &req, &|| false).unwrap(), Outcome::NotStarted); - assert!(start.elapsed() >= QUEUE_WAIT && start.elapsed() < QUEUE_WAIT + REPLY_TIMEOUT * 2, "{:?}", start.elapsed()); - assert!(h.join().unwrap().contains(&"CANCEL self".to_string())); - } - - #[test] - fn a_sentence_that_never_ends_times_out() { - // A stuck speech module: no end event ever. The wait is bounded by the sentence's length. - let (client, srv) = UnixStream::pair().unwrap(); - let h = server(srv, Ending::Hold); - let req = Request { text: "x", rate: 0, lang: None }; - let start = Instant::now(); - assert_eq!(converse(&Stream::Unix(client), &req, &|| false).unwrap_err().kind(), ErrorKind::TimedOut); - assert!(start.elapsed() < SENTENCE_BASE + SENTENCE_PER_CHAR + REPLY_TIMEOUT * 2, "{:?}", start.elapsed()); - assert!(h.join().unwrap().contains(&"CANCEL self".to_string()), "the stuck message is dropped"); - } - - #[test] - fn moving_on_cancels_only_our_own_speech() { - let (client, srv) = UnixStream::pair().unwrap(); - let h = server(srv, Ending::Hold); - let req = Request { text: "Long sentence.", rate: 0, lang: None }; - let start = Instant::now(); - let changed = || start.elapsed() > Duration::from_millis(300); - assert_eq!(converse(&Stream::Unix(client), &req, &changed).unwrap(), Outcome::Interrupted); - let got = h.join().unwrap(); - assert_eq!(speaks(&got), 1, "{got:?}"); - assert!(got.contains(&"CANCEL self".to_string()), "{got:?}"); - assert!(!got.iter().any(|l| l.eq_ignore_ascii_case("CANCEL all"))); - assert!(!got.iter().any(|l| l.starts_with("SET SELF LANGUAGE")), "no language, no command"); - } - - #[test] - fn moving_on_stops_waiting_for_a_slow_server() { - // A server that reads but never answers: Stop acts at once, not after the reply timeout. - let (client, mut srv) = UnixStream::pair().unwrap(); - let h = std::thread::spawn(move || { - let mut all = String::new(); - let _ = srv.read_to_string(&mut all); - all - }); - let req = Request { text: "x", rate: 0, lang: None }; - let start = Instant::now(); - assert_eq!(converse(&Stream::Unix(client), &req, &|| true).unwrap(), Outcome::Interrupted); - assert!(start.elapsed() < REPLY_TIMEOUT, "{:?}", start.elapsed()); - // Stopped while the server may be about to read message data: the empty line and `.` end - // any message first, so `CANCEL self` is never taken for text. - let all = h.join().unwrap(); - assert!(all.ends_with("\r\n\r\n.\r\nCANCEL self\r\nQUIT\r\n"), "{all:?}"); - } - - #[test] - fn a_server_that_only_sends_events_times_out() { - // Events and continuation lines are no reply: they can't keep a command waiting forever. - let (client, mut srv) = UnixStream::pair().unwrap(); - let h = std::thread::spawn(move || while srv.write_all(b"701 BEGIN\r\n225-1\r\n").is_ok() {}); - let req = Request { text: "x", rate: 0, lang: None }; - let start = Instant::now(); - let client = Stream::Unix(client); - assert_eq!(converse(&client, &req, &|| false).unwrap_err().kind(), ErrorKind::TimedOut); - assert!(start.elapsed() < REPLY_TIMEOUT * 3, "{:?}", start.elapsed()); - drop(client); - h.join().unwrap(); - } - - #[test] - fn a_hostile_language_tag_is_never_sent() { - let (client, srv) = UnixStream::pair().unwrap(); - let h = server(srv, Ending::End); - let req = Request { text: "x", rate: 0, lang: Some("hu\r\nCANCEL all") }; - assert_eq!(converse(&Stream::Unix(client), &req, &|| false).unwrap(), Outcome::Done); - let got = h.join().unwrap(); - assert!(!got.iter().any(|l| l.starts_with("SET SELF LANGUAGE") || l.starts_with("CANCEL")), "{got:?}"); - } - - #[test] - fn a_server_that_hangs_up_is_an_error_not_a_hang() { - let (client, mut srv) = UnixStream::pair().unwrap(); - let h = std::thread::spawn(move || { - let mut buf = [0u8; 64]; - let _ = srv.read(&mut buf); - }); - let req = Request { text: "x", rate: 0, lang: None }; - assert!(converse(&Stream::Unix(client), &req, &|| false).is_err()); - h.join().unwrap(); - } - - #[test] - fn a_start_command_that_exits_reports_how() { - let far = Instant::now() + Duration::from_secs(30); - let run = |script: &str| start_server(std::process::Command::new("sh").args(["-c", script]), far, &|| false); - assert!(matches!(run("exit 0"), Started::Exited(true))); - assert!(matches!(run("exit 1"), Started::Exited(false))); - let missing = start_server(&mut std::process::Command::new("no-such-program-for-wordcraft"), far, &|| false); - assert!(matches!(missing, Started::Missing), "{missing:?}"); - } - - /// A start command that never exits, writing its process id to `pid` first (whole, by renaming). - fn hanging_start(pid: &std::path::Path) -> std::process::Command { - let mut c = std::process::Command::new("sh"); - c.args(["-c", "echo $$ > \"$1.part\" && mv \"$1.part\" \"$1\" && exec sleep 30", "sh"]).arg(pid); - c - } - - /// A fresh path for a start command's process id. - fn pid_file(name: &str) -> std::path::PathBuf { - let p = std::env::temp_dir().join(format!("wordcraft-ssip-{}-{name}.pid", std::process::id())); - let _ = std::fs::remove_file(&p); - let _ = std::fs::remove_file(p.with_extension("pid.part")); - p - } - - /// The process id the start command wrote (whole, or still in its `.part` file when it was - /// stopped before renaming it); `None` when it was stopped before writing any. - fn written_pid(pid: &std::path::Path) -> Option { - let part = pid.with_extension("pid.part"); - let id = std::fs::read_to_string(pid).or_else(|_| std::fs::read_to_string(&part)).ok(); - let _ = std::fs::remove_file(pid); - let _ = std::fs::remove_file(part); - id.map(|id| id.trim().to_string()).filter(|id| !id.is_empty()) - } - - /// Is process `id` gone: reaped, not merely killed and left a zombie (which `kill -0` still - /// finds)? - fn gone(id: &str) -> bool { - let probe = std::process::Command::new("sh").args(["-c", "kill -0 \"$1\" 2>/dev/null", "sh", id]).status().unwrap(); - !probe.success() - } - - #[test] - fn a_start_command_that_hangs_is_stopped_at_the_deadline() { - // A start stopped before the stand-in wrote its id is tried again with a longer deadline - // (a loaded machine may not run it at once); one that never wrote it left nothing to reap. - for deadline in [Duration::from_secs(1), Duration::from_secs(3), Duration::from_secs(10)] { - let pid = pid_file("deadline"); - let begun = Instant::now(); - let got = start_server(&mut hanging_start(&pid), begun + deadline, &|| false); - assert!(matches!(got, Started::TimedOut), "{got:?}"); - assert!(begun.elapsed() < deadline + Duration::from_secs(4), "{:?}", begun.elapsed()); - if let Some(id) = written_pid(&pid) { - assert!(gone(&id), "the start command was reaped"); - return; - } - } - } - - #[test] - fn stop_ends_the_wait_for_a_start_command_at_once() { - let pid = pid_file("stop"); - let begun = Instant::now(); - // Stop once the command is surely running. - let got = start_server(&mut hanging_start(&pid), begun + Duration::from_secs(30), &|| pid.exists()); - assert!(matches!(got, Started::Interrupted), "{got:?}"); - assert!(begun.elapsed() < Duration::from_secs(5), "{:?}", begun.elapsed()); - let id = written_pid(&pid).expect("stopped only once the id was written"); - assert!(gone(&id), "the start command was reaped"); - } -} +//! Speech Dispatcher through its own protocol (SSIP) on the user's socket, for systems that have +//! Speech Dispatcher but not the `espeak-ng` command. +//! +//! Talking SSIP directly, instead of starting `spd-say`, keeps two promises `spd-say` can't: the +//! sentence is always data (pipe mode runs a line starting with `!-!` as an SSIP command, and a +//! document must never send commands), and stopping cancels only WordCraft's own speech +//! (`CANCEL self`), where `spd-say -C` would silence every client, a screen reader included. +//! +//! One connection speaks one sentence: it names the client, asks for the begin, end, cancel and +//! pause events, sets the rate and language, sends the text, and waits for the sentence's end +//! event. A sentence another client's message cancels (a screen reader's announcement) is sent +//! again after a pause, twice at most; a sentence cancelled once more or paused by another +//! client halts reading on it ([`Outcome::Halted`]), and so does one that never began +//! ([`Outcome::NotStarted`]). + +use std::io::{ErrorKind, Read, Write}; +use std::net::{TcpStream, ToSocketAddrs}; +use std::os::unix::net::UnixStream; +use std::path::PathBuf; +use std::time::{Duration, Instant}; + +/// How often a wait for the end event looks at whether reading moved on. +const POLL: Duration = Duration::from_millis(30); +/// Longest wait for a reply to a command (the server answers at once; this only bounds a stuck one). +const REPLY_TIMEOUT: Duration = if cfg!(test) { Duration::from_secs(1) } else { Duration::from_secs(5) }; +/// Longest reply line kept (a server sends short lines; this bounds a broken one). +const MAX_LINE: usize = 4096; +/// Speech Dispatcher's TCP port when `SPEECHD_ADDRESS` names none. +const DEFAULT_PORT: u16 = 6560; +/// How long a freshly started server gets to open its socket (it loads its speech modules first). +const START_WAIT: Duration = Duration::from_secs(5); +/// Longest wait for a TCP connection to one address. +const CONNECT_TIMEOUT: Duration = Duration::from_secs(1); +/// The end of a sentence is awaited this long after it began, plus [`SENTENCE_PER_CHAR`] for each +/// character: far more than the slowest reading takes, so only a stuck server reaches it. +const SENTENCE_BASE: Duration = if cfg!(test) { Duration::from_secs(1) } else { Duration::from_secs(30) }; +const SENTENCE_PER_CHAR: Duration = Duration::from_millis(400); +/// Longest wait for a queued sentence to begin (behind other clients' speech, or paused before it +/// began, which sends no pause event). +const QUEUE_WAIT: Duration = if cfg!(test) { Duration::from_secs(1) } else { Duration::from_secs(120) }; +/// The pauses before a sentence another client cancelled is sent again: the other client's +/// message gets time to end, and the server holds ours back while a higher-priority one speaks. +const RESEND_AFTER: [Duration; 2] = + if cfg!(test) { [Duration::from_millis(50), Duration::from_millis(50)] } else { [Duration::from_secs(1), Duration::from_secs(3)] }; + +/// What to say and how. +pub(super) struct Request<'a> { + pub text: &'a str, + /// Speech Dispatcher's rate, -100 to 100. + pub rate: i32, + /// A plain language tag (`hu`, `pt-BR`); anything else is left out. + pub lang: Option<&'a str>, +} + +/// How a sentence ended. +#[derive(Debug, PartialEq, Eq)] +pub(super) enum Outcome { + /// Spoken to the end. + Done, + /// Reading moved on: the sentence was cancelled. + Interrupted, + /// Another client stopped it: cancelled it again after it was sent again, or paused it. + /// Reading halts on it instead of fighting the other client. + Halted, + /// The sentence never began: queued too long, behind other clients' speech, paused before it + /// began (which sends no event), or with a speech module that doesn't start. Reading halts on it. + NotStarted, +} + +/// Where Speech Dispatcher listens. +#[derive(Debug, PartialEq, Eq)] +pub(super) enum Address { + Unix(PathBuf), + /// Host and port. + Inet(String, u16), +} + +/// The server's address, found the way Speech Dispatcher's own client library finds it: +/// `SPEECHD_ADDRESS` when set (`unix_socket[:path]`, `inet_socket[:host[:port]]`), else the socket +/// `speech-dispatcher/speechd.sock` in the runtime folder, else in the cache folder +/// (`XDG_CACHE_HOME`, else `~/.cache`). `None` for an address method it doesn't know, a port that +/// isn't a number, or no folder to look in. +pub(super) fn address(spec: Option<&str>, runtime_dir: Option, cache_home: Option, home: Option) -> Option
{ + let default_socket = || { + let set = |p: Option| p.filter(|p| !p.as_os_str().is_empty()); + set(runtime_dir) + .or_else(|| set(cache_home)) + .or_else(|| set(home).map(|h| h.join(".cache"))) + .map(|d| d.join("speech-dispatcher").join("speechd.sock")) + }; + let Some(spec) = spec.map(str::trim).filter(|a| !a.is_empty()) else { return default_socket().map(Address::Unix) }; + let (method, rest) = spec.split_once(':').unwrap_or((spec, "")); + match method { + "unix_socket" => Some(Address::Unix(if rest.is_empty() { default_socket()? } else { PathBuf::from(rest) })), + "inet_socket" => { + let mut p = rest.split(':'); + let host = p.next().filter(|h| !h.is_empty()).unwrap_or("127.0.0.1"); + let port = p.next().filter(|p| !p.is_empty()).map_or(Some(DEFAULT_PORT), |p| p.parse().ok())?; + Some(Address::Inet(host.to_string(), port)) + } + _ => None, + } +} + +/// Is `host` this machine (the only place a server may be started)? +fn is_local(host: &str) -> bool { + host.eq_ignore_ascii_case("localhost") || host.parse::().is_ok_and(|ip| ip.is_loopback()) +} + +/// A connection to Speech Dispatcher. +pub(super) enum Stream { + Unix(UnixStream), + Tcp(TcpStream), +} + +impl Stream { + fn try_clone(&self) -> std::io::Result { + Ok(match self { + Stream::Unix(s) => Stream::Unix(s.try_clone()?), + Stream::Tcp(s) => Stream::Tcp(s.try_clone()?), + }) + } + + /// Reads give up after [`POLL`] (so a wait can look at whether reading moved on), writes after + /// [`REPLY_TIMEOUT`]. + fn set_timeouts(&self) -> std::io::Result<()> { + match self { + Stream::Unix(s) => { + s.set_read_timeout(Some(POLL))?; + s.set_write_timeout(Some(REPLY_TIMEOUT)) + } + Stream::Tcp(s) => { + s.set_read_timeout(Some(POLL))?; + s.set_write_timeout(Some(REPLY_TIMEOUT)) + } + } + } +} + +impl Read for Stream { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + match self { + Stream::Unix(s) => s.read(buf), + Stream::Tcp(s) => s.read(buf), + } + } +} + +impl Write for Stream { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + match self { + Stream::Unix(s) => s.write(buf), + Stream::Tcp(s) => s.write(buf), + } + } + fn flush(&mut self) -> std::io::Result<()> { + match self { + Stream::Unix(s) => s.flush(), + Stream::Tcp(s) => s.flush(), + } + } +} + +/// Connect to `address`; between the addresses a host name resolves to, give up once `changed()`. +fn open(address: &Address, changed: &dyn Fn() -> bool) -> std::io::Result { + match address { + Address::Unix(path) => UnixStream::connect(path).map(Stream::Unix), + Address::Inet(host, port) => { + let mut last = std::io::Error::from(ErrorKind::NotFound); + for a in (host.as_str(), *port).to_socket_addrs()? { + if changed() { + return Err(moved_on()); + } + match TcpStream::connect_timeout(&a, CONNECT_TIMEOUT) { + Ok(s) => return Ok(Stream::Tcp(s)), + Err(e) => last = e, + } + } + Err(last) + } + } +} + +/// How [`connect`] went. +pub(super) enum Connection { + Open(Stream), + /// Speech Dispatcher isn't installed. + Missing, + /// It is installed but can't be reached: why. + Unreachable(String), + /// Reading moved on while the server was starting. + Interrupted, +} + +/// Connect to the user's Speech Dispatcher, starting it once if it isn't running (as `spd-say` +/// does), unless the address is another machine's. +pub(super) fn connect(changed: &dyn Fn() -> bool) -> Connection { + let env = |k: &str| std::env::var_os(k).map(PathBuf::from); + let spec = std::env::var("SPEECHD_ADDRESS").ok().filter(|s| !s.trim().is_empty()); + let Some(address) = address(spec.as_deref(), env("XDG_RUNTIME_DIR"), env("XDG_CACHE_HOME"), env("HOME")) else { + return if spec.is_some() { Connection::Unreachable("SPEECHD_ADDRESS isn't an address it understands".into()) } else { Connection::Missing }; + }; + match open(&address, changed) { + Ok(s) => return Connection::Open(s), + Err(e) if e.kind() == ErrorKind::Interrupted => return Connection::Interrupted, + Err(_) => {} + } + let mut start = std::process::Command::new("speech-dispatcher"); + start.arg("--spawn"); + // A server started for an address of its own must open it there. + match (&address, spec.is_some()) { + (_, false) => {} + (Address::Unix(path), true) => { + start.args(["--communication-method", "unix_socket", "--socket-path"]).arg(path); + } + (Address::Inet(host, port), true) if is_local(host) => { + start.args(["--communication-method", "inet_socket", "--port", &port.to_string()]); + } + (Address::Inet(..), true) => return Connection::Unreachable("nothing answers at SPEECHD_ADDRESS".into()), + } + // `--spawn` exits once it has forked the server; the wait for it is still bounded and gives way + // to Stop, so a start that hangs can't keep the reading thread (and Read Aloud with it) busy. + let until = Instant::now() + START_WAIT; + match start_server(&mut start, until, changed) { + Started::Exited(true) => {} + Started::Exited(false) => { + return Connection::Unreachable("it isn't running and didn't start (its configuration may turn starting on demand off)".into()); + } + Started::Missing => return Connection::Missing, + Started::Failed(e) => return Connection::Unreachable(format!("it couldn't be started: {e}")), + Started::Interrupted => return Connection::Interrupted, + Started::TimedOut => { + return open(&address, changed).map_or_else(|_| Connection::Unreachable("starting it didn't finish in time".into()), Connection::Open); + } + } + // The server opens its socket a moment after it starts. + loop { + if changed() { + return Connection::Interrupted; + } + if let Ok(s) = open(&address, changed) { + return Connection::Open(s); + } + if Instant::now() >= until { + return Connection::Unreachable("it started but doesn't answer".into()); + } + std::thread::sleep(Duration::from_millis(50)); + } +} + +/// How running the server's start command went. +#[derive(Debug)] +enum Started { + /// It exited, successfully or not. + Exited(bool), + /// The command isn't installed. + Missing, + /// It couldn't be run or waited for. + Failed(std::io::Error), + /// Reading moved on before it exited; it was stopped. + Interrupted, + /// It was still running at the deadline; it was stopped. + TimedOut, +} + +/// Run `command` (output discarded) and wait for it to exit, at most until `until` and only while +/// reading stays put; a command still running then is stopped and reaped. +fn start_server(command: &mut std::process::Command, until: Instant, changed: &dyn Fn() -> bool) -> Started { + use std::process::Stdio; + let mut child = match command.stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null()).spawn() { + Ok(child) => child, + Err(e) if e.kind() == ErrorKind::NotFound => return Started::Missing, + Err(e) => return Started::Failed(e), + }; + let stopped = loop { + match child.try_wait() { + Ok(Some(status)) => return Started::Exited(status.success()), + Ok(None) if changed() => break Started::Interrupted, + Ok(None) if Instant::now() >= until => break Started::TimedOut, + Ok(None) => std::thread::sleep(Duration::from_millis(20)), + Err(e) => break Started::Failed(e), + } + }; + let _ = child.kill(); + let _ = child.wait(); + stopped +} + +/// The sentence as SSIP message data: one line (a line break is a space when read aloud anyway), +/// a leading `.` doubled so it can't end the message, and the terminating `.` line. +pub(super) fn message_data(text: &str) -> String { + let line: String = text.chars().map(|c| if c == '\r' || c == '\n' { ' ' } else { c }).collect(); + let dot = if line.starts_with('.') { "." } else { "" }; + format!("{dot}{line}\r\n.\r\n") +} + +/// Reads reply lines. Bytes wait in `pending` until their line is complete, so a character split +/// across reads stays whole, and a line longer than [`MAX_LINE`] is an error, never a growing buffer. +struct Lines { + reader: R, + pending: Vec, +} + +impl Lines { + fn new(reader: R) -> Self { + Lines { reader, pending: Vec::new() } + } + + /// The next complete line without its line ending, or `Ok(None)` when none is complete after + /// one read (a timeout, or part of a line): the caller looks at its deadline and whether reading + /// moved on between calls, however slowly the bytes trickle in. + fn next(&mut self) -> std::io::Result> { + if let Some(line) = self.complete_line() { + return Ok(Some(line)); + } + if self.pending.len() > MAX_LINE { + return Err(ErrorKind::InvalidData.into()); + } + let mut buf = [0u8; 512]; + match self.reader.read(&mut buf) { + Ok(0) => Err(ErrorKind::UnexpectedEof.into()), + Ok(n) => { + self.pending.extend_from_slice(buf.get(..n).unwrap_or_default()); + Ok(self.complete_line()) + } + Err(e) if matches!(e.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut | ErrorKind::Interrupted) => Ok(None), + Err(e) => Err(e), + } + } + + /// The first complete line in `pending`, taken out of it. + fn complete_line(&mut self) -> Option { + let end = self.pending.iter().position(|&b| b == b'\n')?; + let line: Vec = self.pending.drain(..=end).collect(); + Some(String::from_utf8_lossy(&line).trim_end_matches(['\r', '\n']).to_string()) + } +} + +/// The three-digit code of a final reply line (`225 OK MESSAGE QUEUED`); `None` for a +/// continuation line (`225-12`). +fn final_code(line: &str) -> Option { + let code = line.get(..3)?.parse().ok()?; + (line.as_bytes().get(3) == Some(&b' ') || line.len() == 3).then_some(code) +} + +/// A `2xx` reply, or an error naming the code. +fn ok(code: u16) -> std::io::Result<()> { + if (200..300).contains(&code) { Ok(()) } else { Err(std::io::Error::other(format!("Speech Dispatcher refused a command ({code})"))) } +} + +/// The error a conversation stops with when reading moved on. +fn moved_on() -> std::io::Error { + std::io::Error::new(ErrorKind::Interrupted, "reading moved on") +} + +/// The begin (701), end (702), cancel (703) and pause (704) events. +const BEGIN: u16 = 701; +const END: u16 = 702; +const CANCEL: u16 = 703; +const PAUSE: u16 = 704; + +/// One conversation with the server. +struct Conversation<'a, W, R> { + w: W, + lines: Lines, + /// The last end, cancel or pause event, until it's taken. + event: Option, + /// The message just queued began. + begun: bool, + /// Whether reading moved on (checked between lines and at every read timeout). + changed: &'a dyn Fn() -> bool, +} + +impl Conversation<'_, W, R> { + /// The reply to the command just sent: its final code. An end, cancel or pause event that arrives + /// meanwhile is kept. Gives up after [`REPLY_TIMEOUT`], also when the server keeps sending + /// other lines, and as soon as reading moves on. + fn reply(&mut self) -> std::io::Result { + let start = Instant::now(); + loop { + if let Some(line) = self.lines.next()? { + match final_code(&line) { + Some(BEGIN) => self.begun = true, + Some(code @ (END | CANCEL | PAUSE)) => self.event = Some(code), + Some(code) if (700..800).contains(&code) => {} + Some(code) => return Ok(code), + None => {} + } + } + if start.elapsed() > REPLY_TIMEOUT { + return Err(ErrorKind::TimedOut.into()); + } + if (self.changed)() { + return Err(moved_on()); + } + } + } + + /// Send one command line and read its reply code. + fn command(&mut self, cmd: &str) -> std::io::Result { + self.w.write_all(cmd.as_bytes())?; + self.w.write_all(b"\r\n")?; + self.reply() + } + + /// What became of the message just queued: its end, cancel or pause event, or + /// [`Fate::NotStarted`] when it didn't begin within [`QUEUE_WAIT`]. Once it began, its end is + /// waited for until `limit` has passed (a stuck server: an error). An error as soon as reading + /// moves on. + fn fate(&mut self, limit: Duration) -> std::io::Result { + let queued = Instant::now(); + let mut began = self.begun.then(Instant::now); + loop { + match self.event.take() { + Some(END) => return Ok(Fate::End), + Some(CANCEL) => return Ok(Fate::Cancelled), + Some(_) => return Ok(Fate::Paused), + None => {} + } + if (self.changed)() { + return Err(moved_on()); + } + match began { + None if queued.elapsed() > QUEUE_WAIT => return Ok(Fate::NotStarted), + Some(t) if t.elapsed() > limit => return Err(std::io::Error::new(ErrorKind::TimedOut, "the sentence didn't end")), + _ => {} + } + if let Some(line) = self.lines.next()? { + match final_code(&line) { + Some(BEGIN) => { + began.get_or_insert_with(Instant::now); + } + Some(code @ (END | CANCEL | PAUSE)) => self.event = Some(code), + _ => {} + } + } + } + } + + /// Wait `pause`, reading what the server sends meanwhile; an error as soon as reading moves on. + fn wait(&mut self, pause: Duration) -> std::io::Result<()> { + let start = Instant::now(); + while start.elapsed() < pause { + if (self.changed)() { + return Err(moved_on()); + } + self.lines.next()?; + } + Ok(()) + } + + /// Set up the client, say the sentence, and wait for it to end. Only [`converse`] cancels our + /// own speech, and it stops waiting first, so a cancel or pause event comes from another + /// client. A cancel is most often another client's message, a screen reader's announcement: + /// the sentence is sent again after a pause ([`RESEND_AFTER`]), when the announcement has + /// ended or the server holds ours back until it has. Cancelled again after that, paused, or + /// never begun, the sentence halts reading. + fn speak(&mut self, req: &Request<'_>) -> std::io::Result { + ok(self.command("SET SELF CLIENT_NAME user:wordcraft:read-aloud")?)?; + ok(self.command("SET SELF NOTIFICATION BEGIN on")?)?; + ok(self.command("SET SELF NOTIFICATION END on")?)?; + ok(self.command("SET SELF NOTIFICATION CANCEL on")?)?; + ok(self.command("SET SELF NOTIFICATION PAUSE on")?)?; + ok(self.command(&format!("SET SELF RATE {}", req.rate))?)?; + // A language the server doesn't know leaves its default: no error. The tag is checked + // again here, at the edge of the protocol: it goes on a command line. + if let Some(lang) = req.lang.and_then(super::plain_tag) { + self.command(&format!("SET SELF LANGUAGE {lang}"))?; + } + let chars = u32::try_from(req.text.chars().count()).unwrap_or(u32::MAX); + let limit = SENTENCE_BASE.saturating_add(SENTENCE_PER_CHAR.saturating_mul(chars)); + let mut resends = RESEND_AFTER.iter(); + loop { + self.event = None; + self.begun = false; + ok(self.command("SPEAK")?)?; + self.w.write_all(message_data(req.text).as_bytes())?; + ok(self.reply()?)?; + match self.fate(limit)? { + Fate::End => return Ok(Outcome::Done), + Fate::Cancelled => match resends.next() { + Some(pause) => self.wait(*pause)?, + None => return Ok(Outcome::Halted), + }, + Fate::Paused => return Ok(Outcome::Halted), + Fate::NotStarted => return Ok(Outcome::NotStarted), + } + } + } +} + +/// What became of a queued message. +enum Fate { + End, + Cancelled, + Paused, + NotStarted, +} + +/// The last words after a sentence that didn't finish. +const CLOSE: &[u8] = b"\r\n.\r\nCANCEL self\r\nQUIT\r\n"; + +/// Speak `req` over `stream`, cancelling it when `changed()`. +pub(super) fn converse(stream: &Stream, req: &Request<'_>, changed: &dyn Fn() -> bool) -> std::io::Result { + stream.set_timeouts()?; + let mut c = Conversation { w: stream.try_clone()?, lines: Lines::new(stream.try_clone()?), event: None, begun: false, changed }; + let result = c.speak(req); + // The last words wait for no answer: a server that stopped answering mustn't hold up Stop. + // After anything but a finished sentence our message may still be queued, or the server may + // be reading message data: an empty line and `.` end a message being sent (outside one they + // are two refused commands), and `CANCEL self` drops ours. + let done = matches!(result, Ok(Outcome::Done)); + let _ = c.w.write_all(if done { b"QUIT\r\n" } else { CLOSE }); + match result { + Err(e) if e.kind() == ErrorKind::Interrupted => Ok(Outcome::Interrupted), + r => r, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::{BufRead, BufReader}; + + #[test] + fn the_address_follows_the_environment() { + let unix = |p: &str| Some(Address::Unix(PathBuf::from(p))); + let run = || Some(PathBuf::from("/run/user/1")); + assert_eq!(address(Some("unix_socket:/tmp/s.sock"), None, None, None), unix("/tmp/s.sock")); + assert_eq!(address(None, run(), None, None), unix("/run/user/1/speech-dispatcher/speechd.sock")); + // `unix_socket` alone is the default socket, as in Speech Dispatcher's client library. + assert_eq!(address(Some("unix_socket"), run(), None, None), unix("/run/user/1/speech-dispatcher/speechd.sock")); + assert_eq!(address(Some("unix_socket:"), None, None, Some("/home/u".into())), unix("/home/u/.cache/speech-dispatcher/speechd.sock")); + // Without a runtime folder: the cache folder, `XDG_CACHE_HOME` first. + assert_eq!(address(Some(" "), None, None, Some("/home/u".into())), unix("/home/u/.cache/speech-dispatcher/speechd.sock")); + assert_eq!(address(None, Some("".into()), Some("/c".into()), Some("/home/u".into())), unix("/c/speech-dispatcher/speechd.sock")); + // A TCP address, with the default host and port filled in. + assert_eq!(address(Some("inet_socket:localhost:6561"), run(), None, None), Some(Address::Inet("localhost".into(), 6561))); + assert_eq!(address(Some("inet_socket"), None, None, None), Some(Address::Inet("127.0.0.1".into(), DEFAULT_PORT))); + assert_eq!(address(Some("inet_socket:host"), None, None, None), Some(Address::Inet("host".into(), DEFAULT_PORT))); + assert_eq!(address(Some("inet_socket:host:port"), None, None, None), None); + assert_eq!(address(Some("inet_socket:host:99999"), None, None, None), None); + assert_eq!(address(Some("carrier_pigeon:x"), run(), None, None), None); + assert_eq!(address(None, None, None, None), None); + assert!(is_local("localhost") && is_local("127.0.0.1") && is_local("::1")); + assert!(!is_local("example.org") && !is_local("192.0.2.1")); + } + + #[test] + fn document_text_is_always_message_data() { + // `spd-say -e` would run this line as an SSIP command; here it's spoken text. + assert_eq!(message_data("!-!SET SELF RATE 50"), "!-!SET SELF RATE 50\r\n.\r\n"); + // A line of a single `.` would end the message early, and a line break would start a + // new line that could be one. + assert_eq!(message_data(".\r\nQUIT"), ".. QUIT\r\n.\r\n"); + assert_eq!(message_data("a\n.\nb"), "a . b\r\n.\r\n"); + assert_eq!(final_code("225 OK MESSAGE QUEUED"), Some(225)); + assert_eq!(final_code("225-17"), None); + assert_eq!(final_code("x"), None); + } + + /// Hands out the given reads one by one, then reports a timeout. + struct Reads(std::collections::VecDeque>); + + impl Read for Reads { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + match self.0.pop_front() { + Some(chunk) if !chunk.is_empty() => { + let n = chunk.len().min(buf.len()); + buf[..n].copy_from_slice(&chunk[..n]); + if n < chunk.len() { + self.0.push_front(chunk[n..].to_vec()); + } + Ok(n) + } + _ => Err(ErrorKind::WouldBlock.into()), + } + } + } + + #[test] + fn a_character_split_by_a_read_timeout_stays_whole() { + // `é` is C3 A9; the read times out (the empty read) between its two bytes. + let mut lines = Lines::new(Reads(vec![b"249-Caf\xC3".to_vec(), Vec::new(), b"\xA9\tfr\tnone\r\n702 END\r\n".to_vec()].into())); + assert_eq!(lines.next().unwrap(), None, "half a line"); + assert_eq!(lines.next().unwrap(), None, "the timeout"); + assert_eq!(lines.next().unwrap().as_deref(), Some("249-Café\tfr\tnone")); + assert_eq!(lines.next().unwrap().as_deref(), Some("702 END")); + assert_eq!(lines.next().unwrap(), None); + } + + #[test] + fn a_line_without_an_end_is_an_error_not_a_growing_buffer() { + // A peer that never stops sending and never ends the line. + let mut lines = Lines::new(std::io::repeat(b'x')); + let mut calls = 0; + let err = loop { + calls += 1; + assert!(calls < 100, "never gave up"); + match lines.next() { + Ok(None) => {} + Ok(Some(l)) => panic!("{l}"), + Err(e) => break e, + } + }; + assert_eq!(err.kind(), ErrorKind::InvalidData); + assert!(lines.pending.len() <= MAX_LINE + 512); + } + + #[test] + fn a_trickle_of_bytes_hands_control_back_after_each_read() { + // One byte per read and never a line end: every call returns, so the caller can look at + // its deadline and at whether reading moved on. + let mut lines = Lines::new(Reads(vec![b"2".to_vec(), b"0".to_vec(), b"0".to_vec(), b" OK\r\n".to_vec()].into())); + assert_eq!(lines.next().unwrap(), None); + assert_eq!(lines.next().unwrap(), None); + assert_eq!(lines.next().unwrap(), None); + assert_eq!(lines.next().unwrap().as_deref(), Some("200 OK")); + } + + /// How the stand-in server ends a message. + #[derive(Clone, Copy)] + enum Ending { + /// The end event. + End, + /// Nothing until `CANCEL self`. + Hold, + /// Cancelled by another client every time. + CancelledByOther, + /// Cancelled by another client the first time only. + CancelledOnce, + /// Queued and never begun (paused before it began: no event at all). + NeverBegins, + /// Paused by another client (`PAUSE all`). + PausedByOther, + } + + /// A Speech Dispatcher stand-in: answers every command with `2xx` and ends each message as + /// `ending` says. Returns the lines it received. + fn server(mut s: UnixStream, ending: Ending) -> std::thread::JoinHandle> { + std::thread::spawn(move || { + let mut got = Vec::new(); + let mut r = BufReader::new(s.try_clone().unwrap()); + let mut in_data = false; + let mut messages = 0; + loop { + let mut line = String::new(); + if r.read_line(&mut line).unwrap_or(0) == 0 { + return got; + } + let line = line.trim_end_matches(['\r', '\n']).to_string(); + got.push(line.clone()); + let reply: &[u8] = if in_data { + if line != "." { + continue; + } + in_data = false; + messages += 1; + match ending { + Ending::CancelledOnce if messages == 1 => { + b"225-1\r\n225 OK MESSAGE QUEUED\r\n701 BEGIN\r\n703-1\r\n703-7\r\n703 CANCELED\r\n" + } + Ending::CancelledOnce => b"225-1\r\n225 OK MESSAGE QUEUED\r\n701 BEGIN\r\n702-1\r\n702-7\r\n702 END\r\n", + Ending::NeverBegins => b"225-1\r\n225 OK MESSAGE QUEUED\r\n", + Ending::CancelledByOther => b"225-1\r\n225 OK MESSAGE QUEUED\r\n701 BEGIN\r\n703-1\r\n703-7\r\n703 CANCELED\r\n", + Ending::PausedByOther => b"225-1\r\n225 OK MESSAGE QUEUED\r\n701 BEGIN\r\n704-1\r\n704-7\r\n704 PAUSED\r\n", + Ending::End => b"225-1\r\n225 OK MESSAGE QUEUED\r\n702-1\r\n702-7\r\n702 END\r\n", + Ending::Hold => b"225-1\r\n225 OK MESSAGE QUEUED\r\n701 BEGIN\r\n", + } + } else if line == "SPEAK" { + in_data = true; + b"230 OK RECEIVING DATA\r\n" + } else if line == "CANCEL self" { + b"703-1\r\n703-7\r\n703 CANCELED\r\n210 OK CANCELLED\r\n" + } else if line == "QUIT" { + let _ = s.write_all(b"231 HAPPY HACKING\r\n"); + return got; + } else { + b"200 OK\r\n" + }; + // The client doesn't wait for the answers to its last words. + let _ = s.write_all(reply); + } + }) + } + + fn speaks(got: &[String]) -> usize { + got.iter().filter(|l| *l == "SPEAK").count() + } + + #[test] + fn a_sentence_is_spoken_to_its_end() { + let (client, srv) = UnixStream::pair().unwrap(); + let h = server(srv, Ending::End); + let req = Request { text: "!-!QUIT", rate: 50, lang: Some("pt-BR") }; + assert_eq!(converse(&Stream::Unix(client), &req, &|| false).unwrap(), Outcome::Done); + let got = h.join().unwrap(); + assert!(got.contains(&"SET SELF RATE 50".to_string()), "{got:?}"); + // The region stays: Speech Dispatcher's voices are per region (`en-GB`, `pt-BR`). + assert!(got.contains(&"SET SELF LANGUAGE pt-BR".to_string()), "{got:?}"); + let at = got.iter().position(|l| l == "SPEAK").unwrap(); + assert_eq!(got[at + 1..at + 3], ["!-!QUIT".to_string(), ".".to_string()], "the text went as data"); + assert_eq!(speaks(&got), 1); + assert!(!got.iter().any(|l| l.starts_with("CANCEL")), "nothing cancelled"); + assert_eq!(got.last().map(String::as_str), Some("QUIT")); + } + + #[test] + fn a_sentence_a_screen_reader_cuts_off_is_said_again_after_a_pause() { + // One announcement: the sentence is said again once it has had time to end. + let (client, srv) = UnixStream::pair().unwrap(); + let h = server(srv, Ending::CancelledOnce); + let req = Request { text: "Interrupted by a screen reader.", rate: 0, lang: None }; + let start = Instant::now(); + assert_eq!(converse(&Stream::Unix(client), &req, &|| false).unwrap(), Outcome::Done); + assert!(start.elapsed() >= RESEND_AFTER[0], "it waited before saying it again"); + let got = h.join().unwrap(); + assert_eq!(speaks(&got), 2, "{got:?}"); + assert!(got.contains(&"SET SELF NOTIFICATION BEGIN on".to_string()), "{got:?}"); + assert!(!got.iter().any(|l| l.starts_with("CANCEL")), "{got:?}"); + } + + #[test] + fn a_sentence_another_client_keeps_stopping_halts_reading() { + // Cancelled every time (another client stopping everything, a failing module): two + // tries more, then reading halts, never moving on silently. A pause halts at once. + for (ending, sent) in [(Ending::CancelledByOther, 1 + RESEND_AFTER.len()), (Ending::PausedByOther, 1)] { + let (client, srv) = UnixStream::pair().unwrap(); + let h = server(srv, ending); + let req = Request { text: "Interrupted by a screen reader.", rate: 0, lang: None }; + assert_eq!(converse(&Stream::Unix(client), &req, &|| false).unwrap(), Outcome::Halted); + let got = h.join().unwrap(); + assert_eq!(speaks(&got), sent, "{got:?}"); + assert!(got.contains(&"SET SELF NOTIFICATION PAUSE on".to_string()), "{got:?}"); + // Only our own message is dropped. + assert!(got.contains(&"CANCEL self".to_string()) && !got.iter().any(|l| l.eq_ignore_ascii_case("CANCEL all")), "{got:?}"); + } + } + + #[test] + fn a_withheld_end_times_out_and_the_next_sentence_plays() { + // The server accepts the sentence, begins it, stays connected and never reports its end: + // the wait gives up with an error, drops the message (`CANCEL self`), and closes. + let (client, srv) = UnixStream::pair().unwrap(); + let h = server(srv, Ending::Hold); + let req = Request { text: "Withheld.", rate: 0, lang: None }; + let start = Instant::now(); + let err = converse(&Stream::Unix(client), &req, &|| false).unwrap_err(); + assert_eq!(err.kind(), ErrorKind::TimedOut, "{err}"); + assert!(start.elapsed() < SENTENCE_BASE + SENTENCE_PER_CHAR * 9 + REPLY_TIMEOUT * 2, "{:?}", start.elapsed()); + let got = h.join().unwrap(); + assert_eq!(speaks(&got), 1); + assert!(got.contains(&"CANCEL self".to_string()) && got.last().map(String::as_str) == Some("QUIT"), "{got:?}"); + // Playback recovers: the next sentence, on a new connection as every sentence gets, + // is spoken to its end. + let (client, srv) = UnixStream::pair().unwrap(); + let h = server(srv, Ending::End); + assert_eq!(converse(&Stream::Unix(client), &Request { text: "Next.", rate: 0, lang: None }, &|| false).unwrap(), Outcome::Done); + assert_eq!(speaks(&h.join().unwrap()), 1); + } + + #[test] + fn a_sentence_that_never_begins_halts_reading_instead_of_failing() { + // `PAUSE all` before our message began sends no event: the time queued has its own + // bound, and reaching it halts reading (Play carries on) rather than stopping with an error. + let (client, srv) = UnixStream::pair().unwrap(); + let h = server(srv, Ending::NeverBegins); + let req = Request { text: "Queued behind another client.", rate: 0, lang: None }; + let start = Instant::now(); + assert_eq!(converse(&Stream::Unix(client), &req, &|| false).unwrap(), Outcome::NotStarted); + assert!(start.elapsed() >= QUEUE_WAIT && start.elapsed() < QUEUE_WAIT + REPLY_TIMEOUT * 2, "{:?}", start.elapsed()); + assert!(h.join().unwrap().contains(&"CANCEL self".to_string())); + } + + #[test] + fn a_sentence_that_never_ends_times_out() { + // A stuck speech module: no end event ever. The wait is bounded by the sentence's length. + let (client, srv) = UnixStream::pair().unwrap(); + let h = server(srv, Ending::Hold); + let req = Request { text: "x", rate: 0, lang: None }; + let start = Instant::now(); + assert_eq!(converse(&Stream::Unix(client), &req, &|| false).unwrap_err().kind(), ErrorKind::TimedOut); + assert!(start.elapsed() < SENTENCE_BASE + SENTENCE_PER_CHAR + REPLY_TIMEOUT * 2, "{:?}", start.elapsed()); + assert!(h.join().unwrap().contains(&"CANCEL self".to_string()), "the stuck message is dropped"); + } + + #[test] + fn moving_on_cancels_only_our_own_speech() { + let (client, srv) = UnixStream::pair().unwrap(); + let h = server(srv, Ending::Hold); + let req = Request { text: "Long sentence.", rate: 0, lang: None }; + let start = Instant::now(); + let changed = || start.elapsed() > Duration::from_millis(300); + assert_eq!(converse(&Stream::Unix(client), &req, &changed).unwrap(), Outcome::Interrupted); + let got = h.join().unwrap(); + assert_eq!(speaks(&got), 1, "{got:?}"); + assert!(got.contains(&"CANCEL self".to_string()), "{got:?}"); + assert!(!got.iter().any(|l| l.eq_ignore_ascii_case("CANCEL all"))); + assert!(!got.iter().any(|l| l.starts_with("SET SELF LANGUAGE")), "no language, no command"); + } + + #[test] + fn moving_on_stops_waiting_for_a_slow_server() { + // A server that reads but never answers: Stop acts at once, not after the reply timeout. + let (client, mut srv) = UnixStream::pair().unwrap(); + let h = std::thread::spawn(move || { + let mut all = String::new(); + let _ = srv.read_to_string(&mut all); + all + }); + let req = Request { text: "x", rate: 0, lang: None }; + let start = Instant::now(); + assert_eq!(converse(&Stream::Unix(client), &req, &|| true).unwrap(), Outcome::Interrupted); + assert!(start.elapsed() < REPLY_TIMEOUT, "{:?}", start.elapsed()); + // Stopped while the server may be about to read message data: the empty line and `.` end + // any message first, so `CANCEL self` is never taken for text. + let all = h.join().unwrap(); + assert!(all.ends_with("\r\n\r\n.\r\nCANCEL self\r\nQUIT\r\n"), "{all:?}"); + } + + #[test] + fn a_server_that_only_sends_events_times_out() { + // Events and continuation lines are no reply: they can't keep a command waiting forever. + let (client, mut srv) = UnixStream::pair().unwrap(); + let h = std::thread::spawn(move || while srv.write_all(b"701 BEGIN\r\n225-1\r\n").is_ok() {}); + let req = Request { text: "x", rate: 0, lang: None }; + let start = Instant::now(); + let client = Stream::Unix(client); + assert_eq!(converse(&client, &req, &|| false).unwrap_err().kind(), ErrorKind::TimedOut); + assert!(start.elapsed() < REPLY_TIMEOUT * 3, "{:?}", start.elapsed()); + drop(client); + h.join().unwrap(); + } + + #[test] + fn a_hostile_language_tag_is_never_sent() { + let (client, srv) = UnixStream::pair().unwrap(); + let h = server(srv, Ending::End); + let req = Request { text: "x", rate: 0, lang: Some("hu\r\nCANCEL all") }; + assert_eq!(converse(&Stream::Unix(client), &req, &|| false).unwrap(), Outcome::Done); + let got = h.join().unwrap(); + assert!(!got.iter().any(|l| l.starts_with("SET SELF LANGUAGE") || l.starts_with("CANCEL")), "{got:?}"); + } + + #[test] + fn a_server_that_hangs_up_is_an_error_not_a_hang() { + let (client, mut srv) = UnixStream::pair().unwrap(); + let h = std::thread::spawn(move || { + let mut buf = [0u8; 64]; + let _ = srv.read(&mut buf); + }); + let req = Request { text: "x", rate: 0, lang: None }; + assert!(converse(&Stream::Unix(client), &req, &|| false).is_err()); + h.join().unwrap(); + } + + #[test] + fn a_start_command_that_exits_reports_how() { + let far = Instant::now() + Duration::from_secs(30); + let run = |script: &str| start_server(std::process::Command::new("sh").args(["-c", script]), far, &|| false); + assert!(matches!(run("exit 0"), Started::Exited(true))); + assert!(matches!(run("exit 1"), Started::Exited(false))); + let missing = start_server(&mut std::process::Command::new("no-such-program-for-wordcraft"), far, &|| false); + assert!(matches!(missing, Started::Missing), "{missing:?}"); + } + + /// A start command that never exits, writing its process id to `pid` first (whole, by renaming). + fn hanging_start(pid: &std::path::Path) -> std::process::Command { + let mut c = std::process::Command::new("sh"); + c.args(["-c", "echo $$ > \"$1.part\" && mv \"$1.part\" \"$1\" && exec sleep 30", "sh"]).arg(pid); + c + } + + /// A fresh path for a start command's process id. + fn pid_file(name: &str) -> std::path::PathBuf { + let p = std::env::temp_dir().join(format!("wordcraft-ssip-{}-{name}.pid", std::process::id())); + let _ = std::fs::remove_file(&p); + let _ = std::fs::remove_file(p.with_extension("pid.part")); + p + } + + /// The process id the start command wrote (whole, or still in its `.part` file when it was + /// stopped before renaming it); `None` when it was stopped before writing any. + fn written_pid(pid: &std::path::Path) -> Option { + let part = pid.with_extension("pid.part"); + let id = std::fs::read_to_string(pid).or_else(|_| std::fs::read_to_string(&part)).ok(); + let _ = std::fs::remove_file(pid); + let _ = std::fs::remove_file(part); + id.map(|id| id.trim().to_string()).filter(|id| !id.is_empty()) + } + + /// Is process `id` gone: reaped, not merely killed and left a zombie (which `kill -0` still + /// finds)? + fn gone(id: &str) -> bool { + let probe = std::process::Command::new("sh").args(["-c", "kill -0 \"$1\" 2>/dev/null", "sh", id]).status().unwrap(); + !probe.success() + } + + #[test] + fn a_start_command_that_hangs_is_stopped_at_the_deadline() { + // A start stopped before the stand-in wrote its id is tried again with a longer deadline + // (a loaded machine may not run it at once); one that never wrote it left nothing to reap. + for deadline in [Duration::from_secs(1), Duration::from_secs(3), Duration::from_secs(10)] { + let pid = pid_file("deadline"); + let begun = Instant::now(); + let got = start_server(&mut hanging_start(&pid), begun + deadline, &|| false); + assert!(matches!(got, Started::TimedOut), "{got:?}"); + assert!(begun.elapsed() < deadline + Duration::from_secs(4), "{:?}", begun.elapsed()); + if let Some(id) = written_pid(&pid) { + assert!(gone(&id), "the start command was reaped"); + return; + } + } + } + + #[test] + fn stop_ends_the_wait_for_a_start_command_at_once() { + let pid = pid_file("stop"); + let begun = Instant::now(); + // Stop once the command is surely running. + let got = start_server(&mut hanging_start(&pid), begun + Duration::from_secs(30), &|| pid.exists()); + assert!(matches!(got, Started::Interrupted), "{got:?}"); + assert!(begun.elapsed() < Duration::from_secs(5), "{:?}", begun.elapsed()); + let id = written_pid(&pid).expect("stopped only once the id was written"); + assert!(gone(&id), "the start command was reaped"); + } +} diff --git a/crates/engine/src/speech/tests.rs b/crates/engine/src/speech/tests.rs index 26d8481c94..9433326a78 100644 --- a/crates/engine/src/speech/tests.rs +++ b/crates/engine/src/speech/tests.rs @@ -1,500 +1,500 @@ -use super::*; - -fn texts(v: &[Utterance]) -> Vec<&str> { - v.iter().map(|u| u.text.as_str()).collect() -} - -#[test] -fn powershell_is_launched_from_the_system_folder() { - let tail = std::path::Path::new("System32").join("WindowsPowerShell").join("v1.0").join("powershell.exe"); - let p = windows_powershell(Some("D:\\Win".into())); - assert!(p.starts_with("D:\\Win") && p.ends_with(&tail), "{}", p.display()); - // No SystemRoot, or an empty one: the default Windows folder. - for root in [None, Some("".into())] { - let p = windows_powershell(root); - assert!(p.starts_with("C:\\Windows") && p.ends_with(&tail), "{}", p.display()); - } -} - -#[test] -fn powershell_starts_without_a_console_window() { - // The flags `speak` passes to `creation_flags` (std can't read them back from the - // `Command`): Win32's CREATE_NO_WINDOW (0x0800_0000), or every sentence flashes a - // console (#1122). - let w = windows_speech(Some("D:\\Win".into()), 1.0, None); - assert_eq!(w.creation_flags, 0x0800_0000); - assert_eq!(w.program, windows_powershell(Some("D:\\Win".into()))); - assert_eq!(w.args[..3], ["-NoProfile", "-NonInteractive", "-Command"]); - assert!(w.args[3].contains("$s.Rate = 0;"), "{}", w.args[3]); - assert!(windows_speech(None, 3.0, None).args[3].contains("$s.Rate = 10;")); -} - -#[test] -fn splits_sentences_but_not_abbreviations() { - let t = "Smith et al. found 3.5 times more (see Fig. 2). J. Smith agreed! Did they? Yes… Pick one, e.g. this one."; - let spans = split_sentences(t); - let got: Vec = spans.iter().map(|(a, b)| t.chars().skip(*a).take(b - a).collect()).collect(); - assert_eq!(got, ["Smith et al. found 3.5 times more (see Fig. 2).", "J. Smith agreed!", "Did they?", "Yes…", "Pick one, e.g. this one."]); -} - -#[test] -fn long_sentences_are_split() { - let t = "word ".repeat(400); - let spans = split_sentences(&t); - assert!(spans.len() >= 3); - assert!(spans.iter().all(|(a, b)| b - a <= MAX_SENTENCE + 5)); -} - -/// A word with no space to split at, or a run of punctuation, is still cut into utterances of -/// at most `MAX_UTTERANCE` characters, covering all of it (#3680). -#[test] -fn sentences_without_spaces_are_split() { - for t in ["x".repeat(10_000), "!".repeat(10_000), format!("Go {} now.", "a.".repeat(5_000))] { - let spans = split_sentences(&t); - assert!(spans.len() >= 8, "{} spans", spans.len()); - assert!(spans.iter().all(|(a, b)| a < b && b - a <= MAX_UTTERANCE)); - assert_eq!(spans.first().map(|s| s.0), Some(0)); - assert_eq!(spans.last().map(|s| s.1), Some(t.chars().count())); - assert!(spans.windows(2).all(|w| w[0].1 <= w[1].0)); - } -} - -#[test] -fn tidy_removes_citation_leftovers() { - assert_eq!(tidy("as shown ( ) ."), "as shown."); - assert_eq!(tidy("as shown [] , and"), "as shown, and"); - assert_eq!(tidy(" a ( ( [ ] ) ) b ( x ) [ ) ] ; "), "a b ( x ) [ ) ];"); -} - -/// The tidy that ran replacements until nothing changed, kept to check the one-pass tidy gives -/// the same text. -fn tidy_by_replacing(s: &str) -> String { - let mut t: String = s.split_whitespace().collect::>().join(" "); - loop { - let before = t.len(); - for (from, to) in [("()", ""), ("[]", ""), ("( )", ""), ("[ ]", ""), (" .", "."), (" ,", ","), (" ;", ";"), (" :", ":"), (" ", " ")] { - t = t.replace(from, to); - } - if t.len() == before { - break; - } - } - t.trim().to_string() -} - -#[test] -fn tidy_matches_replacing_until_nothing_changes() { - let alphabet = ['(', ')', '[', ']', ' ', ' ', '\t', '\u{a0}', '.', ',', ';', ':', 'a', 'é']; - let mut seed: u64 = 0x3680; - for _ in 0..20_000 { - seed = seed.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1_442_695_040_888_963_407); - let len = (seed >> 59) as usize + 1; - let s: String = (0..len) - .map(|i| { - let r = seed.rotate_left(5 * i as u32 + 1) ^ (i as u64).wrapping_mul(0x9e37_79b9_7f4a_7c15); - alphabet[(r % alphabet.len() as u64) as usize] - }) - .collect(); - assert_eq!(tidy(&s), tidy_by_replacing(&s), "{s:?}"); - } -} - -/// Nested empty brackets are removed in one pass: 40,000 pairs took seconds when each pass -/// removed one level (#3680). -#[test] -fn tidy_is_linear_on_nested_brackets() { - let n = 40_000; - let started = std::time::Instant::now(); - assert_eq!(tidy(&format!("a {}{} b", "( [".repeat(n), "] )".repeat(n))), "a b"); - assert_eq!(tidy(&format!("{}x{}", "(".repeat(n), ")".repeat(n))).len(), 2 * n + 1); - // Milliseconds here; `tidy_by_replacing` (the old tidy) takes about 5 minutes on this input. - assert!(started.elapsed() < std::time::Duration::from_secs(10), "{:?}", started.elapsed()); -} - -#[test] -fn citation_codes() { - assert!(is_citation_code(" ADDIN ZOTERO_ITEM CSL_CITATION {}")); - assert!(is_citation_code("ADDIN ZOTERO_BIBL {\"uncited\":[]} CSL_BIBLIOGRAPHY")); - assert!(is_citation_code("ADDIN EN.CITE ")); - assert!(is_citation_code("ADDIN CSL_CITATION {}")); - assert!(is_citation_code("CITATION Smi20 \\l 1033")); - assert!(is_citation_code("BIBLIOGRAPHY")); - assert!(!is_citation_code("ADDIN ZOTERO_PREF_1")); - assert!(!is_citation_code("PAGE")); - assert!(!is_citation_code("")); - assert!(!is_citation_code("ADD")); -} - -#[test] -fn plain_document_sentences() { - let doc = Document::from_text("First one. Second one.\nThird paragraph"); - let s = sentences(&doc, &Pos::body(0, 0), None, true); - assert_eq!(texts(&s), ["First one.", "Second one.", "Third paragraph"]); - assert_eq!(s[1].start, Pos::body(0, 11)); - assert_eq!(s[1].end, Pos::body(0, 22)); - // From the caret, and only up to the end of a selection. - let s = sentences(&doc, &Pos::body(0, 11), Some(&Pos::body(1, 5)), true); - assert_eq!(texts(&s), ["Second one.", "Third"]); -} - -#[test] -fn player_moves_through_sentences() { - let doc = Document::from_text("One. Two. Three."); - let mut r = ReadAloud::default(); - r.backend = Backend::Hold; - r.start(sentences(&doc, &Pos::body(0, 0), None, true)); - assert_eq!(r.state(), State::Playing); - assert_eq!(r.current().map(|u| u.text), Some("One.".into())); - r.skip(1); - r.skip(5); - assert_eq!(r.progress(), (2, 3)); - r.skip(-10); - assert_eq!(r.progress(), (0, 3)); - r.play_pause(); - assert_eq!(r.state(), State::Paused); - r.set_rate(99.0); - assert_eq!(r.rate(), MAX_RATE); - r.set_rate(f32::NAN); - assert_eq!(r.rate(), 1.0); - r.play_pause(); - assert_eq!(r.state(), State::Playing); - r.stop(); - assert_eq!(r.state(), State::Stopped); - assert!(!r.open); - assert!(r.current().is_none()); -} - -#[test] -fn player_finishes_on_its_own() { - let doc = Document::from_text("One. Two. Three."); - let mut r = ReadAloud::default(); - r.backend = Backend::Instant; - r.start(sentences(&doc, &Pos::body(0, 0), None, true)); - for _ in 0..200 { - if r.state() == State::Stopped { - break; - } - std::thread::sleep(std::time::Duration::from_millis(5)); - } - assert_eq!(r.state(), State::Stopped); - assert_eq!(r.progress(), (3, 3)); - assert!(r.open, "the player stays open after the last sentence"); - // Play again starts from the top. - r.backend = Backend::Hold; - r.play_pause(); - assert_eq!(r.progress().0, 0); - assert_eq!(r.state(), State::Playing); -} - -#[test] -fn every_reading_counts_as_a_new_one() { - // The worker forgets what it learnt about the speech programs when a reading starts, even - // when the same worker thread carries on (a pause, then Read Aloud again). - let mut r = ReadAloud::default(); - r.backend = Backend::Hold; - let readings = |r: &ReadAloud| lock(&r.shared.0).readings; - let doc = Document::from_text("One. Two."); - r.start(sentences(&doc, &Pos::body(0, 0), None, true)); - let first = readings(&r); - r.play_pause(); - r.start(sentences(&doc, &Pos::body(0, 0), None, true)); - assert_eq!(readings(&r), first + 1); - r.play_pause(); - r.play_pause(); - assert_eq!(readings(&r), first + 1, "pause and play are the same reading"); - r.stop(); -} - -#[cfg(all(unix, not(target_os = "macos")))] -#[test] -fn a_sentence_that_never_began_says_so_not_another_program() { - // No begin event within the queue bound: no other program need be involved, so the - // message names the sentence not starting, not another program stopping it. - let halted = |o| match dispatcher_spoken(Ok(o)) { - Spoken::Halted(m) => m, - _ => String::new(), - }; - assert_eq!(halted(ssip::Outcome::NotStarted), NOT_STARTED); - assert!(!NOT_STARTED.contains("another program")); - assert_eq!(halted(ssip::Outcome::Halted), HALTED_BY_OTHER); -} - -#[test] -fn play_after_a_failed_sentence_plays_it_again() { - // A sentence whose end never came stops reading with an error (as the worker does on a - // timeout); Play starts it again and the message goes. - let mut r = ReadAloud::default(); - r.backend = Backend::Hold; - r.start(sentences(&Document::from_text("One. Two."), &Pos::body(0, 0), None, true)); - r.skip(1); - { - let mut s = lock(&r.shared.0); - s.state = State::Stopped; - s.error = Some("Read Aloud couldn't use Speech Dispatcher: the sentence didn't end".into()); - } - r.play_pause(); - assert_eq!((r.state(), r.progress().0, r.error()), (State::Playing, 1, None)); - r.stop(); -} - -#[test] -fn play_after_a_halt_carries_on_and_clears_the_message() { - // Another program stopped the sentence: reading paused on it with a message, and Play - // goes on from the same sentence. - let mut r = ReadAloud::default(); - r.backend = Backend::Hold; - r.start(sentences(&Document::from_text("One. Two."), &Pos::body(0, 0), None, true)); - { - let mut s = lock(&r.shared.0); - s.state = State::Paused; - s.error = Some("stopped by another program".into()); - } - r.play_pause(); - assert_eq!(r.state(), State::Playing); - assert_eq!(r.progress().0, 0); - assert_eq!(r.error(), None); - r.stop(); -} - -#[test] -fn empty_queue_does_not_play() { - let mut r = ReadAloud::default(); - r.start(Vec::new()); - assert_eq!(r.state(), State::Stopped); - r.skip(1); - r.skip(-1); - r.play_pause(); - assert_eq!(r.state(), State::Stopped); -} -#[test] -fn languages_are_detected_from_the_script() { - for (text, tag) in [ - ("Привет как твои дела?", "ru"), - ("Привіт, як справи? Їжак і ґанок", "uk"), - ("Γειά σου κόσμε", "el"), - ("مرحبا بالعالم", "ar"), - ("שלום עולם", "he"), - ("こんにちは、世界", "ja"), - ("你好,世界", "zh"), - ("안녕하세요 세계", "ko"), - ("สวัสดีชาวโลก", "th"), - ("Hello Привет мир как дела", "ru"), - ("Hello world and Привет", "ru"), - ] { - assert_eq!(detect_language(text), Some(tag), "{text}"); - } - // Latin text, nothing to read, or only punctuation: no guess. - for text in ["Hello, world", "", " ", "?!…", "123"] { - assert_eq!(detect_language(text), None, "{text:?}"); - } -} - -#[test] -fn an_explicit_language_wins_over_detection() { - // The document says so (a Russian run, even of Latin letters). - assert_eq!(speech_language("Privet", Some("ru-RU"), None).as_deref(), Some("ru-RU")); - assert_eq!(speech_language("Привет", Some("uk-UA"), None).as_deref(), Some("uk-UA")); - assert_eq!(speech_language("Hallo Welt", Some("de-DE"), None).as_deref(), Some("de-DE")); - // The default English tag on Cyrillic text (Word writes it on every run): the script wins. - assert_eq!(speech_language("Привет", Some("en-US"), None).as_deref(), Some("ru")); - // Nothing to go on: the default voice. - assert_eq!(speech_language("Hello", None, None), None); - assert_eq!(speech_language("Привет", None, None).as_deref(), Some("ru")); - // A hostile tag is never passed on. - assert_eq!(speech_language("Hello", Some("en'; calc; '"), None), None); -} - -#[test] -fn sentences_carry_the_run_language() { - let mut doc = Document::from_text("Привет как твои дела?\nPrivet."); - let s = sentences(&doc, &Pos::body(0, 0), None, true); - assert_eq!(s.iter().map(|u| u.lang.as_deref()).collect::>(), [Some("ru"), Some("en-US")]); - if let wordcraft_doc::Block::Para(p) = Arc::make_mut(&mut doc.body[1]) { - for r in &mut p.runs { - r.props.lang = Some("ru-RU".into()); - } - } - let s = sentences(&doc, &Pos::body(0, 0), None, true); - assert_eq!(s.iter().map(|u| u.lang.as_deref()).collect::>(), [Some("ru"), Some("ru-RU")]); -} - -#[test] -fn windows_script_reads_utf8_and_picks_the_voice() { - let w = windows_speech(None, 1.0, Some("ru")); - let script = &w.args[3]; - assert!(script.contains("UTF8"), "{script}"); - assert!(script.contains("GetInstalledVoices") && script.contains("SelectVoice"), "{script}"); - assert!(script.contains("'ru'"), "{script}"); - // The default voice when the language is unknown, and no voice code at all. - let d = windows_speech(None, 1.0, None); - assert!(d.args[3].contains("UTF8") && !d.args[3].contains("SelectVoice"), "{}", d.args[3]); - // The sentence is never in the script: it arrives on stdin. - assert!(!windows_speech(None, 1.0, Some("ru-RU")).args[3].contains("Привет")); - // A tag that isn't a plain language tag is dropped, not put in the script. - let h = windows_speech(None, 1.0, Some("ru'; Remove-Item x; '")); - assert!(!h.args[3].contains("Remove-Item"), "{}", h.args[3]); -} - -#[test] -fn espeak_gets_the_language() { - assert_eq!(espeak_args("180", Some("ru-RU")), ["-s", "180", "-v", "ru", "--stdin"]); - assert_eq!(espeak_args("180", Some("uk")), ["-s", "180", "-v", "uk", "--stdin"]); - assert_eq!(espeak_args("180", None), ["-s", "180", "--stdin"]); - assert_eq!(espeak_args("180", Some("--evil")), ["-s", "180", "--stdin"]); - assert_eq!(espeak_args("180", Some("hu-HU")), ["-s", "180", "-v", "hu", "--stdin"]); -} - -#[test] -fn speech_dispatcher_rate_follows_the_speed() { - assert_eq!(spd_rate(1.0), 0); - assert_eq!(spd_rate(MIN_RATE), -50); - assert_eq!(spd_rate(MAX_RATE), 100); - assert_eq!(spd_rate(2.0), 50); - assert_eq!(spd_rate(10.0), 100); - assert_eq!(spd_rate(0.0), -50); - assert_eq!(spd_rate(f32::NAN), 0); - assert_eq!(spd_rate(1.5), 25); -} - -#[test] -fn speech_dispatcher_keeps_the_region() { - let tag = |t| dispatcher_language(Some(t)); - assert_eq!(tag("en-GB").as_deref(), Some("en-GB")); - assert_eq!(tag(" pt-BR ").as_deref(), Some("pt-BR")); - assert_eq!(tag("zh-TW").as_deref(), Some("zh-TW")); - assert_eq!(tag("hu").as_deref(), Some("hu")); - assert_eq!(tag("HU-hu").as_deref(), Some("hu-HU")); - // SSIP takes language and region: script and variant subtags are left out. - assert_eq!(tag("sr-Latn-RS").as_deref(), Some("sr-RS")); - assert_eq!(tag("zh-Hant-TW").as_deref(), Some("zh-TW")); - assert_eq!(tag("es-419").as_deref(), Some("es-419")); - assert_eq!(tag("de-CH-1996").as_deref(), Some("de-CH")); - assert_eq!(tag("sr-Cyrl").as_deref(), Some("sr")); - assert_eq!(dispatcher_language(Some("x-none")), None); - assert_eq!(dispatcher_language(Some("hu\r\nQUIT")), None); - assert_eq!(dispatcher_language(None), None); -} - -#[test] -fn english_text_keeps_the_default_voice() { - // Word writes `en-US` on almost every run: espeak's default voice is English already, so - // no `-v` (as before #1843), and Word's language-less `x-none` is no voice either. - assert_eq!(espeak_args("180", Some("en-US")), ["-s", "180", "--stdin"]); - assert_eq!(espeak_args("180", Some("en-GB")), ["-s", "180", "--stdin"]); - assert_eq!(espeak_args("180", Some("x-none")), ["-s", "180", "--stdin"]); - // Windows: the user's default voice stays when it already speaks the language, any region. - let script = &windows_speech(None, 1.0, Some("en-US")).args[3]; - let guard = script.find("if ($s.Voice.Culture.TwoLetterISOLanguageName -ne 'en')"); - let pick = script.find("GetInstalledVoices"); - assert!(guard.is_some() && pick.is_some() && guard < pick, "{script}"); -} - -#[test] -fn macos_keeps_the_system_voice_for_its_language() { - // Current macOS lists its novelty voices first, in alphabetical order. - let list = "Albert en_US # Hello! My name is Albert.\n\ - Bad News en_US # The light you see at the end of the tunnel.\n\ - Bahh en_US # Do not pull the wool over my eyes.\n\ - Samantha en_US # Hello! My name is Samantha.\n\ - Milena ru_RU # Здравствуйте!\n"; - // English text with an English system voice: no `-v`, so `say` keeps the user's voice. - assert_eq!(mac_voice("en-US", list, "en"), None); - assert_eq!(mac_voice("en", list, "en"), None); - // English text on a Russian system: an English voice, never a novelty one. - assert_eq!(mac_voice("en-US", list, "ru").as_deref(), Some("Samantha")); - assert_eq!(mac_voice("en-US", "Albert en_US # Hi\nBahh en_US # Hi\n", "ru"), None); - // The system voice's language: the chosen voice's, else the locale's, else English. - assert_eq!(mac_default_language(Some("Milena"), list, Some("en_US")), "ru"); - assert_eq!(mac_default_language(Some("Gone"), list, Some("de_DE")), "de"); - assert_eq!(mac_default_language(None, list, Some("ru_RU.UTF-8")), "ru"); - assert_eq!(mac_default_language(None, list, Some("C")), "en"); - assert_eq!(mac_default_language(None, "", None), "en"); -} - -#[test] -fn a_voice_the_program_lacks_falls_back_to_the_default() { - // espeak-ng exits with a failure, saying nothing, for a `-v` it doesn't know. - assert!(retry_without_voice(true, Some(false))); - // It spoke, it was given no voice, or it was stopped (or never exited on its own). - assert!(!retry_without_voice(true, Some(true))); - assert!(!retry_without_voice(false, Some(false))); - assert!(!retry_without_voice(true, None)); -} - -#[test] -fn right_to_left_text_uses_the_complex_script_tag() { - // Persian tagged `w:bidi="fa-IR"` on a `w:val="en-US"` run: Persian, not Arabic. - assert_eq!(speech_language("سلام دنیا", Some("en-US"), Some("fa-IR")).as_deref(), Some("fa-IR")); - assert_eq!(speech_language("שלום עולם", Some("en-US"), Some("he-IL")).as_deref(), Some("he-IL")); - // The complex-script tag is for complex-script text only. - assert_eq!(speech_language("Hello", Some("en-US"), Some("fa-IR")).as_deref(), Some("en-US")); - assert_eq!(speech_language("سلام", Some("en-US"), Some("he-IL")).as_deref(), Some("ar")); -} - -#[test] -fn mixed_sentences_follow_the_other_script() { - // More English letters than Cyrillic ones: the English voice would spell the Russian out. - assert_eq!(detect_language("Я открыл Microsoft Word and PowerPoint"), Some("ru")); - // A single foreign word in an English sentence isn't a real share: the default voice. - assert_eq!(detect_language("The word дом means house"), None); - // Ties between scripts always go the same way, the first script in the list. - assert_eq!(detect_language("αβ дг"), Some("ru")); - assert_eq!(detect_language("дг αβ"), Some("ru")); -} - -#[test] -fn han_letters_keep_a_japanese_or_korean_tag() { - assert_eq!(speech_language("東京都。", Some("ja-JP"), None).as_deref(), Some("ja-JP")); - assert_eq!(speech_language("東京都。", Some("ko-KR"), None).as_deref(), Some("ko-KR")); - assert_eq!(speech_language("東京都。", Some("en-US"), None).as_deref(), Some("zh")); -} - -#[test] -fn macos_voice_matches_the_language() { - let list = "Alex en_US # Most people recognize me by my voice.\n\ - Eddy (Russian (Russia)) ru_RU # Привет!\n\ - Milena ru_RU # Здравствуйте!\n\ - Samantha en_US # Hello!\n\ - Lesya uk_UA # Привіт!\n\ - Daniel en_GB # Hello!\n"; - assert_eq!(mac_voice("ru", list, "en").as_deref(), Some("Eddy (Russian (Russia))")); - assert_eq!(mac_voice("ru-RU", list, "en").as_deref(), Some("Eddy (Russian (Russia))")); - assert_eq!(mac_voice("uk", list, "en").as_deref(), Some("Lesya")); - assert_eq!(mac_voice("en-GB", list, "ru").as_deref(), Some("Daniel")); - // No voice for the language, or nothing listed: the default voice. - assert_eq!(mac_voice("el", list, "en"), None); - assert_eq!(mac_voice("ru", "", "en"), None); - assert_eq!(mac_voice("ru", "garbage line\n\n \n", "en"), None); -} - -#[test] -fn skipped_spans_match_a_scan_of_every_span() { - // Overlapping, nested, touching, empty-range and reversed spans. - let raw = vec![ - (Pos::body(0, 5), Pos::body(0, 9)), - (Pos::body(0, 7), Pos::body(0, 12)), - (Pos::body(0, 20), Pos::body(0, 20)), - (Pos::body(0, 30), Pos::body(2, 3)), - (Pos::body(1, 4), Pos::body(1, 8)), - (Pos::body(3, 9), Pos::body(3, 2)), - (Pos::body(4, 0), Pos::body(4, 1)), - (Pos::body(4, 2), Pos::body(4, 3)), - ]; - let spans = Spans::new(raw.clone()); - for block in 0..6 { - for off in 0..40 { - let p = Pos::body(block, off); - let scan = raw.iter().any(|(a, b)| *a <= p && p <= *b); - assert_eq!(spans.contains(&p), scan, "{p:?}"); - } - } -} - -#[test] -fn many_citations_are_looked_up_quickly() { - // 50,000 disjoint spans and 200,000 lookups: a scan of every span per position would take - // ten billion comparisons. - let spans = Spans::new((0..50_000).map(|i| (Pos::body(0, i * 4), Pos::body(0, i * 4 + 1))).collect()); - let hits = (0..200_000).filter(|&off| spans.contains(&Pos::body(0, off))).count(); - assert_eq!(hits, 100_000); -} +use super::*; + +fn texts(v: &[Utterance]) -> Vec<&str> { + v.iter().map(|u| u.text.as_str()).collect() +} + +#[test] +fn powershell_is_launched_from_the_system_folder() { + let tail = std::path::Path::new("System32").join("WindowsPowerShell").join("v1.0").join("powershell.exe"); + let p = windows_powershell(Some("D:\\Win".into())); + assert!(p.starts_with("D:\\Win") && p.ends_with(&tail), "{}", p.display()); + // No SystemRoot, or an empty one: the default Windows folder. + for root in [None, Some("".into())] { + let p = windows_powershell(root); + assert!(p.starts_with("C:\\Windows") && p.ends_with(&tail), "{}", p.display()); + } +} + +#[test] +fn powershell_starts_without_a_console_window() { + // The flags `speak` passes to `creation_flags` (std can't read them back from the + // `Command`): Win32's CREATE_NO_WINDOW (0x0800_0000), or every sentence flashes a + // console (#1122). + let w = windows_speech(Some("D:\\Win".into()), 1.0, None); + assert_eq!(w.creation_flags, 0x0800_0000); + assert_eq!(w.program, windows_powershell(Some("D:\\Win".into()))); + assert_eq!(w.args[..3], ["-NoProfile", "-NonInteractive", "-Command"]); + assert!(w.args[3].contains("$s.Rate = 0;"), "{}", w.args[3]); + assert!(windows_speech(None, 3.0, None).args[3].contains("$s.Rate = 10;")); +} + +#[test] +fn splits_sentences_but_not_abbreviations() { + let t = "Smith et al. found 3.5 times more (see Fig. 2). J. Smith agreed! Did they? Yes… Pick one, e.g. this one."; + let spans = split_sentences(t); + let got: Vec = spans.iter().map(|(a, b)| t.chars().skip(*a).take(b - a).collect()).collect(); + assert_eq!(got, ["Smith et al. found 3.5 times more (see Fig. 2).", "J. Smith agreed!", "Did they?", "Yes…", "Pick one, e.g. this one."]); +} + +#[test] +fn long_sentences_are_split() { + let t = "word ".repeat(400); + let spans = split_sentences(&t); + assert!(spans.len() >= 3); + assert!(spans.iter().all(|(a, b)| b - a <= MAX_SENTENCE + 5)); +} + +/// A word with no space to split at, or a run of punctuation, is still cut into utterances of +/// at most `MAX_UTTERANCE` characters, covering all of it (#3680). +#[test] +fn sentences_without_spaces_are_split() { + for t in ["x".repeat(10_000), "!".repeat(10_000), format!("Go {} now.", "a.".repeat(5_000))] { + let spans = split_sentences(&t); + assert!(spans.len() >= 8, "{} spans", spans.len()); + assert!(spans.iter().all(|(a, b)| a < b && b - a <= MAX_UTTERANCE)); + assert_eq!(spans.first().map(|s| s.0), Some(0)); + assert_eq!(spans.last().map(|s| s.1), Some(t.chars().count())); + assert!(spans.windows(2).all(|w| w[0].1 <= w[1].0)); + } +} + +#[test] +fn tidy_removes_citation_leftovers() { + assert_eq!(tidy("as shown ( ) ."), "as shown."); + assert_eq!(tidy("as shown [] , and"), "as shown, and"); + assert_eq!(tidy(" a ( ( [ ] ) ) b ( x ) [ ) ] ; "), "a b ( x ) [ ) ];"); +} + +/// The tidy that ran replacements until nothing changed, kept to check the one-pass tidy gives +/// the same text. +fn tidy_by_replacing(s: &str) -> String { + let mut t: String = s.split_whitespace().collect::>().join(" "); + loop { + let before = t.len(); + for (from, to) in [("()", ""), ("[]", ""), ("( )", ""), ("[ ]", ""), (" .", "."), (" ,", ","), (" ;", ";"), (" :", ":"), (" ", " ")] { + t = t.replace(from, to); + } + if t.len() == before { + break; + } + } + t.trim().to_string() +} + +#[test] +fn tidy_matches_replacing_until_nothing_changes() { + let alphabet = ['(', ')', '[', ']', ' ', ' ', '\t', '\u{a0}', '.', ',', ';', ':', 'a', 'é']; + let mut seed: u64 = 0x3680; + for _ in 0..20_000 { + seed = seed.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1_442_695_040_888_963_407); + let len = (seed >> 59) as usize + 1; + let s: String = (0..len) + .map(|i| { + let r = seed.rotate_left(5 * i as u32 + 1) ^ (i as u64).wrapping_mul(0x9e37_79b9_7f4a_7c15); + alphabet[(r % alphabet.len() as u64) as usize] + }) + .collect(); + assert_eq!(tidy(&s), tidy_by_replacing(&s), "{s:?}"); + } +} + +/// Nested empty brackets are removed in one pass: 40,000 pairs took seconds when each pass +/// removed one level (#3680). +#[test] +fn tidy_is_linear_on_nested_brackets() { + let n = 40_000; + let started = std::time::Instant::now(); + assert_eq!(tidy(&format!("a {}{} b", "( [".repeat(n), "] )".repeat(n))), "a b"); + assert_eq!(tidy(&format!("{}x{}", "(".repeat(n), ")".repeat(n))).len(), 2 * n + 1); + // Milliseconds here; `tidy_by_replacing` (the old tidy) takes about 5 minutes on this input. + assert!(started.elapsed() < std::time::Duration::from_secs(10), "{:?}", started.elapsed()); +} + +#[test] +fn citation_codes() { + assert!(is_citation_code(" ADDIN ZOTERO_ITEM CSL_CITATION {}")); + assert!(is_citation_code("ADDIN ZOTERO_BIBL {\"uncited\":[]} CSL_BIBLIOGRAPHY")); + assert!(is_citation_code("ADDIN EN.CITE ")); + assert!(is_citation_code("ADDIN CSL_CITATION {}")); + assert!(is_citation_code("CITATION Smi20 \\l 1033")); + assert!(is_citation_code("BIBLIOGRAPHY")); + assert!(!is_citation_code("ADDIN ZOTERO_PREF_1")); + assert!(!is_citation_code("PAGE")); + assert!(!is_citation_code("")); + assert!(!is_citation_code("ADD")); +} + +#[test] +fn plain_document_sentences() { + let doc = Document::from_text("First one. Second one.\nThird paragraph"); + let s = sentences(&doc, &Pos::body(0, 0), None, true); + assert_eq!(texts(&s), ["First one.", "Second one.", "Third paragraph"]); + assert_eq!(s[1].start, Pos::body(0, 11)); + assert_eq!(s[1].end, Pos::body(0, 22)); + // From the caret, and only up to the end of a selection. + let s = sentences(&doc, &Pos::body(0, 11), Some(&Pos::body(1, 5)), true); + assert_eq!(texts(&s), ["Second one.", "Third"]); +} + +#[test] +fn player_moves_through_sentences() { + let doc = Document::from_text("One. Two. Three."); + let mut r = ReadAloud::default(); + r.backend = Backend::Hold; + r.start(sentences(&doc, &Pos::body(0, 0), None, true)); + assert_eq!(r.state(), State::Playing); + assert_eq!(r.current().map(|u| u.text), Some("One.".into())); + r.skip(1); + r.skip(5); + assert_eq!(r.progress(), (2, 3)); + r.skip(-10); + assert_eq!(r.progress(), (0, 3)); + r.play_pause(); + assert_eq!(r.state(), State::Paused); + r.set_rate(99.0); + assert_eq!(r.rate(), MAX_RATE); + r.set_rate(f32::NAN); + assert_eq!(r.rate(), 1.0); + r.play_pause(); + assert_eq!(r.state(), State::Playing); + r.stop(); + assert_eq!(r.state(), State::Stopped); + assert!(!r.open); + assert!(r.current().is_none()); +} + +#[test] +fn player_finishes_on_its_own() { + let doc = Document::from_text("One. Two. Three."); + let mut r = ReadAloud::default(); + r.backend = Backend::Instant; + r.start(sentences(&doc, &Pos::body(0, 0), None, true)); + for _ in 0..200 { + if r.state() == State::Stopped { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert_eq!(r.state(), State::Stopped); + assert_eq!(r.progress(), (3, 3)); + assert!(r.open, "the player stays open after the last sentence"); + // Play again starts from the top. + r.backend = Backend::Hold; + r.play_pause(); + assert_eq!(r.progress().0, 0); + assert_eq!(r.state(), State::Playing); +} + +#[test] +fn every_reading_counts_as_a_new_one() { + // The worker forgets what it learnt about the speech programs when a reading starts, even + // when the same worker thread carries on (a pause, then Read Aloud again). + let mut r = ReadAloud::default(); + r.backend = Backend::Hold; + let readings = |r: &ReadAloud| lock(&r.shared.0).readings; + let doc = Document::from_text("One. Two."); + r.start(sentences(&doc, &Pos::body(0, 0), None, true)); + let first = readings(&r); + r.play_pause(); + r.start(sentences(&doc, &Pos::body(0, 0), None, true)); + assert_eq!(readings(&r), first + 1); + r.play_pause(); + r.play_pause(); + assert_eq!(readings(&r), first + 1, "pause and play are the same reading"); + r.stop(); +} + +#[cfg(all(unix, not(target_os = "macos")))] +#[test] +fn a_sentence_that_never_began_says_so_not_another_program() { + // No begin event within the queue bound: no other program need be involved, so the + // message names the sentence not starting, not another program stopping it. + let halted = |o| match dispatcher_spoken(Ok(o)) { + Spoken::Halted(m) => m, + _ => String::new(), + }; + assert_eq!(halted(ssip::Outcome::NotStarted), NOT_STARTED); + assert!(!NOT_STARTED.contains("another program")); + assert_eq!(halted(ssip::Outcome::Halted), HALTED_BY_OTHER); +} + +#[test] +fn play_after_a_failed_sentence_plays_it_again() { + // A sentence whose end never came stops reading with an error (as the worker does on a + // timeout); Play starts it again and the message goes. + let mut r = ReadAloud::default(); + r.backend = Backend::Hold; + r.start(sentences(&Document::from_text("One. Two."), &Pos::body(0, 0), None, true)); + r.skip(1); + { + let mut s = lock(&r.shared.0); + s.state = State::Stopped; + s.error = Some("Read Aloud couldn't use Speech Dispatcher: the sentence didn't end".into()); + } + r.play_pause(); + assert_eq!((r.state(), r.progress().0, r.error()), (State::Playing, 1, None)); + r.stop(); +} + +#[test] +fn play_after_a_halt_carries_on_and_clears_the_message() { + // Another program stopped the sentence: reading paused on it with a message, and Play + // goes on from the same sentence. + let mut r = ReadAloud::default(); + r.backend = Backend::Hold; + r.start(sentences(&Document::from_text("One. Two."), &Pos::body(0, 0), None, true)); + { + let mut s = lock(&r.shared.0); + s.state = State::Paused; + s.error = Some("stopped by another program".into()); + } + r.play_pause(); + assert_eq!(r.state(), State::Playing); + assert_eq!(r.progress().0, 0); + assert_eq!(r.error(), None); + r.stop(); +} + +#[test] +fn empty_queue_does_not_play() { + let mut r = ReadAloud::default(); + r.start(Vec::new()); + assert_eq!(r.state(), State::Stopped); + r.skip(1); + r.skip(-1); + r.play_pause(); + assert_eq!(r.state(), State::Stopped); +} +#[test] +fn languages_are_detected_from_the_script() { + for (text, tag) in [ + ("Привет как твои дела?", "ru"), + ("Привіт, як справи? Їжак і ґанок", "uk"), + ("Γειά σου κόσμε", "el"), + ("مرحبا بالعالم", "ar"), + ("שלום עולם", "he"), + ("こんにちは、世界", "ja"), + ("你好,世界", "zh"), + ("안녕하세요 세계", "ko"), + ("สวัสดีชาวโลก", "th"), + ("Hello Привет мир как дела", "ru"), + ("Hello world and Привет", "ru"), + ] { + assert_eq!(detect_language(text), Some(tag), "{text}"); + } + // Latin text, nothing to read, or only punctuation: no guess. + for text in ["Hello, world", "", " ", "?!…", "123"] { + assert_eq!(detect_language(text), None, "{text:?}"); + } +} + +#[test] +fn an_explicit_language_wins_over_detection() { + // The document says so (a Russian run, even of Latin letters). + assert_eq!(speech_language("Privet", Some("ru-RU"), None).as_deref(), Some("ru-RU")); + assert_eq!(speech_language("Привет", Some("uk-UA"), None).as_deref(), Some("uk-UA")); + assert_eq!(speech_language("Hallo Welt", Some("de-DE"), None).as_deref(), Some("de-DE")); + // The default English tag on Cyrillic text (Word writes it on every run): the script wins. + assert_eq!(speech_language("Привет", Some("en-US"), None).as_deref(), Some("ru")); + // Nothing to go on: the default voice. + assert_eq!(speech_language("Hello", None, None), None); + assert_eq!(speech_language("Привет", None, None).as_deref(), Some("ru")); + // A hostile tag is never passed on. + assert_eq!(speech_language("Hello", Some("en'; calc; '"), None), None); +} + +#[test] +fn sentences_carry_the_run_language() { + let mut doc = Document::from_text("Привет как твои дела?\nPrivet."); + let s = sentences(&doc, &Pos::body(0, 0), None, true); + assert_eq!(s.iter().map(|u| u.lang.as_deref()).collect::>(), [Some("ru"), Some("en-US")]); + if let wordcraft_doc::Block::Para(p) = Arc::make_mut(&mut doc.body[1]) { + for r in &mut p.runs { + r.props.lang = Some("ru-RU".into()); + } + } + let s = sentences(&doc, &Pos::body(0, 0), None, true); + assert_eq!(s.iter().map(|u| u.lang.as_deref()).collect::>(), [Some("ru"), Some("ru-RU")]); +} + +#[test] +fn windows_script_reads_utf8_and_picks_the_voice() { + let w = windows_speech(None, 1.0, Some("ru")); + let script = &w.args[3]; + assert!(script.contains("UTF8"), "{script}"); + assert!(script.contains("GetInstalledVoices") && script.contains("SelectVoice"), "{script}"); + assert!(script.contains("'ru'"), "{script}"); + // The default voice when the language is unknown, and no voice code at all. + let d = windows_speech(None, 1.0, None); + assert!(d.args[3].contains("UTF8") && !d.args[3].contains("SelectVoice"), "{}", d.args[3]); + // The sentence is never in the script: it arrives on stdin. + assert!(!windows_speech(None, 1.0, Some("ru-RU")).args[3].contains("Привет")); + // A tag that isn't a plain language tag is dropped, not put in the script. + let h = windows_speech(None, 1.0, Some("ru'; Remove-Item x; '")); + assert!(!h.args[3].contains("Remove-Item"), "{}", h.args[3]); +} + +#[test] +fn espeak_gets_the_language() { + assert_eq!(espeak_args("180", Some("ru-RU")), ["-s", "180", "-v", "ru", "--stdin"]); + assert_eq!(espeak_args("180", Some("uk")), ["-s", "180", "-v", "uk", "--stdin"]); + assert_eq!(espeak_args("180", None), ["-s", "180", "--stdin"]); + assert_eq!(espeak_args("180", Some("--evil")), ["-s", "180", "--stdin"]); + assert_eq!(espeak_args("180", Some("hu-HU")), ["-s", "180", "-v", "hu", "--stdin"]); +} + +#[test] +fn speech_dispatcher_rate_follows_the_speed() { + assert_eq!(spd_rate(1.0), 0); + assert_eq!(spd_rate(MIN_RATE), -50); + assert_eq!(spd_rate(MAX_RATE), 100); + assert_eq!(spd_rate(2.0), 50); + assert_eq!(spd_rate(10.0), 100); + assert_eq!(spd_rate(0.0), -50); + assert_eq!(spd_rate(f32::NAN), 0); + assert_eq!(spd_rate(1.5), 25); +} + +#[test] +fn speech_dispatcher_keeps_the_region() { + let tag = |t| dispatcher_language(Some(t)); + assert_eq!(tag("en-GB").as_deref(), Some("en-GB")); + assert_eq!(tag(" pt-BR ").as_deref(), Some("pt-BR")); + assert_eq!(tag("zh-TW").as_deref(), Some("zh-TW")); + assert_eq!(tag("hu").as_deref(), Some("hu")); + assert_eq!(tag("HU-hu").as_deref(), Some("hu-HU")); + // SSIP takes language and region: script and variant subtags are left out. + assert_eq!(tag("sr-Latn-RS").as_deref(), Some("sr-RS")); + assert_eq!(tag("zh-Hant-TW").as_deref(), Some("zh-TW")); + assert_eq!(tag("es-419").as_deref(), Some("es-419")); + assert_eq!(tag("de-CH-1996").as_deref(), Some("de-CH")); + assert_eq!(tag("sr-Cyrl").as_deref(), Some("sr")); + assert_eq!(dispatcher_language(Some("x-none")), None); + assert_eq!(dispatcher_language(Some("hu\r\nQUIT")), None); + assert_eq!(dispatcher_language(None), None); +} + +#[test] +fn english_text_keeps_the_default_voice() { + // Word writes `en-US` on almost every run: espeak's default voice is English already, so + // no `-v` (as before #1843), and Word's language-less `x-none` is no voice either. + assert_eq!(espeak_args("180", Some("en-US")), ["-s", "180", "--stdin"]); + assert_eq!(espeak_args("180", Some("en-GB")), ["-s", "180", "--stdin"]); + assert_eq!(espeak_args("180", Some("x-none")), ["-s", "180", "--stdin"]); + // Windows: the user's default voice stays when it already speaks the language, any region. + let script = &windows_speech(None, 1.0, Some("en-US")).args[3]; + let guard = script.find("if ($s.Voice.Culture.TwoLetterISOLanguageName -ne 'en')"); + let pick = script.find("GetInstalledVoices"); + assert!(guard.is_some() && pick.is_some() && guard < pick, "{script}"); +} + +#[test] +fn macos_keeps_the_system_voice_for_its_language() { + // Current macOS lists its novelty voices first, in alphabetical order. + let list = "Albert en_US # Hello! My name is Albert.\n\ + Bad News en_US # The light you see at the end of the tunnel.\n\ + Bahh en_US # Do not pull the wool over my eyes.\n\ + Samantha en_US # Hello! My name is Samantha.\n\ + Milena ru_RU # Здравствуйте!\n"; + // English text with an English system voice: no `-v`, so `say` keeps the user's voice. + assert_eq!(mac_voice("en-US", list, "en"), None); + assert_eq!(mac_voice("en", list, "en"), None); + // English text on a Russian system: an English voice, never a novelty one. + assert_eq!(mac_voice("en-US", list, "ru").as_deref(), Some("Samantha")); + assert_eq!(mac_voice("en-US", "Albert en_US # Hi\nBahh en_US # Hi\n", "ru"), None); + // The system voice's language: the chosen voice's, else the locale's, else English. + assert_eq!(mac_default_language(Some("Milena"), list, Some("en_US")), "ru"); + assert_eq!(mac_default_language(Some("Gone"), list, Some("de_DE")), "de"); + assert_eq!(mac_default_language(None, list, Some("ru_RU.UTF-8")), "ru"); + assert_eq!(mac_default_language(None, list, Some("C")), "en"); + assert_eq!(mac_default_language(None, "", None), "en"); +} + +#[test] +fn a_voice_the_program_lacks_falls_back_to_the_default() { + // espeak-ng exits with a failure, saying nothing, for a `-v` it doesn't know. + assert!(retry_without_voice(true, Some(false))); + // It spoke, it was given no voice, or it was stopped (or never exited on its own). + assert!(!retry_without_voice(true, Some(true))); + assert!(!retry_without_voice(false, Some(false))); + assert!(!retry_without_voice(true, None)); +} + +#[test] +fn right_to_left_text_uses_the_complex_script_tag() { + // Persian tagged `w:bidi="fa-IR"` on a `w:val="en-US"` run: Persian, not Arabic. + assert_eq!(speech_language("سلام دنیا", Some("en-US"), Some("fa-IR")).as_deref(), Some("fa-IR")); + assert_eq!(speech_language("שלום עולם", Some("en-US"), Some("he-IL")).as_deref(), Some("he-IL")); + // The complex-script tag is for complex-script text only. + assert_eq!(speech_language("Hello", Some("en-US"), Some("fa-IR")).as_deref(), Some("en-US")); + assert_eq!(speech_language("سلام", Some("en-US"), Some("he-IL")).as_deref(), Some("ar")); +} + +#[test] +fn mixed_sentences_follow_the_other_script() { + // More English letters than Cyrillic ones: the English voice would spell the Russian out. + assert_eq!(detect_language("Я открыл Microsoft Word and PowerPoint"), Some("ru")); + // A single foreign word in an English sentence isn't a real share: the default voice. + assert_eq!(detect_language("The word дом means house"), None); + // Ties between scripts always go the same way, the first script in the list. + assert_eq!(detect_language("αβ дг"), Some("ru")); + assert_eq!(detect_language("дг αβ"), Some("ru")); +} + +#[test] +fn han_letters_keep_a_japanese_or_korean_tag() { + assert_eq!(speech_language("東京都。", Some("ja-JP"), None).as_deref(), Some("ja-JP")); + assert_eq!(speech_language("東京都。", Some("ko-KR"), None).as_deref(), Some("ko-KR")); + assert_eq!(speech_language("東京都。", Some("en-US"), None).as_deref(), Some("zh")); +} + +#[test] +fn macos_voice_matches_the_language() { + let list = "Alex en_US # Most people recognize me by my voice.\n\ + Eddy (Russian (Russia)) ru_RU # Привет!\n\ + Milena ru_RU # Здравствуйте!\n\ + Samantha en_US # Hello!\n\ + Lesya uk_UA # Привіт!\n\ + Daniel en_GB # Hello!\n"; + assert_eq!(mac_voice("ru", list, "en").as_deref(), Some("Eddy (Russian (Russia))")); + assert_eq!(mac_voice("ru-RU", list, "en").as_deref(), Some("Eddy (Russian (Russia))")); + assert_eq!(mac_voice("uk", list, "en").as_deref(), Some("Lesya")); + assert_eq!(mac_voice("en-GB", list, "ru").as_deref(), Some("Daniel")); + // No voice for the language, or nothing listed: the default voice. + assert_eq!(mac_voice("el", list, "en"), None); + assert_eq!(mac_voice("ru", "", "en"), None); + assert_eq!(mac_voice("ru", "garbage line\n\n \n", "en"), None); +} + +#[test] +fn skipped_spans_match_a_scan_of_every_span() { + // Overlapping, nested, touching, empty-range and reversed spans. + let raw = vec![ + (Pos::body(0, 5), Pos::body(0, 9)), + (Pos::body(0, 7), Pos::body(0, 12)), + (Pos::body(0, 20), Pos::body(0, 20)), + (Pos::body(0, 30), Pos::body(2, 3)), + (Pos::body(1, 4), Pos::body(1, 8)), + (Pos::body(3, 9), Pos::body(3, 2)), + (Pos::body(4, 0), Pos::body(4, 1)), + (Pos::body(4, 2), Pos::body(4, 3)), + ]; + let spans = Spans::new(raw.clone()); + for block in 0..6 { + for off in 0..40 { + let p = Pos::body(block, off); + let scan = raw.iter().any(|(a, b)| *a <= p && p <= *b); + assert_eq!(spans.contains(&p), scan, "{p:?}"); + } + } +} + +#[test] +fn many_citations_are_looked_up_quickly() { + // 50,000 disjoint spans and 200,000 lookups: a scan of every span per position would take + // ten billion comparisons. + let spans = Spans::new((0..50_000).map(|i| (Pos::body(0, i * 4), Pos::body(0, i * 4 + 1))).collect()); + let hits = (0..200_000).filter(|&off| spans.contains(&Pos::body(0, off))).count(); + assert_eq!(hits, 100_000); +} diff --git a/crates/engine/src/strict.rs b/crates/engine/src/strict.rs index 972fbedf23..223366b81e 100644 --- a/crates/engine/src/strict.rs +++ b/crates/engine/src/strict.rs @@ -1,233 +1,233 @@ -//! Strict export (#6005): the report of what a PDF or PNG would leave out silently (characters no -//! face can draw, pictures that are missing or can't be decoded), and the refusal built from it. -//! -//! The check runs on the layout and display options the export itself uses, so what it reports is -//! what the output lacks. - -use std::ops::RangeInclusive; - -use serde_json::{Value, json}; -use wordcraft_doc::{Document, MAX_MISSING_PICTURES, MissingPicture}; -use wordcraft_layout::display::DisplayOptions; -use wordcraft_layout::{DocLayout, LayoutCache, StrictOutput}; - -use crate::io::ExportOptions; - -/// The report's JSON schema version; fields may be added without raising it. -pub const SCHEMA: u32 = 1; - -/// What a strict export found missing. -#[derive(Clone, Debug, Default, PartialEq, Eq)] -pub struct StrictReport { - /// (code point, times drawn), sorted by code point. - pub glyphs: Vec<(char, u64)>, - pub pictures: Vec, - /// More distinct characters were missing than are listed. - pub glyphs_truncated: bool, - /// More pictures were missing than are listed. - pub pictures_truncated: bool, - /// Distinct characters found missing, those past the list's cap included. - pub total_glyphs: usize, - /// Pictures found missing, those past the list's cap included. - pub total_pictures: u64, -} - -impl StrictReport { - /// Nothing is missing: the strict export passes. - pub fn passed(&self) -> bool { - self.glyphs.is_empty() && self.pictures.is_empty() && !self.truncated() - } - - /// Either list was cut at its cap. - pub fn truncated(&self) -> bool { - self.glyphs_truncated || self.pictures_truncated - } - - /// The schema-1 JSON of the report. - pub fn to_json(&self) -> Value { - json!({ - "schema": SCHEMA, - "strict": if self.passed() { "passed" } else { "failed" }, - "missing_glyphs": self.glyphs.iter().map(|(c, n)| json!({"cp": code_point(*c), "count": n})).collect::>(), - "missing_pictures": self.pictures.iter().map(|p| json!({"part": p.part, "source_part": p.story, "reason": p.reason.code()})).collect::>(), - "truncated": self.truncated(), - "total_missing_glyphs": self.total_glyphs, - "total_missing_pictures": self.total_pictures, - "glyphs_truncated": self.glyphs_truncated, - "pictures_truncated": self.pictures_truncated, - }) - } - - /// The human lines (without the `strict: ` prefix): one for the characters, one for the - /// pictures; none when nothing is missing. - fn parts(&self) -> Vec { - let glyphs: Vec<(String, u64)> = self.glyphs.iter().map(|(c, n)| (code_point(*c), *n)).collect(); - let pictures: Vec<(String, String, String)> = - self.pictures.iter().map(|p| (p.part.clone(), story_label(&p.story).to_string(), p.reason.label().to_string())).collect(); - human_parts(&glyphs, &pictures, (self.glyphs_truncated, self.pictures_truncated), (self.total_glyphs as u64, self.total_pictures)) - } - - /// The human lines, each `strict: …`. - pub fn lines(&self) -> Vec { - self.parts().into_iter().map(|p| format!("strict: {p}")).collect() - } - - /// The refusal's message: `strict: …`, the lines joined. - pub fn refusal(&self) -> String { - format!("strict: {}", self.parts().join("; ")) - } -} - -/// The human text of a report: (code point text, count) and (part, story label, reason words). -fn human_parts(glyphs: &[(String, u64)], pictures: &[(String, String, String)], cut: (bool, bool), totals: (u64, u64)) -> Vec { - let (glyphs_cut, pictures_cut) = cut; - let mut out = Vec::new(); - if !glyphs.is_empty() { - let list: Vec = glyphs.iter().map(|(cp, n)| format!("{cp} (×{n})")).collect(); - let more = if glyphs_cut { ", …" } else { "" }; - let listed = glyphs.len() as u64; - let n = if glyphs_cut { totals.0.max(listed) } else { listed }; - let first = if n > listed { format!(" (first {listed} listed)") } else { String::new() }; - out.push(format!("{n} character{} cannot be drawn{first}: {}{more}", if n == 1 { "" } else { "s" }, list.join(", "))); - } - if !pictures.is_empty() { - let list: Vec = pictures - .iter() - .map(|(part, at, reason)| if at.is_empty() { format!("{part}: {reason}") } else { format!("{part} ({at}): {reason}") }) - .collect(); - let more = if pictures_cut { "; …" } else { "" }; - let listed = pictures.len() as u64; - let n = if pictures_cut { totals.1.max(listed) } else { listed }; - let first = if n > listed { format!(" (first {listed} listed)") } else { String::new() }; - out.push(format!("{n} picture(s) left out{first}: {}{more}", list.join("; "))); - } - out -} - -/// The `strict: …` human lines of a report in its JSON form (what `file.strictReport` returns). -/// Anything the JSON lacks is left out. -pub fn lines_from_json(report: &Value) -> Vec { - let text = |v: &Value, k: &str| v.get(k).and_then(Value::as_str).unwrap_or_default().to_string(); - let glyphs: Vec<(String, u64)> = report - .get("missing_glyphs") - .and_then(Value::as_array) - .map(|a| a.iter().map(|g| (text(g, "cp"), g.get("count").and_then(Value::as_u64).unwrap_or(0))).collect()) - .unwrap_or_default(); - let pictures: Vec<(String, String, String)> = report - .get("missing_pictures") - .and_then(Value::as_array) - .map(|a| a.iter().map(|p| (text(p, "part"), story_label(&text(p, "source_part")).to_string(), reason_label(&text(p, "reason")))).collect()) - .unwrap_or_default(); - let flag = |k: &str| report.get(k).and_then(Value::as_bool).unwrap_or(false); - // A report without the per-list flags (the top-level one alone) marks both lists. - let (glyphs_cut, pictures_cut) = match (report.get("glyphs_truncated"), report.get("pictures_truncated")) { - (None, None) => (flag("truncated"), flag("truncated")), - _ => (flag("glyphs_truncated"), flag("pictures_truncated")), - }; - let total = |k: &str, listed: usize| report.get(k).and_then(Value::as_u64).unwrap_or(listed as u64); - let totals = (total("total_missing_glyphs", glyphs.len()), total("total_missing_pictures", pictures.len())); - human_parts(&glyphs, &pictures, (glyphs_cut, pictures_cut), totals).into_iter().map(|p| format!("strict: {p}")).collect() -} - -/// The words for a JSON reason code; a code this version does not know is shown as it is. -fn reason_label(code: &str) -> String { - wordcraft_doc::MissingPictureReason::from_code(code).map_or_else(|| code.to_string(), |r| r.label().to_string()) -} - -/// `U+2D30`: upper-case hex, at least four digits. -fn code_point(c: char) -> String { - format!("U+{:04X}", c as u32) -} - -/// The part a picture is in, as the report names it: `header2.xml` for `word/header2.xml`. -fn story_label(story: &str) -> &str { - story.strip_prefix("word/").unwrap_or(story) -} - -/// The report of `doc` as drawn by `lay` with `display` into `output`, on `pages` (0-based; `None` -/// is every page). The watermark is checked with the page, and the pictures dropped when the -/// document was opened are added to those that can't be decoded now, capped like each. -pub fn strict_report( - doc: &Document, - lay: &DocLayout, - display: &DisplayOptions, - pages: Option>, - output: StrictOutput, -) -> StrictReport { - let mut missing = lay.missing_glyphs(doc, display, pages.clone()); - let any_page = lay.pages.iter().enumerate().any(|(n, _)| pages.as_ref().is_none_or(|r| r.contains(&n))); - if any_page && let Some(wm) = &doc.settings.watermark { - let max = match output { - StrictOutput::Pdf => wordcraft_pdf::WATERMARK_MAX_CHARS, - StrictOutput::Png => usize::MAX, - }; - missing.merge(wordcraft_layout::missing_watermark_glyphs(wm, max)); - } - let (undecodable, mut pictures_truncated, undecodable_total) = wordcraft_layout::undecodable_pictures(doc, lay, pages, display, output); - pictures_truncated |= doc.missing_pictures.truncated; - // The two lists overlap where the same picture is in both; those are counted once. - let mut overlap: u64 = 0; - let mut pictures: Vec = Vec::new(); - for p in doc.missing_pictures.list.iter().cloned().chain(undecodable) { - if pictures.contains(&p) { - overlap = overlap.saturating_add(1); - continue; - } - if pictures.len() >= MAX_MISSING_PICTURES { - pictures_truncated = true; - break; - } - pictures.push(p); - } - let total_pictures = doc.missing_pictures.total.saturating_add(undecodable_total).saturating_sub(overlap).max(pictures.len() as u64); - StrictReport { - glyphs: missing.chars, - pictures, - glyphs_truncated: missing.truncated, - pictures_truncated, - total_glyphs: missing.total, - total_pictures, - } -} - -/// The PDF export's options for `opts`: the one place they are built, shared by the export and -/// its strict check. -pub(crate) fn pdf_options(opts: &ExportOptions) -> wordcraft_pdf::PdfOptions { - wordcraft_pdf::PdfOptions { include_markup: opts.markup, comments: opts.markup && opts.comments, numeral: opts.numeral, ..Default::default() } -} - -/// What a page image shows: the display options `file.exportPng` renders with. -pub(crate) fn png_display() -> DisplayOptions { - // A page image is the printed page: headers and footers at full strength. - let mut opts = wordcraft_render::RenderOptions::default(); - opts.display.dim_header = false; - opts.display -} - -/// The strict report of a PDF export of `doc`: every page, laid out as the export lays it out. -pub(crate) fn pdf_report(doc: &Document, opts: &ExportOptions) -> StrictReport { - let pdf = pdf_options(opts); - let lay = wordcraft_layout::layout(doc, &mut LayoutCache::new(), &pdf.layout_options()); - strict_report(doc, &lay, &pdf.display_options(), None, StrictOutput::Pdf) -} - -/// The PDF of `doc`; with `opts.strict`, only if nothing is missing, laid out once for the check -/// and the export. -pub(crate) fn export_pdf(doc: &Document, opts: &ExportOptions) -> Result, String> { - let pdf = pdf_options(opts); - if !opts.strict { - return wordcraft_pdf::export(doc, &pdf).map_err(|e| e.to_string()); - } - let lay = wordcraft_layout::layout(doc, &mut LayoutCache::new(), &pdf.layout_options()); - let report = strict_report(doc, &lay, &pdf.display_options(), None, StrictOutput::Pdf); - if !report.passed() { - return Err(report.refusal()); - } - wordcraft_pdf::export_layout(doc, &lay, &pdf).map_err(|e| e.to_string()) -} - -/// Is `path` a PDF or PNG output (the only outputs a strict export applies to)? -pub(crate) fn is_strict_output(path: &str) -> bool { - let lower = path.to_ascii_lowercase(); - lower.ends_with(".pdf") || lower.ends_with(".png") -} +//! Strict export (#6005): the report of what a PDF or PNG would leave out silently (characters no +//! face can draw, pictures that are missing or can't be decoded), and the refusal built from it. +//! +//! The check runs on the layout and display options the export itself uses, so what it reports is +//! what the output lacks. + +use std::ops::RangeInclusive; + +use serde_json::{Value, json}; +use wordcraft_doc::{Document, MAX_MISSING_PICTURES, MissingPicture}; +use wordcraft_layout::display::DisplayOptions; +use wordcraft_layout::{DocLayout, LayoutCache, StrictOutput}; + +use crate::io::ExportOptions; + +/// The report's JSON schema version; fields may be added without raising it. +pub const SCHEMA: u32 = 1; + +/// What a strict export found missing. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct StrictReport { + /// (code point, times drawn), sorted by code point. + pub glyphs: Vec<(char, u64)>, + pub pictures: Vec, + /// More distinct characters were missing than are listed. + pub glyphs_truncated: bool, + /// More pictures were missing than are listed. + pub pictures_truncated: bool, + /// Distinct characters found missing, those past the list's cap included. + pub total_glyphs: usize, + /// Pictures found missing, those past the list's cap included. + pub total_pictures: u64, +} + +impl StrictReport { + /// Nothing is missing: the strict export passes. + pub fn passed(&self) -> bool { + self.glyphs.is_empty() && self.pictures.is_empty() && !self.truncated() + } + + /// Either list was cut at its cap. + pub fn truncated(&self) -> bool { + self.glyphs_truncated || self.pictures_truncated + } + + /// The schema-1 JSON of the report. + pub fn to_json(&self) -> Value { + json!({ + "schema": SCHEMA, + "strict": if self.passed() { "passed" } else { "failed" }, + "missing_glyphs": self.glyphs.iter().map(|(c, n)| json!({"cp": code_point(*c), "count": n})).collect::>(), + "missing_pictures": self.pictures.iter().map(|p| json!({"part": p.part, "source_part": p.story, "reason": p.reason.code()})).collect::>(), + "truncated": self.truncated(), + "total_missing_glyphs": self.total_glyphs, + "total_missing_pictures": self.total_pictures, + "glyphs_truncated": self.glyphs_truncated, + "pictures_truncated": self.pictures_truncated, + }) + } + + /// The human lines (without the `strict: ` prefix): one for the characters, one for the + /// pictures; none when nothing is missing. + fn parts(&self) -> Vec { + let glyphs: Vec<(String, u64)> = self.glyphs.iter().map(|(c, n)| (code_point(*c), *n)).collect(); + let pictures: Vec<(String, String, String)> = + self.pictures.iter().map(|p| (p.part.clone(), story_label(&p.story).to_string(), p.reason.label().to_string())).collect(); + human_parts(&glyphs, &pictures, (self.glyphs_truncated, self.pictures_truncated), (self.total_glyphs as u64, self.total_pictures)) + } + + /// The human lines, each `strict: …`. + pub fn lines(&self) -> Vec { + self.parts().into_iter().map(|p| format!("strict: {p}")).collect() + } + + /// The refusal's message: `strict: …`, the lines joined. + pub fn refusal(&self) -> String { + format!("strict: {}", self.parts().join("; ")) + } +} + +/// The human text of a report: (code point text, count) and (part, story label, reason words). +fn human_parts(glyphs: &[(String, u64)], pictures: &[(String, String, String)], cut: (bool, bool), totals: (u64, u64)) -> Vec { + let (glyphs_cut, pictures_cut) = cut; + let mut out = Vec::new(); + if !glyphs.is_empty() { + let list: Vec = glyphs.iter().map(|(cp, n)| format!("{cp} (×{n})")).collect(); + let more = if glyphs_cut { ", …" } else { "" }; + let listed = glyphs.len() as u64; + let n = if glyphs_cut { totals.0.max(listed) } else { listed }; + let first = if n > listed { format!(" (first {listed} listed)") } else { String::new() }; + out.push(format!("{n} character{} cannot be drawn{first}: {}{more}", if n == 1 { "" } else { "s" }, list.join(", "))); + } + if !pictures.is_empty() { + let list: Vec = pictures + .iter() + .map(|(part, at, reason)| if at.is_empty() { format!("{part}: {reason}") } else { format!("{part} ({at}): {reason}") }) + .collect(); + let more = if pictures_cut { "; …" } else { "" }; + let listed = pictures.len() as u64; + let n = if pictures_cut { totals.1.max(listed) } else { listed }; + let first = if n > listed { format!(" (first {listed} listed)") } else { String::new() }; + out.push(format!("{n} picture(s) left out{first}: {}{more}", list.join("; "))); + } + out +} + +/// The `strict: …` human lines of a report in its JSON form (what `file.strictReport` returns). +/// Anything the JSON lacks is left out. +pub fn lines_from_json(report: &Value) -> Vec { + let text = |v: &Value, k: &str| v.get(k).and_then(Value::as_str).unwrap_or_default().to_string(); + let glyphs: Vec<(String, u64)> = report + .get("missing_glyphs") + .and_then(Value::as_array) + .map(|a| a.iter().map(|g| (text(g, "cp"), g.get("count").and_then(Value::as_u64).unwrap_or(0))).collect()) + .unwrap_or_default(); + let pictures: Vec<(String, String, String)> = report + .get("missing_pictures") + .and_then(Value::as_array) + .map(|a| a.iter().map(|p| (text(p, "part"), story_label(&text(p, "source_part")).to_string(), reason_label(&text(p, "reason")))).collect()) + .unwrap_or_default(); + let flag = |k: &str| report.get(k).and_then(Value::as_bool).unwrap_or(false); + // A report without the per-list flags (the top-level one alone) marks both lists. + let (glyphs_cut, pictures_cut) = match (report.get("glyphs_truncated"), report.get("pictures_truncated")) { + (None, None) => (flag("truncated"), flag("truncated")), + _ => (flag("glyphs_truncated"), flag("pictures_truncated")), + }; + let total = |k: &str, listed: usize| report.get(k).and_then(Value::as_u64).unwrap_or(listed as u64); + let totals = (total("total_missing_glyphs", glyphs.len()), total("total_missing_pictures", pictures.len())); + human_parts(&glyphs, &pictures, (glyphs_cut, pictures_cut), totals).into_iter().map(|p| format!("strict: {p}")).collect() +} + +/// The words for a JSON reason code; a code this version does not know is shown as it is. +fn reason_label(code: &str) -> String { + wordcraft_doc::MissingPictureReason::from_code(code).map_or_else(|| code.to_string(), |r| r.label().to_string()) +} + +/// `U+2D30`: upper-case hex, at least four digits. +fn code_point(c: char) -> String { + format!("U+{:04X}", c as u32) +} + +/// The part a picture is in, as the report names it: `header2.xml` for `word/header2.xml`. +fn story_label(story: &str) -> &str { + story.strip_prefix("word/").unwrap_or(story) +} + +/// The report of `doc` as drawn by `lay` with `display` into `output`, on `pages` (0-based; `None` +/// is every page). The watermark is checked with the page, and the pictures dropped when the +/// document was opened are added to those that can't be decoded now, capped like each. +pub fn strict_report( + doc: &Document, + lay: &DocLayout, + display: &DisplayOptions, + pages: Option>, + output: StrictOutput, +) -> StrictReport { + let mut missing = lay.missing_glyphs(doc, display, pages.clone()); + let any_page = lay.pages.iter().enumerate().any(|(n, _)| pages.as_ref().is_none_or(|r| r.contains(&n))); + if any_page && let Some(wm) = &doc.settings.watermark { + let max = match output { + StrictOutput::Pdf => wordcraft_pdf::WATERMARK_MAX_CHARS, + StrictOutput::Png => usize::MAX, + }; + missing.merge(wordcraft_layout::missing_watermark_glyphs(wm, max)); + } + let (undecodable, mut pictures_truncated, undecodable_total) = wordcraft_layout::undecodable_pictures(doc, lay, pages, display, output); + pictures_truncated |= doc.missing_pictures.truncated; + // The two lists overlap where the same picture is in both; those are counted once. + let mut overlap: u64 = 0; + let mut pictures: Vec = Vec::new(); + for p in doc.missing_pictures.list.iter().cloned().chain(undecodable) { + if pictures.contains(&p) { + overlap = overlap.saturating_add(1); + continue; + } + if pictures.len() >= MAX_MISSING_PICTURES { + pictures_truncated = true; + break; + } + pictures.push(p); + } + let total_pictures = doc.missing_pictures.total.saturating_add(undecodable_total).saturating_sub(overlap).max(pictures.len() as u64); + StrictReport { + glyphs: missing.chars, + pictures, + glyphs_truncated: missing.truncated, + pictures_truncated, + total_glyphs: missing.total, + total_pictures, + } +} + +/// The PDF export's options for `opts`: the one place they are built, shared by the export and +/// its strict check. +pub(crate) fn pdf_options(opts: &ExportOptions) -> wordcraft_pdf::PdfOptions { + wordcraft_pdf::PdfOptions { include_markup: opts.markup, comments: opts.markup && opts.comments, numeral: opts.numeral, ..Default::default() } +} + +/// What a page image shows: the display options `file.exportPng` renders with. +pub(crate) fn png_display() -> DisplayOptions { + // A page image is the printed page: headers and footers at full strength. + let mut opts = wordcraft_render::RenderOptions::default(); + opts.display.dim_header = false; + opts.display +} + +/// The strict report of a PDF export of `doc`: every page, laid out as the export lays it out. +pub(crate) fn pdf_report(doc: &Document, opts: &ExportOptions) -> StrictReport { + let pdf = pdf_options(opts); + let lay = wordcraft_layout::layout(doc, &mut LayoutCache::new(), &pdf.layout_options()); + strict_report(doc, &lay, &pdf.display_options(), None, StrictOutput::Pdf) +} + +/// The PDF of `doc`; with `opts.strict`, only if nothing is missing, laid out once for the check +/// and the export. +pub(crate) fn export_pdf(doc: &Document, opts: &ExportOptions) -> Result, String> { + let pdf = pdf_options(opts); + if !opts.strict { + return wordcraft_pdf::export(doc, &pdf).map_err(|e| e.to_string()); + } + let lay = wordcraft_layout::layout(doc, &mut LayoutCache::new(), &pdf.layout_options()); + let report = strict_report(doc, &lay, &pdf.display_options(), None, StrictOutput::Pdf); + if !report.passed() { + return Err(report.refusal()); + } + wordcraft_pdf::export_layout(doc, &lay, &pdf).map_err(|e| e.to_string()) +} + +/// Is `path` a PDF or PNG output (the only outputs a strict export applies to)? +pub(crate) fn is_strict_output(path: &str) -> bool { + let lower = path.to_ascii_lowercase(); + lower.ends_with(".pdf") || lower.ends_with(".png") +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 7f4308a813..a05053b78b 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1,13495 +1,13495 @@ -// A copy of craft-proof's hand-written Czech fixture (`crates/proof/src/cs/test_dict.rs` in -// craft-libs, which a dependency's tests can't share); its install helper resolves this parent import. -use wordcraft_proof::cs::install_dictionary; -mod czech_test_dict; - -use serde_json::json; -use std::sync::Arc; -use wordcraft_doc::props::BorderStyle; -use wordcraft_doc::{Pos, StoryRef}; - -use crate::{Session, cmd}; - -mod bidi; -mod documents; -mod find_apostrophes; -mod fit_one_page; -mod font_sizes; -mod fonts; -mod ime_noop; -mod para_tools; -mod revision_checks; -mod typing; -mod wordart; - -/// True, after saying so on stderr, when this build lacks the Language data the proofing tests -/// need (craft-proof built without `CRAFT_I18N_DIR`). The probes are craft-proof's public -/// `english_source` (the embedded English dictionary) and its American/British variant table -/// (`colour` → `color`): a craft-i18n checkout without some of the files embeds only the rest, -/// so both are asked. Without them English still spells from craft-proof's built-in word list, -/// but there are no hyphenation patterns and no variant table, so the tests that check those -/// return early, as craft-proof's own do; with the data (CI, releases) they all run. -pub(crate) fn no_language_data(test: &str) -> bool { - let missing = wordcraft_proof::english_source(wordcraft_proof::ProofLang::En) != Some(wordcraft_proof::EnglishSource::Embedded) - || wordcraft_proof::en_variants::preferred("colour", wordcraft_proof::ProofLang::En).as_deref() != Some("color"); - if missing { - eprintln!("{test}: skipped, needs the Language data (set CRAFT_I18N_DIR to a craft-i18n checkout)"); - } - missing -} - -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}"); -} - -/// Gap 9: AES-encrypted Word files — Standard encryption (Word 2007) and agile packages — open -/// with the right password whatever Word extension they carry, and ask for one or refuse a wrong -/// one the same way. -#[test] -fn aes_encrypted_packages_open_under_any_word_extension() { - let mut doc = wordcraft_doc::Document::from_text("Old secret minutes"); - doc.core.title = "Minutes".into(); - let pkg = wordcraft_docx::write(&doc).unwrap(); - let standard = wordcraft_docx::encrypt_standard_for_tests(&pkg, "letmein", 128).unwrap(); - let agile = wordcraft_docx::encrypt_with_spin_count(&pkg, "letmein", 8).unwrap(); - for (kind, bytes) in [("standard", &standard), ("agile", &agile)] { - for name in ["old.doc", "old.docx", "old.dot", "noext"] { - let (d, encrypted) = crate::io::open_bytes_with(name, bytes, Some("letmein")).unwrap_or_else(|e| panic!("{kind} {name}: {e}")); - assert!(encrypted, "{kind} {name}"); - assert!(d.plain_text(StoryRef::Body).contains("Old secret minutes"), "{kind} {name}"); - assert_eq!(d.core.title, "Minutes"); - let e = crate::io::open_bytes_with(name, bytes, None).unwrap_err(); - assert!(crate::io::needs_password(&e), "{kind} {name}: {e}"); - let e = crate::io::open_bytes_with(name, bytes, Some("letmeout")).unwrap_err(); - assert!(crate::io::wrong_password(&e), "{kind} {name}: {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")); -} - -/// The command a shortcut runs. -fn id(k: &str) -> Option<&'static str> { - cmd::registry().by_shortcut(k).map(|c| c.id) -} - -#[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). - 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")); -} - -#[test] -fn word_default_shortcuts_resolve_per_platform() { - 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")); - } -} - -#[test] -fn word_default_shortcuts_resolve_function_keys() { - 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 confined_sessions_neither_save_the_dictionary_nor_choose_folders_outside_their_roots() { - // #3737 (findings 34 and WC-35): the custom dictionary file and the folder settings are - // held to the automation roots like any other path. - let base = scratch_dir("dict-roots"); - for d in ["in", "out", "outside"] { - std::fs::create_dir_all(base.join(d)).unwrap(); - } - let roots = crate::file_access::AutomationRoots::new(Some(&base.join("in")), Some(&base.join("out"))).unwrap(); - let mut s = s(); - let outside = base.join("outside/custom-dictionary.txt"); - s.user_dict_path = Some(outside.clone()); - let confined = |s: &mut Session, cmd: &str, v: serde_json::Value| crate::file_access::confine(roots.as_ref(), || s.run(cmd, &v)); - assert!(confined(&mut s, "review.addToDictionary", json!({"word": "zxqengconfined"})).is_ok()); - assert!(wordcraft_proof::user_dictionary().contains(&"zxqengconfined".to_string()), "kept in memory"); - assert!(!outside.exists(), "nothing written outside the write root"); - for key in ["dictionariesFolder", "userTemplatesFolder"] { - assert!(confined(&mut s, "file.options", json!({key: base.join("outside").to_string_lossy()})).is_err(), "{key}"); - } - assert_eq!((s.prefs.dictionaries_folder.as_deref(), s.prefs.user_templates_folder.as_deref()), (None, None)); - // A folder inside the read root is still taken. - let inside = base.join("in"); - assert!(confined(&mut s, "file.options", json!({"userTemplatesFolder": inside.to_string_lossy()})).is_ok()); - let _ = std::fs::remove_dir_all(&base); - assert_eq!(s.prefs.user_templates_folder.as_deref(), Some(inside.to_string_lossy().as_ref())); -} - -#[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"); -} - -/// A command that changes nothing is not an edit (#23): no undo step, no unsaved changes -/// and no relayout. -#[test] -fn a_command_that_changes_nothing_is_not_an_edit() { - let mut s = s(); - let rev = s.rev(); - // Backspace at the start of the document: there is nothing to delete. - run(&mut s, "text.backspace", json!({})); - assert_eq!(s.undo_depth(), 0, "no undo step"); - assert!(!s.dirty, "no unsaved changes"); - assert_eq!(s.rev(), rev, "no relayout"); - // Typing nothing is the same. - run(&mut s, "text.insert", json!({"text": ""})); - assert_eq!(s.undo_depth(), 0, "no undo step"); - assert!(!s.dirty, "no unsaved changes"); - // A command that changes something is an edit. - run(&mut s, "text.insert", json!({"text": "Hello"})); - assert_eq!(s.undo_depth(), 1, "one undo step"); - assert!(s.dirty, "the document is edited"); - // Setting the alignment a paragraph already has changes nothing. - let depth = s.undo_depth(); - run(&mut s, "para.align", json!({"value": "center"})); - assert_eq!(s.undo_depth(), depth + 1, "one undo step"); - let depth = s.undo_depth(); - run(&mut s, "para.align", json!({"value": "center"})); - assert_eq!(s.undo_depth(), depth, "no undo step"); - // A command that changes nothing leaves the unsaved-changes flag and the layout revision as - // they were, even when it calls `touch` itself: a saved document is not marked edited for - // nothing, and its layout is not laid out again. - let mut clean = Session::new(wordcraft_doc::Document::new()); - run(&mut clean, "para.align", json!({"value": "left"})); - clean.dirty = false; - let rev = clean.rev(); - run(&mut clean, "para.align", json!({"value": "left"})); - assert!(!clean.dirty, "no unsaved changes"); - assert_eq!(clean.rev(), rev, "no relayout"); - assert_eq!(clean.undo_depth(), 1, "only the first align is a step"); -} - -/// Five hundred commands that change nothing used to fill the whole undo stack and push the -/// user's own edits out of it, so they could never be undone again (#23). -#[test] -fn no_op_commands_do_not_evict_the_undo_history() { - let mut s = s(); - run(&mut s, "text.insert", json!({"text": "Hello"})); - run(&mut s, "caret.docStart", json!({})); - for _ in 0..600 { - run(&mut s, "text.backspace", json!({})); - } - assert_eq!(s.undo_depth(), 1, "the typing step is still there"); - run(&mut s, "edit.undo", json!({})); - assert_eq!(text(&s), "", "the typed text is still undoable"); -} - -/// A command that changes nothing does not throw the redo stack away (#23). -#[test] -fn a_no_op_command_keeps_the_redo_stack() { - let mut s = s(); - run(&mut s, "text.insert", json!({"text": "Hello"})); - run(&mut s, "edit.undo", json!({})); - assert!(s.can_redo()); - run(&mut s, "text.backspace", json!({})); - assert!(s.can_redo(), "the redo stack survives a command that changed nothing"); - run(&mut s, "edit.redo", json!({})); - assert_eq!(text(&s), "Hello"); -} - -/// Undo and Redo move between steps even when a step's document is the one showing: they are -/// never taken back as commands that changed nothing, so Undo can't get stuck on such a step. -#[test] -fn undo_moves_past_a_step_that_changed_nothing() { - let mut s = s(); - run(&mut s, "text.insert", json!({"text": "Hello"})); - s.checkpoint("Nothing"); - let depth = s.undo_depth(); - run(&mut s, "edit.undo", json!({})); - assert_eq!(s.undo_depth(), depth - 1); - assert!(s.can_redo()); - run(&mut s, "edit.redo", json!({})); - assert_eq!(s.undo_depth(), depth); - run(&mut s, "edit.undo", json!({})); - run(&mut s, "edit.undo", json!({})); - assert_eq!(text(&s), ""); -} - -/// Commands that run as one undo step (`join_next_undo`: a dialog's OK, the frames of a drag): -/// a group whose first command changes nothing has no step to merge into, so the next command -/// in it starts the group's step instead of merging into an older edit (#23 follow-up). -#[test] -fn a_joined_command_starts_its_group_when_the_first_changed_nothing() { - let mut s = s(); - run(&mut s, "text.insert", json!({"text": "Hello"})); - let (depth, margins) = (s.undo_depth(), (s.doc.last_section.margin_left, s.doc.last_section.margin_top)); - // Portrait on a portrait page changes nothing. - run(&mut s, "layout.orientation", json!({"value": "portrait"})); - assert_eq!(s.undo_depth(), depth, "the first command of the group is no edit"); - // The next command of the group starts the step the group needs. - s.join_next_undo(); - run(&mut s, "layout.margins", json!({"left": 100.0})); - assert_eq!(s.doc.last_section.margin_left, 100.0); - assert_eq!(s.undo_depth(), depth + 1, "the group's one step"); - // A command that changes nothing in the middle of the group leaves that step alone, and - // the next one merges into it. - s.join_next_undo(); - run(&mut s, "layout.orientation", json!({"value": "portrait"})); - s.join_next_undo(); - run(&mut s, "layout.margins", json!({"top": 60.0})); - assert_eq!(s.undo_depth(), depth + 1, "still the group's one step"); - // Undo takes the whole group back and no more: the typing is still there. - run(&mut s, "edit.undo", json!({})); - assert_eq!((s.doc.last_section.margin_left, s.doc.last_section.margin_top), margins, "the margins the group started from"); - assert_eq!(text(&s), "Hello"); -} - -/// Bold with nothing selected arms the formatting for the next word: the document is the -/// same, so only the caret's formatting changed — and that is an edit (#23). -#[test] -fn a_command_that_only_arms_the_caret_formatting_is_an_edit() { - let mut s = s(); - run(&mut s, "text.insert", json!({"text": "Hello"})); - run(&mut s, "caret.docStart", json!({})); - let depth = s.undo_depth(); - run(&mut s, "format.bold", json!({})); - assert_eq!(s.undo_depth(), depth + 1, "one undo step"); - // Undo takes the armed formatting back, as Word does (docs/typing-parity.md). - run(&mut s, "edit.undo", json!({})); - run(&mut s, "text.insert", json!({"text": "x"})); - assert_eq!(s.doc.para_at(&s.sel.focus).map(|p| p.props_of_char(0).bold), Some(None)); -} - -/// 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(); - c.date_utc = None; - }); - 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 regex_replace_expands_groups() { - // #4177: with Regular expressions on, Replace with expands capture groups. - let rep = |text: &str, q: &str, with: &str| rep_mode(text, q, with, json!({"regex": true})); - let names = "Smith, John and Doe, Jane"; - // The issue's cases: `\n` and `$n` swap words; `${name}` names a group. - assert_eq!(rep(names, r"(\w+), (\w+)", r"\2 \1"), "John Smith and Jane Doe"); - assert_eq!(rep(names, r"(\w+), (\w+)", "$2 $1"), "John Smith and Jane Doe"); - assert_eq!(rep("2024-05", r"(?\d{4})-", "${y}/"), "2024/05"); - // Braced numbers, `$0` / `${0}` (whole match), `$$` (a dollar sign). - assert_eq!(rep("ab", "(a)(b)", "${2}${1}"), "ba"); - assert_eq!(rep("ab", "a", "[$0|${0}]"), "[a|a]b"); - assert_eq!(rep("5", r"(\d)", "$$$1"), "$5"); - // `$name` takes the longest run of letters, digits and `_` (the regex crate's rule). - assert_eq!(rep("ab", "(?a)", "$x_"), "b"); - assert_eq!(rep("ab", "(?a)", "${x}_"), "a_b"); - assert_eq!(rep("ab", "(a)", "$1a"), "b"); - // Missing, unknown and out-of-range groups expand to nothing. - assert_eq!(rep("ab", "(a)", "<$2>"), "<>b"); - assert_eq!(rep("ab", "(a)", r"<\9>"), "<>b"); - assert_eq!(rep("ab", "(a)", "<${nope}>"), "<>b"); - assert_eq!(rep("ab", "(a)", "<$99999999999999999999999>"), "<>b"); - assert_eq!(rep("ab", "(a)|(z)", "<$2>"), "<>b"); - // A `$` that starts no group, and an unclosed brace, stay as written. - assert_eq!(rep("ab", "a", "$ ${x"), "$ ${xb"); - // `\t` a tab, `\\` a backslash; other escapes stay as written. - assert_eq!(rep("ab", "a", r"\t\\\q"), "\t\\\\qb"); - // Word's `^` codes still work. - assert_eq!(rep("ab", "a", "^&^t^&"), "a\tab"); - // `\$` and `\^` keep the character as typed (CodeRabbit on #4177). - assert_eq!(rep("ab", "(a)", r"\$1\^p"), "$1^pb"); - // `special: false` turns off the `^` codes only; groups still expand (CodeRabbit on #4177). - assert_eq!(rep_mode("ab", "(a)", "$1^t", json!({"regex": true, "special": false})), "a^tb"); - // `\n` is a paragraph mark: it splits the paragraph, as `^p` does, in one undo step. - let mut s = s(); - run(&mut s, "document.setText", json!({"text": "one, two"})); - assert_eq!(run(&mut s, "edit.replaceAll", json!({"text": r"(\w+), (\w+)", "with": r"$1\n$2", "regex": true}))["replaced"], 1); - assert_eq!(text(&s), "one\ntwo"); - assert_eq!(s.doc.para_paths(StoryRef::Body).len(), 2); - run(&mut s, "edit.undo", json!({})); - assert_eq!(text(&s), "one, two"); - run(&mut s, "document.setText", json!({"text": "a-b"})); - run(&mut s, "edit.replaceAll", json!({"text": "-", "with": "^p", "regex": true})); - assert_eq!(s.doc.para_paths(StoryRef::Body).len(), 2); - // Plain and wildcard Replace are unchanged: `$1` is literal there. - assert_eq!(rep_mode("ab", "a", "$1", json!({})), "$1b"); - assert_eq!(rep_mode("ab", "(a)", "$1\\1", json!({"wildcards": true})), "$1ab"); - // Hostile Replace text never panics. - run(&mut s, "document.setText", json!({"text": "ab ab"})); - for with in ["$", "${", "${}", "${0", "$\u{e9}", "\\", "${\u{1F600}}", "$18446744073709551616", "${-1}", "$$$", "\\0\\n\\", "^"] { - let _ = s.run("edit.replaceAll", &json!({"text": "(a)(?b)?", "with": with, "regex": true})); - } -} - -/// The body text after Replace All of `q` with `with` in `text`, matching case, with `extra` options. -fn rep_mode(text: &str, q: &str, with: &str, extra: serde_json::Value) -> String { - let mut s = s(); - run(&mut s, "document.setText", json!({"text": text})); - let mut v = json!({"text": q, "with": with, "matchCase": true}); - if let (Some(o), Some(e)) = (v.as_object_mut(), extra.as_object()) { - o.extend(e.clone()); - } - run(&mut s, "edit.replaceAll", v); - self::text(&s) -} - -#[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"); -} - -/// Whether any run in the body is bold. -fn any_bold(s: &Session) -> bool { - s.doc.body.iter().filter_map(|b| b.as_para()).flat_map(|p| p.runs.iter()).any(|r| r.props.bold == Some(true)) -} - -#[test] -fn paste_from_another_program_does_not_bring_back_our_fragment() { - // Review on #6225: when another program owns the clipboard, matching text must not make - // Paste put WordCraft's own formatted fragment back. - let mut s = s(); - run(&mut s, "document.setText", json!({"text": "Bold"})); - run(&mut s, "select.all", json!({})); - run(&mut s, "format.bold", json!({})); - run(&mut s, "edit.copy", json!({})); - assert_eq!(s.clipboard_text, "Bold"); - - // The same text, copied by another program: paste it as plain text, not our bold fragment. - run(&mut s, "document.setText", json!({"text": "Plain "})); - run(&mut s, "caret.docEnd", json!({})); - run(&mut s, "edit.paste", json!({"text": "Bold", "privateClipboard": false})); - assert_eq!(text(&s), "Plain Bold"); - assert!(!any_bold(&s), "{:?}", s.doc.body); - - // Callers that omit the flag (an agent, the CLI) still get the session's own copy. - run(&mut s, "caret.docEnd", json!({})); - run(&mut s, "edit.paste", json!({"text": "Bold"})); - assert!(any_bold(&s), "{:?}", s.doc.body); -} - -#[test] -fn paste_special_does_not_offer_our_fragment_for_another_programs_copy() { - let mut s = s(); - run(&mut s, "document.setText", json!({"text": "Bold"})); - run(&mut s, "select.all", json!({})); - run(&mut s, "format.bold", json!({})); - run(&mut s, "edit.copy", json!({})); - - let r = run(&mut s, "edit.pasteSpecial", json!({"text": "Bold", "privateClipboard": false})); - let ids: Vec<&str> = r["formats"].as_array().map(|a| a.iter().filter_map(|f| f["id"].as_str()).collect()).unwrap_or_default(); - assert!(!ids.contains(&"formatted"), "{ids:?}"); - assert_eq!(s.ui_requests.last().and_then(|r| r.get("privateClipboard")), Some(&json!(false)), "{:?}", s.ui_requests); - - let e = s.run("edit.pasteSpecial", &json!({"as": "formatted", "text": "Bold", "privateClipboard": false})); - assert!(e.is_err(), "another program's copy cannot be pasted as our fragment"); -} - -/// 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); -} - -/// Bold and italic of a paragraph's character, in a document. -fn emphasis_at(d: &wordcraft_doc::Document, block: usize, off: usize) -> (bool, bool) { - match d.body.get(block).map(|b| &**b) { - Some(wordcraft_doc::Block::Para(p)) => { - let c = p.props_of_char(off); - (c.bold.unwrap_or(false), c.italic.unwrap_or(false)) - } - _ => (false, false), - } -} - -#[test] -fn copying_puts_rtf_on_the_clipboard() { - // gap-clipboard-rtf: a copy carries RTF beside its text and HTML, for programs that read RTF. - let mut s = s(); - run(&mut s, "document.setText", json!({"text": "plain bold\nsome italic"})); - s.doc.format_range(&Pos::body(0, 6), &Pos::body(0, 10), &|c| c.bold = Some(true)).unwrap(); - s.doc.format_range(&Pos::body(1, 5), &Pos::body(1, 11), &|c| c.italic = Some(true)).unwrap(); - run(&mut s, "select.all", json!({})); - let r = run(&mut s, "edit.copy", json!({})); - let rtf = r["rtf"].as_str().unwrap_or_default().to_string(); - assert!(rtf.starts_with("{\\rtf1"), "{r}"); - assert_eq!(s.clipboard_rtf, rtf); - let d = wordcraft_formats::rtf::import(rtf.as_bytes()).unwrap(); - let paras: Vec = - d.body.iter().filter_map(|b| if let wordcraft_doc::Block::Para(p) = &**b { Some(p.text.clone()) } else { None }).collect(); - assert_eq!(paras.get(..2), Some(&["plain bold".to_string(), "some italic".to_string()][..]), "{paras:?}"); - assert_eq!(emphasis_at(&d, 0, 0), (false, false)); - assert_eq!(emphasis_at(&d, 0, 7), (true, false)); - assert_eq!(emphasis_at(&d, 1, 7), (false, true)); - - // Pasting it elsewhere keeps the formatting. - let mut t = Session::new(wordcraft_doc::Document::new()); - run(&mut t, "edit.paste", json!({"rtf": rtf})); - assert!(text(&t).starts_with("plain bold\nsome italic"), "{}", text(&t)); - assert_eq!(emphasis_at(&t.doc, 0, 7), (true, false)); - assert_eq!(emphasis_at(&t.doc, 1, 7), (false, true)); - - // Cut carries it too. - run(&mut s, "select.range", json!({"anchor": {"block": 0, "off": 6}, "focus": {"block": 0, "off": 10}})); - let r = run(&mut s, "edit.cut", json!({})); - assert!(r["rtf"].as_str().is_some_and(|t| t.starts_with("{\\rtf1") && t.contains("bold")), "{r}"); - assert_eq!(text(&s).lines().next(), Some("plain ")); - - // Nothing selected: nothing copied, no RTF. - run(&mut s, "caret.docEnd", json!({})); - let r = run(&mut s, "edit.copy", json!({})); - assert!(r.get("rtf").is_none(), "{r}"); -} - -#[test] -fn pasting_hostile_rtf_never_panics() { - let deep = format!("{{\\rtf1 {}x{}", "{\\b ".repeat(5000), "}".repeat(5000)); - let junk = ["{\\rtf1", "{\\rtf1 {\\b bo", "{\\rtf1\\ansi\\u-99999999999? \\bin999999999 x}", "{\\rtf1 \\'zz\\fonttbl{", "{\\rtf\u{0}\u{FFFF}}"]; - for rtf in junk.iter().map(|j| j.to_string()).chain([deep]) { - for id in ["edit.paste", "edit.pasteMerge", "edit.pasteSpecial"] { - let mut t = Session::new(wordcraft_doc::Document::new()); - // `Session::run` turns a panic into an error: look for it. - for v in [json!({"rtf": rtf, "as": "rtf"}), json!({"rtf": rtf, "text": "fallback"})] { - if let Err(e) = t.run(id, &v) { - assert!(!e.to_string().contains("panicked"), "{id}: {e}"); - } - let _ = t.layout(); - } - } - } -} - -#[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() }; - Arc::make_mut(&mut 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::default(), "Numbering toggles off the paragraph's effective numbered level"); - run(&mut s, "para.numbering", json!({})); - run(&mut s, "para.listLevel", json!({"level": 1})); - 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 convert_text_to_table_keeps_inline_content() { - // Issue #3751: the cells were rebuilt from plain text, losing formatting, pictures, links - // and tracked deletions. - use wordcraft_doc::para::InlineObject; - let mut s = s(); - run(&mut s, "document.setText", json!({"text": "Name\t\tLink\tOld"})); - { - let p = s.doc.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap(); - p.format(0, 4, &|c| c.bold = Some(true)).unwrap(); - p.format(6, 10, &|c| c.link = Some("https://example.com".into())).unwrap(); - p.format(11, 14, &|c| c.del = Some(1)).unwrap(); - let img = InlineObject::Image { - media: "pic".into(), - w: 10.0, - h: 10.0, - alt: String::new(), - float: Default::default(), - crop: [0.0; 4], - fx: Default::default(), - ole: None, - }; - p.insert_object(5, img, &Default::default()).unwrap(); - } - run(&mut s, "select.all", json!({})); - run(&mut s, "table.fromText", json!({})); - let t = s.doc.body.iter().find_map(|b| b.as_table()).expect("a table"); - let cell = |c: usize| t.rows[0].cells[c].blocks.iter().find_map(|b| b.as_para()).expect("a paragraph"); - assert_eq!(cell(0).text, "Name"); - assert_eq!(cell(0).props_of_char(0).bold, Some(true), "cell 1 lost bold"); - assert!(matches!(cell(1).objects.as_slice(), [InlineObject::Image { media, .. }] if media == "pic"), "cell 2 lost its picture"); - assert_eq!(cell(2).text, "Link"); - assert_eq!(cell(2).props_of_char(0).link.as_deref(), Some("https://example.com"), "cell 3 lost its link"); - assert_eq!(cell(3).text, "Old"); - assert_eq!(cell(3).props_of_char(0).del, Some(1), "cell 4 lost its tracked deletion"); -} - -/// A table `cols` wide holding `cells` row by row, typed in with Tab; the caret ends in the -/// last cell. -fn typed_table(cols: usize, cells: &[&str]) -> Session { - let mut s = s(); - run(&mut s, "insert.table", json!({"rows": cells.len() / cols, "cols": cols})); - 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 -} - -/// The body as `T` for a table and the text of each paragraph. -fn body_outline(s: &Session) -> Vec { - s.doc.body.iter().map(|b| b.as_para().map_or_else(|| "T".to_string(), |p| p.text.clone())).collect() -} - -fn cell_at(table: u32, r: u32, c: u32) -> serde_json::Value { - json!({"story": "body", "path": [table, r, c, 0], "off": 0}) -} - -/// Convert to Text sets a row's cells apart with a tab, a comma, another character or -/// paragraph marks; rows are paragraphs. -#[test] -fn convert_to_text_takes_each_separator() { - let cases = [ - (json!({}), vec!["a\tb", "c\td"]), - (json!({"separator": "tab"}), vec!["a\tb", "c\td"]), - (json!({"separator": "comma"}), vec!["a,b", "c,d"]), - (json!({"separator": "other", "char": ";"}), vec!["a;b", "c;d"]), - (json!({"separator": "paragraph"}), vec!["a", "b", "c", "d"]), - ]; - for (params, want) in cases { - let mut s = typed_table(2, &["a", "b", "c", "d"]); - run(&mut s, "table.toText", params.clone()); - let got = body_outline(&s); - assert_eq!(got.get(..want.len()), Some(&want.iter().map(|x| x.to_string()).collect::>()[..]), "{params}: {got:?}"); - assert!(s.doc.body.iter().all(|b| b.as_table().is_none()), "{params}"); - } - // U+FFFC marks an object in a paragraph's text, so it cannot be the separator. - let obj = wordcraft_doc::para::OBJ.to_string(); - for bad in [ - json!({"separator": "other"}), - json!({"separator": "other", "char": "\n"}), - json!({"separator": "other", "char": obj}), - json!({"separator": "pipe"}), - ] { - let mut s = typed_table(2, &["a", "b", "c", "d"]); - let depth = s.undo_depth(); - assert!(s.run("table.toText", &bad).is_err(), "{bad}"); - assert!(s.doc.body.iter().any(|b| b.as_table().is_some()), "{bad}: the table stays"); - assert_eq!(s.undo_depth(), depth); - } -} - -/// With rows selected, only those become text; the rows above and below stay tables, and a -/// vertical merge cut by the selection starts again below it. -#[test] -fn convert_to_text_converts_the_selected_rows() { - let mut s = typed_table(2, &["a", "b", "c", "d", "e", "f", "g", "h"]); - let t = s.doc.body.iter().position(|b| b.as_table().is_some()).unwrap() as u32; - // Column 1 merged down rows 1 to 3. - run(&mut s, "select.range", json!({"anchor": cell_at(t, 1, 1), "focus": cell_at(t, 3, 1)})); - run(&mut s, "table.merge", json!({})); - run(&mut s, "select.range", json!({"anchor": cell_at(t, 1, 0), "focus": cell_at(t, 1, 1)})); - run(&mut s, "table.toText", json!({})); - let at = t as usize; - // The merged cell holds d, f and h, one paragraph each. - assert_eq!(body_outline(&s).get(at..at + 5), Some(&["T", "c\td", "f", "h", "T"].map(String::from)[..]), "{:?}", body_outline(&s)); - let head = s.doc.body[at].as_table().unwrap(); - let tail = s.doc.body[at + 4].as_table().unwrap(); - assert_eq!((head.rows.len(), tail.rows.len()), (1, 2)); - use wordcraft_doc::props::VMerge; - let merges: Vec = tail.rows.iter().map(|r| r.cells.last().unwrap().props.vmerge).collect(); - assert_eq!(merges, [VMerge::Restart, VMerge::Continue], "the merge starts again in the first row left"); - assert_eq!(s.sel.focus.path.0, [t + 1], "the caret goes to the converted text"); - - // A merge that went on into the converted rows ends in the last row above them. - let mut s = typed_table(2, &["a", "b", "c", "d", "e", "f"]); - let t = s.doc.body.iter().position(|b| b.as_table().is_some()).unwrap() as u32; - run(&mut s, "select.range", json!({"anchor": cell_at(t, 0, 0), "focus": cell_at(t, 1, 0)})); - run(&mut s, "table.merge", json!({})); - run(&mut s, "select.range", json!({"anchor": cell_at(t, 1, 1), "focus": cell_at(t, 2, 1)})); - run(&mut s, "table.toText", json!({})); - let head = s.doc.body[t as usize].as_table().unwrap(); - assert_eq!(head.rows.len(), 1); - assert_eq!(head.rows[0].cells[0].props.vmerge, VMerge::None, "no merge goes on below the table"); - - // A floating table keeps its place in the part above; the part below goes in the text flow. - let mut s = typed_table(2, &["a", "b", "c", "d", "e", "f"]); - let t = s.doc.body.iter().position(|b| b.as_table().is_some()).unwrap() as u32; - let float = wordcraft_doc::props::TableFloat { x: 100.0, y: 200.0, overlap: true, ..Default::default() }; - s.doc.table_mut(StoryRef::Body, &wordcraft_doc::Path(vec![t])).unwrap().props.float = Some(float); - run(&mut s, "select.range", json!({"anchor": cell_at(t, 1, 0), "focus": cell_at(t, 1, 1)})); - run(&mut s, "table.toText", json!({})); - let at = t as usize; - assert_eq!(s.doc.body[at].as_table().unwrap().props.float, Some(float)); - assert_eq!(s.doc.body[at + 2].as_table().unwrap().props.float, None, "the part below doesn't sit on the part above"); - - // The caret alone in a cell converts every row, as before. - let mut s = typed_table(2, &["a", "b", "c", "d", "e", "f"]); - run(&mut s, "table.toText", json!({})); - assert!(s.doc.body.iter().all(|b| b.as_table().is_none())); -} - -/// A content control around the table keeps both its ends when the first rows, the last rows -/// or every row become text. -#[test] -fn convert_to_text_keeps_a_content_control_around_the_table() { - use wordcraft_doc::{ContentControl, ControlWrap, InlineObject}; - let has = |p: &wordcraft_doc::Paragraph, start: bool| { - p.objects.iter().any(|o| if start { matches!(o, InlineObject::ControlStart { .. }) } else { matches!(o, InlineObject::ControlEnd) }) - }; - for rows in [Some((0, 0)), Some((2, 2)), None] { - let mut s = typed_table(2, &["a", "b", "c", "d", "e", "f"]); - let t = s.doc.body.iter().position(|b| b.as_table().is_some()).unwrap() as u32; - s.doc.table_mut(StoryRef::Body, &wordcraft_doc::Path(vec![t])).unwrap().controls = - ControlWrap { open: vec![ContentControl { tag: "wrap".into(), ..Default::default() }], close: 1 }; - assert!(!s.doc.has_unbalanced_controls()); - match rows { - Some((r0, r1)) => run(&mut s, "select.range", json!({"anchor": cell_at(t, r0, 0), "focus": cell_at(t, r1, 1)})), - None => run(&mut s, "select.range", json!({"anchor": cell_at(t, 1, 0), "focus": cell_at(t, 1, 0)})), - }; - run(&mut s, "table.toText", json!({})); - assert!(!s.doc.has_unbalanced_controls(), "{rows:?}: {:?}", body_outline(&s)); - let at = t as usize; - if rows != Some((2, 2)) { - assert!(s.doc.body[at].as_para().is_some_and(|p| has(p, true)), "{rows:?}: the control starts on the text"); - } - if rows != Some((0, 0)) { - let last = s.doc.body.iter().skip(at).rfind(|b| b.as_para().is_some_and(|p| !p.text.is_empty()) || b.as_table().is_some()).unwrap(); - assert!(last.as_para().is_some_and(|p| has(p, false)), "{rows:?}: the control ends on the text"); - } - } - - // A control around rows 0 to 3, row 1 converted: the table above closes it, the table below - // opens it again. - let mut s = typed_table(2, &["a", "b", "c", "d", "e", "f", "g", "h"]); - let t = s.doc.body.iter().position(|b| b.as_table().is_some()).unwrap() as u32; - { - let table = s.doc.table_mut(StoryRef::Body, &wordcraft_doc::Path(vec![t])).unwrap(); - table.rows[0].controls.open = vec![ContentControl { id: Some(7), tag: "rows".into(), ..Default::default() }]; - table.rows[3].controls.close = 1; - } - run(&mut s, "select.range", json!({"anchor": cell_at(t, 1, 0), "focus": cell_at(t, 1, 1)})); - run(&mut s, "table.toText", json!({})); - let at = t as usize; - let head = s.doc.body[at].as_table().unwrap(); - let tail = s.doc.body[at + 2].as_table().unwrap(); - assert_eq!((head.rows.len(), head.rows[0].controls.close), (1, 1), "the table above closes the control"); - assert_eq!(tail.rows[0].controls.open.iter().map(|c| c.tag.as_str()).collect::>(), ["rows"], "the table below opens it"); - assert_eq!((head.rows[0].controls.open[0].id, tail.rows[0].controls.open[0].id), (Some(7), None), "one w:id in the file"); - assert_eq!(tail.rows[1].controls.close, 1); - assert!(!s.doc.has_unbalanced_controls()); -} - -/// The text keeps its formatting, and a table nested in a cell stays a table. -#[test] -fn convert_to_text_keeps_formatting_and_nested_tables() { - let mut s = typed_table(2, &["Name", "X"]); - let t = s.doc.body.iter().position(|b| b.as_table().is_some()).unwrap() as u32; - let first = json!({"story": "body", "path": [t, 0, 0, 0], "off": 0}); - let end = json!({"story": "body", "path": [t, 0, 0, 0], "off": 4}); - run(&mut s, "select.range", json!({"anchor": first, "focus": end})); - run(&mut s, "format.bold", json!({})); - // A table nested in the second cell, after its text. - s.sel = crate::Selection::caret(wordcraft_doc::Pos { story: StoryRef::Body, path: wordcraft_doc::Path(vec![t, 0, 1, 0]), off: 1 }); - run(&mut s, "text.newParagraph", json!({})); - run(&mut s, "insert.table", json!({"rows": 1, "cols": 1})); - run(&mut s, "text.insert", json!({"text": "Inner"})); - s.sel = crate::Selection::caret(wordcraft_doc::Pos { story: StoryRef::Body, path: wordcraft_doc::Path(vec![t, 0, 0, 0]), off: 0 }); - run(&mut s, "table.toText", json!({})); - let at = t as usize; - let line = s.doc.body[at].as_para().unwrap(); - assert_eq!(line.text, "Name\tX"); - assert_eq!(line.props_of_char(0).bold, Some(true), "the text lost its bold"); - assert_ne!(line.props_of_char(4).bold, Some(true), "the tab takes no formatting"); - let inner = s.doc.body.iter().skip(at + 1).find_map(|b| b.as_table()).expect("the nested table stays a table"); - assert_eq!(table_cell_text(inner), "Inner"); - - // A selected row whose first cell starts with a nested table: the caret goes to the row's - // text, not back into the table above. - let mut s = typed_table(2, &["A", "B", "", "D"]); - let t = s.doc.body.iter().position(|b| b.as_table().is_some()).unwrap() as u32; - s.sel = crate::Selection::caret(wordcraft_doc::Pos { story: StoryRef::Body, path: wordcraft_doc::Path(vec![t, 1, 0, 0]), off: 0 }); - run(&mut s, "insert.table", json!({"rows": 1, "cols": 1})); - let row = &s.doc.body[t as usize].as_table().unwrap().rows[1]; - assert!(row.cells[0].blocks.first().is_some_and(|b| b.as_table().is_some()), "the cell starts with the nested table"); - run(&mut s, "select.range", json!({"anchor": cell_at(t, 1, 1), "focus": cell_at(t, 1, 1)})); - s.sel.anchor = wordcraft_doc::Pos { story: StoryRef::Body, path: wordcraft_doc::Path(vec![t, 1, 0, 1]), off: 0 }; - run(&mut s, "table.toText", json!({})); - let at = t as usize; - assert_eq!(body_outline(&s).get(at..at + 3), Some(&["T", "T", "\tD"].map(String::from)[..]), "{:?}", body_outline(&s)); - assert_eq!(s.sel.focus.path.0, [t + 2], "the caret is on the row's text"); -} - -fn table_cell_text(t: &wordcraft_doc::Table) -> String { - t.rows.iter().flat_map(|r| &r.cells).flat_map(|c| &c.blocks).filter_map(|b| b.as_para()).map(|p| p.text.clone()).collect() -} - -#[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); -} - -/// Tests that install a test dictionary wait for the dictionaries-folder test, which changes the -/// folder and then reads what each language reports: an install in between reports no file. -static DICTIONARIES: std::sync::Mutex<()> = std::sync::Mutex::new(()); - -fn dictionaries_turn() -> std::sync::MutexGuard<'static, ()> { - DICTIONARIES.lock().unwrap_or_else(std::sync::PoisonError::into_inner) -} - -/// 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() { - let _turn = dictionaries_turn(); - 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() { - let _turn = dictionaries_turn(); - 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 › Language › 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 _turn = dictionaries_turn(); - 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), - // The others the tests install, so reloading from the folder keeps their words. - ("it_IT", ITALIAN_TEST_AFF, ITALIAN_TEST_DIC), - ("es_ES", SPANISH_TEST_AFF, SPANISH_TEST_DIC), - ("es_UY", SPANISH_TEST_AFF, URUGUAYAN_TEST_DIC), - // #3252: set_dictionaries_folder() now reloads Czech too (it used to leave its cache - // alone), so a run of this test overlapping czech_proofing_through_the_review_commands - // would otherwise force a reload that finds no cs_CZ here and empties it mid-test. Same - // fixture content either way keeps such a race harmless, as it already does for Polish. - ("cs_CZ", czech_test_dict::AFF, czech_test_dict::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()); - // Each language reports the folder's file, which is searched first. - let reported = |got: Option<&serde_json::Value>, name: &str| { - let want = dir.join(format!("{name}.dic")); - assert_eq!(got, Some(&json!(want.to_string_lossy())), "{name}: {dicts:?}"); - assert_eq!(wordcraft_proof::hunspell::find(&[name]).map(|(_, dic)| dic), Some(want)); - }; - reported(file("pl").as_ref(), "pl_PL"); - reported(file("pt").as_ref(), "pt_BR"); - reported(file("id").as_ref(), "id_ID"); - // Each Spanish variety reports its own file (Spain, Mexico, Latin America, Uruguay, in order). - let spanish: Vec = dicts.iter().filter(|d| d["language"] == "es").map(|d| d["file"].clone()).collect(); - assert_eq!(spanish.len(), 4); - reported(spanish.first(), "es_ES"); - reported(spanish.last(), "es_UY"); - // 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() { - let _turn = dictionaries_turn(); - 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"); -} - -/// The spelling issues of the document: each word with its first suggestion. -fn spelling_with_first_suggestion(s: &mut Session) -> Vec<(String, String)> { - let issues = run(s, "review.issues", json!({})); - issues - .as_array() - .unwrap() - .iter() - .filter(|i| i["kind"] == "spelling") - .map(|i| (i["text"].as_str().unwrap().to_string(), i["suggestions"][0].as_str().unwrap_or("").to_string())) - .collect() -} - -fn pairs(v: &[(&str, &str)]) -> Vec<(String, String)> { - v.iter().map(|(a, b)| (a.to_string(), b.to_string())).collect() -} - -/// English (UK), (Canada) and (Australia) through Review › Language (#1281, #1045): each accepts -/// its own spellings and flags the other dialect's with its own form suggested first, F7 finds -/// it and Change fixes it. -#[test] -fn english_dialects_through_the_review_commands() { - if no_language_data("english_dialects_through_the_review_commands") { - return; - } - let mut s = s(); - run(&mut s, "document.setText", json!({"text": "The colour of the center."})); - // US English by default: the British spelling is flagged, the American one isn't. - assert_eq!(spelling_with_first_suggestion(&mut s), pairs(&[("colour", "color")])); - run(&mut s, "select.all", json!({})); - run(&mut s, "review.language", json!({"lang": "en_gb", "default": true})); - assert_eq!(s.doc.styles.default_chr.lang.as_deref(), Some("en-GB")); - assert_eq!(run(&mut s, "format.state", json!({}))["lang"], "en-GB"); - assert_eq!(spelling_with_first_suggestion(&mut s), pairs(&[("center", "centre")])); - run(&mut s, "caret.docStart", json!({})); - assert_eq!(run(&mut s, "review.spelling", json!({}))["text"], "center"); - run(&mut s, "review.applySuggestion", json!({"text": "centre"})); - assert_eq!(text(&s), "The colour of the centre."); - assert!(spelling_with_first_suggestion(&mut s).is_empty()); - // Canada: British -our and -re, American -ize. - run(&mut s, "document.setText", json!({"text": "We organise the colour and analyze the centre."})); - run(&mut s, "select.all", json!({})); - run(&mut s, "review.language", json!({"lang": "en-CA", "default": true})); - assert_eq!(spelling_with_first_suggestion(&mut s), pairs(&[("organise", "organize")])); - // Australia spells the British way: -ise is right, -yze isn't. - run(&mut s, "select.all", json!({})); - run(&mut s, "review.language", json!({"lang": "en-AU", "default": true})); - assert_eq!(spelling_with_first_suggestion(&mut s), pairs(&[("analyze", "analyse")])); - // Add to Dictionary overrules the dialect. - run(&mut s, "review.addToDictionary", json!({"word": "analyze"})); - assert!(spelling_with_first_suggestion(&mut s).is_empty()); - wordcraft_proof::remove_word("analyze"); -} - -/// A British document keeps its languages through .docx (`w:lang` in the defaults and on a run) -/// and is proofed the same way when it comes back. -#[test] -fn english_dialects_survive_a_docx_round_trip() { - if no_language_data("english_dialects_survive_a_docx_round_trip") { - return; - } - use std::io::Read; - let mut s = s(); - run(&mut s, "document.setText", json!({"text": "Colour and color. Centre and center."})); - run(&mut s, "select.all", json!({})); - run(&mut s, "review.language", json!({"lang": "en-GB", "default": true})); - run(&mut s, "select.text", json!({"text": "Centre and center."})); - run(&mut s, "review.language", json!({"lang": "en-US"})); - let before = spelling_with_first_suggestion(&mut s); - assert_eq!(before, pairs(&[("color", "colour"), ("Centre", "Center")])); - let bytes = crate::io::save_bytes("british.docx", &s.doc).unwrap(); - let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap(); - let mut part = |name: &str| { - let mut xml = String::new(); - zip.by_name(name).unwrap().read_to_string(&mut xml).unwrap(); - xml - }; - assert!(part("word/styles.xml").contains(r#"w:val="en-GB""#), "the default language is written"); - assert!(part("word/document.xml").contains(r#"w:val="en-US""#), "the run's language is written"); - let doc = crate::io::open_bytes("british.docx", &bytes).unwrap(); - assert_eq!(doc.styles.default_chr.lang.as_deref(), Some("en-GB")); - let mut back = Session::new(doc); - assert_eq!(spelling_with_first_suggestion(&mut back), before); -} - -/// An English interface writes new documents in the region's English (English (UK) on a British -/// system, #1045); another region's language leaves US English. -#[test] -fn new_documents_follow_the_regions_english() { - if no_language_data("new_documents_follow_the_regions_english") { - return; - } - let mut s = s(); - s.system_region = wordcraft_geom::Region::from_tag("en_GB.UTF-8"); - assert_eq!(s.region_english().as_deref(), Some("en-GB")); - let tag = s.region_english(); - s.set_editing_language(tag.as_deref()); - assert_eq!(s.doc.styles.default_chr.lang.as_deref(), Some("en-GB"), "the untouched document follows"); - run(&mut s, "document.setText", json!({"text": "Colour, color."})); - assert_eq!(spelling_with_first_suggestion(&mut s), pairs(&[("color", "colour")])); - // The region chosen in Options wins over the system's. - run(&mut s, "file.options", json!({"region": "en-CA"})); - assert_eq!(s.region_english().as_deref(), Some("en-CA")); - for (tag, english) in [("pl-PL", None), ("en-US", None), ("en-AU", Some("en-AU")), ("auto", Some("en-GB"))] { - run(&mut s, "file.options", json!({"region": tag})); - assert_eq!(s.region_english().as_deref(), english, "{tag}"); - } - s.system_region = wordcraft_geom::Region::from_tag("fr-FR"); - assert_eq!(s.region_english(), None); -} - -/// A Spanish interface writes new documents in the region's Spanish (#3360): Uruguay's on a -/// Uruguayan system, Mexico's on a Mexican one, Spain's elsewhere. -#[test] -fn new_documents_follow_the_regions_spanish() { - let mut s = s(); - s.system_region = wordcraft_geom::Region::from_tag("es_UY.UTF-8"); - assert_eq!(s.region_spanish(), "es-UY"); - // The ISO 639-2 code is Spanish too (CodeRabbit review on #3562). - s.system_region = wordcraft_geom::Region::from_tag("spa_MX.UTF-8"); - assert_eq!(s.region_spanish(), "es-MX"); - s.system_region = wordcraft_geom::Region::from_tag("es_UY.UTF-8"); - s.set_editing_language(Some(&s.region_spanish())); - assert_eq!(s.doc.styles.default_chr.lang.as_deref(), Some("es-UY"), "the untouched document follows"); - for (tag, spanish) in [("es-MX", "es-MX"), ("es-419", "es-419"), ("es-ES", "es-ES"), ("en-US", "es-ES"), ("fr-FR", "es-ES"), ("auto", "es-UY")] { - run(&mut s, "file.options", json!({"region": tag})); - assert_eq!(s.region_spanish(), spanish, "{tag}"); - } -} - -// Hand-written synthetic fixtures (no system dictionary data): Spain's has `vosotros` forms, -// Uruguay's has `vos` ones' verbs only (the forms come from the voseo rules). -const SPANISH_TEST_AFF: &str = - "SET UTF-8\nFLAG UTF-8\nTRY aeosrnidlctumpbgvyqhfzjñáéíóú\nMAP 2\nMAP eé\nMAP ií\nREP 2\nREP v b\nREP b v\nSFX S Y 1\nSFX S 0 s [aeiou]\n"; -const SPANISH_TEST_DIC: &str = "10\nesto\nes\nuna\nprueba/S\ndel\ncorrector\ntambién\nhabéis\nvenido\ndecir\n"; -const URUGUAYAN_TEST_DIC: &str = "10\nesto\nes\nuna\nprueba/S\ndel\ncorrector\ntambién\ntener\nvenir\ndecir\n"; - -fn spanish_test_dictionaries() { - use wordcraft_proof::ProofLang; - let _turn = dictionaries_turn(); - for (lang, dic) in [(ProofLang::Es, SPANISH_TEST_DIC), (ProofLang::EsUy, URUGUAYAN_TEST_DIC)] { - wordcraft_proof::es::install_dictionary(lang, SPANISH_TEST_AFF, dic).unwrap(); - } -} - -/// #3553: Spanish proofing through the same commands as Review, F7, Change and Add to -/// Dictionary: `prueva` and `corector` are flagged with `prueba` and `corrector` offered. -#[test] -fn spanish_proofing_through_the_review_commands() { - spanish_test_dictionaries(); - let mut s = s(); - run(&mut s, "document.setText", json!({"text": "esto es una prueva del corector. Habéis venido?"})); - run(&mut s, "select.all", json!({})); - run(&mut s, "review.language", json!({"lang": "es_es", "default": true})); - assert_eq!(s.doc.styles.default_chr.lang.as_deref(), Some("es-ES")); - let issues = run(&mut s, "review.issues", json!({})); - let arr = issues.as_array().unwrap(); - assert!(arr.iter().all(|i| i["lang"] == "es"), "{issues}"); - let has = |text: &str, kind: &str, sugg: &str| { - arr.iter().any(|i| i["text"] == text && i["kind"] == kind && i["suggestions"].as_array().unwrap().iter().any(|x| x == sugg)) - }; - assert!(has("prueva", "spelling", "prueba"), "{issues}"); - assert!(has("corector", "spelling", "corrector"), "{issues}"); - assert!(has("Habéis venido?", "grammar", "¿Habéis venido?"), "{issues}"); - assert_eq!(arr.len(), 3, "{issues}"); - assert!(arr.iter().any(|i| i["message"] == "Posible error ortográfico"), "{issues}"); - run(&mut s, "caret.docStart", json!({})); - assert_eq!(run(&mut s, "review.spelling", json!({}))["text"], "prueva"); - run(&mut s, "review.applySuggestion", json!({"text": "prueba"})); - assert!(text(&s).starts_with("esto es una prueba del"), "{}", text(&s)); - run(&mut s, "review.addToDictionary", json!({"word": "corector"})); - let left = run(&mut s, "review.issues", json!({})); - assert_eq!(left.as_array().unwrap().len(), 1, "{left}"); -} - -/// #3562 review: Add to Dictionary and Ignore All get the issue's own text, decomposed (NFD) as -/// typed; the word is then accepted decomposed and composed, and its issue range was the typed -/// bytes. -#[test] -fn add_and_ignore_all_accept_both_normalization_forms() { - spanish_test_dictionaries(); - let mut s = s(); - let text = "esto zqanade\u{301}do y zqignora\u{301}do"; - run(&mut s, "document.setText", json!({"text": text})); - run(&mut s, "select.all", json!({})); - run(&mut s, "review.language", json!({"lang": "es-ES"})); - let issues = run(&mut s, "review.issues", json!({})); - let words: Vec<(String, u64)> = issues - .as_array() - .unwrap() - .iter() - .filter(|i| i["kind"] == "spelling") - .map(|i| (i["text"].as_str().unwrap().to_string(), i["start"]["off"].as_u64().unwrap())) - .collect(); - assert_eq!(words, [("zqanade\u{301}do".to_string(), 5), ("zqignora\u{301}do".to_string(), text.find("zqign").unwrap() as u64)], "{issues}"); - run(&mut s, "review.addToDictionary", json!({"word": words[0].0})); - run(&mut s, "review.ignoreAll", json!({"word": words[1].0})); - assert!(run(&mut s, "review.issues", json!({})).as_array().unwrap().is_empty()); - // The composed spellings are the same words. - run(&mut s, "document.setText", json!({"text": "esto zqanadédo y zqignorádo"})); - run(&mut s, "select.all", json!({})); - run(&mut s, "review.language", json!({"lang": "es-ES"})); - assert!(run(&mut s, "review.issues", json!({})).as_array().unwrap().is_empty()); - wordcraft_proof::remove_word("zqanadédo"); -} - -/// Uruguayan Spanish accepts the `vos` forms, imperatives included, that Spain's flags. -#[test] -fn uruguayan_spanish_accepts_vos_forms() { - spanish_test_dictionaries(); - let mut s = s(); - run(&mut s, "document.setText", json!({"text": "Vení, decime qué tenés."})); - run(&mut s, "select.all", json!({})); - run(&mut s, "review.language", json!({"lang": "es-UY"})); - assert_eq!(run(&mut s, "format.state", json!({}))["lang"], "es-UY"); - let spelling = |s: &mut Session| -> Vec { - let issues = run(s, "review.issues", json!({})); - issues.as_array().unwrap().iter().filter(|i| i["kind"] == "spelling").filter_map(|i| i["text"].as_str().map(String::from)).collect() - }; - assert_eq!(spelling(&mut s), ["qué"], "only the word the fixture lacks"); - run(&mut s, "select.all", json!({})); - run(&mut s, "review.language", json!({"lang": "es-ES"})); - assert_eq!(spelling(&mut s), ["Vení", "decime", "qué", "tenés"]); -} - -/// 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. -const ITALIAN_TEST_AFF: &str = "SET UTF-8\nTRY aeiortnsclcaffèéè\nMAP 2\nMAP aà\nMAP eéè\nREP 1\nREP caffe caffè\n"; -const ITALIAN_TEST_DIC: &str = "6\ncasa\ncaffè\nroma\ndisse\ne\nper\n"; - -fn italian_test_dictionary() { - let _turn = dictionaries_turn(); - wordcraft_proof::it::install_dictionary(ITALIAN_TEST_AFF, ITALIAN_TEST_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); -} - -/// #2882: Set As Default survives preference serialization and overrides the interface language. -#[test] -fn default_proofing_language_survives_a_restart() { - let mut first = s(); - first.set_editing_language(Some("en-US")); - run(&mut first, "review.language", json!({"lang": "FR_fr", "default": true})); - let saved = serde_json::to_vec(&first.prefs).unwrap(); - run(&mut first, "review.language", json!({"lang": "en-GB"})); - run(&mut first, "edit.undo", json!({})); - run(&mut first, "file.new", json!({})); - assert_eq!(first.doc.styles.default_chr.lang.as_deref(), Some("fr-FR")); - - let mut second = s(); - second.prefs = serde_json::from_slice(&saved).unwrap(); - second.set_editing_language(Some("pl-PL")); - assert_eq!(second.doc.styles.default_chr.lang.as_deref(), Some("fr-FR")); - assert!(!second.dirty && !second.can_undo()); - run(&mut second, "file.new", json!({})); - assert_eq!(second.doc.styles.default_chr.lang.as_deref(), Some("fr-FR")); - let data = crate::cmd::insert::base64_encode(b"Bonjour"); - run(&mut second, "file.open", json!({"path": "note.txt", "data": data})); - assert_eq!(second.doc.styles.default_chr.lang.as_deref(), Some("fr-FR")); - - let mut english = wordcraft_doc::Document::from_text("English document"); - Arc::make_mut(&mut english.styles).default_chr.lang = Some("en-GB".into()); - let bytes = crate::io::save_bytes("english.docx", &english).unwrap(); - let data = crate::cmd::insert::base64_encode(&bytes); - run(&mut second, "file.open", json!({"path": "english.docx", "data": data})); - second.set_editing_language(Some("ru-RU")); - assert_eq!(second.doc.styles.default_chr.lang.as_deref(), Some("en-GB")); - run(&mut second, "file.new", json!({"template": "letter"})); - assert_eq!(second.doc.styles.default_chr.lang.as_deref(), Some("en-US")); -} - -/// Older or malformed settings retain the interface fallback and unrelated preferences. -#[test] -fn default_proofing_language_ignores_damaged_preferences() { - for value in [json!(null), json!("not a tag"), json!(17), json!([])] { - let mut session = s(); - session.prefs = serde_json::from_value(json!({"defaultLanguage": value, "detectLanguage": false})).unwrap(); - session.set_editing_language(Some("pl-PL")); - assert_eq!(session.doc.styles.default_chr.lang.as_deref(), Some("pl-PL")); - assert!(!session.prefs.detect_language); - } - let mut old = s(); - old.prefs = serde_json::from_str("{}").unwrap(); - old.set_editing_language(Some("ru-RU")); - assert_eq!(old.doc.styles.default_chr.lang.as_deref(), Some("ru-RU")); -} - -/// #2882: a final protection rejection rolls back the saved default as well as the document. -#[test] -fn rejected_default_proofing_language_does_not_survive_a_restart() { - for saved in [None, Some("pl-PL")] { - let mut session = Session::new(wordcraft_doc::Document::from_text("Locked intro.\nEditable text.\nLocked end.")); - session.prefs.default_language = saved.map(str::to_string); - session.set_editing_language(Some("en-US")); - run(&mut session, "select.text", json!({"text": "Editable text."})); - run(&mut session, "review.restrict", json!({"exception": "add"})); - run(&mut session, "review.restrict", json!({"mode": "readOnly"})); - run(&mut session, "select.text", json!({"text": "Editable"})); - // Selection-only language formatting is allowed inside the exception. - run(&mut session, "review.language", json!({"lang": "en-GB"})); - let before = session.doc.clone(); - let prefs = serde_json::to_value(&session.prefs).unwrap(); - let undo = session.undo_labels(); - let error = session.run("review.language", &json!({"lang": "fr-FR", "default": true})).unwrap_err(); - assert!(error.to_string().contains("only the editable regions"), "{error}"); - assert_eq!(session.doc, before); - assert_eq!(session.undo_labels(), undo); - assert_eq!(serde_json::to_value(&session.prefs).unwrap(), prefs); - let mut restarted = s(); - restarted.prefs = serde_json::from_value(prefs).unwrap(); - restarted.set_editing_language(Some("en-US")); - run(&mut restarted, "file.new", json!({})); - assert_eq!(restarted.doc.styles.default_chr.lang.as_deref(), Some(saved.unwrap_or("en-US"))); - } -} - -/// 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})); -} - -/// An item's own colour survives item edits and layout changes, and a style colour drops it. -#[test] -fn smart_art_item_colors_stay_until_a_style_is_picked() { - let mut s = s(); - run(&mut s, "insert.smartArt", json!({"layout": "process"})); - let colors = |s: &Session| cmd::smart_art::selected_smart_art(s).unwrap().items.iter().map(|i| i.color.map(|c| c.hex())).collect::>(); - assert!(!cmd::smart_art::selected_smart_art(&s).unwrap().has_custom_colors()); - run(&mut s, "smartArt.itemColor", json!({"index": 1, "color": "FF8800"})); - assert_eq!(colors(&s), [None, Some("FF8800".to_string()), None, None]); - run(&mut s, "smartArt.items", json!({"items": [{"text": "A"}, {"text": "B", "color": "#ff8800"}]})); - run(&mut s, "smartArt.layout", json!({"layout": "cycle"})); - run(&mut s, "smartArt.addShape", json!({"after": 0})); - assert_eq!(colors(&s), [None, None, Some("FF8800".to_string())]); - assert!(s.run("smartArt.itemColor", &json!({"index": 0, "color": "nope"})).is_err()); - run(&mut s, "smartArt.itemColor", json!({"index": 2})); - assert!(!cmd::smart_art::selected_smart_art(&s).unwrap().has_custom_colors()); - run(&mut s, "smartArt.itemColor", json!({"index": 0, "color": "00FF00"})); - run(&mut s, "smartArt.colors", json!({"variant": "accent2"})); - assert!(!cmd::smart_art::selected_smart_art(&s).unwrap().has_custom_colors()); - run(&mut s, "edit.undo", json!({})); - assert!(cmd::smart_art::selected_smart_art(&s).unwrap().has_custom_colors(), "undo brings the colours back"); -} - -/// A malformed colour must not silently replace a coloured item with its style colour. -#[test] -fn smart_art_item_colors_reject_invalid_values_without_changing_the_graphic() { - let mut s = s(); - run(&mut s, "insert.smartArt", json!({"items": [{"text": "Keep", "color": "123456"}]})); - let before = cmd::smart_art::selected_smart_art(&s).unwrap(); - for command in ["insert.smartArt", "smartArt.items"] { - for color in [json!("nope"), json!(42), json!(true), json!([]), json!({})] { - assert!(s.run(command, &json!({"items": [{"text": "Replace", "color": color}]})).is_err(), "{command}: {color}"); - assert_eq!(cmd::smart_art::selected_smart_art(&s).unwrap(), before); - } - run(&mut s, command, json!({"items": [{"text": "Default", "color": null}, {"text": "Omitted"}]})); - assert!(!cmd::smart_art::selected_smart_art(&s).unwrap().has_custom_colors()); - run(&mut s, "edit.undo", json!({})); - assert_eq!(cmd::smart_art::selected_smart_art(&s).unwrap(), before); - } -} - -/// #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.itemColor", - "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"); -} - -/// #3621: with thousands of collapsed headings, an Enter above them (which shifts every index) -/// finds them again without scanning the document once per heading, and a caret move doesn't -/// redo the matching. -#[test] -fn outline_many_collapsed_headings_stay_fast_after_edits() { - const N: usize = 8000; - let text: Vec = (0..N).flat_map(|i| [format!("Heading {i}"), format!("body {i}")]).collect(); - let mut d = wordcraft_doc::Document::from_text(&text.join("\n")); - for i in 0..N { - let p = d.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(2 * i)).unwrap(); - p.props.style = Some("Heading1".into()); - } - let mut s = Session::new(d); - run(&mut s, "view.outline", json!({})); - run(&mut s, "outline.showLevel1", json!({})); - s.sel = crate::Selection::caret(Pos::body(0, 0)); - run(&mut s, "outline.collapse", json!({})); - let collapsed_texts = - |s: &Session| -> Vec { s.view.outline.collapsed.iter().map(|(i, _)| s.doc.body[*i].as_para().unwrap().text.clone()).collect() }; - let before = collapsed_texts(&s); - assert_eq!(before.len(), N, "every heading is collapsed"); - s.sel = crate::Selection::caret(Pos::body(1, 0)); - for _ in 0..10 { - run(&mut s, "text.newParagraph", json!({})); - } - for _ in 0..20 { - run(&mut s, "caret.right", json!({})); - } - assert_eq!(collapsed_texts(&s), before, "the same headings stay collapsed"); -} - -/// #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 formula's `\*` switches format its result when the formula is inserted and when Update Field -/// works it out again. -#[test] -fn table_formula_results_take_the_format_switches() { - for (formula, inserted, updated) in [ - (r"=SUM(ABOVE) \* CardText", "twelve", "twenty-one"), - (r"=SUM(ABOVE) \* CardText \* Caps", "Twelve", "Twenty-one"), - (r"=SUM(ABOVE) \* OrdText \* Upper", "TWELFTH", "TWENTY-FIRST"), - (r"=SUM(ABOVE) \* MERGEFORMAT", "12", "21"), - ] { - let mut s = formula_table(); - let r = run(&mut s, "table.formula", json!({"formula": formula})); - assert_eq!((r["formula"].as_str(), r["result"].as_str()), (Some(formula), Some(inserted))); - 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(updated), "{formula}"); - } -} - -#[test] -fn table_formula_results_take_dollar_text_and_hex() { - for (formula, inserted, updated) in [ - (r"=SUM(ABOVE) \* DollarText", "twelve and 00/100", "twenty-one and 00/100"), - (r"=SUM(ABOVE) \* DollarText \* Upper", "TWELVE AND 00/100", "TWENTY-ONE AND 00/100"), - (r"=SUM(ABOVE) \* Hex", "C", "15"), - ] { - let mut s = formula_table(); - let r = run(&mut s, "table.formula", json!({"formula": formula})); - assert_eq!((r["formula"].as_str(), r["result"].as_str()), (Some(formula), Some(inserted))); - 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(updated), "{formula}"); - } -} - -/// 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() -} - -#[test] -fn table_sort_rejects_invalid_columns_without_changing_rows_or_history() { - for column in [ - json!(-1), - json!(0.5), - json!(0.0), - json!("0"), - json!(null), - json!(true), - json!([]), - json!({}), - json!(1), - json!(u32::MAX as u64 + 1), - json!(u64::MAX), - ] { - let mut s = column_table("en-US", &["Charlie", "Alpha", "Bravo"]); - s.close_typing(); - run(&mut s, "text.insert", json!({"text": "!"})); - let before = s.doc.clone(); - let selection = s.sel.clone(); - let dirty = s.dirty; - assert!(s.run("table.sort", &json!({"column": column, "header": false})).is_err(), "column {column}"); - assert_eq!(s.doc, before, "column {column}"); - assert_eq!(s.sel, selection, "column {column}"); - assert_eq!(s.dirty, dirty, "column {column}"); - run(&mut s, "edit.undo", json!({})); - assert_eq!(column_texts(&s), ["Charlie", "Alpha", "Bravo"], "column {column}: refusal must not consume Undo"); - run(&mut s, "edit.redo", json!({})); - assert_eq!(s.doc, before, "column {column}: Redo must restore the preceding edit"); - } -} - -#[test] -fn table_sort_keeps_default_and_valid_columns() { - let mut s = s(); - run(&mut s, "insert.table", json!({"rows": 3, "cols": 2})); - for (k, value) in ["Charlie", "1", "Alpha", "3", "Bravo", "2"].into_iter().enumerate() { - if k > 0 { - run(&mut s, "text.tab", json!({})); - } - run(&mut s, "text.insert", json!({"text": value})); - } - let original = s.doc.clone(); - for params in [json!({"header": false}), json!({"column": 0, "header": false})] { - run(&mut s, "table.sort", params); - assert_eq!(column_texts(&s), ["Alpha", "Bravo", "Charlie"]); - run(&mut s, "edit.undo", json!({})); - assert_eq!(s.doc, original); - } - run(&mut s, "table.sort", json!({"column": 1, "header": false})); - assert_eq!(column_texts(&s), ["Charlie", "Bravo", "Alpha"]); - run(&mut s, "table.sort", json!({"column": 1, "header": false, "descending": true})); - assert_eq!(column_texts(&s), ["Alpha", "Bravo", "Charlie"]); - // A column may be missing in some rows. Its present cells still sort normally. - let at = s.doc.body.iter().position(|b| b.as_table().is_some()).unwrap() as u32; - let table = s.doc.table_mut(StoryRef::Body, &wordcraft_doc::Path(vec![at])).unwrap(); - table.rows[0].cells.remove(1); - run(&mut s, "table.sort", json!({"column": 1, "header": false})); - assert_eq!(column_texts(&s), ["Charlie", "Bravo", "Alpha"]); - - let mut s = column_table("en-US", &["Title", "3", "1", "2"]); - let at = s.doc.body.iter().position(|b| b.as_table().is_some()).unwrap() as u32; - s.doc.table_mut(StoryRef::Body, &wordcraft_doc::Path(vec![at])).unwrap().rows[0].props.header = true; - run(&mut s, "table.sort", json!({"column": 0})); - assert_eq!(column_texts(&s), ["Title", "1", "2", "3"]); -} - -/// #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")); - Arc::make_mut(&mut 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"); - - // A replace with no `text` and no earlier search is a mistake, not "0 matches" (#3271). - let mut s = super::tests::s(); - for (id, params) in [("edit.replaceAll", json!({"find": "cat", "replace": "dog"})), ("edit.replace", json!({})), ("edit.replaceAll", json!({}))] { - let e = s.run(id, ¶ms).unwrap_err().to_string(); - assert!(e.contains("`text` (string) is required"), "{id}: {e}"); - } - run(&mut s, "edit.replaceAll", json!({"text": "", "with": "x"})); - - // 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); -} - -#[test] -fn window_commands_need_the_desktop_and_name_a_window() { - use crate::cmd::view::OtherWindow; - let mut s = s(); - // Web, CLI, MCP: one window, so View › Window is disabled. - for id in ["view.arrangeAll", "view.sideBySide", "view.syncScroll"] { - assert!(s.run(id, &json!({})).is_err(), "{id} without a window host"); - } - s.windows.available = true; - run(&mut s, "view.arrangeAll", json!({})); - assert_eq!(s.ui_requests.pop(), Some(json!({"windows": "arrange"}))); - assert!(s.run("view.sideBySide", &json!({})).is_err(), "no other window"); - s.windows.others = vec![OtherWindow { id: 7, title: "Notes".into() }, OtherWindow { id: 9, title: "Draft".into() }]; - let e = s.run("view.sideBySide", &json!({})).unwrap_err().to_string(); - assert!(e.contains("7 (Notes)") && e.contains("9 (Draft)"), "several windows: say which: {e}"); - assert!(s.run("view.sideBySide", &json!({"window": 8})).is_err(), "an unknown window"); - assert!(s.run("view.sideBySide", &json!({"window": "9"})).is_err()); - // Side by Side pairs with one window and scrolls together until turned off. - assert!(s.run("view.syncScroll", &json!({})).is_err(), "only while Side by Side is on"); - let r = run(&mut s, "view.sideBySide", json!({"window": 7})); - assert_eq!((r["value"].as_bool(), s.windows.side_by_side, s.windows.sync_scroll), (Some(true), Some(7), true)); - assert_eq!(s.ui_requests.pop(), Some(json!({"windows": "sideBySide", "with": 7}))); - run(&mut s, "view.syncScroll", json!({})); - assert!(!s.windows.sync_scroll); - assert_eq!(s.ui_requests.pop(), Some(json!({"windows": "syncScroll", "with": 7, "value": false}))); - // Asking again for the partner it already has keeps scrolling off and pairs nothing anew. - let r = run(&mut s, "view.sideBySide", json!({"window": 7, "value": true})); - assert_eq!((r["syncScroll"].as_bool(), s.windows.sync_scroll), (Some(false), false)); - assert!(s.ui_requests.is_empty()); - // Another partner replaces the first; the bare button turns it off. - run(&mut s, "view.sideBySide", json!({"window": 9})); - assert_eq!(std::mem::take(&mut s.ui_requests), [json!({"windows": "sideBySide", "off": 7}), json!({"windows": "sideBySide", "with": 9})]); - run(&mut s, "view.sideBySide", json!({})); - assert_eq!((s.windows.side_by_side, s.windows.sync_scroll), (None, false)); - assert_eq!(s.ui_requests.pop(), Some(json!({"windows": "sideBySide", "off": 9}))); - assert!(!s.dirty, "window commands never touch the document"); -} - -/// 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); -} - -/// Change Picture keeps the width and takes the replacement's aspect ratio: swapping a figure for -/// a revised one of a different shape must not stretch it into the old frame. -#[cfg(not(target_arch = "wasm32"))] -#[test] -fn picture_change_keeps_width_and_takes_new_aspect() { - let dir = scratch_dir("picture-change-aspect"); - let mut wide = Vec::new(); - image::RgbImage::from_pixel(40, 20, image::Rgb([200, 30, 30])).write_to(&mut std::io::Cursor::new(&mut wide), image::ImageFormat::Png).unwrap(); - let mut tall = Vec::new(); - image::RgbImage::from_pixel(30, 60, image::Rgb([30, 30, 200])).write_to(&mut std::io::Cursor::new(&mut tall), image::ImageFormat::Png).unwrap(); - let (first, second) = (dir.join("wide.png"), dir.join("tall.png")); - std::fs::write(&first, &wide).unwrap(); - std::fs::write(&second, &tall).unwrap(); - - let mut s = Session::new(wordcraft_doc::Document::new()); - run(&mut s, "insert.picture", json!({"path": first.to_string_lossy()})); - let size = |s: &Session| { - let mut out = (0.0f32, 0.0f32, [1.0f32; 4]); - s.doc.objects_in_reading_order(&mut |o| { - if let wordcraft_doc::para::InlineObject::Image { w, h, crop, .. } = o { - out = (*w, *h, *crop); - } - }); - out - }; - let (w0, h0, _) = size(&s); - assert!((w0 / h0 - 2.0).abs() < 1e-3, "inserted at its own 2:1 shape: {w0} x {h0}"); - run(&mut s, "picture.crop", json!({"left": 0.1})); - let (w_cropped, _, crop) = size(&s); - assert!(crop[0] > 0.0, "cropped before the change"); - run(&mut s, "picture.change", json!({"path": second.to_string_lossy()})); - let (w1, h1, crop) = size(&s); - assert!((w1 - w_cropped).abs() < 1e-3, "the width is kept: {w_cropped} -> {w1}"); - assert!((h1 / w1 - 2.0).abs() < 1e-3, "the height follows the new 1:2 picture: {w1} x {h1}"); - assert_eq!(crop, [0.0; 4], "the old picture's crop is dropped"); - 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"); - - // A name given with the document's extension is the whole file name: an agent or a script - // passes one, and appending the extension again made "Report.docm.docm" (#2983). - s.run("file.rename", &json!({"name": "Report.docm"})).unwrap(); - assert_eq!(s.path.as_ref(), Some(&dir.join("Report.docm"))); - assert!(!dir.join("Report.docm.docm").exists()); - // Another extension, or the extension alone, is a stem and still gets one. - s.run("file.rename", &json!({"name": "Report.2026"})).unwrap(); - assert_eq!(s.path.as_ref(), Some(&dir.join("Report.2026.docm"))); - s.run("file.rename", &json!({"name": ".docm"})).unwrap(); - assert_eq!(s.path.as_ref(), Some(&dir.join(".docm.docm"))); - assert_eq!(std::fs::read(dir.join(".docm.docm")).unwrap(), b"original bytes"); - // A name whose bytes do not line up with the extension's length: comparing by slicing at - // `len - ext.len()` could land inside a character and panic (#2983 review). - s.run("file.rename", &json!({"name": "abécde"})).unwrap(); - assert_eq!(s.path.as_ref(), Some(&dir.join("abécde.docm"))); - s.run("file.rename", &json!({"name": "résumé.docm"})).unwrap(); - assert_eq!(s.path.as_ref(), Some(&dir.join("résumé.docm"))); - // Whatever its case; the file is named as typed, so the extension follows the name. - s.run("file.rename", &json!({"name": "Report.DOCM"})).unwrap(); - assert_eq!(s.path.as_ref(), Some(&dir.join("Report.DOCM"))); - assert!(!dir.join("Report.DOCM.docm").exists()); - 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()); -} - -/// A version snapshot belongs to the document it was taken from: opening another document (or -/// starting a new one) starts a new version history, so a restore can't put the previous -/// document's content into the open one (#1327 follow-up). -#[test] -fn a_version_snapshot_does_not_survive_a_document_switch() { - let mut s = Session::new(wordcraft_doc::Document::from_text("First document.")); - s.run("file.versions", &json!({"save": "snapshot"})).unwrap(); - assert_eq!(s.versions.len(), 1); - - // A new document: the snapshot of the previous one is gone with it. - s.run("file.new", &json!({})).unwrap(); - assert!(s.versions.is_empty(), "the snapshot of the previous document survived the switch"); - assert!(s.run("file.versions", &json!({"restore": 0})).is_err(), "the old snapshot is still restorable"); - assert_ne!(s.doc.plain_text(StoryRef::Body), "First document.", "the old document's content came back"); - - // The same for an opened file. - s.run("file.versions", &json!({"save": "snapshot"})).unwrap(); - s.run("file.open", &json!({"path": "other.txt", "data": "aGk="})).unwrap(); - assert!(s.versions.is_empty(), "the snapshot of the previous document survived the switch"); - assert!(s.run("file.versions", &json!({"restore": 0})).is_err(), "the old snapshot is still restorable"); - assert_eq!(s.doc.plain_text(StoryRef::Body), "hi"); - - // A snapshot of the open document still restores into it. - s.run("file.versions", &json!({"save": "snapshot"})).unwrap(); - s.run("text.insert", &json!({"text": " there"})).unwrap(); - s.run("file.versions", &json!({"restore": 0})).unwrap(); - assert_eq!(s.doc.plain_text(StoryRef::Body), "hi"); -} - -/// A comparison that opens the original replaces the open document, so the version snapshots -/// of the document it replaces go with it: a restore must not put the previous document back -/// over the comparison result (#1327 follow-up). -#[test] -fn a_version_snapshot_does_not_survive_a_comparison() { - let dir = std::env::temp_dir().join(format!("wordcraft-compare-versions-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let original = dir.join("original.docx"); - let mut other = Session::new(wordcraft_doc::Document::from_text("Original document.")); - other.run("file.save", &json!({"path": original.to_string_lossy()})).unwrap(); - - let mut s = Session::new(wordcraft_doc::Document::from_text("Current document.")); - s.run("file.versions", &json!({"save": "snapshot"})).unwrap(); - assert_eq!(s.versions.len(), 1); - s.run("review.compare", &json!({"original": original.to_string_lossy(), "text": "Revised document."})).unwrap(); - assert!(s.versions.is_empty(), "the snapshot of the previous document survived the comparison"); - assert!(s.run("file.versions", &json!({"restore": 0})).is_err(), "the old snapshot is still restorable"); - let _ = std::fs::remove_dir_all(&dir); -} - -/// Compare's new and revised results are untitled documents even without an `original` path. -#[test] -fn a_version_snapshot_does_not_survive_an_untitled_comparison() { - for show_in in ["new", "revised"] { - let mut s = Session::new(wordcraft_doc::Document::from_text("Current document.")); - s.run("file.versions", &json!({"save": "snapshot"})).unwrap(); - s.run("review.compare", &json!({"text": "Revised document.", "showIn": show_in})).unwrap(); - assert!(s.versions.is_empty(), "old snapshots survived a {show_in} comparison"); - assert!(s.run("file.versions", &json!({"restore": 0})).is_err()); - assert!(s.path.is_none() && s.file_name.is_none()); - } -} - -/// A comparison with an `original` replaces the open document in place — the handler loads the -/// original into `s.doc` without `set_document` — so `document_id` does not move and two -/// identical comparisons produce identical documents. A comparison is an edit of its own all -/// the same: the `replaces_document` guard keeps its undo step (#4004 review). -#[test] -fn a_repeated_comparison_is_still_an_edit() { - let dir = std::env::temp_dir().join(format!("wordcraft-compare-twice-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let original = dir.join("original.docx"); - let mut other = Session::new(wordcraft_doc::Document::from_text("Original document.")); - other.run("file.save", &json!({"path": original.to_string_lossy()})).unwrap(); - - let mut s = Session::new(wordcraft_doc::Document::from_text("Current document.")); - let compare = json!({"original": original.to_string_lossy(), "text": "Revised document."}); - s.run("review.compare", &compare).unwrap(); - let depth = s.undo_depth(); - assert!(s.dirty); - s.run("review.compare", &compare).unwrap(); - assert_eq!(s.undo_depth(), depth + 1, "the second comparison is an edit of its own"); - assert!(s.dirty, "the comparison result is unsaved"); - let _ = std::fs::remove_dir_all(&dir); -} - -/// A rejected comparison leaves the current document and its snapshots together. -#[test] -fn a_rejected_comparison_keeps_the_current_versions() { - use wordcraft_doc::{CharProps, InlineObject}; - - let mut s = Session::new(wordcraft_doc::Document::from_text("Editable. Locked.")); - s.path = Some(std::path::PathBuf::from("original.docx")); - s.file_name = Some("original.docx".into()); - s.run("file.versions", &json!({"save": "snapshot"})).unwrap(); - let end = s.doc.end_of(StoryRef::Body); - s.doc.insert_object(&end, InlineObject::PermEnd { id: 1 }, &CharProps::default()).unwrap(); - let start = s.doc.start_of(StoryRef::Body); - s.doc.insert_object(&start, InlineObject::PermStart { id: 1, group: String::new(), editor: s.author.clone() }, &CharProps::default()).unwrap(); - s.run("review.restrict", &json!({"mode": "readOnly"})).unwrap(); - let before = s.doc.clone(); - - assert!(s.run("review.compare", &json!({"text": "Different document.", "showIn": "new"})).is_err()); - assert_eq!(s.doc, before); - assert_eq!(s.versions.len(), 1, "a rejected comparison discarded the current document's snapshot"); - assert_eq!(s.path.as_deref(), Some(std::path::Path::new("original.docx"))); - assert_eq!(s.file_name.as_deref(), Some("original.docx")); -} - -/// A mailing result opens beside a protected source document, which keeps its save target, -/// versions, password and text. -#[test] -fn a_mailing_result_beside_a_protected_document_keeps_its_file_identity() { - use wordcraft_doc::{CharProps, InlineObject}; - - let mut s = Session::new(wordcraft_doc::Document::from_text("Protected source.")); - s.path = Some(std::path::PathBuf::from("source.docx")); - s.file_name = Some("source.docx".into()); - s.run("file.versions", &json!({"save": "snapshot"})).unwrap(); - let end = s.doc.end_of(StoryRef::Body); - s.doc.insert_object(&end, InlineObject::PermEnd { id: 1 }, &CharProps::default()).unwrap(); - let start = s.doc.start_of(StoryRef::Body); - s.doc.insert_object(&start, InlineObject::PermStart { id: 1, group: String::new(), editor: s.author.clone() }, &CharProps::default()).unwrap(); - s.run("review.restrict", &json!({"mode": "readOnly"})).unwrap(); - let before = s.doc.clone(); - let before_id = s.document_id(); - let before_dirty = s.dirty; - s.password = Some(crate::io::Password::new("synthetic file password")); - s.bib_style = "Chicago".into(); - - s.run("mailings.envelopes", &json!({"delivery": "Recipient"})).unwrap(); - assert_ne!(s.document_id(), before_id, "the envelope is a new document"); - s.activate(before_id).unwrap(); - assert_eq!(s.doc, before); - assert_eq!(s.versions.len(), 1); - assert_eq!(s.path.as_deref(), Some(std::path::Path::new("source.docx"))); - assert_eq!(s.file_name.as_deref(), Some("source.docx")); - assert_eq!(s.document_id(), before_id); - assert_eq!(s.dirty, before_dirty); - assert_eq!(s.password.as_ref().map(crate::io::Password::as_str), Some("synthetic file password")); - assert_eq!(s.bib_style, "Chicago"); -} - -/// #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() }; - Arc::make_mut(&mut s.doc.styles).upsert(Style { - id: "HideC".into(), - name: "Hide C".into(), - kind: StyleKind::Character, - chr: hidden.clone(), - ..Default::default() - }); - Arc::make_mut(&mut 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(); - Arc::make_mut(&mut 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(); - Arc::make_mut(&mut 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. -
  3. Classificazione dell’evento
  4. -
  5. Azioni correttive
  6. -
-
- - - - - -
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); -} - -/// Shape Format › Shape Fill with transparency and gradients (#1500): each form sets the fill of -/// every selected shape in one undo step; a colour alone keeps the transparency; hostile values -/// are clamped; the old `{"color"}` form still works. -#[test] -fn shape_fill_transparency_and_gradients() { - use wordcraft_doc::fill::{Fill, GradientKind, PathShape}; - use wordcraft_doc::props::Rgb; - let mut s = s(); - run(&mut s, "insert.shape", json!({"kind": "rectangle"})); - run(&mut s, "select.collapse", json!({"end": true})); - run(&mut s, "insert.shape", json!({"kind": "ellipse"})); - run(&mut s, "select.objects", json!({"index": 0})); - run(&mut s, "select.addObject", json!({"index": 1})); - let fills = |s: &Session| -> Vec> { - cmd::objects::all_objects(s) - .into_iter() - .map(|(_, o)| match o { - wordcraft_doc::InlineObject::Shape { fill, .. } => fill, - o => panic!("{o:?}"), - }) - .collect() - }; - let before = fills(&s); - run(&mut s, "shape.fill", json!({"color": "112233", "transparency": 40})); - let solid = Fill::Solid { color: Rgb(0x11, 0x22, 0x33), transparency: 40.0 }; - assert_eq!(fills(&s), [Some(solid.clone()), Some(solid)], "both selected shapes"); - run(&mut s, "shape.fill", json!({"color": "445566"})); - assert_eq!(fills(&s)[1], Some(Fill::Solid { color: Rgb(0x44, 0x55, 0x66), transparency: 40.0 }), "a colour alone keeps the transparency"); - run( - &mut s, - "shape.fill", - json!({"gradient": {"type": "rectangular", "focus": [50, 50, 50, 50], "stops": [{"pos": 100, "color": "FFFFFF", "transparency": 30}, {"pos": 0, "color": "000000"}]}}), - ); - let Some(Fill::Gradient(g)) = &fills(&s)[0] else { panic!("{:?}", fills(&s)) }; - assert_eq!(g.kind, GradientKind::Path { shape: PathShape::Rect, focus: [50.0; 4] }); - assert_eq!(g.stops.iter().map(|st| (st.pos, st.color, st.transparency)).collect::>(), [(0.0, Rgb::BLACK, 0.0), (100.0, Rgb::WHITE, 30.0)]); - assert!(g.rot_with_shape, "turns with the shape, as Word's new gradients do"); - run( - &mut s, - "shape.fill", - json!({"gradient": {"type": "linear", "angle": -90, "stops": [{"pos": -5, "color": "FF0000", "transparency": 500}, {"pos": 1e9, "color": "0000FF"}]}}), - ); - let Some(Fill::Gradient(g)) = &fills(&s)[1] else { panic!() }; - assert_eq!(g.kind, GradientKind::Linear { angle: 270.0, scaled: false }); - assert_eq!((g.stops[0].pos, g.stops[0].transparency, g.stops[1].pos), (0.0, 100.0, 100.0)); - // One undo step per command, for every shape. - run(&mut s, "edit.undo", json!({})); - assert!(matches!(fills(&s)[1], Some(Fill::Gradient(_)))); - for _ in 0..3 { - run(&mut s, "edit.undo", json!({})); - } - assert_eq!(fills(&s), before); - run(&mut s, "shape.fill", json!({"color": null})); - assert_eq!(fills(&s), [None, None]); - for bad in [ - json!({"gradient": {"stops": []}}), - json!({"gradient": {"type": "spiral", "stops": [{"pos": 0, "color": "000000"}, {"pos": 100, "color": "FFFFFF"}]}}), - json!({"gradient": 5}), - json!({"color": "nope"}), - ] { - assert!(s.run("shape.fill", &bad).is_err(), "{bad}"); - } -} - -/// A drag in the Format Shape pane applies its value as it goes and undoes in one step. -#[test] -fn steps_since_a_mark_merge_into_one_undo_step() { - let mut s = s(); - run(&mut s, "insert.shape", json!({"kind": "rectangle"})); - let fill = |s: &Session| match cmd::objects::selected(s) { - Some((_, wordcraft_doc::InlineObject::Shape { fill, .. })) => fill, - o => panic!("{o:?}"), - }; - let before = fill(&s); - let mark = s.undo_mark(); - for t in [10, 20, 30] { - run(&mut s, "shape.fill", json!({"color": "112233", "transparency": t})); - } - s.merge_undo_since(mark); - assert_eq!(s.undo_mark(), mark + 1); - run(&mut s, "edit.undo", json!({})); - assert_eq!(fill(&s), before); - s.merge_undo_since(mark + 5); - assert_eq!(s.undo_mark(), mark, "nothing newer: nothing merged"); -} - -/// 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; - Arc::make_mut(&mut d.styles).get_mut("TOC2").unwrap().para.tabs = Some(vec![right]); - let num = Arc::make_mut(&mut 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"); - - // A selection is as good as its worst paragraph: an empty one or one starting with a space - // refuses a drop cap for all (the button greys out); None works while there is one to take away. - let mut s = self::s(); - run(&mut s, "text.insert", json!({"text": "First"})); - run(&mut s, "text.newParagraph", json!({})); - run(&mut s, "text.newParagraph", json!({})); - run(&mut s, "text.insert", json!({"text": "Third"})); - assert_eq!(s.doc.body.len(), 3); - run(&mut s, "select.all", json!({})); - assert!(s.run("insert.dropCap", &json!({"position": "dropped"})).is_err(), "an empty paragraph in the selection"); - assert!(s.doc.body.iter().filter_map(|b| b.as_para()).all(|p| p.props.drop_cap.is_none()), "nothing changed"); - assert!(s.why_disabled("insert.dropCap").is_some(), "greyed out: nothing can take a drop cap or lose one"); - run(&mut s, "select.range", json!({"anchor": {"block": 2, "off": 0}, "focus": {"block": 2, "off": 0}})); - run(&mut s, "insert.dropCap", json!({"position": "dropped"})); - // From the drop cap up into the empty paragraph (the caret there): None takes it away, the - // other positions are refused, as the enabled check says. - run(&mut s, "select.range", json!({"anchor": {"block": 2, "off": 5}, "focus": {"block": 1, "off": 0}})); - assert_eq!(s.why_disabled("insert.dropCap"), None, "a drop cap to take away"); - assert!(s.run("insert.dropCap", &json!({"position": "margin"})).is_err(), "the empty paragraph can't take one"); - assert!(s.run("insert.dropCap", &json!({"position": "none"})).is_ok(), "None takes it away"); - assert!(s.doc.body.iter().filter_map(|b| b.as_para()).all(|p| p.props.drop_cap.is_none()), "taken away"); - let mut s = self::s(); - run(&mut s, "text.insert", json!({"text": " first"})); - run(&mut s, "text.newParagraph", json!({})); - run(&mut s, "text.insert", json!({"text": "Second"})); - run(&mut s, "select.all", json!({})); - assert!(s.run("insert.dropCap", &json!({})).is_err(), "a paragraph starting with a space in the selection"); -} - -/// #2601: `keep` moves a whole selection to Dropped / In margin without flattening it: each -/// paragraph keeps its own lines, distance and font, one without a drop cap gets the defaults, -/// and it is one undo step. Without `keep` the options not given are reset, as before. -#[test] -fn drop_cap_keep_leaves_each_paragraphs_options() { - let caps = |s: &Session| { - s.doc - .body - .iter() - .filter_map(|b| b.as_para()) - .map(|p| (p.props.drop_cap, p.props.drop_cap_margin, p.props.drop_cap_distance, p.props.drop_cap_font.clone())) - .collect::>() - }; - let mut s = s(); - run(&mut s, "text.insert", json!({"text": "First"})); - for text in ["Second", "Third"] { - run(&mut s, "text.newParagraph", json!({})); - run(&mut s, "text.insert", json!({"text": text})); - } - run(&mut s, "select.range", json!({"anchor": {"block": 0, "off": 0}, "focus": {"block": 0, "off": 0}})); - run(&mut s, "insert.dropCap", json!({"position": "dropped", "lines": 5, "distance": 7.0, "font": "Liberation Mono"})); - run(&mut s, "select.range", json!({"anchor": {"block": 1, "off": 0}, "focus": {"block": 1, "off": 0}})); - run(&mut s, "insert.dropCap", json!({"position": "dropped", "lines": 2, "distance": 3.0})); - let before = caps(&s); - run(&mut s, "select.all", json!({})); - run(&mut s, "insert.dropCap", json!({"position": "margin", "keep": true})); - assert_eq!( - caps(&s), - vec![ - (Some(5), Some(true), Some(7.0), Some("Liberation Mono".to_string())), - (Some(2), Some(true), Some(3.0), None), - (Some(3), Some(true), None, None), - ], - "each keeps its own options; the one without gets the defaults" - ); - run(&mut s, "edit.undo", json!({})); - assert_eq!(caps(&s), before, "one undo step"); - - // Options given explicitly win over the kept ones; a junk `keep` is false. - run(&mut s, "insert.dropCap", json!({"position": "dropped", "keep": true, "lines": 4})); - assert_eq!(caps(&s).iter().map(|c| c.0).collect::>(), vec![Some(4); 3]); - assert_eq!(caps(&s).first().map(|c| (c.2, c.3.clone())), Some((Some(7.0), Some("Liberation Mono".to_string()))), "distance and font still kept"); - // A blank font given with `keep` clears the kept font (the text's own again). - run(&mut s, "insert.dropCap", json!({"position": "dropped", "keep": true, "font": " "})); - assert_eq!(caps(&s).first().map(|c| c.3.clone()), Some(None), "blank font clears the kept one"); - run(&mut s, "insert.dropCap", json!({"position": "dropped", "lines": 5, "distance": 7.0, "font": "Liberation Mono"})); - run(&mut s, "insert.dropCap", json!({"position": "margin", "keep": "yes"})); - assert_eq!(caps(&s), vec![(Some(3), Some(true), None, None); 3], "junk keep: reset what isn't given"); - // None ignores keep. - run(&mut s, "insert.dropCap", json!({"position": "none", "keep": true})); - assert_eq!(caps(&s), vec![(None, None, None, None); 3]); -} - -#[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}"); - } -} - -/// #3490: `table.cellMargins` preserves existing margins for any sides not passed. -#[test] -fn table_cell_margins_preserves_unpassed_sides() { - let mut s = s(); - run(&mut s, "insert.table", json!({"rows": 1, "cols": 1})); - let (tp, _, _) = s.sel.focus.path.cell().unwrap(); - - // 1. Updating 'top' only retains default left (5.4), bottom (0.0), and right (5.4). - run(&mut s, "table.cellMargins", json!({"top": 20.0})); - let margins = s.doc.table(StoryRef::Body, &tp).unwrap().props.cell_margins.unwrap(); - assert_eq!(margins, [20.0, 5.4, 0.0, 5.4]); - - // 2. Updating 'left' subsequently retains previously set top (20.0), bottom (0.0), and right (5.4). - run(&mut s, "table.cellMargins", json!({"left": 10.0})); - let margins = s.doc.table(StoryRef::Body, &tp).unwrap().props.cell_margins.unwrap(); - assert_eq!(margins, [20.0, 10.0, 0.0, 5.4]); - - // 3. Updating 'bottom' retains top (20.0), left (10.0), and right (5.4). - run(&mut s, "table.cellMargins", json!({"bottom": 8.0})); - let margins = s.doc.table(StoryRef::Body, &tp).unwrap().props.cell_margins.unwrap(); - assert_eq!(margins, [20.0, 10.0, 8.0, 5.4]); - - // 4. Updating 'right' retains top (20.0), left (10.0), and bottom (8.0). - run(&mut s, "table.cellMargins", json!({"right": 15.0})); - let margins = s.doc.table(StoryRef::Body, &tp).unwrap().props.cell_margins.unwrap(); - assert_eq!(margins, [20.0, 10.0, 8.0, 15.0]); - - // 5. Updating multiple sides at once retains unpassed sides. - run(&mut s, "table.cellMargins", json!({"top": 0.0, "bottom": 0.0})); - let margins = s.doc.table(StoryRef::Body, &tp).unwrap().props.cell_margins.unwrap(); - assert_eq!(margins, [0.0, 10.0, 0.0, 15.0]); -} - -#[test] -fn headless_ui_requests_stay_bounded() { - let mut s = s(); - let big = "x".repeat(1 << 20); - for _ in 0..200 { - run(&mut s, "edit.pasteSpecial", json!({"text": big})); - run(&mut s, "edit.goto", json!({})); - } - assert!(s.ui_requests.len() <= 64, "len = {}", s.ui_requests.len()); - let size: usize = s.ui_requests.iter().map(|r| r.to_string().len()).sum(); - assert!(size < 4 << 20, "queued {size} bytes"); -} - -/// `file.exportPng` writes only to `.png` paths, so automation can't replace other files with -/// page images (#3696). -#[cfg(not(target_arch = "wasm32"))] -#[test] -fn export_png_refuses_a_path_that_is_not_png() { - let dir = scratch_dir("export-png-extension"); - let mut s = s(); - run(&mut s, "text.insert", json!({"text": "Hello"})); - for name in ["notes.txt", "config", "report.docx", "page.png.txt"] { - let target = dir.join(name); - std::fs::write(&target, b"precious user notes").unwrap(); - let r = s.run("file.exportPng", &json!({"path": target.to_string_lossy()})); - assert!(matches!(r, Err(crate::CmdError::Params(_))), "{name}: {r:?}"); - assert_eq!(std::fs::read(&target).unwrap(), b"precious user notes", "{name} was changed"); - } - // A .png path still works, whatever its case. - for name in ["page.png", "PAGE.PNG"] { - let target = dir.join(name); - s.run("file.exportPng", &json!({"path": target.to_string_lossy()})).unwrap(); - assert!(std::fs::read(&target).unwrap().starts_with(b"\x89PNG"), "{name} is not a PNG"); - } - let _ = std::fs::remove_dir_all(&dir); -} - -/// The body's IME replacement epoch advances when the whole body is swapped (setText, Undo/Redo) -/// and stays put for ordinary edits; an equation editing session cannot ride across a replacement. -#[test] -fn ime_body_epoch_marks_body_replacements_not_edits() { - let mut s = s(); - let id = s.document_id(); - run(&mut s, "text.insert", json!({"text": "one"})); - let first = s.ime_body_epoch(); - run(&mut s, "text.insert", json!({"text": " two"})); - assert_eq!(s.ime_body_epoch(), first, "typing is not a replacement"); - - run(&mut s, "document.setText", json!({"text": "same text"})); - let replaced = s.ime_body_epoch(); - assert!(replaced > first, "setText replaces the body under the same document id"); - assert_eq!(s.document_id(), id, "the document id itself is unchanged"); - - // The same text again still replaces the body: a composition must not survive on content - // equality. - run(&mut s, "document.setText", json!({"text": "same text"})); - assert!(s.ime_body_epoch() > replaced); - - // Undo swaps the body back. - let before_undo = s.ime_body_epoch(); - assert!(s.undo()); - assert!(s.ime_body_epoch() > before_undo, "Undo replaces the body"); -} - -/// Replacing the document body while an equation is being edited ends that equation editing -/// session. -#[test] -fn a_body_replacement_ends_equation_editing() { - let mut s = s(); - run(&mut s, "insert.equation", json!({"linear": "x+y"})); - assert!(s.math.is_some()); - run(&mut s, "document.setText", json!({"text": "x+y"})); - assert!(s.math.is_none(), "the old equation's editing session is gone"); -} - -/// A failed command that cleared the equation session puts it back with the document. -#[test] -fn a_failed_replacement_keeps_equation_editing() { - let mut s = s(); - run(&mut s, "insert.equation", json!({"linear": "x+y"})); - let session = s.math.as_ref().map(|m| m.session); - assert!(session.is_some()); - // Missing `text` is rejected before any mutation. - assert!(s.run("document.setText", &json!({})).is_err()); - assert_eq!(s.math.as_ref().map(|m| m.session), session); -} - -/// Track Changes: deleting a range whose text is already marked deleted is a real no-op. It -/// creates no revision record, no undo-visible change and no IME revision. -#[test] -fn deleting_an_already_deleted_tracked_range_is_a_classified_noop() { - let mut s = s(); - run(&mut s, "text.insert", json!({"text": "abc"})); - run(&mut s, "review.trackChanges", json!({"value": true})); - s.sel = crate::Selection { anchor: Pos::body(0, 0), focus: Pos::body(0, 1) }; - run(&mut s, "text.backspace", json!({})); - let revisions = s.doc.revisions.len(); - let revision = s.ime_state_revision().expect("an IME revision"); - s.sel = crate::Selection { anchor: Pos::body(0, 0), focus: Pos::body(0, 1) }; - run(&mut s, "text.backspace", json!({})); - let outcome = s.ime_edit_outcome(); - assert!(outcome.classified, "{outcome:?}"); - assert!(!outcome.content_changed && !outcome.state_changed, "{outcome:?}"); - assert_eq!(s.doc.revisions.len(), revisions, "no unused Delete revision is appended"); - assert_eq!(s.ime_state_revision(), Some(revision), "the no-op did not move the settled caret"); - s.sel = crate::Selection { anchor: Pos::body(0, 0), focus: Pos::body(0, 1) }; - assert!(s.ime_state_revision() > Some(revision), "re-selecting moves the caret, not the document"); -} - -/// `text.insert` classifies the automatic rewrites its own handler performs: smart quotes, -/// list autoformat and AutoCorrect are `transformed`, not the IME's literal insert. -#[test] -fn text_insert_marks_automatic_rewrites_as_transformed() { - let mut session = s(); - run(&mut session, "text.insert", json!({"text": "\""})); - assert!(session.ime_edit_outcome().transformed, "smart quote: {:?}", session.ime_edit_outcome()); - assert_eq!(text(&session), "\u{201C}"); - - let mut session = s(); - for c in ["W", "o", "r", "d", " ", "(", "c", ")", " "] { - run(&mut session, "text.insert", json!({"text": c})); - } - assert!(session.ime_edit_outcome().transformed, "AutoCorrect: {:?}", session.ime_edit_outcome()); - - let mut session = s(); - run(&mut session, "text.insert", json!({"text": "*"})); - run(&mut session, "text.insert", json!({"text": " "})); - assert!(session.ime_edit_outcome().transformed, "list AutoFormat: {:?}", session.ime_edit_outcome()); - - let mut session = s(); - run(&mut session, "text.insert", json!({"text": "A--b"})); - run(&mut session, "text.insert", json!({"text": " "})); - assert!(session.ime_edit_outcome().transformed, "dash AutoFormat: {:?}", session.ime_edit_outcome()); - assert_eq!(text(&session), "A\u{2014}b "); - - let mut session = s(); - run(&mut session, "tools.autocorrect", json!({"doubleSpacePeriod": true})); - for c in "Hello ".chars() { - run(&mut session, "text.insert", json!({"text": c.to_string()})); - } - let outcome = session.ime_edit_outcome(); - assert!(outcome.transformed && outcome.content_changed && outcome.state_changed, "double-space period: {outcome:?}"); - assert_eq!(text(&session), "Hello. "); - run(&mut session, "edit.undo", json!({})); - assert_eq!(text(&session), "Hello ", "undo restores the spaces in one step"); -} - -#[test] -fn style_set_keeps_imported_styles_latent_styles_and_defaults() { - use wordcraft_doc::props::{Rgb, TextColor}; - use wordcraft_doc::styles::{Style, StyleKind}; - for name in ["basic", "lines", "shaded", "casual", "centered", "minimalist", "title"] { - let mut s = s(); - let mut body = s.doc.styles.get("Normal").unwrap().clone(); - body.id = "BodyText".into(); - body.name = "Body Text".into(); - body.builtin = true; - body.based_on = Some("Normal".into()); - body.chr.size = Some(20.0); - body.chr.color = Some(TextColor::Rgb(Rgb(0xFF, 0, 0))); - let grid = Style { id: "GridTable4".into(), name: "Grid Table 4".into(), kind: StyleKind::Table, builtin: true, ..Default::default() }; - Arc::make_mut(&mut s.doc.styles).styles.push(body.clone()); - Arc::make_mut(&mut s.doc.styles).styles.push(grid.clone()); - Arc::make_mut(&mut s.doc.styles).latent_styles = Some("".into()); - Arc::make_mut(&mut s.doc.styles).default_chr.lang = Some("de-DE".into()); - Arc::make_mut(&mut s.doc.styles).default_chr.size = Some(11.0); - run(&mut s, "design.styleSet", json!({ "name": name })); - assert_eq!(s.doc.styles.get("BodyText"), Some(&body), "{name}: BodyText"); - assert_eq!(s.doc.styles.get("GridTable4"), Some(&grid), "{name}: GridTable4"); - assert_eq!(s.doc.styles.latent_styles.as_deref(), Some(""), "{name}"); - assert_eq!(s.doc.styles.default_chr.lang.as_deref(), Some("de-DE"), "{name}"); - assert_eq!(s.doc.styles.default_chr.size, Some(11.0), "{name}"); - assert_eq!(s.doc.styles.styles.iter().filter(|x| x.id == "Heading1").count(), 1, "{name}"); - let saved = crate::io::save_bytes("x.docx", &s.doc).unwrap(); - let back = crate::io::open_bytes("x.docx", &saved).unwrap(); - assert!(back.styles.get("BodyText").is_some(), "{name}: BodyText after round trip"); - } - let mut s = s(); - run(&mut s, "design.styleSet", json!({ "name": "title" })); - assert_eq!(s.doc.styles.get("Title").unwrap().chr.size, Some(36.0)); -} - -fn style_size(s: &Session, id: &str) -> f32 { - s.doc.styles.resolve_char(Some(id), &Default::default()).size -} - -#[test] -fn scale_font_sizes_adds_points_to_every_style() { - let mut s = Session::new(crate::sample::sample_document()); - let ids: Vec = s.doc.styles.styles.iter().map(|x| x.id.clone()).collect(); - let before: Vec = ids.iter().map(|i| style_size(&s, i)).collect(); - let base = s.doc.styles.resolve_char(None, &Default::default()).size; - let runs = serde_json::to_string(&s.doc.body).unwrap(); - run(&mut s, "design.scaleFontSizes", json!({"delta": 2})); - // Every style (explicit or inherited) grows by exactly 2, once. - for (i, b) in ids.iter().zip(&before) { - assert_eq!(style_size(&s, i), b + 2.0, "{i}"); - } - assert_eq!(s.doc.styles.resolve_char(None, &Default::default()).size, base + 2.0); - assert_eq!(serde_json::to_string(&s.doc.body).unwrap(), runs, "runs untouched"); - run(&mut s, "edit.undo", json!({})); - for (i, b) in ids.iter().zip(&before) { - assert_eq!(style_size(&s, i), *b, "undo {i}"); - } -} - -#[test] -fn scale_font_sizes_percent_and_clamp_and_errors() { - let mut s = Session::new(crate::sample::sample_document()); - let n = style_size(&s, "Normal"); - run(&mut s, "design.scaleFontSizes", json!({"percent": 10})); - assert_eq!(style_size(&s, "Normal"), (n * 1.1 * 2.0).round() / 2.0); - run(&mut s, "design.scaleFontSizes", json!({"delta": -500})); - assert_eq!(style_size(&s, "Normal"), 1.0); - for bad in [ - json!({}), - json!({"delta": 1e9}), - json!({"percent": -100}), - json!({"percent": 1e9}), - json!({"delta": "x"}), - json!({"delta": 0}), - json!({"percent": 0}), - json!({"delta": 1, "percent": 10}), - json!({"delta": 2, "percent": "x"}), - json!({"percent": 10, "delta": null}), - ] { - assert!(s.run("design.scaleFontSizes", &bad).is_err(), "{bad}"); - } -} - -#[test] -fn scale_font_sizes_reaches_char_table_and_cs_sizes_and_unset_default() { - use wordcraft_doc::props::CharProps; - use wordcraft_doc::{Style, StyleKind, styles::TablePart}; - let mut s = s(); - Arc::make_mut(&mut s.doc.styles).default_chr.size = None; - Arc::make_mut(&mut s.doc.styles).default_chr.size_cs = Some(10.0); - let mut ch = Style { id: "Big".into(), name: "Big".into(), kind: StyleKind::Character, ..Default::default() }; - ch.chr.size = Some(20.0); - ch.chr.size_cs = Some(22.0); - let mut tb = Style { id: "Grid".into(), name: "Grid".into(), kind: StyleKind::Table, ..Default::default() }; - let mut parts = wordcraft_doc::styles::TableStyleParts::default(); - parts.header_chr.size = Some(14.0); - parts.regions.entry(TablePart::Band2Horz).or_default().chr.size = Some(8.0); - tb.table = Some(parts); - Arc::make_mut(&mut s.doc.styles).styles.extend([ch, tb]); - run(&mut s, "design.scaleFontSizes", json!({"delta": 2})); - let st = &s.doc.styles; - assert_eq!(st.default_chr.size, Some(13.0), "unset default is 11 pt, made explicit"); - assert_eq!(st.default_chr.size_cs, Some(12.0)); - let big = CharProps { style: Some("Big".into()), ..Default::default() }; - assert_eq!(st.resolve_char(None, &big).size, 22.0); - assert_eq!(st.resolve_char(None, &big).size_cs, 24.0); - let grid = st.styles.iter().find(|x| x.id == "Grid").and_then(|x| x.table.as_ref()).unwrap(); - assert_eq!(grid.header_chr.size, Some(16.0)); - assert_eq!(grid.regions.get(&TablePart::Band2Horz).unwrap().chr.size, Some(10.0)); -} - -/// #3676: a 1-based page parameter becomes a 0-based index without wrapping. -/// On 32-bit and wasm the old cast truncated 2^32 to 0 and `0 - 1` panicked. -#[test] -fn page_index_saturates_instead_of_wrapping_to_page_one() { - assert_eq!(crate::p::page_index(0), 0); - assert_eq!(crate::p::page_index(1), 0); - assert_eq!(crate::p::page_index(2), 1); - assert_ne!(crate::p::page_index(1u64 << 32), 0, "2^32 must not wrap to page 1"); - assert_eq!(crate::p::page_index(u64::MAX), usize::MAX - 1); -} - -/// #3676: absolute page numbers that cannot be an index are rejected, not silently -/// turned into page 1. `edit.goto` clamps to the last page, as it does past the end. -#[test] -fn hostile_absolute_pages_never_land_on_page_one() { - let mut s = 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": 1u64 << 32})); - assert_eq!(page(&mut s), 5, "an out-of-range page lands on the last page"); - run(&mut s, "edit.goto", json!({"page": u64::MAX})); - assert_eq!(page(&mut s), 5, "u64::MAX lands on the last page"); - run(&mut s, "edit.goto", json!({"page": 0})); - assert_eq!(page(&mut s), 1, "page 0 is the first page"); -} - -/// #3676: `caret.set` takes a 0-based page; an oversized page is rejected instead of -/// truncating to page 0 on 32-bit and wasm. -#[test] -fn caret_set_rejects_a_page_number_that_does_not_fit() { - let mut s = s(); - run(&mut s, "text.insert", json!({"text": "Hello"})); - let r = s.run("caret.set", &json!({"page": 1u64 << 32, "x": 100.0, "y": 100.0})); - assert!(matches!(r, Err(crate::CmdError::Params(_))), "page 2^32: {r:?}"); -} - -/// #3676: `file.exportPng` rejects a page number that cannot be an index instead of -/// exporting page 1 (the 32-bit truncation) and writing it to the requested path. -#[cfg(not(target_arch = "wasm32"))] -#[test] -fn export_png_rejects_a_page_number_that_does_not_fit() { - let dir = scratch_dir("export-png-hostile-page"); - let mut s = s(); - run(&mut s, "text.insert", json!({"text": "Hello"})); - let target = dir.join("page.png"); - let r = s.run("file.exportPng", &json!({"path": target.to_string_lossy(), "page": 1u64 << 32})); - assert!(matches!(r, Err(crate::CmdError::Params(_))), "page 2^32: {r:?}"); - assert!(!target.exists(), "no file is written for an invalid page"); - let r = s.run("file.exportPng", &json!({"path": target.to_string_lossy(), "page": u64::MAX})); - assert!(matches!(r, Err(crate::CmdError::Params(_))), "page u64::MAX: {r:?}"); - assert!(!target.exists(), "no file is written for an invalid page"); - let _ = std::fs::remove_dir_all(&dir); -} - -#[test] -fn screen_paper_color_is_a_persistent_preference_not_document_formatting() { - use crate::cmd::view::PaperColor; - let mut s = s(); - run(&mut s, "text.insert", json!({"text": "Screen paper test", "raw": true})); - let before = serde_json::to_value(&s.doc).unwrap(); - let docx_before = wordcraft_docx::write(&s.doc).unwrap(); - s.dirty = false; - let dirty = s.dirty; - s.view.dark_mode = true; - for (value, expected) in - [("green", PaperColor::Green), ("cream", PaperColor::Cream), ("gray", PaperColor::Gray), ("document", PaperColor::Document)] - { - run(&mut s, "view.paperColor", json!({"value": value})); - assert_eq!(s.prefs.paper_color, expected); - assert_eq!(serde_json::to_value(&s.doc).unwrap(), before); - assert_eq!(s.dirty, dirty); - assert_eq!(wordcraft_docx::write(&s.doc).unwrap(), docx_before); - assert!(!s.view.dark_mode, "choosing a tint turns off dark paper"); - let saved = serde_json::to_value(&s.prefs).unwrap(); - let restored: crate::Prefs = serde_json::from_value(saved).unwrap(); - assert_eq!(restored.paper_color, expected); - } - for v in [json!({}), json!({"value": "red"}), json!({"value": 42})] { - assert!(s.run("view.paperColor", &v).is_err()); - assert_eq!(s.prefs.paper_color, PaperColor::Document); - } - let legacy: crate::Prefs = serde_json::from_value(json!({})).unwrap(); - assert_eq!(legacy.paper_color, PaperColor::Document); - let future: crate::Prefs = serde_json::from_value(json!({"paperColor":"future", "countNotes":false})).unwrap(); - assert_eq!(future.paper_color, PaperColor::Document); - assert!(!future.count_notes); -} - -/// Font › Emphasis mark puts a mark over (or under) each selected character, and the layout -/// draws one per character; Format state reports it, and none takes it off. -#[test] -fn emphasis_marks_are_set_and_drawn() { - use wordcraft_doc::props::EmphasisMark; - let mut s = Session::new(wordcraft_doc::Document::from_text("強調する")); - run(&mut s, "select.all", json!({})); - run(&mut s, "format.emphasisMark", json!({"mark": "circle"})); - let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); - assert_eq!(p.props_of_char(0).emphasis, Some(EmphasisMark::Circle)); - assert_eq!(run(&mut s, "format.state", json!({}))["emphasisMark"], "circle"); - // One mark per character: hollow circles, drawn as shapes. - let draws = s.layout().pages.first().map(|pg| wordcraft_layout::display::page_display(&s.doc, pg, &Default::default())).unwrap_or_default(); - let marks = draws - .iter() - .filter(|d| matches!(d, wordcraft_layout::display::Draw::Shape { kind: wordcraft_doc::para::ShapeKind::Ellipse, fill: None, .. })) - .count(); - assert_eq!(marks, 4); - run(&mut s, "format.emphasisMark", json!({"mark": "none"})); - assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().props_of_char(0).emphasis, Some(EmphasisMark::None)); - assert!(s.run("format.emphasisMark", &json!({"mark": "sparkles"})).is_err()); -} - -/// Preferences saved while OpenDocument files had their own settings still read, without them, -/// as does a document saved with what those settings kept for its layout. -#[test] -fn what_the_opendocument_settings_saved_still_reads() { - let prefs: crate::Prefs = serde_json::from_value(json!({"countNotes": false, "odf": {"preset": "libreoffice", "columnGap": 0.0}})).unwrap(); - assert!(!prefs.count_notes); - let layout: wordcraft_doc::OdfLayout = serde_json::from_value(json!({"pictureLinesFitPictures": true, "tabsFromIndent": true})).unwrap(); - assert!(layout.tabs_from_indent); -} - -#[test] -fn an_odt_charts_paragraph_is_written_as_read_after_drawing_it() { - // Opening an ODT draws its charts (from their data); that changes nothing in the document, - // so the paragraph showing one is still written as read. - let mut s = Session::new(wordcraft_doc::Document::new()); - run(&mut s, "insert.chart", json!({"type": "column"})); - let first = wordcraft_formats::odt::export(&s.doc).unwrap(); - let doc = crate::io::open_bytes("t.odt", &first).unwrap(); - let mut z = zip::ZipArchive::new(std::io::Cursor::new(wordcraft_formats::odt::export(&doc).unwrap())).unwrap(); - let mut content = String::new(); - std::io::Read::read_to_string(&mut z.by_name("content.xml").unwrap(), &mut content).unwrap(); - assert!(content.contains(r#"draw:name="Chart1""#) && !content.contains("wcChart"), "{content}"); -} - -/// A picture an OpenDocument file makes wider than the text (a rule across the whole page) stays -/// that wide, and its paragraph is written as read: opening a file changes nothing in it. -#[test] -fn an_odts_picture_wider_than_the_text_keeps_its_size() { - 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:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0" xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0" xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0" xmlns:xlink="http://www.w3.org/1999/xlink""#; - let styles = format!( - r#""# - ); - let content = format!( - r#"Text"# - ); - let png = [b"\x89PNG\r\n\x1a\n".as_slice(), &[7u8; 64]].concat(); - 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", b"application/vnd.oasis.opendocument.text".as_slice()), - ("content.xml", content.as_bytes()), - ("styles.xml", styles.as_bytes()), - ("Pictures/r.png", &png), - ] { - z.start_file(name, o).unwrap(); - std::io::Write::write_all(&mut z, data).unwrap(); - } - let doc = crate::io::open_bytes("t.odt", &z.finish().unwrap().into_inner()).unwrap(); - let width = doc.body[0] - .as_para() - .unwrap() - .objects - .iter() - .find_map(|o| if let wordcraft_doc::para::InlineObject::Image { w, .. } = o { Some(*w) } else { None }); - let want = 18.0 * 72.0 / 2.54; - assert!(width.is_some_and(|w| (w - want).abs() < 0.1), "{width:?}, not fitted to the text"); - let mut z = zip::ZipArchive::new(std::io::Cursor::new(wordcraft_formats::odt::export(&doc).unwrap())).unwrap(); - let mut saved = String::new(); - std::io::Read::read_to_string(&mut z.by_name("content.xml").unwrap(), &mut saved).unwrap(); - assert!(saved.contains(r#"draw:name="Rule""#) && saved.contains(r#"svg:width="18cm""#), "written as read: {saved}"); -} - -/// Numbering or a new style makes the styles be written from the model on save: a document in -/// German stays in German, rather than taking the default language of what opens it next. -#[test] -fn an_odts_language_survives_a_save_after_numbering_or_a_new_style() { - let ns = r#"xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0""#; - let styles = format!( - r#""# - ); - let content = format!( - r#"Hallo"# - ); - 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()), ("styles.xml", styles.as_str())] - { - z.start_file(name, o).unwrap(); - std::io::Write::write_all(&mut z, data.as_bytes()).unwrap(); - } - let odt = z.finish().unwrap().into_inner(); - for (command, params) in [("para.numbering", json!({})), ("styles.create", json!({"name": "Notiz"}))] { - let mut s = Session::new(crate::io::open_bytes("t.odt", &odt).unwrap()); - assert_eq!(cmd::default_lang(&s).as_deref(), Some("de-DE"), "the document's, not English"); - run(&mut s, command, params); - let saved = wordcraft_formats::odt::export(&s.doc).unwrap(); - let back = crate::io::open_bytes("t.odt", &saved).unwrap(); - assert_eq!(back.styles.default_chr.lang.as_deref(), Some("de-DE"), "after {command}"); - } -} - -/// A Word package whose body holds `body` and, as `rIdC`, the chunk part `word/afchunk.{ext}`. -#[cfg(not(target_arch = "wasm32"))] -fn docx_with_chunk(body: &str, ext: &str, content_type: &str, chunk: &[u8]) -> Vec { - use std::io::Write; - let files: [(String, Vec); 4] = [ - ( - "[Content_Types].xml".into(), - format!(r#""#).into_bytes(), - ), - ("_rels/.rels".into(), br#""#.to_vec()), - ( - "word/_rels/document.xml.rels".into(), - format!(r#""#).into_bytes(), - ), - ( - "word/document.xml".into(), - format!(r#"{body}"#).into_bytes(), - ), - ]; - let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); - for (name, bytes) in files.iter().map(|(n, b)| (n.as_str(), b.as_slice())).chain([(format!("word/afchunk.{ext}").as_str(), chunk)]) { - zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap(); - zw.write_all(bytes).unwrap(); - } - zw.finish().unwrap().into_inner() -} - -/// A Single File Web Page under a Word or RTF name opens as one, like the web pages of #2853. -#[test] -fn a_single_file_web_page_saved_as_doc_opens_by_content() { - let mht = "MIME-Version: 1.0\r\nContent-Type: multipart/related; boundary=\"B\"\r\n\r\n--B\r\nContent-Type: text/html; charset=\"utf-8\"\r\n\r\n

Izvje\u{161}taj

\r\n--B--\r\n"; - for name in ["report.doc", "report.docx", "report.rtf"] { - let doc = crate::io::open_bytes(name, mht.as_bytes()).unwrap_or_else(|e| panic!("{name}: {e}")); - assert_eq!(doc.plain_text(StoryRef::Body), "Izvje\u{161}taj", "{name}"); - } -} - -#[cfg(not(target_arch = "wasm32"))] -#[test] -fn alt_chunks_open_in_place_as_word_brings_them_in() { - let png = wordcraft_formats::model::base64_encode(&tiny_png()); - let mht = format!( - "MIME-Version: 1.0\r\nContent-Type: multipart/related; boundary=\"B\"\r\n\r\n--B\r\nContent-Location: file:///C:/doc.htm\r\n\ - Content-Transfer-Encoding: quoted-printable\r\nContent-Type: text/html; charset=\"utf-8\"\r\n\r\n\ -

=C4=8Clanak 1.

\r\n\ - --B\r\nContent-Location: file:///C:/doc_files/a.png\r\nContent-Transfer-Encoding: base64\r\nContent-Type: image/png\r\n\r\n{png}\r\n--B--\r\n" - ); - let p = |t: &str| format!(r#"{t}"#); - let chunk = r#""#; - let bytes = docx_with_chunk(&format!("{}{chunk}{}", p("Before"), p("After")), "mht", "message/rfc822", mht.as_bytes()); - let doc = crate::io::open_bytes("chunk.docx", &bytes).unwrap(); - let text = doc.plain_text(StoryRef::Body); - assert!(text.starts_with("Before\nČlanak 1.\n") && text.ends_with("After"), "{text:?}"); - let para = doc.para(StoryRef::Body, &wordcraft_doc::Path::top(1)).unwrap(); - assert_eq!(para.props.align, Some(wordcraft_doc::Align::Center)); - // Web page text: Times New Roman unless the page says otherwise. - assert_eq!(para.props_of_char(0).font.as_deref(), Some("Times New Roman")); - // The picture came along, its bytes under the key it shows. - let key = doc - .body - .iter() - .find_map(|b| match &**b { - wordcraft_doc::Block::Para(p) => p.objects.iter().find_map(|o| match o { - wordcraft_doc::InlineObject::Image { media, .. } => Some(media.clone()), - _ => None, - }), - _ => None, - }) - .expect("the chunk's picture"); - assert_eq!(doc.media.get(&key).map(|m| m.to_vec()), Some(tiny_png())); - - // HTML (in its content type's charset), RTF and plain-text chunks come in through their - // importers too, and an MHT chunk is known by what it holds. - for (ext, ct, data, want) in [ - ("htm", "text/html; charset=windows-1250", &b"

\xc8lanak chunk

"[..], "Članak chunk"), - ("rtf", "application/rtf", br"{\rtf1\ansi Rich chunk\par}", "Rich chunk"), - ("txt", "text/plain", b"Plain chunk", "Plain chunk"), - ("bin", "", mht.as_bytes(), "Članak 1."), - ] { - let bytes = docx_with_chunk(&format!("{chunk}{}", p("End")), ext, ct, data); - let text = crate::io::open_bytes("chunk.docx", &bytes).unwrap().plain_text(StoryRef::Body); - assert!(text.starts_with(want) && text.ends_with("End"), "{ext}: {text:?}"); - } -} - -#[cfg(not(target_arch = "wasm32"))] -#[test] -fn text_from_file_brings_the_files_pictures() { - let dir = scratch_dir("text-from-file-pictures"); - let mut src = Session::new(wordcraft_doc::Document::new()); - let list = Arc::make_mut(&mut src.doc.numbering).add_list(wordcraft_doc::numbering::ListKind::Bullet); - // A list item, then a paragraph (a single paragraph would take the caret's formatting). - src.doc.split_paragraph(&Pos::body(0, 0)).unwrap(); - src.doc.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().props.numbering = - Some(wordcraft_doc::props::NumRef { num: list, level: 0 }); - let key = src.doc.add_media(tiny_png(), "png"); - let image = wordcraft_doc::InlineObject::Image { - media: key, - w: 20.0, - h: 20.0, - alt: String::new(), - float: Default::default(), - crop: [0.0; 4], - ole: None, - fx: Default::default(), - }; - src.doc.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().insert_object(0, image, &Default::default()).unwrap(); - let path = dir.join("pictures.docx"); - std::fs::write(&path, wordcraft_docx::write(&src.doc).unwrap()).unwrap(); - - let mut s = s(); - // A picture and a list of our own first, so the file's key and list id would point at ours. - let ours = s.doc.add_media(vec![9, 9, 9], "png"); - let our_list = Arc::make_mut(&mut s.doc.numbering).add_list(wordcraft_doc::numbering::ListKind::Numbered); - run(&mut s, "insert.textFromFile", json!({"path": path.to_string_lossy()})); - let _ = std::fs::remove_dir_all(&dir); - let shown: Vec = s - .doc - .body - .iter() - .filter_map(|b| match &**b { - wordcraft_doc::Block::Para(p) => p.objects.iter().find_map(|o| match o { - wordcraft_doc::InlineObject::Image { media, .. } => Some(media.clone()), - _ => None, - }), - _ => None, - }) - .collect(); - assert_eq!(shown.len(), 1); - assert_ne!(shown[0], ours); - assert_eq!(s.doc.media.get(&shown[0]).map(|m| m.to_vec()), Some(tiny_png())); - let listed: Vec = s - .doc - .body - .iter() - .filter_map(|b| match &**b { - wordcraft_doc::Block::Para(p) => p.props.numbering.map(|n| n.num), - _ => None, - }) - .collect(); - assert_eq!(listed.len(), 1); - assert_ne!(listed[0], our_list); - assert!(s.doc.numbering.level(listed[0], 0).is_some_and(|l| l.format == wordcraft_doc::section::NumFormat::Bullet)); -} - -#[test] -fn page_down_and_up_move_by_a_page_in_the_continuous_views() { - // Web Layout, Draft and Outline are one long page: Page Down/Up move as far as they do - // between the pages of Print Layout (#5290). - let after_page_down = |view: &str| { - let mut s = s(); - for i in 0..200 { - run(&mut s, "text.insert", json!({"text": format!("Paragraph {i}")})); - run(&mut s, "text.newParagraph", json!({})); - } - run(&mut s, view, json!({})); - run(&mut s, "caret.docStart", json!({})); - let start = s.sel.focus.clone(); - run(&mut s, "caret.pageDown", json!({})); - let down = s.sel.focus.clone(); - run(&mut s, "caret.pageUp", json!({})); - assert_eq!(s.sel.focus, start, "{view}: Page Up goes back"); - run(&mut s, "caret.docEnd", json!({})); - let end = s.sel.focus.clone(); - run(&mut s, "caret.pageDown", json!({})); - assert_eq!(s.sel.focus, end, "{view}: Page Down stops at the end"); - down - }; - let print = after_page_down("view.printLayout"); - assert!(print.path.0[0] > 10, "Print Layout: Page Down goes to the next page: {print:?}"); - for view in ["view.draft", "view.outline", "view.webLayout"] { - assert_eq!(after_page_down(view), print, "{view}"); - } -} - -#[test] -fn page_down_and_up_never_stay_on_a_line_in_the_continuous_views() { - // A page with less than an inch of text area: the views move by the one-inch minimum. The - // caret ends a long line and the paragraphs after it are narrow (a large right indent): the - // line a page away, not the one the caret is on, is where Page Down goes, however far right - // of it the caret is. - for view in ["view.draft", "view.outline", "view.webLayout"] { - let mut s = s(); - run(&mut s, "layout.margins", json!({"top": 370, "bottom": 370})); - let long = "Lorem ipsum dolor sit amet ".repeat(4); - run(&mut s, "text.insert", json!({"text": long})); - run(&mut s, "text.newParagraph", json!({})); - run(&mut s, "para.indents", json!({"right": 700})); - for i in 0..60 { - if i > 0 { - run(&mut s, "text.newParagraph", json!({})); - } - run(&mut s, "text.insert", json!({"text": "a"})); - } - run(&mut s, view, json!({})); - run(&mut s, "caret.docStart", json!({})); - run(&mut s, "caret.end", json!({})); - assert_eq!(s.sel.focus.off, long.chars().count(), "{view}: the first paragraph is one line"); - let mut at = s.sel.focus.clone(); - for step in 0..3 { - run(&mut s, "caret.pageDown", json!({})); - assert!(s.sel.focus > at, "{view}: Page Down {step} moves on from {at:?}"); - at = s.sel.focus.clone(); - } - for step in 0..3 { - run(&mut s, "caret.pageUp", json!({})); - assert!(s.sel.focus < at, "{view}: Page Up {step} moves back from {at:?}"); - at = s.sel.focus.clone(); - } - } -} - -#[test] -fn page_down_extends_a_selection_that_ends_in_collapsed_outline_text() { - // Selecting a collapsed heading and its content ends the selection in hidden text; Page Down - // still extends it from where it shows (the heading), keeping its anchor. - let mut s = s(); - run(&mut s, "text.insert", json!({"text": "First"})); - for i in 0..5 { - run(&mut s, "text.newParagraph", json!({})); - run(&mut s, "text.insert", json!({"text": format!("Hidden {i}")})); - } - run(&mut s, "text.newParagraph", json!({})); - run(&mut s, "text.insert", json!({"text": "Second"})); - for i in 0..200 { - run(&mut s, "text.newParagraph", json!({})); - run(&mut s, "text.insert", json!({"text": format!("Shown {i}")})); - } - run(&mut s, "view.outline", json!({})); - for block in [0, 6] { - run(&mut s, "caret.set", json!({"pos": {"block": block}})); - run(&mut s, "outline.promoteToHeading1", json!({})); - } - run(&mut s, "outline.collapse", json!({"block": 0})); - run(&mut s, "outline.selectSubtree", json!({"block": 0})); - let anchor = s.sel.anchor.clone(); - assert_eq!(s.sel.focus.path.0, [5], "the selection ends in the last hidden paragraph"); - assert!(s.layout().caret(&s.sel.focus).is_none(), "the outline doesn't show its end"); - run(&mut s, "caret.pageDown", json!({"extend": true})); - assert_eq!(s.sel.anchor, anchor, "the anchor stays"); - assert!(s.sel.focus.path.0[0] > 6, "Page Down extends the selection a page on: {:?}", s.sel.focus); - assert!(s.layout().caret(&s.sel.focus).is_some(), "to a place the outline shows"); -} - -/// A picture an OpenDocument file gives no size is shown at its own, fitted to the text as other -/// formats' are; one with a stated size keeps it, in line too. -#[test] -fn an_odts_picture_is_fitted_only_when_it_states_no_size() { - let ns = r#"xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0" xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0" xmlns:xlink="http://www.w3.org/1999/xlink""#; - let styles = format!( - r#""# - ); - let mut png = std::io::Cursor::new(Vec::new()); - image::RgbImage::new(1000, 500).write_to(&mut png, image::ImageFormat::Png).unwrap(); - let png = png.into_inner(); - let open = |attrs: &str| { - let content = format!( - r#""# - ); - 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", b"application/vnd.oasis.opendocument.text".as_slice()), - ("content.xml", content.as_bytes()), - ("styles.xml", styles.as_bytes()), - ("Pictures/a.png", &png), - ] { - z.start_file(name, o).unwrap(); - std::io::Write::write_all(&mut z, data).unwrap(); - } - let doc = crate::io::open_bytes("t.odt", &z.finish().unwrap().into_inner()).unwrap(); - doc.body[0] - .as_para() - .unwrap() - .objects - .iter() - .find_map(|o| if let wordcraft_doc::para::InlineObject::Image { w, h, .. } = o { Some((*w, *h)) } else { None }) - .unwrap() - }; - let (w, h) = open(""); - assert!((w - 306.14).abs() < 0.5 && (h - 153.07).abs() < 0.5, "fitted to the text: {w} {h}"); - let (w, _) = open(r#" svg:width="18cm" svg:height="9cm""#); - assert!((w - 18.0 * 72.0 / 2.54).abs() < 0.5, "as the file says: {w}"); -} - -/// Issue #5667's document: `Intro`, then an inserted `Appendix` paragraph whose mark carries a -/// section break (Normal, indented), then `Body` in Heading 1. The whole of `Appendix` is -/// tracked as an insertion by `rev`, or as a deletion when `delete`. -fn sectioned_revision(delete: bool) -> Session { - use wordcraft_doc::{Revision, RevisionKind}; - let mut s = Session::new(wordcraft_doc::Document::from_text("Intro\nAppendix\nBody")); - run(&mut s, "caret.set", json!({"pos": {"block": 2, "off": 0}})); - run(&mut s, "para.style", json!({"style": "Heading1"})); - let kind = if delete { RevisionKind::Delete } else { RevisionKind::Insert }; - s.doc.revisions.push(Revision { kind, author: "Ada".into(), date: String::new(), move_name: None }); - s.doc.last_section.margin_left = 72.0; - let mut sect = s.doc.last_section.clone(); - sect.margin_left = 144.0; - let p = s.doc.para_mut(StoryRef::Body, &wordcraft_doc::Path(vec![1])).unwrap(); - let len = p.len(); - p.format(0, len, &|c| if delete { c.del = Some(0) } else { c.ins = Some(0) }).unwrap(); - if delete { - p.mark.del = Some(0) - } else { - p.mark.ins = Some(0) - } - p.props.indent_left = Some(36.0); - p.section = Some(Box::new(sect)); - s -} - -#[test] -fn resolving_away_an_empty_paragraph_with_a_section_break_keeps_the_next_paragraphs_properties() { - // #5667: the resolved paragraph was left empty but kept for its section break, so the next - // paragraph joined it and took its properties (Normal, its indent). It goes whole instead, - // as an empty paragraph without a break does, and its section joins the following one. - for (delete, cmd) in [(false, "review.rejectAll"), (true, "review.acceptAll")] { - let mut s = sectioned_revision(delete); - assert_eq!(s.doc.sections().len(), 2); - run(&mut s, cmd, json!({})); - assert_eq!(body_texts(&s), ["Intro", "Body"], "{cmd}"); - let body = s.doc.para(StoryRef::Body, &wordcraft_doc::Path(vec![1])).unwrap(); - assert_eq!(body.props.style.as_deref(), Some("Heading1"), "{cmd}: Body keeps its style"); - assert_eq!(body.props.indent_left, None, "{cmd}: and not the removed paragraph's indent"); - assert!(s.doc.body.iter().filter_map(|b| b.as_para()).all(|p| p.section.is_none()), "{cmd}: the break went with it"); - assert_eq!(s.doc.sections().len(), 1, "{cmd}"); - assert_eq!(s.doc.last_section.margin_left, 72.0, "{cmd}: the following section's setup stays"); - } -} +// A copy of craft-proof's hand-written Czech fixture (`crates/proof/src/cs/test_dict.rs` in +// craft-libs, which a dependency's tests can't share); its install helper resolves this parent import. +use wordcraft_proof::cs::install_dictionary; +mod czech_test_dict; + +use serde_json::json; +use std::sync::Arc; +use wordcraft_doc::props::BorderStyle; +use wordcraft_doc::{Pos, StoryRef}; + +use crate::{Session, cmd}; + +mod bidi; +mod documents; +mod find_apostrophes; +mod fit_one_page; +mod font_sizes; +mod fonts; +mod ime_noop; +mod para_tools; +mod revision_checks; +mod typing; +mod wordart; + +/// True, after saying so on stderr, when this build lacks the Language data the proofing tests +/// need (craft-proof built without `CRAFT_I18N_DIR`). The probes are craft-proof's public +/// `english_source` (the embedded English dictionary) and its American/British variant table +/// (`colour` → `color`): a craft-i18n checkout without some of the files embeds only the rest, +/// so both are asked. Without them English still spells from craft-proof's built-in word list, +/// but there are no hyphenation patterns and no variant table, so the tests that check those +/// return early, as craft-proof's own do; with the data (CI, releases) they all run. +pub(crate) fn no_language_data(test: &str) -> bool { + let missing = wordcraft_proof::english_source(wordcraft_proof::ProofLang::En) != Some(wordcraft_proof::EnglishSource::Embedded) + || wordcraft_proof::en_variants::preferred("colour", wordcraft_proof::ProofLang::En).as_deref() != Some("color"); + if missing { + eprintln!("{test}: skipped, needs the Language data (set CRAFT_I18N_DIR to a craft-i18n checkout)"); + } + missing +} + +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}"); +} + +/// Gap 9: AES-encrypted Word files — Standard encryption (Word 2007) and agile packages — open +/// with the right password whatever Word extension they carry, and ask for one or refuse a wrong +/// one the same way. +#[test] +fn aes_encrypted_packages_open_under_any_word_extension() { + let mut doc = wordcraft_doc::Document::from_text("Old secret minutes"); + doc.core.title = "Minutes".into(); + let pkg = wordcraft_docx::write(&doc).unwrap(); + let standard = wordcraft_docx::encrypt_standard_for_tests(&pkg, "letmein", 128).unwrap(); + let agile = wordcraft_docx::encrypt_with_spin_count(&pkg, "letmein", 8).unwrap(); + for (kind, bytes) in [("standard", &standard), ("agile", &agile)] { + for name in ["old.doc", "old.docx", "old.dot", "noext"] { + let (d, encrypted) = crate::io::open_bytes_with(name, bytes, Some("letmein")).unwrap_or_else(|e| panic!("{kind} {name}: {e}")); + assert!(encrypted, "{kind} {name}"); + assert!(d.plain_text(StoryRef::Body).contains("Old secret minutes"), "{kind} {name}"); + assert_eq!(d.core.title, "Minutes"); + let e = crate::io::open_bytes_with(name, bytes, None).unwrap_err(); + assert!(crate::io::needs_password(&e), "{kind} {name}: {e}"); + let e = crate::io::open_bytes_with(name, bytes, Some("letmeout")).unwrap_err(); + assert!(crate::io::wrong_password(&e), "{kind} {name}: {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")); +} + +/// The command a shortcut runs. +fn id(k: &str) -> Option<&'static str> { + cmd::registry().by_shortcut(k).map(|c| c.id) +} + +#[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). + 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")); +} + +#[test] +fn word_default_shortcuts_resolve_per_platform() { + 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")); + } +} + +#[test] +fn word_default_shortcuts_resolve_function_keys() { + 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 confined_sessions_neither_save_the_dictionary_nor_choose_folders_outside_their_roots() { + // #3737 (findings 34 and WC-35): the custom dictionary file and the folder settings are + // held to the automation roots like any other path. + let base = scratch_dir("dict-roots"); + for d in ["in", "out", "outside"] { + std::fs::create_dir_all(base.join(d)).unwrap(); + } + let roots = crate::file_access::AutomationRoots::new(Some(&base.join("in")), Some(&base.join("out"))).unwrap(); + let mut s = s(); + let outside = base.join("outside/custom-dictionary.txt"); + s.user_dict_path = Some(outside.clone()); + let confined = |s: &mut Session, cmd: &str, v: serde_json::Value| crate::file_access::confine(roots.as_ref(), || s.run(cmd, &v)); + assert!(confined(&mut s, "review.addToDictionary", json!({"word": "zxqengconfined"})).is_ok()); + assert!(wordcraft_proof::user_dictionary().contains(&"zxqengconfined".to_string()), "kept in memory"); + assert!(!outside.exists(), "nothing written outside the write root"); + for key in ["dictionariesFolder", "userTemplatesFolder"] { + assert!(confined(&mut s, "file.options", json!({key: base.join("outside").to_string_lossy()})).is_err(), "{key}"); + } + assert_eq!((s.prefs.dictionaries_folder.as_deref(), s.prefs.user_templates_folder.as_deref()), (None, None)); + // A folder inside the read root is still taken. + let inside = base.join("in"); + assert!(confined(&mut s, "file.options", json!({"userTemplatesFolder": inside.to_string_lossy()})).is_ok()); + let _ = std::fs::remove_dir_all(&base); + assert_eq!(s.prefs.user_templates_folder.as_deref(), Some(inside.to_string_lossy().as_ref())); +} + +#[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"); +} + +/// A command that changes nothing is not an edit (#23): no undo step, no unsaved changes +/// and no relayout. +#[test] +fn a_command_that_changes_nothing_is_not_an_edit() { + let mut s = s(); + let rev = s.rev(); + // Backspace at the start of the document: there is nothing to delete. + run(&mut s, "text.backspace", json!({})); + assert_eq!(s.undo_depth(), 0, "no undo step"); + assert!(!s.dirty, "no unsaved changes"); + assert_eq!(s.rev(), rev, "no relayout"); + // Typing nothing is the same. + run(&mut s, "text.insert", json!({"text": ""})); + assert_eq!(s.undo_depth(), 0, "no undo step"); + assert!(!s.dirty, "no unsaved changes"); + // A command that changes something is an edit. + run(&mut s, "text.insert", json!({"text": "Hello"})); + assert_eq!(s.undo_depth(), 1, "one undo step"); + assert!(s.dirty, "the document is edited"); + // Setting the alignment a paragraph already has changes nothing. + let depth = s.undo_depth(); + run(&mut s, "para.align", json!({"value": "center"})); + assert_eq!(s.undo_depth(), depth + 1, "one undo step"); + let depth = s.undo_depth(); + run(&mut s, "para.align", json!({"value": "center"})); + assert_eq!(s.undo_depth(), depth, "no undo step"); + // A command that changes nothing leaves the unsaved-changes flag and the layout revision as + // they were, even when it calls `touch` itself: a saved document is not marked edited for + // nothing, and its layout is not laid out again. + let mut clean = Session::new(wordcraft_doc::Document::new()); + run(&mut clean, "para.align", json!({"value": "left"})); + clean.dirty = false; + let rev = clean.rev(); + run(&mut clean, "para.align", json!({"value": "left"})); + assert!(!clean.dirty, "no unsaved changes"); + assert_eq!(clean.rev(), rev, "no relayout"); + assert_eq!(clean.undo_depth(), 1, "only the first align is a step"); +} + +/// Five hundred commands that change nothing used to fill the whole undo stack and push the +/// user's own edits out of it, so they could never be undone again (#23). +#[test] +fn no_op_commands_do_not_evict_the_undo_history() { + let mut s = s(); + run(&mut s, "text.insert", json!({"text": "Hello"})); + run(&mut s, "caret.docStart", json!({})); + for _ in 0..600 { + run(&mut s, "text.backspace", json!({})); + } + assert_eq!(s.undo_depth(), 1, "the typing step is still there"); + run(&mut s, "edit.undo", json!({})); + assert_eq!(text(&s), "", "the typed text is still undoable"); +} + +/// A command that changes nothing does not throw the redo stack away (#23). +#[test] +fn a_no_op_command_keeps_the_redo_stack() { + let mut s = s(); + run(&mut s, "text.insert", json!({"text": "Hello"})); + run(&mut s, "edit.undo", json!({})); + assert!(s.can_redo()); + run(&mut s, "text.backspace", json!({})); + assert!(s.can_redo(), "the redo stack survives a command that changed nothing"); + run(&mut s, "edit.redo", json!({})); + assert_eq!(text(&s), "Hello"); +} + +/// Undo and Redo move between steps even when a step's document is the one showing: they are +/// never taken back as commands that changed nothing, so Undo can't get stuck on such a step. +#[test] +fn undo_moves_past_a_step_that_changed_nothing() { + let mut s = s(); + run(&mut s, "text.insert", json!({"text": "Hello"})); + s.checkpoint("Nothing"); + let depth = s.undo_depth(); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.undo_depth(), depth - 1); + assert!(s.can_redo()); + run(&mut s, "edit.redo", json!({})); + assert_eq!(s.undo_depth(), depth); + run(&mut s, "edit.undo", json!({})); + run(&mut s, "edit.undo", json!({})); + assert_eq!(text(&s), ""); +} + +/// Commands that run as one undo step (`join_next_undo`: a dialog's OK, the frames of a drag): +/// a group whose first command changes nothing has no step to merge into, so the next command +/// in it starts the group's step instead of merging into an older edit (#23 follow-up). +#[test] +fn a_joined_command_starts_its_group_when_the_first_changed_nothing() { + let mut s = s(); + run(&mut s, "text.insert", json!({"text": "Hello"})); + let (depth, margins) = (s.undo_depth(), (s.doc.last_section.margin_left, s.doc.last_section.margin_top)); + // Portrait on a portrait page changes nothing. + run(&mut s, "layout.orientation", json!({"value": "portrait"})); + assert_eq!(s.undo_depth(), depth, "the first command of the group is no edit"); + // The next command of the group starts the step the group needs. + s.join_next_undo(); + run(&mut s, "layout.margins", json!({"left": 100.0})); + assert_eq!(s.doc.last_section.margin_left, 100.0); + assert_eq!(s.undo_depth(), depth + 1, "the group's one step"); + // A command that changes nothing in the middle of the group leaves that step alone, and + // the next one merges into it. + s.join_next_undo(); + run(&mut s, "layout.orientation", json!({"value": "portrait"})); + s.join_next_undo(); + run(&mut s, "layout.margins", json!({"top": 60.0})); + assert_eq!(s.undo_depth(), depth + 1, "still the group's one step"); + // Undo takes the whole group back and no more: the typing is still there. + run(&mut s, "edit.undo", json!({})); + assert_eq!((s.doc.last_section.margin_left, s.doc.last_section.margin_top), margins, "the margins the group started from"); + assert_eq!(text(&s), "Hello"); +} + +/// Bold with nothing selected arms the formatting for the next word: the document is the +/// same, so only the caret's formatting changed — and that is an edit (#23). +#[test] +fn a_command_that_only_arms_the_caret_formatting_is_an_edit() { + let mut s = s(); + run(&mut s, "text.insert", json!({"text": "Hello"})); + run(&mut s, "caret.docStart", json!({})); + let depth = s.undo_depth(); + run(&mut s, "format.bold", json!({})); + assert_eq!(s.undo_depth(), depth + 1, "one undo step"); + // Undo takes the armed formatting back, as Word does (docs/typing-parity.md). + run(&mut s, "edit.undo", json!({})); + run(&mut s, "text.insert", json!({"text": "x"})); + assert_eq!(s.doc.para_at(&s.sel.focus).map(|p| p.props_of_char(0).bold), Some(None)); +} + +/// 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(); + c.date_utc = None; + }); + 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 regex_replace_expands_groups() { + // #4177: with Regular expressions on, Replace with expands capture groups. + let rep = |text: &str, q: &str, with: &str| rep_mode(text, q, with, json!({"regex": true})); + let names = "Smith, John and Doe, Jane"; + // The issue's cases: `\n` and `$n` swap words; `${name}` names a group. + assert_eq!(rep(names, r"(\w+), (\w+)", r"\2 \1"), "John Smith and Jane Doe"); + assert_eq!(rep(names, r"(\w+), (\w+)", "$2 $1"), "John Smith and Jane Doe"); + assert_eq!(rep("2024-05", r"(?\d{4})-", "${y}/"), "2024/05"); + // Braced numbers, `$0` / `${0}` (whole match), `$$` (a dollar sign). + assert_eq!(rep("ab", "(a)(b)", "${2}${1}"), "ba"); + assert_eq!(rep("ab", "a", "[$0|${0}]"), "[a|a]b"); + assert_eq!(rep("5", r"(\d)", "$$$1"), "$5"); + // `$name` takes the longest run of letters, digits and `_` (the regex crate's rule). + assert_eq!(rep("ab", "(?a)", "$x_"), "b"); + assert_eq!(rep("ab", "(?a)", "${x}_"), "a_b"); + assert_eq!(rep("ab", "(a)", "$1a"), "b"); + // Missing, unknown and out-of-range groups expand to nothing. + assert_eq!(rep("ab", "(a)", "<$2>"), "<>b"); + assert_eq!(rep("ab", "(a)", r"<\9>"), "<>b"); + assert_eq!(rep("ab", "(a)", "<${nope}>"), "<>b"); + assert_eq!(rep("ab", "(a)", "<$99999999999999999999999>"), "<>b"); + assert_eq!(rep("ab", "(a)|(z)", "<$2>"), "<>b"); + // A `$` that starts no group, and an unclosed brace, stay as written. + assert_eq!(rep("ab", "a", "$ ${x"), "$ ${xb"); + // `\t` a tab, `\\` a backslash; other escapes stay as written. + assert_eq!(rep("ab", "a", r"\t\\\q"), "\t\\\\qb"); + // Word's `^` codes still work. + assert_eq!(rep("ab", "a", "^&^t^&"), "a\tab"); + // `\$` and `\^` keep the character as typed (CodeRabbit on #4177). + assert_eq!(rep("ab", "(a)", r"\$1\^p"), "$1^pb"); + // `special: false` turns off the `^` codes only; groups still expand (CodeRabbit on #4177). + assert_eq!(rep_mode("ab", "(a)", "$1^t", json!({"regex": true, "special": false})), "a^tb"); + // `\n` is a paragraph mark: it splits the paragraph, as `^p` does, in one undo step. + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "one, two"})); + assert_eq!(run(&mut s, "edit.replaceAll", json!({"text": r"(\w+), (\w+)", "with": r"$1\n$2", "regex": true}))["replaced"], 1); + assert_eq!(text(&s), "one\ntwo"); + assert_eq!(s.doc.para_paths(StoryRef::Body).len(), 2); + run(&mut s, "edit.undo", json!({})); + assert_eq!(text(&s), "one, two"); + run(&mut s, "document.setText", json!({"text": "a-b"})); + run(&mut s, "edit.replaceAll", json!({"text": "-", "with": "^p", "regex": true})); + assert_eq!(s.doc.para_paths(StoryRef::Body).len(), 2); + // Plain and wildcard Replace are unchanged: `$1` is literal there. + assert_eq!(rep_mode("ab", "a", "$1", json!({})), "$1b"); + assert_eq!(rep_mode("ab", "(a)", "$1\\1", json!({"wildcards": true})), "$1ab"); + // Hostile Replace text never panics. + run(&mut s, "document.setText", json!({"text": "ab ab"})); + for with in ["$", "${", "${}", "${0", "$\u{e9}", "\\", "${\u{1F600}}", "$18446744073709551616", "${-1}", "$$$", "\\0\\n\\", "^"] { + let _ = s.run("edit.replaceAll", &json!({"text": "(a)(?b)?", "with": with, "regex": true})); + } +} + +/// The body text after Replace All of `q` with `with` in `text`, matching case, with `extra` options. +fn rep_mode(text: &str, q: &str, with: &str, extra: serde_json::Value) -> String { + let mut s = s(); + run(&mut s, "document.setText", json!({"text": text})); + let mut v = json!({"text": q, "with": with, "matchCase": true}); + if let (Some(o), Some(e)) = (v.as_object_mut(), extra.as_object()) { + o.extend(e.clone()); + } + run(&mut s, "edit.replaceAll", v); + self::text(&s) +} + +#[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"); +} + +/// Whether any run in the body is bold. +fn any_bold(s: &Session) -> bool { + s.doc.body.iter().filter_map(|b| b.as_para()).flat_map(|p| p.runs.iter()).any(|r| r.props.bold == Some(true)) +} + +#[test] +fn paste_from_another_program_does_not_bring_back_our_fragment() { + // Review on #6225: when another program owns the clipboard, matching text must not make + // Paste put WordCraft's own formatted fragment back. + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "Bold"})); + run(&mut s, "select.all", json!({})); + run(&mut s, "format.bold", json!({})); + run(&mut s, "edit.copy", json!({})); + assert_eq!(s.clipboard_text, "Bold"); + + // The same text, copied by another program: paste it as plain text, not our bold fragment. + run(&mut s, "document.setText", json!({"text": "Plain "})); + run(&mut s, "caret.docEnd", json!({})); + run(&mut s, "edit.paste", json!({"text": "Bold", "privateClipboard": false})); + assert_eq!(text(&s), "Plain Bold"); + assert!(!any_bold(&s), "{:?}", s.doc.body); + + // Callers that omit the flag (an agent, the CLI) still get the session's own copy. + run(&mut s, "caret.docEnd", json!({})); + run(&mut s, "edit.paste", json!({"text": "Bold"})); + assert!(any_bold(&s), "{:?}", s.doc.body); +} + +#[test] +fn paste_special_does_not_offer_our_fragment_for_another_programs_copy() { + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "Bold"})); + run(&mut s, "select.all", json!({})); + run(&mut s, "format.bold", json!({})); + run(&mut s, "edit.copy", json!({})); + + let r = run(&mut s, "edit.pasteSpecial", json!({"text": "Bold", "privateClipboard": false})); + let ids: Vec<&str> = r["formats"].as_array().map(|a| a.iter().filter_map(|f| f["id"].as_str()).collect()).unwrap_or_default(); + assert!(!ids.contains(&"formatted"), "{ids:?}"); + assert_eq!(s.ui_requests.last().and_then(|r| r.get("privateClipboard")), Some(&json!(false)), "{:?}", s.ui_requests); + + let e = s.run("edit.pasteSpecial", &json!({"as": "formatted", "text": "Bold", "privateClipboard": false})); + assert!(e.is_err(), "another program's copy cannot be pasted as our fragment"); +} + +/// 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); +} + +/// Bold and italic of a paragraph's character, in a document. +fn emphasis_at(d: &wordcraft_doc::Document, block: usize, off: usize) -> (bool, bool) { + match d.body.get(block).map(|b| &**b) { + Some(wordcraft_doc::Block::Para(p)) => { + let c = p.props_of_char(off); + (c.bold.unwrap_or(false), c.italic.unwrap_or(false)) + } + _ => (false, false), + } +} + +#[test] +fn copying_puts_rtf_on_the_clipboard() { + // gap-clipboard-rtf: a copy carries RTF beside its text and HTML, for programs that read RTF. + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "plain bold\nsome italic"})); + s.doc.format_range(&Pos::body(0, 6), &Pos::body(0, 10), &|c| c.bold = Some(true)).unwrap(); + s.doc.format_range(&Pos::body(1, 5), &Pos::body(1, 11), &|c| c.italic = Some(true)).unwrap(); + run(&mut s, "select.all", json!({})); + let r = run(&mut s, "edit.copy", json!({})); + let rtf = r["rtf"].as_str().unwrap_or_default().to_string(); + assert!(rtf.starts_with("{\\rtf1"), "{r}"); + assert_eq!(s.clipboard_rtf, rtf); + let d = wordcraft_formats::rtf::import(rtf.as_bytes()).unwrap(); + let paras: Vec = + d.body.iter().filter_map(|b| if let wordcraft_doc::Block::Para(p) = &**b { Some(p.text.clone()) } else { None }).collect(); + assert_eq!(paras.get(..2), Some(&["plain bold".to_string(), "some italic".to_string()][..]), "{paras:?}"); + assert_eq!(emphasis_at(&d, 0, 0), (false, false)); + assert_eq!(emphasis_at(&d, 0, 7), (true, false)); + assert_eq!(emphasis_at(&d, 1, 7), (false, true)); + + // Pasting it elsewhere keeps the formatting. + let mut t = Session::new(wordcraft_doc::Document::new()); + run(&mut t, "edit.paste", json!({"rtf": rtf})); + assert!(text(&t).starts_with("plain bold\nsome italic"), "{}", text(&t)); + assert_eq!(emphasis_at(&t.doc, 0, 7), (true, false)); + assert_eq!(emphasis_at(&t.doc, 1, 7), (false, true)); + + // Cut carries it too. + run(&mut s, "select.range", json!({"anchor": {"block": 0, "off": 6}, "focus": {"block": 0, "off": 10}})); + let r = run(&mut s, "edit.cut", json!({})); + assert!(r["rtf"].as_str().is_some_and(|t| t.starts_with("{\\rtf1") && t.contains("bold")), "{r}"); + assert_eq!(text(&s).lines().next(), Some("plain ")); + + // Nothing selected: nothing copied, no RTF. + run(&mut s, "caret.docEnd", json!({})); + let r = run(&mut s, "edit.copy", json!({})); + assert!(r.get("rtf").is_none(), "{r}"); +} + +#[test] +fn pasting_hostile_rtf_never_panics() { + let deep = format!("{{\\rtf1 {}x{}", "{\\b ".repeat(5000), "}".repeat(5000)); + let junk = ["{\\rtf1", "{\\rtf1 {\\b bo", "{\\rtf1\\ansi\\u-99999999999? \\bin999999999 x}", "{\\rtf1 \\'zz\\fonttbl{", "{\\rtf\u{0}\u{FFFF}}"]; + for rtf in junk.iter().map(|j| j.to_string()).chain([deep]) { + for id in ["edit.paste", "edit.pasteMerge", "edit.pasteSpecial"] { + let mut t = Session::new(wordcraft_doc::Document::new()); + // `Session::run` turns a panic into an error: look for it. + for v in [json!({"rtf": rtf, "as": "rtf"}), json!({"rtf": rtf, "text": "fallback"})] { + if let Err(e) = t.run(id, &v) { + assert!(!e.to_string().contains("panicked"), "{id}: {e}"); + } + let _ = t.layout(); + } + } + } +} + +#[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() }; + Arc::make_mut(&mut 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::default(), "Numbering toggles off the paragraph's effective numbered level"); + run(&mut s, "para.numbering", json!({})); + run(&mut s, "para.listLevel", json!({"level": 1})); + 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 convert_text_to_table_keeps_inline_content() { + // Issue #3751: the cells were rebuilt from plain text, losing formatting, pictures, links + // and tracked deletions. + use wordcraft_doc::para::InlineObject; + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "Name\t\tLink\tOld"})); + { + let p = s.doc.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap(); + p.format(0, 4, &|c| c.bold = Some(true)).unwrap(); + p.format(6, 10, &|c| c.link = Some("https://example.com".into())).unwrap(); + p.format(11, 14, &|c| c.del = Some(1)).unwrap(); + let img = InlineObject::Image { + media: "pic".into(), + w: 10.0, + h: 10.0, + alt: String::new(), + float: Default::default(), + crop: [0.0; 4], + fx: Default::default(), + ole: None, + }; + p.insert_object(5, img, &Default::default()).unwrap(); + } + run(&mut s, "select.all", json!({})); + run(&mut s, "table.fromText", json!({})); + let t = s.doc.body.iter().find_map(|b| b.as_table()).expect("a table"); + let cell = |c: usize| t.rows[0].cells[c].blocks.iter().find_map(|b| b.as_para()).expect("a paragraph"); + assert_eq!(cell(0).text, "Name"); + assert_eq!(cell(0).props_of_char(0).bold, Some(true), "cell 1 lost bold"); + assert!(matches!(cell(1).objects.as_slice(), [InlineObject::Image { media, .. }] if media == "pic"), "cell 2 lost its picture"); + assert_eq!(cell(2).text, "Link"); + assert_eq!(cell(2).props_of_char(0).link.as_deref(), Some("https://example.com"), "cell 3 lost its link"); + assert_eq!(cell(3).text, "Old"); + assert_eq!(cell(3).props_of_char(0).del, Some(1), "cell 4 lost its tracked deletion"); +} + +/// A table `cols` wide holding `cells` row by row, typed in with Tab; the caret ends in the +/// last cell. +fn typed_table(cols: usize, cells: &[&str]) -> Session { + let mut s = s(); + run(&mut s, "insert.table", json!({"rows": cells.len() / cols, "cols": cols})); + 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 +} + +/// The body as `T` for a table and the text of each paragraph. +fn body_outline(s: &Session) -> Vec { + s.doc.body.iter().map(|b| b.as_para().map_or_else(|| "T".to_string(), |p| p.text.clone())).collect() +} + +fn cell_at(table: u32, r: u32, c: u32) -> serde_json::Value { + json!({"story": "body", "path": [table, r, c, 0], "off": 0}) +} + +/// Convert to Text sets a row's cells apart with a tab, a comma, another character or +/// paragraph marks; rows are paragraphs. +#[test] +fn convert_to_text_takes_each_separator() { + let cases = [ + (json!({}), vec!["a\tb", "c\td"]), + (json!({"separator": "tab"}), vec!["a\tb", "c\td"]), + (json!({"separator": "comma"}), vec!["a,b", "c,d"]), + (json!({"separator": "other", "char": ";"}), vec!["a;b", "c;d"]), + (json!({"separator": "paragraph"}), vec!["a", "b", "c", "d"]), + ]; + for (params, want) in cases { + let mut s = typed_table(2, &["a", "b", "c", "d"]); + run(&mut s, "table.toText", params.clone()); + let got = body_outline(&s); + assert_eq!(got.get(..want.len()), Some(&want.iter().map(|x| x.to_string()).collect::>()[..]), "{params}: {got:?}"); + assert!(s.doc.body.iter().all(|b| b.as_table().is_none()), "{params}"); + } + // U+FFFC marks an object in a paragraph's text, so it cannot be the separator. + let obj = wordcraft_doc::para::OBJ.to_string(); + for bad in [ + json!({"separator": "other"}), + json!({"separator": "other", "char": "\n"}), + json!({"separator": "other", "char": obj}), + json!({"separator": "pipe"}), + ] { + let mut s = typed_table(2, &["a", "b", "c", "d"]); + let depth = s.undo_depth(); + assert!(s.run("table.toText", &bad).is_err(), "{bad}"); + assert!(s.doc.body.iter().any(|b| b.as_table().is_some()), "{bad}: the table stays"); + assert_eq!(s.undo_depth(), depth); + } +} + +/// With rows selected, only those become text; the rows above and below stay tables, and a +/// vertical merge cut by the selection starts again below it. +#[test] +fn convert_to_text_converts_the_selected_rows() { + let mut s = typed_table(2, &["a", "b", "c", "d", "e", "f", "g", "h"]); + let t = s.doc.body.iter().position(|b| b.as_table().is_some()).unwrap() as u32; + // Column 1 merged down rows 1 to 3. + run(&mut s, "select.range", json!({"anchor": cell_at(t, 1, 1), "focus": cell_at(t, 3, 1)})); + run(&mut s, "table.merge", json!({})); + run(&mut s, "select.range", json!({"anchor": cell_at(t, 1, 0), "focus": cell_at(t, 1, 1)})); + run(&mut s, "table.toText", json!({})); + let at = t as usize; + // The merged cell holds d, f and h, one paragraph each. + assert_eq!(body_outline(&s).get(at..at + 5), Some(&["T", "c\td", "f", "h", "T"].map(String::from)[..]), "{:?}", body_outline(&s)); + let head = s.doc.body[at].as_table().unwrap(); + let tail = s.doc.body[at + 4].as_table().unwrap(); + assert_eq!((head.rows.len(), tail.rows.len()), (1, 2)); + use wordcraft_doc::props::VMerge; + let merges: Vec = tail.rows.iter().map(|r| r.cells.last().unwrap().props.vmerge).collect(); + assert_eq!(merges, [VMerge::Restart, VMerge::Continue], "the merge starts again in the first row left"); + assert_eq!(s.sel.focus.path.0, [t + 1], "the caret goes to the converted text"); + + // A merge that went on into the converted rows ends in the last row above them. + let mut s = typed_table(2, &["a", "b", "c", "d", "e", "f"]); + let t = s.doc.body.iter().position(|b| b.as_table().is_some()).unwrap() as u32; + run(&mut s, "select.range", json!({"anchor": cell_at(t, 0, 0), "focus": cell_at(t, 1, 0)})); + run(&mut s, "table.merge", json!({})); + run(&mut s, "select.range", json!({"anchor": cell_at(t, 1, 1), "focus": cell_at(t, 2, 1)})); + run(&mut s, "table.toText", json!({})); + let head = s.doc.body[t as usize].as_table().unwrap(); + assert_eq!(head.rows.len(), 1); + assert_eq!(head.rows[0].cells[0].props.vmerge, VMerge::None, "no merge goes on below the table"); + + // A floating table keeps its place in the part above; the part below goes in the text flow. + let mut s = typed_table(2, &["a", "b", "c", "d", "e", "f"]); + let t = s.doc.body.iter().position(|b| b.as_table().is_some()).unwrap() as u32; + let float = wordcraft_doc::props::TableFloat { x: 100.0, y: 200.0, overlap: true, ..Default::default() }; + s.doc.table_mut(StoryRef::Body, &wordcraft_doc::Path(vec![t])).unwrap().props.float = Some(float); + run(&mut s, "select.range", json!({"anchor": cell_at(t, 1, 0), "focus": cell_at(t, 1, 1)})); + run(&mut s, "table.toText", json!({})); + let at = t as usize; + assert_eq!(s.doc.body[at].as_table().unwrap().props.float, Some(float)); + assert_eq!(s.doc.body[at + 2].as_table().unwrap().props.float, None, "the part below doesn't sit on the part above"); + + // The caret alone in a cell converts every row, as before. + let mut s = typed_table(2, &["a", "b", "c", "d", "e", "f"]); + run(&mut s, "table.toText", json!({})); + assert!(s.doc.body.iter().all(|b| b.as_table().is_none())); +} + +/// A content control around the table keeps both its ends when the first rows, the last rows +/// or every row become text. +#[test] +fn convert_to_text_keeps_a_content_control_around_the_table() { + use wordcraft_doc::{ContentControl, ControlWrap, InlineObject}; + let has = |p: &wordcraft_doc::Paragraph, start: bool| { + p.objects.iter().any(|o| if start { matches!(o, InlineObject::ControlStart { .. }) } else { matches!(o, InlineObject::ControlEnd) }) + }; + for rows in [Some((0, 0)), Some((2, 2)), None] { + let mut s = typed_table(2, &["a", "b", "c", "d", "e", "f"]); + let t = s.doc.body.iter().position(|b| b.as_table().is_some()).unwrap() as u32; + s.doc.table_mut(StoryRef::Body, &wordcraft_doc::Path(vec![t])).unwrap().controls = + ControlWrap { open: vec![ContentControl { tag: "wrap".into(), ..Default::default() }], close: 1 }; + assert!(!s.doc.has_unbalanced_controls()); + match rows { + Some((r0, r1)) => run(&mut s, "select.range", json!({"anchor": cell_at(t, r0, 0), "focus": cell_at(t, r1, 1)})), + None => run(&mut s, "select.range", json!({"anchor": cell_at(t, 1, 0), "focus": cell_at(t, 1, 0)})), + }; + run(&mut s, "table.toText", json!({})); + assert!(!s.doc.has_unbalanced_controls(), "{rows:?}: {:?}", body_outline(&s)); + let at = t as usize; + if rows != Some((2, 2)) { + assert!(s.doc.body[at].as_para().is_some_and(|p| has(p, true)), "{rows:?}: the control starts on the text"); + } + if rows != Some((0, 0)) { + let last = s.doc.body.iter().skip(at).rfind(|b| b.as_para().is_some_and(|p| !p.text.is_empty()) || b.as_table().is_some()).unwrap(); + assert!(last.as_para().is_some_and(|p| has(p, false)), "{rows:?}: the control ends on the text"); + } + } + + // A control around rows 0 to 3, row 1 converted: the table above closes it, the table below + // opens it again. + let mut s = typed_table(2, &["a", "b", "c", "d", "e", "f", "g", "h"]); + let t = s.doc.body.iter().position(|b| b.as_table().is_some()).unwrap() as u32; + { + let table = s.doc.table_mut(StoryRef::Body, &wordcraft_doc::Path(vec![t])).unwrap(); + table.rows[0].controls.open = vec![ContentControl { id: Some(7), tag: "rows".into(), ..Default::default() }]; + table.rows[3].controls.close = 1; + } + run(&mut s, "select.range", json!({"anchor": cell_at(t, 1, 0), "focus": cell_at(t, 1, 1)})); + run(&mut s, "table.toText", json!({})); + let at = t as usize; + let head = s.doc.body[at].as_table().unwrap(); + let tail = s.doc.body[at + 2].as_table().unwrap(); + assert_eq!((head.rows.len(), head.rows[0].controls.close), (1, 1), "the table above closes the control"); + assert_eq!(tail.rows[0].controls.open.iter().map(|c| c.tag.as_str()).collect::>(), ["rows"], "the table below opens it"); + assert_eq!((head.rows[0].controls.open[0].id, tail.rows[0].controls.open[0].id), (Some(7), None), "one w:id in the file"); + assert_eq!(tail.rows[1].controls.close, 1); + assert!(!s.doc.has_unbalanced_controls()); +} + +/// The text keeps its formatting, and a table nested in a cell stays a table. +#[test] +fn convert_to_text_keeps_formatting_and_nested_tables() { + let mut s = typed_table(2, &["Name", "X"]); + let t = s.doc.body.iter().position(|b| b.as_table().is_some()).unwrap() as u32; + let first = json!({"story": "body", "path": [t, 0, 0, 0], "off": 0}); + let end = json!({"story": "body", "path": [t, 0, 0, 0], "off": 4}); + run(&mut s, "select.range", json!({"anchor": first, "focus": end})); + run(&mut s, "format.bold", json!({})); + // A table nested in the second cell, after its text. + s.sel = crate::Selection::caret(wordcraft_doc::Pos { story: StoryRef::Body, path: wordcraft_doc::Path(vec![t, 0, 1, 0]), off: 1 }); + run(&mut s, "text.newParagraph", json!({})); + run(&mut s, "insert.table", json!({"rows": 1, "cols": 1})); + run(&mut s, "text.insert", json!({"text": "Inner"})); + s.sel = crate::Selection::caret(wordcraft_doc::Pos { story: StoryRef::Body, path: wordcraft_doc::Path(vec![t, 0, 0, 0]), off: 0 }); + run(&mut s, "table.toText", json!({})); + let at = t as usize; + let line = s.doc.body[at].as_para().unwrap(); + assert_eq!(line.text, "Name\tX"); + assert_eq!(line.props_of_char(0).bold, Some(true), "the text lost its bold"); + assert_ne!(line.props_of_char(4).bold, Some(true), "the tab takes no formatting"); + let inner = s.doc.body.iter().skip(at + 1).find_map(|b| b.as_table()).expect("the nested table stays a table"); + assert_eq!(table_cell_text(inner), "Inner"); + + // A selected row whose first cell starts with a nested table: the caret goes to the row's + // text, not back into the table above. + let mut s = typed_table(2, &["A", "B", "", "D"]); + let t = s.doc.body.iter().position(|b| b.as_table().is_some()).unwrap() as u32; + s.sel = crate::Selection::caret(wordcraft_doc::Pos { story: StoryRef::Body, path: wordcraft_doc::Path(vec![t, 1, 0, 0]), off: 0 }); + run(&mut s, "insert.table", json!({"rows": 1, "cols": 1})); + let row = &s.doc.body[t as usize].as_table().unwrap().rows[1]; + assert!(row.cells[0].blocks.first().is_some_and(|b| b.as_table().is_some()), "the cell starts with the nested table"); + run(&mut s, "select.range", json!({"anchor": cell_at(t, 1, 1), "focus": cell_at(t, 1, 1)})); + s.sel.anchor = wordcraft_doc::Pos { story: StoryRef::Body, path: wordcraft_doc::Path(vec![t, 1, 0, 1]), off: 0 }; + run(&mut s, "table.toText", json!({})); + let at = t as usize; + assert_eq!(body_outline(&s).get(at..at + 3), Some(&["T", "T", "\tD"].map(String::from)[..]), "{:?}", body_outline(&s)); + assert_eq!(s.sel.focus.path.0, [t + 2], "the caret is on the row's text"); +} + +fn table_cell_text(t: &wordcraft_doc::Table) -> String { + t.rows.iter().flat_map(|r| &r.cells).flat_map(|c| &c.blocks).filter_map(|b| b.as_para()).map(|p| p.text.clone()).collect() +} + +#[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); +} + +/// Tests that install a test dictionary wait for the dictionaries-folder test, which changes the +/// folder and then reads what each language reports: an install in between reports no file. +static DICTIONARIES: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +fn dictionaries_turn() -> std::sync::MutexGuard<'static, ()> { + DICTIONARIES.lock().unwrap_or_else(std::sync::PoisonError::into_inner) +} + +/// 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() { + let _turn = dictionaries_turn(); + 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() { + let _turn = dictionaries_turn(); + 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 › Language › 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 _turn = dictionaries_turn(); + 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), + // The others the tests install, so reloading from the folder keeps their words. + ("it_IT", ITALIAN_TEST_AFF, ITALIAN_TEST_DIC), + ("es_ES", SPANISH_TEST_AFF, SPANISH_TEST_DIC), + ("es_UY", SPANISH_TEST_AFF, URUGUAYAN_TEST_DIC), + // #3252: set_dictionaries_folder() now reloads Czech too (it used to leave its cache + // alone), so a run of this test overlapping czech_proofing_through_the_review_commands + // would otherwise force a reload that finds no cs_CZ here and empties it mid-test. Same + // fixture content either way keeps such a race harmless, as it already does for Polish. + ("cs_CZ", czech_test_dict::AFF, czech_test_dict::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()); + // Each language reports the folder's file, which is searched first. + let reported = |got: Option<&serde_json::Value>, name: &str| { + let want = dir.join(format!("{name}.dic")); + assert_eq!(got, Some(&json!(want.to_string_lossy())), "{name}: {dicts:?}"); + assert_eq!(wordcraft_proof::hunspell::find(&[name]).map(|(_, dic)| dic), Some(want)); + }; + reported(file("pl").as_ref(), "pl_PL"); + reported(file("pt").as_ref(), "pt_BR"); + reported(file("id").as_ref(), "id_ID"); + // Each Spanish variety reports its own file (Spain, Mexico, Latin America, Uruguay, in order). + let spanish: Vec = dicts.iter().filter(|d| d["language"] == "es").map(|d| d["file"].clone()).collect(); + assert_eq!(spanish.len(), 4); + reported(spanish.first(), "es_ES"); + reported(spanish.last(), "es_UY"); + // 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() { + let _turn = dictionaries_turn(); + 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"); +} + +/// The spelling issues of the document: each word with its first suggestion. +fn spelling_with_first_suggestion(s: &mut Session) -> Vec<(String, String)> { + let issues = run(s, "review.issues", json!({})); + issues + .as_array() + .unwrap() + .iter() + .filter(|i| i["kind"] == "spelling") + .map(|i| (i["text"].as_str().unwrap().to_string(), i["suggestions"][0].as_str().unwrap_or("").to_string())) + .collect() +} + +fn pairs(v: &[(&str, &str)]) -> Vec<(String, String)> { + v.iter().map(|(a, b)| (a.to_string(), b.to_string())).collect() +} + +/// English (UK), (Canada) and (Australia) through Review › Language (#1281, #1045): each accepts +/// its own spellings and flags the other dialect's with its own form suggested first, F7 finds +/// it and Change fixes it. +#[test] +fn english_dialects_through_the_review_commands() { + if no_language_data("english_dialects_through_the_review_commands") { + return; + } + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "The colour of the center."})); + // US English by default: the British spelling is flagged, the American one isn't. + assert_eq!(spelling_with_first_suggestion(&mut s), pairs(&[("colour", "color")])); + run(&mut s, "select.all", json!({})); + run(&mut s, "review.language", json!({"lang": "en_gb", "default": true})); + assert_eq!(s.doc.styles.default_chr.lang.as_deref(), Some("en-GB")); + assert_eq!(run(&mut s, "format.state", json!({}))["lang"], "en-GB"); + assert_eq!(spelling_with_first_suggestion(&mut s), pairs(&[("center", "centre")])); + run(&mut s, "caret.docStart", json!({})); + assert_eq!(run(&mut s, "review.spelling", json!({}))["text"], "center"); + run(&mut s, "review.applySuggestion", json!({"text": "centre"})); + assert_eq!(text(&s), "The colour of the centre."); + assert!(spelling_with_first_suggestion(&mut s).is_empty()); + // Canada: British -our and -re, American -ize. + run(&mut s, "document.setText", json!({"text": "We organise the colour and analyze the centre."})); + run(&mut s, "select.all", json!({})); + run(&mut s, "review.language", json!({"lang": "en-CA", "default": true})); + assert_eq!(spelling_with_first_suggestion(&mut s), pairs(&[("organise", "organize")])); + // Australia spells the British way: -ise is right, -yze isn't. + run(&mut s, "select.all", json!({})); + run(&mut s, "review.language", json!({"lang": "en-AU", "default": true})); + assert_eq!(spelling_with_first_suggestion(&mut s), pairs(&[("analyze", "analyse")])); + // Add to Dictionary overrules the dialect. + run(&mut s, "review.addToDictionary", json!({"word": "analyze"})); + assert!(spelling_with_first_suggestion(&mut s).is_empty()); + wordcraft_proof::remove_word("analyze"); +} + +/// A British document keeps its languages through .docx (`w:lang` in the defaults and on a run) +/// and is proofed the same way when it comes back. +#[test] +fn english_dialects_survive_a_docx_round_trip() { + if no_language_data("english_dialects_survive_a_docx_round_trip") { + return; + } + use std::io::Read; + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "Colour and color. Centre and center."})); + run(&mut s, "select.all", json!({})); + run(&mut s, "review.language", json!({"lang": "en-GB", "default": true})); + run(&mut s, "select.text", json!({"text": "Centre and center."})); + run(&mut s, "review.language", json!({"lang": "en-US"})); + let before = spelling_with_first_suggestion(&mut s); + assert_eq!(before, pairs(&[("color", "colour"), ("Centre", "Center")])); + let bytes = crate::io::save_bytes("british.docx", &s.doc).unwrap(); + let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap(); + let mut part = |name: &str| { + let mut xml = String::new(); + zip.by_name(name).unwrap().read_to_string(&mut xml).unwrap(); + xml + }; + assert!(part("word/styles.xml").contains(r#"w:val="en-GB""#), "the default language is written"); + assert!(part("word/document.xml").contains(r#"w:val="en-US""#), "the run's language is written"); + let doc = crate::io::open_bytes("british.docx", &bytes).unwrap(); + assert_eq!(doc.styles.default_chr.lang.as_deref(), Some("en-GB")); + let mut back = Session::new(doc); + assert_eq!(spelling_with_first_suggestion(&mut back), before); +} + +/// An English interface writes new documents in the region's English (English (UK) on a British +/// system, #1045); another region's language leaves US English. +#[test] +fn new_documents_follow_the_regions_english() { + if no_language_data("new_documents_follow_the_regions_english") { + return; + } + let mut s = s(); + s.system_region = wordcraft_geom::Region::from_tag("en_GB.UTF-8"); + assert_eq!(s.region_english().as_deref(), Some("en-GB")); + let tag = s.region_english(); + s.set_editing_language(tag.as_deref()); + assert_eq!(s.doc.styles.default_chr.lang.as_deref(), Some("en-GB"), "the untouched document follows"); + run(&mut s, "document.setText", json!({"text": "Colour, color."})); + assert_eq!(spelling_with_first_suggestion(&mut s), pairs(&[("color", "colour")])); + // The region chosen in Options wins over the system's. + run(&mut s, "file.options", json!({"region": "en-CA"})); + assert_eq!(s.region_english().as_deref(), Some("en-CA")); + for (tag, english) in [("pl-PL", None), ("en-US", None), ("en-AU", Some("en-AU")), ("auto", Some("en-GB"))] { + run(&mut s, "file.options", json!({"region": tag})); + assert_eq!(s.region_english().as_deref(), english, "{tag}"); + } + s.system_region = wordcraft_geom::Region::from_tag("fr-FR"); + assert_eq!(s.region_english(), None); +} + +/// A Spanish interface writes new documents in the region's Spanish (#3360): Uruguay's on a +/// Uruguayan system, Mexico's on a Mexican one, Spain's elsewhere. +#[test] +fn new_documents_follow_the_regions_spanish() { + let mut s = s(); + s.system_region = wordcraft_geom::Region::from_tag("es_UY.UTF-8"); + assert_eq!(s.region_spanish(), "es-UY"); + // The ISO 639-2 code is Spanish too (CodeRabbit review on #3562). + s.system_region = wordcraft_geom::Region::from_tag("spa_MX.UTF-8"); + assert_eq!(s.region_spanish(), "es-MX"); + s.system_region = wordcraft_geom::Region::from_tag("es_UY.UTF-8"); + s.set_editing_language(Some(&s.region_spanish())); + assert_eq!(s.doc.styles.default_chr.lang.as_deref(), Some("es-UY"), "the untouched document follows"); + for (tag, spanish) in [("es-MX", "es-MX"), ("es-419", "es-419"), ("es-ES", "es-ES"), ("en-US", "es-ES"), ("fr-FR", "es-ES"), ("auto", "es-UY")] { + run(&mut s, "file.options", json!({"region": tag})); + assert_eq!(s.region_spanish(), spanish, "{tag}"); + } +} + +// Hand-written synthetic fixtures (no system dictionary data): Spain's has `vosotros` forms, +// Uruguay's has `vos` ones' verbs only (the forms come from the voseo rules). +const SPANISH_TEST_AFF: &str = + "SET UTF-8\nFLAG UTF-8\nTRY aeosrnidlctumpbgvyqhfzjñáéíóú\nMAP 2\nMAP eé\nMAP ií\nREP 2\nREP v b\nREP b v\nSFX S Y 1\nSFX S 0 s [aeiou]\n"; +const SPANISH_TEST_DIC: &str = "10\nesto\nes\nuna\nprueba/S\ndel\ncorrector\ntambién\nhabéis\nvenido\ndecir\n"; +const URUGUAYAN_TEST_DIC: &str = "10\nesto\nes\nuna\nprueba/S\ndel\ncorrector\ntambién\ntener\nvenir\ndecir\n"; + +fn spanish_test_dictionaries() { + use wordcraft_proof::ProofLang; + let _turn = dictionaries_turn(); + for (lang, dic) in [(ProofLang::Es, SPANISH_TEST_DIC), (ProofLang::EsUy, URUGUAYAN_TEST_DIC)] { + wordcraft_proof::es::install_dictionary(lang, SPANISH_TEST_AFF, dic).unwrap(); + } +} + +/// #3553: Spanish proofing through the same commands as Review, F7, Change and Add to +/// Dictionary: `prueva` and `corector` are flagged with `prueba` and `corrector` offered. +#[test] +fn spanish_proofing_through_the_review_commands() { + spanish_test_dictionaries(); + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "esto es una prueva del corector. Habéis venido?"})); + run(&mut s, "select.all", json!({})); + run(&mut s, "review.language", json!({"lang": "es_es", "default": true})); + assert_eq!(s.doc.styles.default_chr.lang.as_deref(), Some("es-ES")); + let issues = run(&mut s, "review.issues", json!({})); + let arr = issues.as_array().unwrap(); + assert!(arr.iter().all(|i| i["lang"] == "es"), "{issues}"); + let has = |text: &str, kind: &str, sugg: &str| { + arr.iter().any(|i| i["text"] == text && i["kind"] == kind && i["suggestions"].as_array().unwrap().iter().any(|x| x == sugg)) + }; + assert!(has("prueva", "spelling", "prueba"), "{issues}"); + assert!(has("corector", "spelling", "corrector"), "{issues}"); + assert!(has("Habéis venido?", "grammar", "¿Habéis venido?"), "{issues}"); + assert_eq!(arr.len(), 3, "{issues}"); + assert!(arr.iter().any(|i| i["message"] == "Posible error ortográfico"), "{issues}"); + run(&mut s, "caret.docStart", json!({})); + assert_eq!(run(&mut s, "review.spelling", json!({}))["text"], "prueva"); + run(&mut s, "review.applySuggestion", json!({"text": "prueba"})); + assert!(text(&s).starts_with("esto es una prueba del"), "{}", text(&s)); + run(&mut s, "review.addToDictionary", json!({"word": "corector"})); + let left = run(&mut s, "review.issues", json!({})); + assert_eq!(left.as_array().unwrap().len(), 1, "{left}"); +} + +/// #3562 review: Add to Dictionary and Ignore All get the issue's own text, decomposed (NFD) as +/// typed; the word is then accepted decomposed and composed, and its issue range was the typed +/// bytes. +#[test] +fn add_and_ignore_all_accept_both_normalization_forms() { + spanish_test_dictionaries(); + let mut s = s(); + let text = "esto zqanade\u{301}do y zqignora\u{301}do"; + run(&mut s, "document.setText", json!({"text": text})); + run(&mut s, "select.all", json!({})); + run(&mut s, "review.language", json!({"lang": "es-ES"})); + let issues = run(&mut s, "review.issues", json!({})); + let words: Vec<(String, u64)> = issues + .as_array() + .unwrap() + .iter() + .filter(|i| i["kind"] == "spelling") + .map(|i| (i["text"].as_str().unwrap().to_string(), i["start"]["off"].as_u64().unwrap())) + .collect(); + assert_eq!(words, [("zqanade\u{301}do".to_string(), 5), ("zqignora\u{301}do".to_string(), text.find("zqign").unwrap() as u64)], "{issues}"); + run(&mut s, "review.addToDictionary", json!({"word": words[0].0})); + run(&mut s, "review.ignoreAll", json!({"word": words[1].0})); + assert!(run(&mut s, "review.issues", json!({})).as_array().unwrap().is_empty()); + // The composed spellings are the same words. + run(&mut s, "document.setText", json!({"text": "esto zqanadédo y zqignorádo"})); + run(&mut s, "select.all", json!({})); + run(&mut s, "review.language", json!({"lang": "es-ES"})); + assert!(run(&mut s, "review.issues", json!({})).as_array().unwrap().is_empty()); + wordcraft_proof::remove_word("zqanadédo"); +} + +/// Uruguayan Spanish accepts the `vos` forms, imperatives included, that Spain's flags. +#[test] +fn uruguayan_spanish_accepts_vos_forms() { + spanish_test_dictionaries(); + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "Vení, decime qué tenés."})); + run(&mut s, "select.all", json!({})); + run(&mut s, "review.language", json!({"lang": "es-UY"})); + assert_eq!(run(&mut s, "format.state", json!({}))["lang"], "es-UY"); + let spelling = |s: &mut Session| -> Vec { + let issues = run(s, "review.issues", json!({})); + issues.as_array().unwrap().iter().filter(|i| i["kind"] == "spelling").filter_map(|i| i["text"].as_str().map(String::from)).collect() + }; + assert_eq!(spelling(&mut s), ["qué"], "only the word the fixture lacks"); + run(&mut s, "select.all", json!({})); + run(&mut s, "review.language", json!({"lang": "es-ES"})); + assert_eq!(spelling(&mut s), ["Vení", "decime", "qué", "tenés"]); +} + +/// 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. +const ITALIAN_TEST_AFF: &str = "SET UTF-8\nTRY aeiortnsclcaffèéè\nMAP 2\nMAP aà\nMAP eéè\nREP 1\nREP caffe caffè\n"; +const ITALIAN_TEST_DIC: &str = "6\ncasa\ncaffè\nroma\ndisse\ne\nper\n"; + +fn italian_test_dictionary() { + let _turn = dictionaries_turn(); + wordcraft_proof::it::install_dictionary(ITALIAN_TEST_AFF, ITALIAN_TEST_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); +} + +/// #2882: Set As Default survives preference serialization and overrides the interface language. +#[test] +fn default_proofing_language_survives_a_restart() { + let mut first = s(); + first.set_editing_language(Some("en-US")); + run(&mut first, "review.language", json!({"lang": "FR_fr", "default": true})); + let saved = serde_json::to_vec(&first.prefs).unwrap(); + run(&mut first, "review.language", json!({"lang": "en-GB"})); + run(&mut first, "edit.undo", json!({})); + run(&mut first, "file.new", json!({})); + assert_eq!(first.doc.styles.default_chr.lang.as_deref(), Some("fr-FR")); + + let mut second = s(); + second.prefs = serde_json::from_slice(&saved).unwrap(); + second.set_editing_language(Some("pl-PL")); + assert_eq!(second.doc.styles.default_chr.lang.as_deref(), Some("fr-FR")); + assert!(!second.dirty && !second.can_undo()); + run(&mut second, "file.new", json!({})); + assert_eq!(second.doc.styles.default_chr.lang.as_deref(), Some("fr-FR")); + let data = crate::cmd::insert::base64_encode(b"Bonjour"); + run(&mut second, "file.open", json!({"path": "note.txt", "data": data})); + assert_eq!(second.doc.styles.default_chr.lang.as_deref(), Some("fr-FR")); + + let mut english = wordcraft_doc::Document::from_text("English document"); + Arc::make_mut(&mut english.styles).default_chr.lang = Some("en-GB".into()); + let bytes = crate::io::save_bytes("english.docx", &english).unwrap(); + let data = crate::cmd::insert::base64_encode(&bytes); + run(&mut second, "file.open", json!({"path": "english.docx", "data": data})); + second.set_editing_language(Some("ru-RU")); + assert_eq!(second.doc.styles.default_chr.lang.as_deref(), Some("en-GB")); + run(&mut second, "file.new", json!({"template": "letter"})); + assert_eq!(second.doc.styles.default_chr.lang.as_deref(), Some("en-US")); +} + +/// Older or malformed settings retain the interface fallback and unrelated preferences. +#[test] +fn default_proofing_language_ignores_damaged_preferences() { + for value in [json!(null), json!("not a tag"), json!(17), json!([])] { + let mut session = s(); + session.prefs = serde_json::from_value(json!({"defaultLanguage": value, "detectLanguage": false})).unwrap(); + session.set_editing_language(Some("pl-PL")); + assert_eq!(session.doc.styles.default_chr.lang.as_deref(), Some("pl-PL")); + assert!(!session.prefs.detect_language); + } + let mut old = s(); + old.prefs = serde_json::from_str("{}").unwrap(); + old.set_editing_language(Some("ru-RU")); + assert_eq!(old.doc.styles.default_chr.lang.as_deref(), Some("ru-RU")); +} + +/// #2882: a final protection rejection rolls back the saved default as well as the document. +#[test] +fn rejected_default_proofing_language_does_not_survive_a_restart() { + for saved in [None, Some("pl-PL")] { + let mut session = Session::new(wordcraft_doc::Document::from_text("Locked intro.\nEditable text.\nLocked end.")); + session.prefs.default_language = saved.map(str::to_string); + session.set_editing_language(Some("en-US")); + run(&mut session, "select.text", json!({"text": "Editable text."})); + run(&mut session, "review.restrict", json!({"exception": "add"})); + run(&mut session, "review.restrict", json!({"mode": "readOnly"})); + run(&mut session, "select.text", json!({"text": "Editable"})); + // Selection-only language formatting is allowed inside the exception. + run(&mut session, "review.language", json!({"lang": "en-GB"})); + let before = session.doc.clone(); + let prefs = serde_json::to_value(&session.prefs).unwrap(); + let undo = session.undo_labels(); + let error = session.run("review.language", &json!({"lang": "fr-FR", "default": true})).unwrap_err(); + assert!(error.to_string().contains("only the editable regions"), "{error}"); + assert_eq!(session.doc, before); + assert_eq!(session.undo_labels(), undo); + assert_eq!(serde_json::to_value(&session.prefs).unwrap(), prefs); + let mut restarted = s(); + restarted.prefs = serde_json::from_value(prefs).unwrap(); + restarted.set_editing_language(Some("en-US")); + run(&mut restarted, "file.new", json!({})); + assert_eq!(restarted.doc.styles.default_chr.lang.as_deref(), Some(saved.unwrap_or("en-US"))); + } +} + +/// 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})); +} + +/// An item's own colour survives item edits and layout changes, and a style colour drops it. +#[test] +fn smart_art_item_colors_stay_until_a_style_is_picked() { + let mut s = s(); + run(&mut s, "insert.smartArt", json!({"layout": "process"})); + let colors = |s: &Session| cmd::smart_art::selected_smart_art(s).unwrap().items.iter().map(|i| i.color.map(|c| c.hex())).collect::>(); + assert!(!cmd::smart_art::selected_smart_art(&s).unwrap().has_custom_colors()); + run(&mut s, "smartArt.itemColor", json!({"index": 1, "color": "FF8800"})); + assert_eq!(colors(&s), [None, Some("FF8800".to_string()), None, None]); + run(&mut s, "smartArt.items", json!({"items": [{"text": "A"}, {"text": "B", "color": "#ff8800"}]})); + run(&mut s, "smartArt.layout", json!({"layout": "cycle"})); + run(&mut s, "smartArt.addShape", json!({"after": 0})); + assert_eq!(colors(&s), [None, None, Some("FF8800".to_string())]); + assert!(s.run("smartArt.itemColor", &json!({"index": 0, "color": "nope"})).is_err()); + run(&mut s, "smartArt.itemColor", json!({"index": 2})); + assert!(!cmd::smart_art::selected_smart_art(&s).unwrap().has_custom_colors()); + run(&mut s, "smartArt.itemColor", json!({"index": 0, "color": "00FF00"})); + run(&mut s, "smartArt.colors", json!({"variant": "accent2"})); + assert!(!cmd::smart_art::selected_smart_art(&s).unwrap().has_custom_colors()); + run(&mut s, "edit.undo", json!({})); + assert!(cmd::smart_art::selected_smart_art(&s).unwrap().has_custom_colors(), "undo brings the colours back"); +} + +/// A malformed colour must not silently replace a coloured item with its style colour. +#[test] +fn smart_art_item_colors_reject_invalid_values_without_changing_the_graphic() { + let mut s = s(); + run(&mut s, "insert.smartArt", json!({"items": [{"text": "Keep", "color": "123456"}]})); + let before = cmd::smart_art::selected_smart_art(&s).unwrap(); + for command in ["insert.smartArt", "smartArt.items"] { + for color in [json!("nope"), json!(42), json!(true), json!([]), json!({})] { + assert!(s.run(command, &json!({"items": [{"text": "Replace", "color": color}]})).is_err(), "{command}: {color}"); + assert_eq!(cmd::smart_art::selected_smart_art(&s).unwrap(), before); + } + run(&mut s, command, json!({"items": [{"text": "Default", "color": null}, {"text": "Omitted"}]})); + assert!(!cmd::smart_art::selected_smart_art(&s).unwrap().has_custom_colors()); + run(&mut s, "edit.undo", json!({})); + assert_eq!(cmd::smart_art::selected_smart_art(&s).unwrap(), before); + } +} + +/// #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.itemColor", + "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"); +} + +/// #3621: with thousands of collapsed headings, an Enter above them (which shifts every index) +/// finds them again without scanning the document once per heading, and a caret move doesn't +/// redo the matching. +#[test] +fn outline_many_collapsed_headings_stay_fast_after_edits() { + const N: usize = 8000; + let text: Vec = (0..N).flat_map(|i| [format!("Heading {i}"), format!("body {i}")]).collect(); + let mut d = wordcraft_doc::Document::from_text(&text.join("\n")); + for i in 0..N { + let p = d.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(2 * i)).unwrap(); + p.props.style = Some("Heading1".into()); + } + let mut s = Session::new(d); + run(&mut s, "view.outline", json!({})); + run(&mut s, "outline.showLevel1", json!({})); + s.sel = crate::Selection::caret(Pos::body(0, 0)); + run(&mut s, "outline.collapse", json!({})); + let collapsed_texts = + |s: &Session| -> Vec { s.view.outline.collapsed.iter().map(|(i, _)| s.doc.body[*i].as_para().unwrap().text.clone()).collect() }; + let before = collapsed_texts(&s); + assert_eq!(before.len(), N, "every heading is collapsed"); + s.sel = crate::Selection::caret(Pos::body(1, 0)); + for _ in 0..10 { + run(&mut s, "text.newParagraph", json!({})); + } + for _ in 0..20 { + run(&mut s, "caret.right", json!({})); + } + assert_eq!(collapsed_texts(&s), before, "the same headings stay collapsed"); +} + +/// #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 formula's `\*` switches format its result when the formula is inserted and when Update Field +/// works it out again. +#[test] +fn table_formula_results_take_the_format_switches() { + for (formula, inserted, updated) in [ + (r"=SUM(ABOVE) \* CardText", "twelve", "twenty-one"), + (r"=SUM(ABOVE) \* CardText \* Caps", "Twelve", "Twenty-one"), + (r"=SUM(ABOVE) \* OrdText \* Upper", "TWELFTH", "TWENTY-FIRST"), + (r"=SUM(ABOVE) \* MERGEFORMAT", "12", "21"), + ] { + let mut s = formula_table(); + let r = run(&mut s, "table.formula", json!({"formula": formula})); + assert_eq!((r["formula"].as_str(), r["result"].as_str()), (Some(formula), Some(inserted))); + 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(updated), "{formula}"); + } +} + +#[test] +fn table_formula_results_take_dollar_text_and_hex() { + for (formula, inserted, updated) in [ + (r"=SUM(ABOVE) \* DollarText", "twelve and 00/100", "twenty-one and 00/100"), + (r"=SUM(ABOVE) \* DollarText \* Upper", "TWELVE AND 00/100", "TWENTY-ONE AND 00/100"), + (r"=SUM(ABOVE) \* Hex", "C", "15"), + ] { + let mut s = formula_table(); + let r = run(&mut s, "table.formula", json!({"formula": formula})); + assert_eq!((r["formula"].as_str(), r["result"].as_str()), (Some(formula), Some(inserted))); + 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(updated), "{formula}"); + } +} + +/// 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() +} + +#[test] +fn table_sort_rejects_invalid_columns_without_changing_rows_or_history() { + for column in [ + json!(-1), + json!(0.5), + json!(0.0), + json!("0"), + json!(null), + json!(true), + json!([]), + json!({}), + json!(1), + json!(u32::MAX as u64 + 1), + json!(u64::MAX), + ] { + let mut s = column_table("en-US", &["Charlie", "Alpha", "Bravo"]); + s.close_typing(); + run(&mut s, "text.insert", json!({"text": "!"})); + let before = s.doc.clone(); + let selection = s.sel.clone(); + let dirty = s.dirty; + assert!(s.run("table.sort", &json!({"column": column, "header": false})).is_err(), "column {column}"); + assert_eq!(s.doc, before, "column {column}"); + assert_eq!(s.sel, selection, "column {column}"); + assert_eq!(s.dirty, dirty, "column {column}"); + run(&mut s, "edit.undo", json!({})); + assert_eq!(column_texts(&s), ["Charlie", "Alpha", "Bravo"], "column {column}: refusal must not consume Undo"); + run(&mut s, "edit.redo", json!({})); + assert_eq!(s.doc, before, "column {column}: Redo must restore the preceding edit"); + } +} + +#[test] +fn table_sort_keeps_default_and_valid_columns() { + let mut s = s(); + run(&mut s, "insert.table", json!({"rows": 3, "cols": 2})); + for (k, value) in ["Charlie", "1", "Alpha", "3", "Bravo", "2"].into_iter().enumerate() { + if k > 0 { + run(&mut s, "text.tab", json!({})); + } + run(&mut s, "text.insert", json!({"text": value})); + } + let original = s.doc.clone(); + for params in [json!({"header": false}), json!({"column": 0, "header": false})] { + run(&mut s, "table.sort", params); + assert_eq!(column_texts(&s), ["Alpha", "Bravo", "Charlie"]); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc, original); + } + run(&mut s, "table.sort", json!({"column": 1, "header": false})); + assert_eq!(column_texts(&s), ["Charlie", "Bravo", "Alpha"]); + run(&mut s, "table.sort", json!({"column": 1, "header": false, "descending": true})); + assert_eq!(column_texts(&s), ["Alpha", "Bravo", "Charlie"]); + // A column may be missing in some rows. Its present cells still sort normally. + let at = s.doc.body.iter().position(|b| b.as_table().is_some()).unwrap() as u32; + let table = s.doc.table_mut(StoryRef::Body, &wordcraft_doc::Path(vec![at])).unwrap(); + table.rows[0].cells.remove(1); + run(&mut s, "table.sort", json!({"column": 1, "header": false})); + assert_eq!(column_texts(&s), ["Charlie", "Bravo", "Alpha"]); + + let mut s = column_table("en-US", &["Title", "3", "1", "2"]); + let at = s.doc.body.iter().position(|b| b.as_table().is_some()).unwrap() as u32; + s.doc.table_mut(StoryRef::Body, &wordcraft_doc::Path(vec![at])).unwrap().rows[0].props.header = true; + run(&mut s, "table.sort", json!({"column": 0})); + assert_eq!(column_texts(&s), ["Title", "1", "2", "3"]); +} + +/// #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")); + Arc::make_mut(&mut 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"); + + // A replace with no `text` and no earlier search is a mistake, not "0 matches" (#3271). + let mut s = super::tests::s(); + for (id, params) in [("edit.replaceAll", json!({"find": "cat", "replace": "dog"})), ("edit.replace", json!({})), ("edit.replaceAll", json!({}))] { + let e = s.run(id, ¶ms).unwrap_err().to_string(); + assert!(e.contains("`text` (string) is required"), "{id}: {e}"); + } + run(&mut s, "edit.replaceAll", json!({"text": "", "with": "x"})); + + // 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); +} + +#[test] +fn window_commands_need_the_desktop_and_name_a_window() { + use crate::cmd::view::OtherWindow; + let mut s = s(); + // Web, CLI, MCP: one window, so View › Window is disabled. + for id in ["view.arrangeAll", "view.sideBySide", "view.syncScroll"] { + assert!(s.run(id, &json!({})).is_err(), "{id} without a window host"); + } + s.windows.available = true; + run(&mut s, "view.arrangeAll", json!({})); + assert_eq!(s.ui_requests.pop(), Some(json!({"windows": "arrange"}))); + assert!(s.run("view.sideBySide", &json!({})).is_err(), "no other window"); + s.windows.others = vec![OtherWindow { id: 7, title: "Notes".into() }, OtherWindow { id: 9, title: "Draft".into() }]; + let e = s.run("view.sideBySide", &json!({})).unwrap_err().to_string(); + assert!(e.contains("7 (Notes)") && e.contains("9 (Draft)"), "several windows: say which: {e}"); + assert!(s.run("view.sideBySide", &json!({"window": 8})).is_err(), "an unknown window"); + assert!(s.run("view.sideBySide", &json!({"window": "9"})).is_err()); + // Side by Side pairs with one window and scrolls together until turned off. + assert!(s.run("view.syncScroll", &json!({})).is_err(), "only while Side by Side is on"); + let r = run(&mut s, "view.sideBySide", json!({"window": 7})); + assert_eq!((r["value"].as_bool(), s.windows.side_by_side, s.windows.sync_scroll), (Some(true), Some(7), true)); + assert_eq!(s.ui_requests.pop(), Some(json!({"windows": "sideBySide", "with": 7}))); + run(&mut s, "view.syncScroll", json!({})); + assert!(!s.windows.sync_scroll); + assert_eq!(s.ui_requests.pop(), Some(json!({"windows": "syncScroll", "with": 7, "value": false}))); + // Asking again for the partner it already has keeps scrolling off and pairs nothing anew. + let r = run(&mut s, "view.sideBySide", json!({"window": 7, "value": true})); + assert_eq!((r["syncScroll"].as_bool(), s.windows.sync_scroll), (Some(false), false)); + assert!(s.ui_requests.is_empty()); + // Another partner replaces the first; the bare button turns it off. + run(&mut s, "view.sideBySide", json!({"window": 9})); + assert_eq!(std::mem::take(&mut s.ui_requests), [json!({"windows": "sideBySide", "off": 7}), json!({"windows": "sideBySide", "with": 9})]); + run(&mut s, "view.sideBySide", json!({})); + assert_eq!((s.windows.side_by_side, s.windows.sync_scroll), (None, false)); + assert_eq!(s.ui_requests.pop(), Some(json!({"windows": "sideBySide", "off": 9}))); + assert!(!s.dirty, "window commands never touch the document"); +} + +/// 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); +} + +/// Change Picture keeps the width and takes the replacement's aspect ratio: swapping a figure for +/// a revised one of a different shape must not stretch it into the old frame. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn picture_change_keeps_width_and_takes_new_aspect() { + let dir = scratch_dir("picture-change-aspect"); + let mut wide = Vec::new(); + image::RgbImage::from_pixel(40, 20, image::Rgb([200, 30, 30])).write_to(&mut std::io::Cursor::new(&mut wide), image::ImageFormat::Png).unwrap(); + let mut tall = Vec::new(); + image::RgbImage::from_pixel(30, 60, image::Rgb([30, 30, 200])).write_to(&mut std::io::Cursor::new(&mut tall), image::ImageFormat::Png).unwrap(); + let (first, second) = (dir.join("wide.png"), dir.join("tall.png")); + std::fs::write(&first, &wide).unwrap(); + std::fs::write(&second, &tall).unwrap(); + + let mut s = Session::new(wordcraft_doc::Document::new()); + run(&mut s, "insert.picture", json!({"path": first.to_string_lossy()})); + let size = |s: &Session| { + let mut out = (0.0f32, 0.0f32, [1.0f32; 4]); + s.doc.objects_in_reading_order(&mut |o| { + if let wordcraft_doc::para::InlineObject::Image { w, h, crop, .. } = o { + out = (*w, *h, *crop); + } + }); + out + }; + let (w0, h0, _) = size(&s); + assert!((w0 / h0 - 2.0).abs() < 1e-3, "inserted at its own 2:1 shape: {w0} x {h0}"); + run(&mut s, "picture.crop", json!({"left": 0.1})); + let (w_cropped, _, crop) = size(&s); + assert!(crop[0] > 0.0, "cropped before the change"); + run(&mut s, "picture.change", json!({"path": second.to_string_lossy()})); + let (w1, h1, crop) = size(&s); + assert!((w1 - w_cropped).abs() < 1e-3, "the width is kept: {w_cropped} -> {w1}"); + assert!((h1 / w1 - 2.0).abs() < 1e-3, "the height follows the new 1:2 picture: {w1} x {h1}"); + assert_eq!(crop, [0.0; 4], "the old picture's crop is dropped"); + 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"); + + // A name given with the document's extension is the whole file name: an agent or a script + // passes one, and appending the extension again made "Report.docm.docm" (#2983). + s.run("file.rename", &json!({"name": "Report.docm"})).unwrap(); + assert_eq!(s.path.as_ref(), Some(&dir.join("Report.docm"))); + assert!(!dir.join("Report.docm.docm").exists()); + // Another extension, or the extension alone, is a stem and still gets one. + s.run("file.rename", &json!({"name": "Report.2026"})).unwrap(); + assert_eq!(s.path.as_ref(), Some(&dir.join("Report.2026.docm"))); + s.run("file.rename", &json!({"name": ".docm"})).unwrap(); + assert_eq!(s.path.as_ref(), Some(&dir.join(".docm.docm"))); + assert_eq!(std::fs::read(dir.join(".docm.docm")).unwrap(), b"original bytes"); + // A name whose bytes do not line up with the extension's length: comparing by slicing at + // `len - ext.len()` could land inside a character and panic (#2983 review). + s.run("file.rename", &json!({"name": "abécde"})).unwrap(); + assert_eq!(s.path.as_ref(), Some(&dir.join("abécde.docm"))); + s.run("file.rename", &json!({"name": "résumé.docm"})).unwrap(); + assert_eq!(s.path.as_ref(), Some(&dir.join("résumé.docm"))); + // Whatever its case; the file is named as typed, so the extension follows the name. + s.run("file.rename", &json!({"name": "Report.DOCM"})).unwrap(); + assert_eq!(s.path.as_ref(), Some(&dir.join("Report.DOCM"))); + assert!(!dir.join("Report.DOCM.docm").exists()); + 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()); +} + +/// A version snapshot belongs to the document it was taken from: opening another document (or +/// starting a new one) starts a new version history, so a restore can't put the previous +/// document's content into the open one (#1327 follow-up). +#[test] +fn a_version_snapshot_does_not_survive_a_document_switch() { + let mut s = Session::new(wordcraft_doc::Document::from_text("First document.")); + s.run("file.versions", &json!({"save": "snapshot"})).unwrap(); + assert_eq!(s.versions.len(), 1); + + // A new document: the snapshot of the previous one is gone with it. + s.run("file.new", &json!({})).unwrap(); + assert!(s.versions.is_empty(), "the snapshot of the previous document survived the switch"); + assert!(s.run("file.versions", &json!({"restore": 0})).is_err(), "the old snapshot is still restorable"); + assert_ne!(s.doc.plain_text(StoryRef::Body), "First document.", "the old document's content came back"); + + // The same for an opened file. + s.run("file.versions", &json!({"save": "snapshot"})).unwrap(); + s.run("file.open", &json!({"path": "other.txt", "data": "aGk="})).unwrap(); + assert!(s.versions.is_empty(), "the snapshot of the previous document survived the switch"); + assert!(s.run("file.versions", &json!({"restore": 0})).is_err(), "the old snapshot is still restorable"); + assert_eq!(s.doc.plain_text(StoryRef::Body), "hi"); + + // A snapshot of the open document still restores into it. + s.run("file.versions", &json!({"save": "snapshot"})).unwrap(); + s.run("text.insert", &json!({"text": " there"})).unwrap(); + s.run("file.versions", &json!({"restore": 0})).unwrap(); + assert_eq!(s.doc.plain_text(StoryRef::Body), "hi"); +} + +/// A comparison that opens the original replaces the open document, so the version snapshots +/// of the document it replaces go with it: a restore must not put the previous document back +/// over the comparison result (#1327 follow-up). +#[test] +fn a_version_snapshot_does_not_survive_a_comparison() { + let dir = std::env::temp_dir().join(format!("wordcraft-compare-versions-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let original = dir.join("original.docx"); + let mut other = Session::new(wordcraft_doc::Document::from_text("Original document.")); + other.run("file.save", &json!({"path": original.to_string_lossy()})).unwrap(); + + let mut s = Session::new(wordcraft_doc::Document::from_text("Current document.")); + s.run("file.versions", &json!({"save": "snapshot"})).unwrap(); + assert_eq!(s.versions.len(), 1); + s.run("review.compare", &json!({"original": original.to_string_lossy(), "text": "Revised document."})).unwrap(); + assert!(s.versions.is_empty(), "the snapshot of the previous document survived the comparison"); + assert!(s.run("file.versions", &json!({"restore": 0})).is_err(), "the old snapshot is still restorable"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Compare's new and revised results are untitled documents even without an `original` path. +#[test] +fn a_version_snapshot_does_not_survive_an_untitled_comparison() { + for show_in in ["new", "revised"] { + let mut s = Session::new(wordcraft_doc::Document::from_text("Current document.")); + s.run("file.versions", &json!({"save": "snapshot"})).unwrap(); + s.run("review.compare", &json!({"text": "Revised document.", "showIn": show_in})).unwrap(); + assert!(s.versions.is_empty(), "old snapshots survived a {show_in} comparison"); + assert!(s.run("file.versions", &json!({"restore": 0})).is_err()); + assert!(s.path.is_none() && s.file_name.is_none()); + } +} + +/// A comparison with an `original` replaces the open document in place — the handler loads the +/// original into `s.doc` without `set_document` — so `document_id` does not move and two +/// identical comparisons produce identical documents. A comparison is an edit of its own all +/// the same: the `replaces_document` guard keeps its undo step (#4004 review). +#[test] +fn a_repeated_comparison_is_still_an_edit() { + let dir = std::env::temp_dir().join(format!("wordcraft-compare-twice-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let original = dir.join("original.docx"); + let mut other = Session::new(wordcraft_doc::Document::from_text("Original document.")); + other.run("file.save", &json!({"path": original.to_string_lossy()})).unwrap(); + + let mut s = Session::new(wordcraft_doc::Document::from_text("Current document.")); + let compare = json!({"original": original.to_string_lossy(), "text": "Revised document."}); + s.run("review.compare", &compare).unwrap(); + let depth = s.undo_depth(); + assert!(s.dirty); + s.run("review.compare", &compare).unwrap(); + assert_eq!(s.undo_depth(), depth + 1, "the second comparison is an edit of its own"); + assert!(s.dirty, "the comparison result is unsaved"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A rejected comparison leaves the current document and its snapshots together. +#[test] +fn a_rejected_comparison_keeps_the_current_versions() { + use wordcraft_doc::{CharProps, InlineObject}; + + let mut s = Session::new(wordcraft_doc::Document::from_text("Editable. Locked.")); + s.path = Some(std::path::PathBuf::from("original.docx")); + s.file_name = Some("original.docx".into()); + s.run("file.versions", &json!({"save": "snapshot"})).unwrap(); + let end = s.doc.end_of(StoryRef::Body); + s.doc.insert_object(&end, InlineObject::PermEnd { id: 1 }, &CharProps::default()).unwrap(); + let start = s.doc.start_of(StoryRef::Body); + s.doc.insert_object(&start, InlineObject::PermStart { id: 1, group: String::new(), editor: s.author.clone() }, &CharProps::default()).unwrap(); + s.run("review.restrict", &json!({"mode": "readOnly"})).unwrap(); + let before = s.doc.clone(); + + assert!(s.run("review.compare", &json!({"text": "Different document.", "showIn": "new"})).is_err()); + assert_eq!(s.doc, before); + assert_eq!(s.versions.len(), 1, "a rejected comparison discarded the current document's snapshot"); + assert_eq!(s.path.as_deref(), Some(std::path::Path::new("original.docx"))); + assert_eq!(s.file_name.as_deref(), Some("original.docx")); +} + +/// A mailing result opens beside a protected source document, which keeps its save target, +/// versions, password and text. +#[test] +fn a_mailing_result_beside_a_protected_document_keeps_its_file_identity() { + use wordcraft_doc::{CharProps, InlineObject}; + + let mut s = Session::new(wordcraft_doc::Document::from_text("Protected source.")); + s.path = Some(std::path::PathBuf::from("source.docx")); + s.file_name = Some("source.docx".into()); + s.run("file.versions", &json!({"save": "snapshot"})).unwrap(); + let end = s.doc.end_of(StoryRef::Body); + s.doc.insert_object(&end, InlineObject::PermEnd { id: 1 }, &CharProps::default()).unwrap(); + let start = s.doc.start_of(StoryRef::Body); + s.doc.insert_object(&start, InlineObject::PermStart { id: 1, group: String::new(), editor: s.author.clone() }, &CharProps::default()).unwrap(); + s.run("review.restrict", &json!({"mode": "readOnly"})).unwrap(); + let before = s.doc.clone(); + let before_id = s.document_id(); + let before_dirty = s.dirty; + s.password = Some(crate::io::Password::new("synthetic file password")); + s.bib_style = "Chicago".into(); + + s.run("mailings.envelopes", &json!({"delivery": "Recipient"})).unwrap(); + assert_ne!(s.document_id(), before_id, "the envelope is a new document"); + s.activate(before_id).unwrap(); + assert_eq!(s.doc, before); + assert_eq!(s.versions.len(), 1); + assert_eq!(s.path.as_deref(), Some(std::path::Path::new("source.docx"))); + assert_eq!(s.file_name.as_deref(), Some("source.docx")); + assert_eq!(s.document_id(), before_id); + assert_eq!(s.dirty, before_dirty); + assert_eq!(s.password.as_ref().map(crate::io::Password::as_str), Some("synthetic file password")); + assert_eq!(s.bib_style, "Chicago"); +} + +/// #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() }; + Arc::make_mut(&mut s.doc.styles).upsert(Style { + id: "HideC".into(), + name: "Hide C".into(), + kind: StyleKind::Character, + chr: hidden.clone(), + ..Default::default() + }); + Arc::make_mut(&mut 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(); + Arc::make_mut(&mut 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(); + Arc::make_mut(&mut 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. +
  3. Classificazione dell’evento
  4. +
  5. Azioni correttive
  6. +
+ + + + + + +
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); +} + +/// Shape Format › Shape Fill with transparency and gradients (#1500): each form sets the fill of +/// every selected shape in one undo step; a colour alone keeps the transparency; hostile values +/// are clamped; the old `{"color"}` form still works. +#[test] +fn shape_fill_transparency_and_gradients() { + use wordcraft_doc::fill::{Fill, GradientKind, PathShape}; + use wordcraft_doc::props::Rgb; + let mut s = s(); + run(&mut s, "insert.shape", json!({"kind": "rectangle"})); + run(&mut s, "select.collapse", json!({"end": true})); + run(&mut s, "insert.shape", json!({"kind": "ellipse"})); + run(&mut s, "select.objects", json!({"index": 0})); + run(&mut s, "select.addObject", json!({"index": 1})); + let fills = |s: &Session| -> Vec> { + cmd::objects::all_objects(s) + .into_iter() + .map(|(_, o)| match o { + wordcraft_doc::InlineObject::Shape { fill, .. } => fill, + o => panic!("{o:?}"), + }) + .collect() + }; + let before = fills(&s); + run(&mut s, "shape.fill", json!({"color": "112233", "transparency": 40})); + let solid = Fill::Solid { color: Rgb(0x11, 0x22, 0x33), transparency: 40.0 }; + assert_eq!(fills(&s), [Some(solid.clone()), Some(solid)], "both selected shapes"); + run(&mut s, "shape.fill", json!({"color": "445566"})); + assert_eq!(fills(&s)[1], Some(Fill::Solid { color: Rgb(0x44, 0x55, 0x66), transparency: 40.0 }), "a colour alone keeps the transparency"); + run( + &mut s, + "shape.fill", + json!({"gradient": {"type": "rectangular", "focus": [50, 50, 50, 50], "stops": [{"pos": 100, "color": "FFFFFF", "transparency": 30}, {"pos": 0, "color": "000000"}]}}), + ); + let Some(Fill::Gradient(g)) = &fills(&s)[0] else { panic!("{:?}", fills(&s)) }; + assert_eq!(g.kind, GradientKind::Path { shape: PathShape::Rect, focus: [50.0; 4] }); + assert_eq!(g.stops.iter().map(|st| (st.pos, st.color, st.transparency)).collect::>(), [(0.0, Rgb::BLACK, 0.0), (100.0, Rgb::WHITE, 30.0)]); + assert!(g.rot_with_shape, "turns with the shape, as Word's new gradients do"); + run( + &mut s, + "shape.fill", + json!({"gradient": {"type": "linear", "angle": -90, "stops": [{"pos": -5, "color": "FF0000", "transparency": 500}, {"pos": 1e9, "color": "0000FF"}]}}), + ); + let Some(Fill::Gradient(g)) = &fills(&s)[1] else { panic!() }; + assert_eq!(g.kind, GradientKind::Linear { angle: 270.0, scaled: false }); + assert_eq!((g.stops[0].pos, g.stops[0].transparency, g.stops[1].pos), (0.0, 100.0, 100.0)); + // One undo step per command, for every shape. + run(&mut s, "edit.undo", json!({})); + assert!(matches!(fills(&s)[1], Some(Fill::Gradient(_)))); + for _ in 0..3 { + run(&mut s, "edit.undo", json!({})); + } + assert_eq!(fills(&s), before); + run(&mut s, "shape.fill", json!({"color": null})); + assert_eq!(fills(&s), [None, None]); + for bad in [ + json!({"gradient": {"stops": []}}), + json!({"gradient": {"type": "spiral", "stops": [{"pos": 0, "color": "000000"}, {"pos": 100, "color": "FFFFFF"}]}}), + json!({"gradient": 5}), + json!({"color": "nope"}), + ] { + assert!(s.run("shape.fill", &bad).is_err(), "{bad}"); + } +} + +/// A drag in the Format Shape pane applies its value as it goes and undoes in one step. +#[test] +fn steps_since_a_mark_merge_into_one_undo_step() { + let mut s = s(); + run(&mut s, "insert.shape", json!({"kind": "rectangle"})); + let fill = |s: &Session| match cmd::objects::selected(s) { + Some((_, wordcraft_doc::InlineObject::Shape { fill, .. })) => fill, + o => panic!("{o:?}"), + }; + let before = fill(&s); + let mark = s.undo_mark(); + for t in [10, 20, 30] { + run(&mut s, "shape.fill", json!({"color": "112233", "transparency": t})); + } + s.merge_undo_since(mark); + assert_eq!(s.undo_mark(), mark + 1); + run(&mut s, "edit.undo", json!({})); + assert_eq!(fill(&s), before); + s.merge_undo_since(mark + 5); + assert_eq!(s.undo_mark(), mark, "nothing newer: nothing merged"); +} + +/// 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; + Arc::make_mut(&mut d.styles).get_mut("TOC2").unwrap().para.tabs = Some(vec![right]); + let num = Arc::make_mut(&mut 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"); + + // A selection is as good as its worst paragraph: an empty one or one starting with a space + // refuses a drop cap for all (the button greys out); None works while there is one to take away. + let mut s = self::s(); + run(&mut s, "text.insert", json!({"text": "First"})); + run(&mut s, "text.newParagraph", json!({})); + run(&mut s, "text.newParagraph", json!({})); + run(&mut s, "text.insert", json!({"text": "Third"})); + assert_eq!(s.doc.body.len(), 3); + run(&mut s, "select.all", json!({})); + assert!(s.run("insert.dropCap", &json!({"position": "dropped"})).is_err(), "an empty paragraph in the selection"); + assert!(s.doc.body.iter().filter_map(|b| b.as_para()).all(|p| p.props.drop_cap.is_none()), "nothing changed"); + assert!(s.why_disabled("insert.dropCap").is_some(), "greyed out: nothing can take a drop cap or lose one"); + run(&mut s, "select.range", json!({"anchor": {"block": 2, "off": 0}, "focus": {"block": 2, "off": 0}})); + run(&mut s, "insert.dropCap", json!({"position": "dropped"})); + // From the drop cap up into the empty paragraph (the caret there): None takes it away, the + // other positions are refused, as the enabled check says. + run(&mut s, "select.range", json!({"anchor": {"block": 2, "off": 5}, "focus": {"block": 1, "off": 0}})); + assert_eq!(s.why_disabled("insert.dropCap"), None, "a drop cap to take away"); + assert!(s.run("insert.dropCap", &json!({"position": "margin"})).is_err(), "the empty paragraph can't take one"); + assert!(s.run("insert.dropCap", &json!({"position": "none"})).is_ok(), "None takes it away"); + assert!(s.doc.body.iter().filter_map(|b| b.as_para()).all(|p| p.props.drop_cap.is_none()), "taken away"); + let mut s = self::s(); + run(&mut s, "text.insert", json!({"text": " first"})); + run(&mut s, "text.newParagraph", json!({})); + run(&mut s, "text.insert", json!({"text": "Second"})); + run(&mut s, "select.all", json!({})); + assert!(s.run("insert.dropCap", &json!({})).is_err(), "a paragraph starting with a space in the selection"); +} + +/// #2601: `keep` moves a whole selection to Dropped / In margin without flattening it: each +/// paragraph keeps its own lines, distance and font, one without a drop cap gets the defaults, +/// and it is one undo step. Without `keep` the options not given are reset, as before. +#[test] +fn drop_cap_keep_leaves_each_paragraphs_options() { + let caps = |s: &Session| { + s.doc + .body + .iter() + .filter_map(|b| b.as_para()) + .map(|p| (p.props.drop_cap, p.props.drop_cap_margin, p.props.drop_cap_distance, p.props.drop_cap_font.clone())) + .collect::>() + }; + let mut s = s(); + run(&mut s, "text.insert", json!({"text": "First"})); + for text in ["Second", "Third"] { + run(&mut s, "text.newParagraph", json!({})); + run(&mut s, "text.insert", json!({"text": text})); + } + run(&mut s, "select.range", json!({"anchor": {"block": 0, "off": 0}, "focus": {"block": 0, "off": 0}})); + run(&mut s, "insert.dropCap", json!({"position": "dropped", "lines": 5, "distance": 7.0, "font": "Liberation Mono"})); + run(&mut s, "select.range", json!({"anchor": {"block": 1, "off": 0}, "focus": {"block": 1, "off": 0}})); + run(&mut s, "insert.dropCap", json!({"position": "dropped", "lines": 2, "distance": 3.0})); + let before = caps(&s); + run(&mut s, "select.all", json!({})); + run(&mut s, "insert.dropCap", json!({"position": "margin", "keep": true})); + assert_eq!( + caps(&s), + vec![ + (Some(5), Some(true), Some(7.0), Some("Liberation Mono".to_string())), + (Some(2), Some(true), Some(3.0), None), + (Some(3), Some(true), None, None), + ], + "each keeps its own options; the one without gets the defaults" + ); + run(&mut s, "edit.undo", json!({})); + assert_eq!(caps(&s), before, "one undo step"); + + // Options given explicitly win over the kept ones; a junk `keep` is false. + run(&mut s, "insert.dropCap", json!({"position": "dropped", "keep": true, "lines": 4})); + assert_eq!(caps(&s).iter().map(|c| c.0).collect::>(), vec![Some(4); 3]); + assert_eq!(caps(&s).first().map(|c| (c.2, c.3.clone())), Some((Some(7.0), Some("Liberation Mono".to_string()))), "distance and font still kept"); + // A blank font given with `keep` clears the kept font (the text's own again). + run(&mut s, "insert.dropCap", json!({"position": "dropped", "keep": true, "font": " "})); + assert_eq!(caps(&s).first().map(|c| c.3.clone()), Some(None), "blank font clears the kept one"); + run(&mut s, "insert.dropCap", json!({"position": "dropped", "lines": 5, "distance": 7.0, "font": "Liberation Mono"})); + run(&mut s, "insert.dropCap", json!({"position": "margin", "keep": "yes"})); + assert_eq!(caps(&s), vec![(Some(3), Some(true), None, None); 3], "junk keep: reset what isn't given"); + // None ignores keep. + run(&mut s, "insert.dropCap", json!({"position": "none", "keep": true})); + assert_eq!(caps(&s), vec![(None, None, None, None); 3]); +} + +#[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}"); + } +} + +/// #3490: `table.cellMargins` preserves existing margins for any sides not passed. +#[test] +fn table_cell_margins_preserves_unpassed_sides() { + let mut s = s(); + run(&mut s, "insert.table", json!({"rows": 1, "cols": 1})); + let (tp, _, _) = s.sel.focus.path.cell().unwrap(); + + // 1. Updating 'top' only retains default left (5.4), bottom (0.0), and right (5.4). + run(&mut s, "table.cellMargins", json!({"top": 20.0})); + let margins = s.doc.table(StoryRef::Body, &tp).unwrap().props.cell_margins.unwrap(); + assert_eq!(margins, [20.0, 5.4, 0.0, 5.4]); + + // 2. Updating 'left' subsequently retains previously set top (20.0), bottom (0.0), and right (5.4). + run(&mut s, "table.cellMargins", json!({"left": 10.0})); + let margins = s.doc.table(StoryRef::Body, &tp).unwrap().props.cell_margins.unwrap(); + assert_eq!(margins, [20.0, 10.0, 0.0, 5.4]); + + // 3. Updating 'bottom' retains top (20.0), left (10.0), and right (5.4). + run(&mut s, "table.cellMargins", json!({"bottom": 8.0})); + let margins = s.doc.table(StoryRef::Body, &tp).unwrap().props.cell_margins.unwrap(); + assert_eq!(margins, [20.0, 10.0, 8.0, 5.4]); + + // 4. Updating 'right' retains top (20.0), left (10.0), and bottom (8.0). + run(&mut s, "table.cellMargins", json!({"right": 15.0})); + let margins = s.doc.table(StoryRef::Body, &tp).unwrap().props.cell_margins.unwrap(); + assert_eq!(margins, [20.0, 10.0, 8.0, 15.0]); + + // 5. Updating multiple sides at once retains unpassed sides. + run(&mut s, "table.cellMargins", json!({"top": 0.0, "bottom": 0.0})); + let margins = s.doc.table(StoryRef::Body, &tp).unwrap().props.cell_margins.unwrap(); + assert_eq!(margins, [0.0, 10.0, 0.0, 15.0]); +} + +#[test] +fn headless_ui_requests_stay_bounded() { + let mut s = s(); + let big = "x".repeat(1 << 20); + for _ in 0..200 { + run(&mut s, "edit.pasteSpecial", json!({"text": big})); + run(&mut s, "edit.goto", json!({})); + } + assert!(s.ui_requests.len() <= 64, "len = {}", s.ui_requests.len()); + let size: usize = s.ui_requests.iter().map(|r| r.to_string().len()).sum(); + assert!(size < 4 << 20, "queued {size} bytes"); +} + +/// `file.exportPng` writes only to `.png` paths, so automation can't replace other files with +/// page images (#3696). +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn export_png_refuses_a_path_that_is_not_png() { + let dir = scratch_dir("export-png-extension"); + let mut s = s(); + run(&mut s, "text.insert", json!({"text": "Hello"})); + for name in ["notes.txt", "config", "report.docx", "page.png.txt"] { + let target = dir.join(name); + std::fs::write(&target, b"precious user notes").unwrap(); + let r = s.run("file.exportPng", &json!({"path": target.to_string_lossy()})); + assert!(matches!(r, Err(crate::CmdError::Params(_))), "{name}: {r:?}"); + assert_eq!(std::fs::read(&target).unwrap(), b"precious user notes", "{name} was changed"); + } + // A .png path still works, whatever its case. + for name in ["page.png", "PAGE.PNG"] { + let target = dir.join(name); + s.run("file.exportPng", &json!({"path": target.to_string_lossy()})).unwrap(); + assert!(std::fs::read(&target).unwrap().starts_with(b"\x89PNG"), "{name} is not a PNG"); + } + let _ = std::fs::remove_dir_all(&dir); +} + +/// The body's IME replacement epoch advances when the whole body is swapped (setText, Undo/Redo) +/// and stays put for ordinary edits; an equation editing session cannot ride across a replacement. +#[test] +fn ime_body_epoch_marks_body_replacements_not_edits() { + let mut s = s(); + let id = s.document_id(); + run(&mut s, "text.insert", json!({"text": "one"})); + let first = s.ime_body_epoch(); + run(&mut s, "text.insert", json!({"text": " two"})); + assert_eq!(s.ime_body_epoch(), first, "typing is not a replacement"); + + run(&mut s, "document.setText", json!({"text": "same text"})); + let replaced = s.ime_body_epoch(); + assert!(replaced > first, "setText replaces the body under the same document id"); + assert_eq!(s.document_id(), id, "the document id itself is unchanged"); + + // The same text again still replaces the body: a composition must not survive on content + // equality. + run(&mut s, "document.setText", json!({"text": "same text"})); + assert!(s.ime_body_epoch() > replaced); + + // Undo swaps the body back. + let before_undo = s.ime_body_epoch(); + assert!(s.undo()); + assert!(s.ime_body_epoch() > before_undo, "Undo replaces the body"); +} + +/// Replacing the document body while an equation is being edited ends that equation editing +/// session. +#[test] +fn a_body_replacement_ends_equation_editing() { + let mut s = s(); + run(&mut s, "insert.equation", json!({"linear": "x+y"})); + assert!(s.math.is_some()); + run(&mut s, "document.setText", json!({"text": "x+y"})); + assert!(s.math.is_none(), "the old equation's editing session is gone"); +} + +/// A failed command that cleared the equation session puts it back with the document. +#[test] +fn a_failed_replacement_keeps_equation_editing() { + let mut s = s(); + run(&mut s, "insert.equation", json!({"linear": "x+y"})); + let session = s.math.as_ref().map(|m| m.session); + assert!(session.is_some()); + // Missing `text` is rejected before any mutation. + assert!(s.run("document.setText", &json!({})).is_err()); + assert_eq!(s.math.as_ref().map(|m| m.session), session); +} + +/// Track Changes: deleting a range whose text is already marked deleted is a real no-op. It +/// creates no revision record, no undo-visible change and no IME revision. +#[test] +fn deleting_an_already_deleted_tracked_range_is_a_classified_noop() { + let mut s = s(); + run(&mut s, "text.insert", json!({"text": "abc"})); + run(&mut s, "review.trackChanges", json!({"value": true})); + s.sel = crate::Selection { anchor: Pos::body(0, 0), focus: Pos::body(0, 1) }; + run(&mut s, "text.backspace", json!({})); + let revisions = s.doc.revisions.len(); + let revision = s.ime_state_revision().expect("an IME revision"); + s.sel = crate::Selection { anchor: Pos::body(0, 0), focus: Pos::body(0, 1) }; + run(&mut s, "text.backspace", json!({})); + let outcome = s.ime_edit_outcome(); + assert!(outcome.classified, "{outcome:?}"); + assert!(!outcome.content_changed && !outcome.state_changed, "{outcome:?}"); + assert_eq!(s.doc.revisions.len(), revisions, "no unused Delete revision is appended"); + assert_eq!(s.ime_state_revision(), Some(revision), "the no-op did not move the settled caret"); + s.sel = crate::Selection { anchor: Pos::body(0, 0), focus: Pos::body(0, 1) }; + assert!(s.ime_state_revision() > Some(revision), "re-selecting moves the caret, not the document"); +} + +/// `text.insert` classifies the automatic rewrites its own handler performs: smart quotes, +/// list autoformat and AutoCorrect are `transformed`, not the IME's literal insert. +#[test] +fn text_insert_marks_automatic_rewrites_as_transformed() { + let mut session = s(); + run(&mut session, "text.insert", json!({"text": "\""})); + assert!(session.ime_edit_outcome().transformed, "smart quote: {:?}", session.ime_edit_outcome()); + assert_eq!(text(&session), "\u{201C}"); + + let mut session = s(); + for c in ["W", "o", "r", "d", " ", "(", "c", ")", " "] { + run(&mut session, "text.insert", json!({"text": c})); + } + assert!(session.ime_edit_outcome().transformed, "AutoCorrect: {:?}", session.ime_edit_outcome()); + + let mut session = s(); + run(&mut session, "text.insert", json!({"text": "*"})); + run(&mut session, "text.insert", json!({"text": " "})); + assert!(session.ime_edit_outcome().transformed, "list AutoFormat: {:?}", session.ime_edit_outcome()); + + let mut session = s(); + run(&mut session, "text.insert", json!({"text": "A--b"})); + run(&mut session, "text.insert", json!({"text": " "})); + assert!(session.ime_edit_outcome().transformed, "dash AutoFormat: {:?}", session.ime_edit_outcome()); + assert_eq!(text(&session), "A\u{2014}b "); + + let mut session = s(); + run(&mut session, "tools.autocorrect", json!({"doubleSpacePeriod": true})); + for c in "Hello ".chars() { + run(&mut session, "text.insert", json!({"text": c.to_string()})); + } + let outcome = session.ime_edit_outcome(); + assert!(outcome.transformed && outcome.content_changed && outcome.state_changed, "double-space period: {outcome:?}"); + assert_eq!(text(&session), "Hello. "); + run(&mut session, "edit.undo", json!({})); + assert_eq!(text(&session), "Hello ", "undo restores the spaces in one step"); +} + +#[test] +fn style_set_keeps_imported_styles_latent_styles_and_defaults() { + use wordcraft_doc::props::{Rgb, TextColor}; + use wordcraft_doc::styles::{Style, StyleKind}; + for name in ["basic", "lines", "shaded", "casual", "centered", "minimalist", "title"] { + let mut s = s(); + let mut body = s.doc.styles.get("Normal").unwrap().clone(); + body.id = "BodyText".into(); + body.name = "Body Text".into(); + body.builtin = true; + body.based_on = Some("Normal".into()); + body.chr.size = Some(20.0); + body.chr.color = Some(TextColor::Rgb(Rgb(0xFF, 0, 0))); + let grid = Style { id: "GridTable4".into(), name: "Grid Table 4".into(), kind: StyleKind::Table, builtin: true, ..Default::default() }; + Arc::make_mut(&mut s.doc.styles).styles.push(body.clone()); + Arc::make_mut(&mut s.doc.styles).styles.push(grid.clone()); + Arc::make_mut(&mut s.doc.styles).latent_styles = Some("".into()); + Arc::make_mut(&mut s.doc.styles).default_chr.lang = Some("de-DE".into()); + Arc::make_mut(&mut s.doc.styles).default_chr.size = Some(11.0); + run(&mut s, "design.styleSet", json!({ "name": name })); + assert_eq!(s.doc.styles.get("BodyText"), Some(&body), "{name}: BodyText"); + assert_eq!(s.doc.styles.get("GridTable4"), Some(&grid), "{name}: GridTable4"); + assert_eq!(s.doc.styles.latent_styles.as_deref(), Some(""), "{name}"); + assert_eq!(s.doc.styles.default_chr.lang.as_deref(), Some("de-DE"), "{name}"); + assert_eq!(s.doc.styles.default_chr.size, Some(11.0), "{name}"); + assert_eq!(s.doc.styles.styles.iter().filter(|x| x.id == "Heading1").count(), 1, "{name}"); + let saved = crate::io::save_bytes("x.docx", &s.doc).unwrap(); + let back = crate::io::open_bytes("x.docx", &saved).unwrap(); + assert!(back.styles.get("BodyText").is_some(), "{name}: BodyText after round trip"); + } + let mut s = s(); + run(&mut s, "design.styleSet", json!({ "name": "title" })); + assert_eq!(s.doc.styles.get("Title").unwrap().chr.size, Some(36.0)); +} + +fn style_size(s: &Session, id: &str) -> f32 { + s.doc.styles.resolve_char(Some(id), &Default::default()).size +} + +#[test] +fn scale_font_sizes_adds_points_to_every_style() { + let mut s = Session::new(crate::sample::sample_document()); + let ids: Vec = s.doc.styles.styles.iter().map(|x| x.id.clone()).collect(); + let before: Vec = ids.iter().map(|i| style_size(&s, i)).collect(); + let base = s.doc.styles.resolve_char(None, &Default::default()).size; + let runs = serde_json::to_string(&s.doc.body).unwrap(); + run(&mut s, "design.scaleFontSizes", json!({"delta": 2})); + // Every style (explicit or inherited) grows by exactly 2, once. + for (i, b) in ids.iter().zip(&before) { + assert_eq!(style_size(&s, i), b + 2.0, "{i}"); + } + assert_eq!(s.doc.styles.resolve_char(None, &Default::default()).size, base + 2.0); + assert_eq!(serde_json::to_string(&s.doc.body).unwrap(), runs, "runs untouched"); + run(&mut s, "edit.undo", json!({})); + for (i, b) in ids.iter().zip(&before) { + assert_eq!(style_size(&s, i), *b, "undo {i}"); + } +} + +#[test] +fn scale_font_sizes_percent_and_clamp_and_errors() { + let mut s = Session::new(crate::sample::sample_document()); + let n = style_size(&s, "Normal"); + run(&mut s, "design.scaleFontSizes", json!({"percent": 10})); + assert_eq!(style_size(&s, "Normal"), (n * 1.1 * 2.0).round() / 2.0); + run(&mut s, "design.scaleFontSizes", json!({"delta": -500})); + assert_eq!(style_size(&s, "Normal"), 1.0); + for bad in [ + json!({}), + json!({"delta": 1e9}), + json!({"percent": -100}), + json!({"percent": 1e9}), + json!({"delta": "x"}), + json!({"delta": 0}), + json!({"percent": 0}), + json!({"delta": 1, "percent": 10}), + json!({"delta": 2, "percent": "x"}), + json!({"percent": 10, "delta": null}), + ] { + assert!(s.run("design.scaleFontSizes", &bad).is_err(), "{bad}"); + } +} + +#[test] +fn scale_font_sizes_reaches_char_table_and_cs_sizes_and_unset_default() { + use wordcraft_doc::props::CharProps; + use wordcraft_doc::{Style, StyleKind, styles::TablePart}; + let mut s = s(); + Arc::make_mut(&mut s.doc.styles).default_chr.size = None; + Arc::make_mut(&mut s.doc.styles).default_chr.size_cs = Some(10.0); + let mut ch = Style { id: "Big".into(), name: "Big".into(), kind: StyleKind::Character, ..Default::default() }; + ch.chr.size = Some(20.0); + ch.chr.size_cs = Some(22.0); + let mut tb = Style { id: "Grid".into(), name: "Grid".into(), kind: StyleKind::Table, ..Default::default() }; + let mut parts = wordcraft_doc::styles::TableStyleParts::default(); + parts.header_chr.size = Some(14.0); + parts.regions.entry(TablePart::Band2Horz).or_default().chr.size = Some(8.0); + tb.table = Some(parts); + Arc::make_mut(&mut s.doc.styles).styles.extend([ch, tb]); + run(&mut s, "design.scaleFontSizes", json!({"delta": 2})); + let st = &s.doc.styles; + assert_eq!(st.default_chr.size, Some(13.0), "unset default is 11 pt, made explicit"); + assert_eq!(st.default_chr.size_cs, Some(12.0)); + let big = CharProps { style: Some("Big".into()), ..Default::default() }; + assert_eq!(st.resolve_char(None, &big).size, 22.0); + assert_eq!(st.resolve_char(None, &big).size_cs, 24.0); + let grid = st.styles.iter().find(|x| x.id == "Grid").and_then(|x| x.table.as_ref()).unwrap(); + assert_eq!(grid.header_chr.size, Some(16.0)); + assert_eq!(grid.regions.get(&TablePart::Band2Horz).unwrap().chr.size, Some(10.0)); +} + +/// #3676: a 1-based page parameter becomes a 0-based index without wrapping. +/// On 32-bit and wasm the old cast truncated 2^32 to 0 and `0 - 1` panicked. +#[test] +fn page_index_saturates_instead_of_wrapping_to_page_one() { + assert_eq!(crate::p::page_index(0), 0); + assert_eq!(crate::p::page_index(1), 0); + assert_eq!(crate::p::page_index(2), 1); + assert_ne!(crate::p::page_index(1u64 << 32), 0, "2^32 must not wrap to page 1"); + assert_eq!(crate::p::page_index(u64::MAX), usize::MAX - 1); +} + +/// #3676: absolute page numbers that cannot be an index are rejected, not silently +/// turned into page 1. `edit.goto` clamps to the last page, as it does past the end. +#[test] +fn hostile_absolute_pages_never_land_on_page_one() { + let mut s = 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": 1u64 << 32})); + assert_eq!(page(&mut s), 5, "an out-of-range page lands on the last page"); + run(&mut s, "edit.goto", json!({"page": u64::MAX})); + assert_eq!(page(&mut s), 5, "u64::MAX lands on the last page"); + run(&mut s, "edit.goto", json!({"page": 0})); + assert_eq!(page(&mut s), 1, "page 0 is the first page"); +} + +/// #3676: `caret.set` takes a 0-based page; an oversized page is rejected instead of +/// truncating to page 0 on 32-bit and wasm. +#[test] +fn caret_set_rejects_a_page_number_that_does_not_fit() { + let mut s = s(); + run(&mut s, "text.insert", json!({"text": "Hello"})); + let r = s.run("caret.set", &json!({"page": 1u64 << 32, "x": 100.0, "y": 100.0})); + assert!(matches!(r, Err(crate::CmdError::Params(_))), "page 2^32: {r:?}"); +} + +/// #3676: `file.exportPng` rejects a page number that cannot be an index instead of +/// exporting page 1 (the 32-bit truncation) and writing it to the requested path. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn export_png_rejects_a_page_number_that_does_not_fit() { + let dir = scratch_dir("export-png-hostile-page"); + let mut s = s(); + run(&mut s, "text.insert", json!({"text": "Hello"})); + let target = dir.join("page.png"); + let r = s.run("file.exportPng", &json!({"path": target.to_string_lossy(), "page": 1u64 << 32})); + assert!(matches!(r, Err(crate::CmdError::Params(_))), "page 2^32: {r:?}"); + assert!(!target.exists(), "no file is written for an invalid page"); + let r = s.run("file.exportPng", &json!({"path": target.to_string_lossy(), "page": u64::MAX})); + assert!(matches!(r, Err(crate::CmdError::Params(_))), "page u64::MAX: {r:?}"); + assert!(!target.exists(), "no file is written for an invalid page"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn screen_paper_color_is_a_persistent_preference_not_document_formatting() { + use crate::cmd::view::PaperColor; + let mut s = s(); + run(&mut s, "text.insert", json!({"text": "Screen paper test", "raw": true})); + let before = serde_json::to_value(&s.doc).unwrap(); + let docx_before = wordcraft_docx::write(&s.doc).unwrap(); + s.dirty = false; + let dirty = s.dirty; + s.view.dark_mode = true; + for (value, expected) in + [("green", PaperColor::Green), ("cream", PaperColor::Cream), ("gray", PaperColor::Gray), ("document", PaperColor::Document)] + { + run(&mut s, "view.paperColor", json!({"value": value})); + assert_eq!(s.prefs.paper_color, expected); + assert_eq!(serde_json::to_value(&s.doc).unwrap(), before); + assert_eq!(s.dirty, dirty); + assert_eq!(wordcraft_docx::write(&s.doc).unwrap(), docx_before); + assert!(!s.view.dark_mode, "choosing a tint turns off dark paper"); + let saved = serde_json::to_value(&s.prefs).unwrap(); + let restored: crate::Prefs = serde_json::from_value(saved).unwrap(); + assert_eq!(restored.paper_color, expected); + } + for v in [json!({}), json!({"value": "red"}), json!({"value": 42})] { + assert!(s.run("view.paperColor", &v).is_err()); + assert_eq!(s.prefs.paper_color, PaperColor::Document); + } + let legacy: crate::Prefs = serde_json::from_value(json!({})).unwrap(); + assert_eq!(legacy.paper_color, PaperColor::Document); + let future: crate::Prefs = serde_json::from_value(json!({"paperColor":"future", "countNotes":false})).unwrap(); + assert_eq!(future.paper_color, PaperColor::Document); + assert!(!future.count_notes); +} + +/// Font › Emphasis mark puts a mark over (or under) each selected character, and the layout +/// draws one per character; Format state reports it, and none takes it off. +#[test] +fn emphasis_marks_are_set_and_drawn() { + use wordcraft_doc::props::EmphasisMark; + let mut s = Session::new(wordcraft_doc::Document::from_text("強調する")); + run(&mut s, "select.all", json!({})); + run(&mut s, "format.emphasisMark", json!({"mark": "circle"})); + let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); + assert_eq!(p.props_of_char(0).emphasis, Some(EmphasisMark::Circle)); + assert_eq!(run(&mut s, "format.state", json!({}))["emphasisMark"], "circle"); + // One mark per character: hollow circles, drawn as shapes. + let draws = s.layout().pages.first().map(|pg| wordcraft_layout::display::page_display(&s.doc, pg, &Default::default())).unwrap_or_default(); + let marks = draws + .iter() + .filter(|d| matches!(d, wordcraft_layout::display::Draw::Shape { kind: wordcraft_doc::para::ShapeKind::Ellipse, fill: None, .. })) + .count(); + assert_eq!(marks, 4); + run(&mut s, "format.emphasisMark", json!({"mark": "none"})); + assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().props_of_char(0).emphasis, Some(EmphasisMark::None)); + assert!(s.run("format.emphasisMark", &json!({"mark": "sparkles"})).is_err()); +} + +/// Preferences saved while OpenDocument files had their own settings still read, without them, +/// as does a document saved with what those settings kept for its layout. +#[test] +fn what_the_opendocument_settings_saved_still_reads() { + let prefs: crate::Prefs = serde_json::from_value(json!({"countNotes": false, "odf": {"preset": "libreoffice", "columnGap": 0.0}})).unwrap(); + assert!(!prefs.count_notes); + let layout: wordcraft_doc::OdfLayout = serde_json::from_value(json!({"pictureLinesFitPictures": true, "tabsFromIndent": true})).unwrap(); + assert!(layout.tabs_from_indent); +} + +#[test] +fn an_odt_charts_paragraph_is_written_as_read_after_drawing_it() { + // Opening an ODT draws its charts (from their data); that changes nothing in the document, + // so the paragraph showing one is still written as read. + let mut s = Session::new(wordcraft_doc::Document::new()); + run(&mut s, "insert.chart", json!({"type": "column"})); + let first = wordcraft_formats::odt::export(&s.doc).unwrap(); + let doc = crate::io::open_bytes("t.odt", &first).unwrap(); + let mut z = zip::ZipArchive::new(std::io::Cursor::new(wordcraft_formats::odt::export(&doc).unwrap())).unwrap(); + let mut content = String::new(); + std::io::Read::read_to_string(&mut z.by_name("content.xml").unwrap(), &mut content).unwrap(); + assert!(content.contains(r#"draw:name="Chart1""#) && !content.contains("wcChart"), "{content}"); +} + +/// A picture an OpenDocument file makes wider than the text (a rule across the whole page) stays +/// that wide, and its paragraph is written as read: opening a file changes nothing in it. +#[test] +fn an_odts_picture_wider_than_the_text_keeps_its_size() { + 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:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0" xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0" xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0" xmlns:xlink="http://www.w3.org/1999/xlink""#; + let styles = format!( + r#""# + ); + let content = format!( + r#"Text"# + ); + let png = [b"\x89PNG\r\n\x1a\n".as_slice(), &[7u8; 64]].concat(); + 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", b"application/vnd.oasis.opendocument.text".as_slice()), + ("content.xml", content.as_bytes()), + ("styles.xml", styles.as_bytes()), + ("Pictures/r.png", &png), + ] { + z.start_file(name, o).unwrap(); + std::io::Write::write_all(&mut z, data).unwrap(); + } + let doc = crate::io::open_bytes("t.odt", &z.finish().unwrap().into_inner()).unwrap(); + let width = doc.body[0] + .as_para() + .unwrap() + .objects + .iter() + .find_map(|o| if let wordcraft_doc::para::InlineObject::Image { w, .. } = o { Some(*w) } else { None }); + let want = 18.0 * 72.0 / 2.54; + assert!(width.is_some_and(|w| (w - want).abs() < 0.1), "{width:?}, not fitted to the text"); + let mut z = zip::ZipArchive::new(std::io::Cursor::new(wordcraft_formats::odt::export(&doc).unwrap())).unwrap(); + let mut saved = String::new(); + std::io::Read::read_to_string(&mut z.by_name("content.xml").unwrap(), &mut saved).unwrap(); + assert!(saved.contains(r#"draw:name="Rule""#) && saved.contains(r#"svg:width="18cm""#), "written as read: {saved}"); +} + +/// Numbering or a new style makes the styles be written from the model on save: a document in +/// German stays in German, rather than taking the default language of what opens it next. +#[test] +fn an_odts_language_survives_a_save_after_numbering_or_a_new_style() { + let ns = r#"xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0""#; + let styles = format!( + r#""# + ); + let content = format!( + r#"Hallo"# + ); + 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()), ("styles.xml", styles.as_str())] + { + z.start_file(name, o).unwrap(); + std::io::Write::write_all(&mut z, data.as_bytes()).unwrap(); + } + let odt = z.finish().unwrap().into_inner(); + for (command, params) in [("para.numbering", json!({})), ("styles.create", json!({"name": "Notiz"}))] { + let mut s = Session::new(crate::io::open_bytes("t.odt", &odt).unwrap()); + assert_eq!(cmd::default_lang(&s).as_deref(), Some("de-DE"), "the document's, not English"); + run(&mut s, command, params); + let saved = wordcraft_formats::odt::export(&s.doc).unwrap(); + let back = crate::io::open_bytes("t.odt", &saved).unwrap(); + assert_eq!(back.styles.default_chr.lang.as_deref(), Some("de-DE"), "after {command}"); + } +} + +/// A Word package whose body holds `body` and, as `rIdC`, the chunk part `word/afchunk.{ext}`. +#[cfg(not(target_arch = "wasm32"))] +fn docx_with_chunk(body: &str, ext: &str, content_type: &str, chunk: &[u8]) -> Vec { + use std::io::Write; + let files: [(String, Vec); 4] = [ + ( + "[Content_Types].xml".into(), + format!(r#""#).into_bytes(), + ), + ("_rels/.rels".into(), br#""#.to_vec()), + ( + "word/_rels/document.xml.rels".into(), + format!(r#""#).into_bytes(), + ), + ( + "word/document.xml".into(), + format!(r#"{body}"#).into_bytes(), + ), + ]; + let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + for (name, bytes) in files.iter().map(|(n, b)| (n.as_str(), b.as_slice())).chain([(format!("word/afchunk.{ext}").as_str(), chunk)]) { + zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap(); + zw.write_all(bytes).unwrap(); + } + zw.finish().unwrap().into_inner() +} + +/// A Single File Web Page under a Word or RTF name opens as one, like the web pages of #2853. +#[test] +fn a_single_file_web_page_saved_as_doc_opens_by_content() { + let mht = "MIME-Version: 1.0\r\nContent-Type: multipart/related; boundary=\"B\"\r\n\r\n--B\r\nContent-Type: text/html; charset=\"utf-8\"\r\n\r\n

Izvje\u{161}taj

\r\n--B--\r\n"; + for name in ["report.doc", "report.docx", "report.rtf"] { + let doc = crate::io::open_bytes(name, mht.as_bytes()).unwrap_or_else(|e| panic!("{name}: {e}")); + assert_eq!(doc.plain_text(StoryRef::Body), "Izvje\u{161}taj", "{name}"); + } +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn alt_chunks_open_in_place_as_word_brings_them_in() { + let png = wordcraft_formats::model::base64_encode(&tiny_png()); + let mht = format!( + "MIME-Version: 1.0\r\nContent-Type: multipart/related; boundary=\"B\"\r\n\r\n--B\r\nContent-Location: file:///C:/doc.htm\r\n\ + Content-Transfer-Encoding: quoted-printable\r\nContent-Type: text/html; charset=\"utf-8\"\r\n\r\n\ +

=C4=8Clanak 1.

\r\n\ + --B\r\nContent-Location: file:///C:/doc_files/a.png\r\nContent-Transfer-Encoding: base64\r\nContent-Type: image/png\r\n\r\n{png}\r\n--B--\r\n" + ); + let p = |t: &str| format!(r#"{t}"#); + let chunk = r#""#; + let bytes = docx_with_chunk(&format!("{}{chunk}{}", p("Before"), p("After")), "mht", "message/rfc822", mht.as_bytes()); + let doc = crate::io::open_bytes("chunk.docx", &bytes).unwrap(); + let text = doc.plain_text(StoryRef::Body); + assert!(text.starts_with("Before\nČlanak 1.\n") && text.ends_with("After"), "{text:?}"); + let para = doc.para(StoryRef::Body, &wordcraft_doc::Path::top(1)).unwrap(); + assert_eq!(para.props.align, Some(wordcraft_doc::Align::Center)); + // Web page text: Times New Roman unless the page says otherwise. + assert_eq!(para.props_of_char(0).font.as_deref(), Some("Times New Roman")); + // The picture came along, its bytes under the key it shows. + let key = doc + .body + .iter() + .find_map(|b| match &**b { + wordcraft_doc::Block::Para(p) => p.objects.iter().find_map(|o| match o { + wordcraft_doc::InlineObject::Image { media, .. } => Some(media.clone()), + _ => None, + }), + _ => None, + }) + .expect("the chunk's picture"); + assert_eq!(doc.media.get(&key).map(|m| m.to_vec()), Some(tiny_png())); + + // HTML (in its content type's charset), RTF and plain-text chunks come in through their + // importers too, and an MHT chunk is known by what it holds. + for (ext, ct, data, want) in [ + ("htm", "text/html; charset=windows-1250", &b"

\xc8lanak chunk

"[..], "Članak chunk"), + ("rtf", "application/rtf", br"{\rtf1\ansi Rich chunk\par}", "Rich chunk"), + ("txt", "text/plain", b"Plain chunk", "Plain chunk"), + ("bin", "", mht.as_bytes(), "Članak 1."), + ] { + let bytes = docx_with_chunk(&format!("{chunk}{}", p("End")), ext, ct, data); + let text = crate::io::open_bytes("chunk.docx", &bytes).unwrap().plain_text(StoryRef::Body); + assert!(text.starts_with(want) && text.ends_with("End"), "{ext}: {text:?}"); + } +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn text_from_file_brings_the_files_pictures() { + let dir = scratch_dir("text-from-file-pictures"); + let mut src = Session::new(wordcraft_doc::Document::new()); + let list = Arc::make_mut(&mut src.doc.numbering).add_list(wordcraft_doc::numbering::ListKind::Bullet); + // A list item, then a paragraph (a single paragraph would take the caret's formatting). + src.doc.split_paragraph(&Pos::body(0, 0)).unwrap(); + src.doc.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().props.numbering = + Some(wordcraft_doc::props::NumRef { num: list, level: 0 }); + let key = src.doc.add_media(tiny_png(), "png"); + let image = wordcraft_doc::InlineObject::Image { + media: key, + w: 20.0, + h: 20.0, + alt: String::new(), + float: Default::default(), + crop: [0.0; 4], + ole: None, + fx: Default::default(), + }; + src.doc.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().insert_object(0, image, &Default::default()).unwrap(); + let path = dir.join("pictures.docx"); + std::fs::write(&path, wordcraft_docx::write(&src.doc).unwrap()).unwrap(); + + let mut s = s(); + // A picture and a list of our own first, so the file's key and list id would point at ours. + let ours = s.doc.add_media(vec![9, 9, 9], "png"); + let our_list = Arc::make_mut(&mut s.doc.numbering).add_list(wordcraft_doc::numbering::ListKind::Numbered); + run(&mut s, "insert.textFromFile", json!({"path": path.to_string_lossy()})); + let _ = std::fs::remove_dir_all(&dir); + let shown: Vec = s + .doc + .body + .iter() + .filter_map(|b| match &**b { + wordcraft_doc::Block::Para(p) => p.objects.iter().find_map(|o| match o { + wordcraft_doc::InlineObject::Image { media, .. } => Some(media.clone()), + _ => None, + }), + _ => None, + }) + .collect(); + assert_eq!(shown.len(), 1); + assert_ne!(shown[0], ours); + assert_eq!(s.doc.media.get(&shown[0]).map(|m| m.to_vec()), Some(tiny_png())); + let listed: Vec = s + .doc + .body + .iter() + .filter_map(|b| match &**b { + wordcraft_doc::Block::Para(p) => p.props.numbering.map(|n| n.num), + _ => None, + }) + .collect(); + assert_eq!(listed.len(), 1); + assert_ne!(listed[0], our_list); + assert!(s.doc.numbering.level(listed[0], 0).is_some_and(|l| l.format == wordcraft_doc::section::NumFormat::Bullet)); +} + +#[test] +fn page_down_and_up_move_by_a_page_in_the_continuous_views() { + // Web Layout, Draft and Outline are one long page: Page Down/Up move as far as they do + // between the pages of Print Layout (#5290). + let after_page_down = |view: &str| { + let mut s = s(); + for i in 0..200 { + run(&mut s, "text.insert", json!({"text": format!("Paragraph {i}")})); + run(&mut s, "text.newParagraph", json!({})); + } + run(&mut s, view, json!({})); + run(&mut s, "caret.docStart", json!({})); + let start = s.sel.focus.clone(); + run(&mut s, "caret.pageDown", json!({})); + let down = s.sel.focus.clone(); + run(&mut s, "caret.pageUp", json!({})); + assert_eq!(s.sel.focus, start, "{view}: Page Up goes back"); + run(&mut s, "caret.docEnd", json!({})); + let end = s.sel.focus.clone(); + run(&mut s, "caret.pageDown", json!({})); + assert_eq!(s.sel.focus, end, "{view}: Page Down stops at the end"); + down + }; + let print = after_page_down("view.printLayout"); + assert!(print.path.0[0] > 10, "Print Layout: Page Down goes to the next page: {print:?}"); + for view in ["view.draft", "view.outline", "view.webLayout"] { + assert_eq!(after_page_down(view), print, "{view}"); + } +} + +#[test] +fn page_down_and_up_never_stay_on_a_line_in_the_continuous_views() { + // A page with less than an inch of text area: the views move by the one-inch minimum. The + // caret ends a long line and the paragraphs after it are narrow (a large right indent): the + // line a page away, not the one the caret is on, is where Page Down goes, however far right + // of it the caret is. + for view in ["view.draft", "view.outline", "view.webLayout"] { + let mut s = s(); + run(&mut s, "layout.margins", json!({"top": 370, "bottom": 370})); + let long = "Lorem ipsum dolor sit amet ".repeat(4); + run(&mut s, "text.insert", json!({"text": long})); + run(&mut s, "text.newParagraph", json!({})); + run(&mut s, "para.indents", json!({"right": 700})); + for i in 0..60 { + if i > 0 { + run(&mut s, "text.newParagraph", json!({})); + } + run(&mut s, "text.insert", json!({"text": "a"})); + } + run(&mut s, view, json!({})); + run(&mut s, "caret.docStart", json!({})); + run(&mut s, "caret.end", json!({})); + assert_eq!(s.sel.focus.off, long.chars().count(), "{view}: the first paragraph is one line"); + let mut at = s.sel.focus.clone(); + for step in 0..3 { + run(&mut s, "caret.pageDown", json!({})); + assert!(s.sel.focus > at, "{view}: Page Down {step} moves on from {at:?}"); + at = s.sel.focus.clone(); + } + for step in 0..3 { + run(&mut s, "caret.pageUp", json!({})); + assert!(s.sel.focus < at, "{view}: Page Up {step} moves back from {at:?}"); + at = s.sel.focus.clone(); + } + } +} + +#[test] +fn page_down_extends_a_selection_that_ends_in_collapsed_outline_text() { + // Selecting a collapsed heading and its content ends the selection in hidden text; Page Down + // still extends it from where it shows (the heading), keeping its anchor. + let mut s = s(); + run(&mut s, "text.insert", json!({"text": "First"})); + for i in 0..5 { + run(&mut s, "text.newParagraph", json!({})); + run(&mut s, "text.insert", json!({"text": format!("Hidden {i}")})); + } + run(&mut s, "text.newParagraph", json!({})); + run(&mut s, "text.insert", json!({"text": "Second"})); + for i in 0..200 { + run(&mut s, "text.newParagraph", json!({})); + run(&mut s, "text.insert", json!({"text": format!("Shown {i}")})); + } + run(&mut s, "view.outline", json!({})); + for block in [0, 6] { + run(&mut s, "caret.set", json!({"pos": {"block": block}})); + run(&mut s, "outline.promoteToHeading1", json!({})); + } + run(&mut s, "outline.collapse", json!({"block": 0})); + run(&mut s, "outline.selectSubtree", json!({"block": 0})); + let anchor = s.sel.anchor.clone(); + assert_eq!(s.sel.focus.path.0, [5], "the selection ends in the last hidden paragraph"); + assert!(s.layout().caret(&s.sel.focus).is_none(), "the outline doesn't show its end"); + run(&mut s, "caret.pageDown", json!({"extend": true})); + assert_eq!(s.sel.anchor, anchor, "the anchor stays"); + assert!(s.sel.focus.path.0[0] > 6, "Page Down extends the selection a page on: {:?}", s.sel.focus); + assert!(s.layout().caret(&s.sel.focus).is_some(), "to a place the outline shows"); +} + +/// A picture an OpenDocument file gives no size is shown at its own, fitted to the text as other +/// formats' are; one with a stated size keeps it, in line too. +#[test] +fn an_odts_picture_is_fitted_only_when_it_states_no_size() { + let ns = r#"xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0" xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0" xmlns:xlink="http://www.w3.org/1999/xlink""#; + let styles = format!( + r#""# + ); + let mut png = std::io::Cursor::new(Vec::new()); + image::RgbImage::new(1000, 500).write_to(&mut png, image::ImageFormat::Png).unwrap(); + let png = png.into_inner(); + let open = |attrs: &str| { + let content = format!( + r#""# + ); + 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", b"application/vnd.oasis.opendocument.text".as_slice()), + ("content.xml", content.as_bytes()), + ("styles.xml", styles.as_bytes()), + ("Pictures/a.png", &png), + ] { + z.start_file(name, o).unwrap(); + std::io::Write::write_all(&mut z, data).unwrap(); + } + let doc = crate::io::open_bytes("t.odt", &z.finish().unwrap().into_inner()).unwrap(); + doc.body[0] + .as_para() + .unwrap() + .objects + .iter() + .find_map(|o| if let wordcraft_doc::para::InlineObject::Image { w, h, .. } = o { Some((*w, *h)) } else { None }) + .unwrap() + }; + let (w, h) = open(""); + assert!((w - 306.14).abs() < 0.5 && (h - 153.07).abs() < 0.5, "fitted to the text: {w} {h}"); + let (w, _) = open(r#" svg:width="18cm" svg:height="9cm""#); + assert!((w - 18.0 * 72.0 / 2.54).abs() < 0.5, "as the file says: {w}"); +} + +/// Issue #5667's document: `Intro`, then an inserted `Appendix` paragraph whose mark carries a +/// section break (Normal, indented), then `Body` in Heading 1. The whole of `Appendix` is +/// tracked as an insertion by `rev`, or as a deletion when `delete`. +fn sectioned_revision(delete: bool) -> Session { + use wordcraft_doc::{Revision, RevisionKind}; + let mut s = Session::new(wordcraft_doc::Document::from_text("Intro\nAppendix\nBody")); + run(&mut s, "caret.set", json!({"pos": {"block": 2, "off": 0}})); + run(&mut s, "para.style", json!({"style": "Heading1"})); + let kind = if delete { RevisionKind::Delete } else { RevisionKind::Insert }; + s.doc.revisions.push(Revision { kind, author: "Ada".into(), date: String::new(), move_name: None }); + s.doc.last_section.margin_left = 72.0; + let mut sect = s.doc.last_section.clone(); + sect.margin_left = 144.0; + let p = s.doc.para_mut(StoryRef::Body, &wordcraft_doc::Path(vec![1])).unwrap(); + let len = p.len(); + p.format(0, len, &|c| if delete { c.del = Some(0) } else { c.ins = Some(0) }).unwrap(); + if delete { + p.mark.del = Some(0) + } else { + p.mark.ins = Some(0) + } + p.props.indent_left = Some(36.0); + p.section = Some(Box::new(sect)); + s +} + +#[test] +fn resolving_away_an_empty_paragraph_with_a_section_break_keeps_the_next_paragraphs_properties() { + // #5667: the resolved paragraph was left empty but kept for its section break, so the next + // paragraph joined it and took its properties (Normal, its indent). It goes whole instead, + // as an empty paragraph without a break does, and its section joins the following one. + for (delete, cmd) in [(false, "review.rejectAll"), (true, "review.acceptAll")] { + let mut s = sectioned_revision(delete); + assert_eq!(s.doc.sections().len(), 2); + run(&mut s, cmd, json!({})); + assert_eq!(body_texts(&s), ["Intro", "Body"], "{cmd}"); + let body = s.doc.para(StoryRef::Body, &wordcraft_doc::Path(vec![1])).unwrap(); + assert_eq!(body.props.style.as_deref(), Some("Heading1"), "{cmd}: Body keeps its style"); + assert_eq!(body.props.indent_left, None, "{cmd}: and not the removed paragraph's indent"); + assert!(s.doc.body.iter().filter_map(|b| b.as_para()).all(|p| p.section.is_none()), "{cmd}: the break went with it"); + assert_eq!(s.doc.sections().len(), 1, "{cmd}"); + assert_eq!(s.doc.last_section.margin_left, 72.0, "{cmd}: the following section's setup stays"); + } +} diff --git a/crates/engine/src/tests/bidi.rs b/crates/engine/src/tests/bidi.rs index 3c234ec09a..61ff1977db 100644 --- a/crates/engine/src/tests/bidi.rs +++ b/crates/engine/src/tests/bidi.rs @@ -1,325 +1,325 @@ -//! Right-to-left editing commands: paragraph direction, alignment as seen on the page, font -//! commands on Persian text, arrow keys in bidirectional text, DOCX save and open. - -use serde_json::json; -use std::sync::Arc; -use wordcraft_doc::props::Align; -use wordcraft_doc::{Pos, StoryRef}; - -use crate::Session; - -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}")) -} - -/// A session with `text` (paragraphs split on `\n`), every paragraph right to left when `rtl`. -fn session(text: &str, rtl: bool) -> Session { - let mut s = Session::new(wordcraft_doc::Document::new()); - run(&mut s, "document.setText", json!({"text": text})); - if rtl { - run(&mut s, "select.all", json!({})); - run(&mut s, "para.rtl", json!({})); - run(&mut s, "caret.docStart", json!({})); - } - s -} - -fn props(s: &Session, i: usize) -> wordcraft_doc::props::ParaProps { - s.doc.para(StoryRef::Body, &wordcraft_doc::Path::top(i)).unwrap().props.clone() -} - -fn caret_x(s: &mut Session) -> f32 { - let l = s.layout(); - l.caret(&s.sel.focus).unwrap().x -} - -#[test] -fn direction_commands_change_only_the_reading_order() { - let mut s = session("سلام دنیا\nHello", false); - run(&mut s, "para.alignCenter", json!({})); - run(&mut s, "para.rtl", json!({})); - assert_eq!(props(&s, 0).bidi, Some(true)); - assert_eq!(props(&s, 0).align, Some(Align::Center), "direction doesn't touch alignment"); - // Left-to-right again, and `value: false` means the other direction. - run(&mut s, "para.ltr", json!({})); - assert_eq!(props(&s, 0).bidi, Some(false)); - run(&mut s, "para.ltr", json!({"value": false})); - assert_eq!(props(&s, 0).bidi, Some(true)); - // A new right-to-left paragraph starts at the right margin (start-aligned). - let mut s = session("متن", true); - assert!((caret_x(&mut s) - 540.0).abs() < 0.5, "caret at {}", caret_x(&mut s)); -} - -#[test] -fn alignment_buttons_mean_what_they_show_in_rtl_paragraphs() { - let mut s = session("متن فارسی", true); - // Align Left in a right-to-left paragraph moves text to the left: its end edge. - run(&mut s, "para.alignLeft", json!({})); - assert_eq!(props(&s, 0).align, Some(Align::Right)); - let x_end = s.layout().caret(&Pos::body(0, "متن فارسی".len())).unwrap().x; - assert!((x_end - 72.0).abs() < 0.5, "text ends at the left margin: {x_end}"); - // Align Right is the start edge; pressing it again keeps it there. - run(&mut s, "para.alignRight", json!({})); - assert_eq!(props(&s, 0).align, Some(Align::Left)); - run(&mut s, "para.alignRight", json!({})); - assert_eq!(props(&s, 0).align, Some(Align::Left)); - // Logical values for scripts. - run(&mut s, "para.align", json!({"value": "end"})); - assert_eq!(props(&s, 0).align, Some(Align::Right)); - run(&mut s, "para.align", json!({"value": "right"})); - assert_eq!(props(&s, 0).align, Some(Align::Left)); -} - -#[test] -fn para_set_on_page_follows_each_paragraphs_own_direction() { - // The Paragraph dialog's alignment and indents are as seen on the page (#1346): over a - // right-to-left and a left-to-right paragraph, Left is each one's own left side. - let mut s = session("سلام دنیا\nHello", false); - run(&mut s, "caret.docStart", json!({})); - run(&mut s, "para.rtl", json!({})); - run(&mut s, "select.all", json!({})); - let depth = s.undo_depth(); - run(&mut s, "para.set", json!({"props": {"spaceAfter": 6}, "onPage": {"align": "left", "left": 36}})); - assert_eq!(s.undo_depth(), depth + 1, "one undo step"); - let (rtl, ltr) = (props(&s, 0), props(&s, 1)); - assert_eq!((rtl.align, rtl.indent_right, rtl.indent_left, rtl.space_after), (Some(Align::Right), Some(36.0), None, Some(6.0))); - assert_eq!((ltr.align, ltr.indent_left, ltr.indent_right, ltr.space_after), (Some(Align::Left), Some(36.0), None, Some(6.0))); - // A direction in `props` comes first: both are right to left now, so Right is their start. - run(&mut s, "para.set", json!({"props": {"bidi": true}, "onPage": {"right": 18}})); - assert_eq!((props(&s, 1).bidi, props(&s, 1).indent_left, props(&s, 1).indent_right), (Some(true), Some(18.0), None)); - assert!(s.run("para.set", &json!({"props": {}, "onPage": {"align": "middle"}})).is_err()); - // Undo takes each command back whole. - run(&mut s, "edit.undo", json!({})); - run(&mut s, "edit.undo", json!({})); - assert_eq!((props(&s, 0).align, props(&s, 0).indent_right, props(&s, 1).indent_left), (None, None, None)); -} - -#[test] -fn font_commands_reach_persian_text() { - let mut s = session("کتاب خوب", true); - run(&mut s, "select.all", json!({})); - run(&mut s, "format.bold", json!({})); - run(&mut s, "format.size", json!({"size": 18})); - run(&mut s, "format.font", json!({"name": "Vazirmatn"})); - let p = s.doc.para(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap(); - let c = p.props_of_char(0).clone(); - let rc = s.doc.styles.resolve_char(None, &c).complex(); - assert_eq!((rc.font.as_str(), rc.size, rc.bold), ("Vazirmatn", 18.0, true), "the complex-script formatting follows"); - // Saved and reopened, Word sees bold and size for the Persian text too. - let bytes = crate::io::save_bytes("x.docx", &s.doc).unwrap(); - let d = crate::io::open_bytes("x.docx", &bytes).unwrap(); - let c = d.para(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().props_of_char(0).clone(); - let rc = d.styles.resolve_char(None, &c).complex(); - assert_eq!((rc.font.as_str(), rc.size, rc.bold), ("Vazirmatn", 18.0, true)); - assert_eq!(d.para(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().props.bidi, Some(true)); -} - -#[test] -fn font_commands_override_inherited_complex_script_properties_explicitly() { - use wordcraft_doc::props::CharProps; - - let mut s = session("שלום ABC", true); - Arc::make_mut(&mut s.doc.styles).default_chr.size_cs = Some(30.0); - Arc::make_mut(&mut s.doc.styles).default_chr.bold_cs = Some(true); - Arc::make_mut(&mut s.doc.styles).default_chr.italic_cs = Some(true); - run(&mut s, "select.all", json!({})); - run(&mut s, "format.bold", json!({"value": false})); - run(&mut s, "format.italic", json!({"value": false})); - run(&mut s, "format.size", json!({"size": 18})); - for (command, expected_size) in [ - (None, 18.0), - (Some("format.growFont"), 20.0), - (Some("format.shrinkFont"), 18.0), - (Some("format.growFont1"), 19.0), - (Some("format.shrinkFont1"), 18.0), - ] { - if let Some(command) = command { - run(&mut s, command, json!({})); - } - let p = s.doc.para(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap(); - let c = p.props_of_char(0); - assert_eq!((c.size_cs, c.bold_cs, c.italic_cs), (Some(expected_size), Some(false), Some(false))); - let d = crate::io::open_bytes("x.docx", &crate::io::save_bytes("x.docx", &s.doc).unwrap()).unwrap(); - let c = d.para(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().props_of_char(0); - let cs = d.styles.resolve_char(None, c).complex(); - assert_eq!((cs.size, cs.bold, cs.italic), (expected_size, false, false)); - } - // The font dialog's command keeps formatting both scripts when given one value. - run(&mut s, "format.set", json!({"props": {"size": 10, "bold": true, "italic": true}})); - let p = s.doc.para(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap(); - let c: &CharProps = p.props_of_char(0); - assert_eq!((c.size_cs, c.bold_cs, c.italic_cs), (Some(10.0), Some(true), Some(true))); - // Callers can still supply distinct values explicitly, including false. - run(&mut s, "format.set", json!({"props": {"size": 12, "sizeCs": 18, "bold": true, "boldCs": false, "italic": true, "italicCs": false}})); - let p = s.doc.para(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap(); - let c = p.props_of_char(0); - assert_eq!((c.size, c.size_cs, c.bold_cs, c.italic_cs), (Some(12.0), Some(18.0), Some(false), Some(false))); -} - -#[test] -fn arrow_keys_move_visually_in_rtl_text() { - let fa = "سلام دنیا"; - let mut s = session(fa, true); - // From the start (the right edge), Left moves forward through the text. - let x0 = caret_x(&mut s); - run(&mut s, "caret.left", json!({})); - assert!(s.sel.focus.off > 0, "Left moves to the next character in right-to-left text"); - assert!(caret_x(&mut s) < x0, "and the caret moves left on the screen"); - run(&mut s, "caret.right", json!({})); - assert_eq!(s.sel.focus.off, 0); - // Right at the start edge goes nowhere in the first paragraph (no previous one). - run(&mut s, "caret.right", json!({})); - assert_eq!(s.sel.focus.off, 0); - // Left all the way reaches the end, then the next paragraph. - let mut s = session(&format!("{fa}\nبعدی"), true); - // (Stepping until the paragraph changes: the لا ligature is one caret position for two letters.) - let mut steps = 0; - while s.sel.focus.path.last() == 0 && steps < 40 { - run(&mut s, "caret.left", json!({})); - steps += 1; - } - assert!(steps <= fa.chars().count() + 1, "{steps} steps"); - assert_eq!(s.sel.focus.path.last(), 1, "past the left end of the line: the next paragraph"); - assert_eq!(s.sel.focus.off, 0, "at its start (right edge)"); - // Shift+Left selects forward in logical order. - run(&mut s, "caret.docStart", json!({})); - run(&mut s, "caret.left", json!({"extend": true})); - let (a, b) = s.sel.ordered(); - assert_eq!((a.off, b.off > 0), (0, true)); - // Left with a selection collapses to its left edge: the logical end. - run(&mut s, "caret.left", json!({})); - assert_eq!(s.sel.focus.off, b.off); -} - -#[test] -fn arrow_keys_in_mixed_text_visit_each_position() { - // A left-to-right paragraph with a Persian word: Right walks across the screen. - let t = "ab سلام cd"; - let mut s = session(t, false); - let mut xs = vec![caret_x(&mut s)]; - for _ in 0..30 { - let before = s.sel.focus.clone(); - run(&mut s, "caret.right", json!({})); - if s.sel.focus == before { - break; - } - xs.push(caret_x(&mut s)); - } - assert!(xs.windows(2).all(|w| w[1] > w[0]), "each Right moves right: {xs:?}"); - assert_eq!(s.sel.focus.off, t.len(), "ends at the end of the paragraph"); -} - -#[test] -fn word_moves_follow_reading_order() { - let mut s = session("یک دو سه", true); - run(&mut s, "caret.wordLeft", json!({})); - assert!(s.sel.focus.off > 0, "Ctrl+Left goes to the next word in right-to-left text"); - run(&mut s, "caret.wordRight", json!({})); - assert_eq!(s.sel.focus.off, 0); -} - -#[test] -fn typing_persian_keeps_logical_order() { - let mut s = session("", true); - for w in ["سلام", " ", "WordCraft", " ", "۱۴۰۳"] { - run(&mut s, "text.insert", json!({"text": w})); - } - assert_eq!(s.doc.plain_text(StoryRef::Body), "سلام WordCraft ۱۴۰۳"); - // The caret after typing a number at the end sits right after it on the screen. - let l = s.layout(); - let c = l.caret(&s.sel.focus).unwrap(); - assert!(c.x < 540.0 && c.x > 72.0); - // select.text finds Persian text. - run(&mut s, "select.text", json!({"text": "سلام"})); - assert_eq!(s.selected_text(), "سلام"); -} - -#[test] -fn explicit_alignment_sets_without_toggling() { - for rtl in [false, true] { - let mut s = session("متن", rtl); - for v in ["center", "center", "right", "right", "left", "left"] { - run(&mut s, "para.align", json!({"value": v})); - let shown = props(&s, 0).align.unwrap_or_default().visual(rtl); - let want = match v { - "center" => Align::Center, - "right" => Align::Right, - _ => Align::Left, - }; - assert_eq!(shown, want, "{v} twice stays {v} (rtl={rtl})"); - } - } -} - -#[test] -fn typing_kurdish_in_new_document_advances_caret_smoothly() { - let mut s = Session::new(wordcraft_doc::Document::new()); - let mut first_x = None; - let mut last_x = 0.0; - for ch in ["م", "ن", " ", "ن", "ا", "و", "م", " ", "ه", "ێ", "ڤ", "ا", "ر", "ه"] { - run(&mut s, "text.insert", json!({"text": ch})); - let l = s.layout(); - let c = l.caret(&s.sel.focus).unwrap(); - if first_x.is_none() { - first_x = Some(c.x); - } - last_x = c.x; - // The caret must not jump back to the left margin (72.0), but stay near the right margin (540.0). - assert!(c.x > 400.0, "caret should not be at left margin: {}", c.x); - } - assert!(last_x < first_x.unwrap(), "caret should advance leftwards overall"); - assert_eq!(props(&s, 0).bidi, Some(true), "paragraph auto-detected as right-to-left"); -} - -/// The runs of the first paragraph in a saved DOCX: (has `w:rtl`, text). -fn saved_runs(s: &Session) -> Vec<(bool, String)> { - use std::io::Read; - let bytes = crate::io::save_bytes("x.docx", &s.doc).unwrap(); - let mut xml = String::new(); - zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap().by_name("word/document.xml").unwrap().read_to_string(&mut xml).unwrap(); - let para = xml.split("").nth(1).unwrap().split("").next().unwrap().to_string(); - para.split("") - .skip(1) - .map(|r| { - let r = r.split("").next().unwrap(); - let text: String = r.split("").skip(1).filter_map(|t| t.split("").next()).collect(); - (r.contains(""), text) - }) - .collect() -} - -#[test] -fn latin_typed_into_reopened_hebrew_is_saved_left_to_right() { - // #1543: a saved file's Hebrew runs say `w:rtl`. Latin typed next to them after reopening - // takes its direction from its own letters, as on screen, so it's saved in a run of its own - // without `w:rtl` (Word would otherwise lay it out right to left again). - let s = session("לפני אחרי", true); - let bytes = crate::io::save_bytes("x.docx", &s.doc).unwrap(); - let mut s = Session::new(crate::io::open_bytes("x.docx", &bytes).unwrap()); - assert!(s.doc.para(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().runs.iter().all(|r| r.props.rtl == Some(true))); - s.sel = crate::Selection::caret(Pos::new(StoryRef::Body, wordcraft_doc::Path::top(0), "לפני ".len())); - run(&mut s, "text.insert", json!({"text": "Foo Bar 2026 "})); - let runs = saved_runs(&s); - assert!(runs.contains(&(false, "Foo Bar 2026".to_string())), "{runs:?}"); - assert!(runs.iter().all(|(rtl, t)| !rtl || !t.chars().any(|c| c.is_ascii_alphanumeric())), "{runs:?}"); - assert_eq!(runs.iter().map(|(_, t)| t.as_str()).collect::(), "לפני Foo Bar 2026 אחרי"); -} - -#[test] -fn a_latin_link_inserted_into_reopened_hebrew_is_saved_left_to_right() { - // Other insertions start from the caret's formatting too (#1543): a link's Latin text - // inserted between reopened Hebrew words isn't saved as right-to-left. - let s = session("לפני אחרי", true); - let bytes = crate::io::save_bytes("x.docx", &s.doc).unwrap(); - let mut s = Session::new(crate::io::open_bytes("x.docx", &bytes).unwrap()); - s.sel = crate::Selection::caret(Pos::new(StoryRef::Body, wordcraft_doc::Path::top(0), "לפני ".len())); - run(&mut s, "insert.link", json!({"url": "https://example.com", "text": "WordCraft site"})); - let runs = saved_runs(&s); - assert!(runs.contains(&(false, "WordCraft site".to_string())), "{runs:?}"); - // The caret's own formatting, as Reveal Formatting shows it, still says right to left. - s.sel = crate::Selection::caret(Pos::new(StoryRef::Body, wordcraft_doc::Path::top(0), "לפ".len())); - s.pending = None; // moving the caret by hand doesn't drop the link's pending formatting - assert_eq!(s.caret_props().rtl, Some(true)); - assert_eq!(s.typing_props().rtl, None); - assert_eq!(s.typing_props().lang_bidi.as_deref(), Some("he-IL"), "the bidi language stays"); -} +//! Right-to-left editing commands: paragraph direction, alignment as seen on the page, font +//! commands on Persian text, arrow keys in bidirectional text, DOCX save and open. + +use serde_json::json; +use std::sync::Arc; +use wordcraft_doc::props::Align; +use wordcraft_doc::{Pos, StoryRef}; + +use crate::Session; + +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}")) +} + +/// A session with `text` (paragraphs split on `\n`), every paragraph right to left when `rtl`. +fn session(text: &str, rtl: bool) -> Session { + let mut s = Session::new(wordcraft_doc::Document::new()); + run(&mut s, "document.setText", json!({"text": text})); + if rtl { + run(&mut s, "select.all", json!({})); + run(&mut s, "para.rtl", json!({})); + run(&mut s, "caret.docStart", json!({})); + } + s +} + +fn props(s: &Session, i: usize) -> wordcraft_doc::props::ParaProps { + s.doc.para(StoryRef::Body, &wordcraft_doc::Path::top(i)).unwrap().props.clone() +} + +fn caret_x(s: &mut Session) -> f32 { + let l = s.layout(); + l.caret(&s.sel.focus).unwrap().x +} + +#[test] +fn direction_commands_change_only_the_reading_order() { + let mut s = session("سلام دنیا\nHello", false); + run(&mut s, "para.alignCenter", json!({})); + run(&mut s, "para.rtl", json!({})); + assert_eq!(props(&s, 0).bidi, Some(true)); + assert_eq!(props(&s, 0).align, Some(Align::Center), "direction doesn't touch alignment"); + // Left-to-right again, and `value: false` means the other direction. + run(&mut s, "para.ltr", json!({})); + assert_eq!(props(&s, 0).bidi, Some(false)); + run(&mut s, "para.ltr", json!({"value": false})); + assert_eq!(props(&s, 0).bidi, Some(true)); + // A new right-to-left paragraph starts at the right margin (start-aligned). + let mut s = session("متن", true); + assert!((caret_x(&mut s) - 540.0).abs() < 0.5, "caret at {}", caret_x(&mut s)); +} + +#[test] +fn alignment_buttons_mean_what_they_show_in_rtl_paragraphs() { + let mut s = session("متن فارسی", true); + // Align Left in a right-to-left paragraph moves text to the left: its end edge. + run(&mut s, "para.alignLeft", json!({})); + assert_eq!(props(&s, 0).align, Some(Align::Right)); + let x_end = s.layout().caret(&Pos::body(0, "متن فارسی".len())).unwrap().x; + assert!((x_end - 72.0).abs() < 0.5, "text ends at the left margin: {x_end}"); + // Align Right is the start edge; pressing it again keeps it there. + run(&mut s, "para.alignRight", json!({})); + assert_eq!(props(&s, 0).align, Some(Align::Left)); + run(&mut s, "para.alignRight", json!({})); + assert_eq!(props(&s, 0).align, Some(Align::Left)); + // Logical values for scripts. + run(&mut s, "para.align", json!({"value": "end"})); + assert_eq!(props(&s, 0).align, Some(Align::Right)); + run(&mut s, "para.align", json!({"value": "right"})); + assert_eq!(props(&s, 0).align, Some(Align::Left)); +} + +#[test] +fn para_set_on_page_follows_each_paragraphs_own_direction() { + // The Paragraph dialog's alignment and indents are as seen on the page (#1346): over a + // right-to-left and a left-to-right paragraph, Left is each one's own left side. + let mut s = session("سلام دنیا\nHello", false); + run(&mut s, "caret.docStart", json!({})); + run(&mut s, "para.rtl", json!({})); + run(&mut s, "select.all", json!({})); + let depth = s.undo_depth(); + run(&mut s, "para.set", json!({"props": {"spaceAfter": 6}, "onPage": {"align": "left", "left": 36}})); + assert_eq!(s.undo_depth(), depth + 1, "one undo step"); + let (rtl, ltr) = (props(&s, 0), props(&s, 1)); + assert_eq!((rtl.align, rtl.indent_right, rtl.indent_left, rtl.space_after), (Some(Align::Right), Some(36.0), None, Some(6.0))); + assert_eq!((ltr.align, ltr.indent_left, ltr.indent_right, ltr.space_after), (Some(Align::Left), Some(36.0), None, Some(6.0))); + // A direction in `props` comes first: both are right to left now, so Right is their start. + run(&mut s, "para.set", json!({"props": {"bidi": true}, "onPage": {"right": 18}})); + assert_eq!((props(&s, 1).bidi, props(&s, 1).indent_left, props(&s, 1).indent_right), (Some(true), Some(18.0), None)); + assert!(s.run("para.set", &json!({"props": {}, "onPage": {"align": "middle"}})).is_err()); + // Undo takes each command back whole. + run(&mut s, "edit.undo", json!({})); + run(&mut s, "edit.undo", json!({})); + assert_eq!((props(&s, 0).align, props(&s, 0).indent_right, props(&s, 1).indent_left), (None, None, None)); +} + +#[test] +fn font_commands_reach_persian_text() { + let mut s = session("کتاب خوب", true); + run(&mut s, "select.all", json!({})); + run(&mut s, "format.bold", json!({})); + run(&mut s, "format.size", json!({"size": 18})); + run(&mut s, "format.font", json!({"name": "Vazirmatn"})); + let p = s.doc.para(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap(); + let c = p.props_of_char(0).clone(); + let rc = s.doc.styles.resolve_char(None, &c).complex(); + assert_eq!((rc.font.as_str(), rc.size, rc.bold), ("Vazirmatn", 18.0, true), "the complex-script formatting follows"); + // Saved and reopened, Word sees bold and size for the Persian text too. + let bytes = crate::io::save_bytes("x.docx", &s.doc).unwrap(); + let d = crate::io::open_bytes("x.docx", &bytes).unwrap(); + let c = d.para(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().props_of_char(0).clone(); + let rc = d.styles.resolve_char(None, &c).complex(); + assert_eq!((rc.font.as_str(), rc.size, rc.bold), ("Vazirmatn", 18.0, true)); + assert_eq!(d.para(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().props.bidi, Some(true)); +} + +#[test] +fn font_commands_override_inherited_complex_script_properties_explicitly() { + use wordcraft_doc::props::CharProps; + + let mut s = session("שלום ABC", true); + Arc::make_mut(&mut s.doc.styles).default_chr.size_cs = Some(30.0); + Arc::make_mut(&mut s.doc.styles).default_chr.bold_cs = Some(true); + Arc::make_mut(&mut s.doc.styles).default_chr.italic_cs = Some(true); + run(&mut s, "select.all", json!({})); + run(&mut s, "format.bold", json!({"value": false})); + run(&mut s, "format.italic", json!({"value": false})); + run(&mut s, "format.size", json!({"size": 18})); + for (command, expected_size) in [ + (None, 18.0), + (Some("format.growFont"), 20.0), + (Some("format.shrinkFont"), 18.0), + (Some("format.growFont1"), 19.0), + (Some("format.shrinkFont1"), 18.0), + ] { + if let Some(command) = command { + run(&mut s, command, json!({})); + } + let p = s.doc.para(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap(); + let c = p.props_of_char(0); + assert_eq!((c.size_cs, c.bold_cs, c.italic_cs), (Some(expected_size), Some(false), Some(false))); + let d = crate::io::open_bytes("x.docx", &crate::io::save_bytes("x.docx", &s.doc).unwrap()).unwrap(); + let c = d.para(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().props_of_char(0); + let cs = d.styles.resolve_char(None, c).complex(); + assert_eq!((cs.size, cs.bold, cs.italic), (expected_size, false, false)); + } + // The font dialog's command keeps formatting both scripts when given one value. + run(&mut s, "format.set", json!({"props": {"size": 10, "bold": true, "italic": true}})); + let p = s.doc.para(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap(); + let c: &CharProps = p.props_of_char(0); + assert_eq!((c.size_cs, c.bold_cs, c.italic_cs), (Some(10.0), Some(true), Some(true))); + // Callers can still supply distinct values explicitly, including false. + run(&mut s, "format.set", json!({"props": {"size": 12, "sizeCs": 18, "bold": true, "boldCs": false, "italic": true, "italicCs": false}})); + let p = s.doc.para(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap(); + let c = p.props_of_char(0); + assert_eq!((c.size, c.size_cs, c.bold_cs, c.italic_cs), (Some(12.0), Some(18.0), Some(false), Some(false))); +} + +#[test] +fn arrow_keys_move_visually_in_rtl_text() { + let fa = "سلام دنیا"; + let mut s = session(fa, true); + // From the start (the right edge), Left moves forward through the text. + let x0 = caret_x(&mut s); + run(&mut s, "caret.left", json!({})); + assert!(s.sel.focus.off > 0, "Left moves to the next character in right-to-left text"); + assert!(caret_x(&mut s) < x0, "and the caret moves left on the screen"); + run(&mut s, "caret.right", json!({})); + assert_eq!(s.sel.focus.off, 0); + // Right at the start edge goes nowhere in the first paragraph (no previous one). + run(&mut s, "caret.right", json!({})); + assert_eq!(s.sel.focus.off, 0); + // Left all the way reaches the end, then the next paragraph. + let mut s = session(&format!("{fa}\nبعدی"), true); + // (Stepping until the paragraph changes: the لا ligature is one caret position for two letters.) + let mut steps = 0; + while s.sel.focus.path.last() == 0 && steps < 40 { + run(&mut s, "caret.left", json!({})); + steps += 1; + } + assert!(steps <= fa.chars().count() + 1, "{steps} steps"); + assert_eq!(s.sel.focus.path.last(), 1, "past the left end of the line: the next paragraph"); + assert_eq!(s.sel.focus.off, 0, "at its start (right edge)"); + // Shift+Left selects forward in logical order. + run(&mut s, "caret.docStart", json!({})); + run(&mut s, "caret.left", json!({"extend": true})); + let (a, b) = s.sel.ordered(); + assert_eq!((a.off, b.off > 0), (0, true)); + // Left with a selection collapses to its left edge: the logical end. + run(&mut s, "caret.left", json!({})); + assert_eq!(s.sel.focus.off, b.off); +} + +#[test] +fn arrow_keys_in_mixed_text_visit_each_position() { + // A left-to-right paragraph with a Persian word: Right walks across the screen. + let t = "ab سلام cd"; + let mut s = session(t, false); + let mut xs = vec![caret_x(&mut s)]; + for _ in 0..30 { + let before = s.sel.focus.clone(); + run(&mut s, "caret.right", json!({})); + if s.sel.focus == before { + break; + } + xs.push(caret_x(&mut s)); + } + assert!(xs.windows(2).all(|w| w[1] > w[0]), "each Right moves right: {xs:?}"); + assert_eq!(s.sel.focus.off, t.len(), "ends at the end of the paragraph"); +} + +#[test] +fn word_moves_follow_reading_order() { + let mut s = session("یک دو سه", true); + run(&mut s, "caret.wordLeft", json!({})); + assert!(s.sel.focus.off > 0, "Ctrl+Left goes to the next word in right-to-left text"); + run(&mut s, "caret.wordRight", json!({})); + assert_eq!(s.sel.focus.off, 0); +} + +#[test] +fn typing_persian_keeps_logical_order() { + let mut s = session("", true); + for w in ["سلام", " ", "WordCraft", " ", "۱۴۰۳"] { + run(&mut s, "text.insert", json!({"text": w})); + } + assert_eq!(s.doc.plain_text(StoryRef::Body), "سلام WordCraft ۱۴۰۳"); + // The caret after typing a number at the end sits right after it on the screen. + let l = s.layout(); + let c = l.caret(&s.sel.focus).unwrap(); + assert!(c.x < 540.0 && c.x > 72.0); + // select.text finds Persian text. + run(&mut s, "select.text", json!({"text": "سلام"})); + assert_eq!(s.selected_text(), "سلام"); +} + +#[test] +fn explicit_alignment_sets_without_toggling() { + for rtl in [false, true] { + let mut s = session("متن", rtl); + for v in ["center", "center", "right", "right", "left", "left"] { + run(&mut s, "para.align", json!({"value": v})); + let shown = props(&s, 0).align.unwrap_or_default().visual(rtl); + let want = match v { + "center" => Align::Center, + "right" => Align::Right, + _ => Align::Left, + }; + assert_eq!(shown, want, "{v} twice stays {v} (rtl={rtl})"); + } + } +} + +#[test] +fn typing_kurdish_in_new_document_advances_caret_smoothly() { + let mut s = Session::new(wordcraft_doc::Document::new()); + let mut first_x = None; + let mut last_x = 0.0; + for ch in ["م", "ن", " ", "ن", "ا", "و", "م", " ", "ه", "ێ", "ڤ", "ا", "ر", "ه"] { + run(&mut s, "text.insert", json!({"text": ch})); + let l = s.layout(); + let c = l.caret(&s.sel.focus).unwrap(); + if first_x.is_none() { + first_x = Some(c.x); + } + last_x = c.x; + // The caret must not jump back to the left margin (72.0), but stay near the right margin (540.0). + assert!(c.x > 400.0, "caret should not be at left margin: {}", c.x); + } + assert!(last_x < first_x.unwrap(), "caret should advance leftwards overall"); + assert_eq!(props(&s, 0).bidi, Some(true), "paragraph auto-detected as right-to-left"); +} + +/// The runs of the first paragraph in a saved DOCX: (has `w:rtl`, text). +fn saved_runs(s: &Session) -> Vec<(bool, String)> { + use std::io::Read; + let bytes = crate::io::save_bytes("x.docx", &s.doc).unwrap(); + let mut xml = String::new(); + zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap().by_name("word/document.xml").unwrap().read_to_string(&mut xml).unwrap(); + let para = xml.split("").nth(1).unwrap().split("").next().unwrap().to_string(); + para.split("") + .skip(1) + .map(|r| { + let r = r.split("").next().unwrap(); + let text: String = r.split("").skip(1).filter_map(|t| t.split("").next()).collect(); + (r.contains(""), text) + }) + .collect() +} + +#[test] +fn latin_typed_into_reopened_hebrew_is_saved_left_to_right() { + // #1543: a saved file's Hebrew runs say `w:rtl`. Latin typed next to them after reopening + // takes its direction from its own letters, as on screen, so it's saved in a run of its own + // without `w:rtl` (Word would otherwise lay it out right to left again). + let s = session("לפני אחרי", true); + let bytes = crate::io::save_bytes("x.docx", &s.doc).unwrap(); + let mut s = Session::new(crate::io::open_bytes("x.docx", &bytes).unwrap()); + assert!(s.doc.para(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().runs.iter().all(|r| r.props.rtl == Some(true))); + s.sel = crate::Selection::caret(Pos::new(StoryRef::Body, wordcraft_doc::Path::top(0), "לפני ".len())); + run(&mut s, "text.insert", json!({"text": "Foo Bar 2026 "})); + let runs = saved_runs(&s); + assert!(runs.contains(&(false, "Foo Bar 2026".to_string())), "{runs:?}"); + assert!(runs.iter().all(|(rtl, t)| !rtl || !t.chars().any(|c| c.is_ascii_alphanumeric())), "{runs:?}"); + assert_eq!(runs.iter().map(|(_, t)| t.as_str()).collect::(), "לפני Foo Bar 2026 אחרי"); +} + +#[test] +fn a_latin_link_inserted_into_reopened_hebrew_is_saved_left_to_right() { + // Other insertions start from the caret's formatting too (#1543): a link's Latin text + // inserted between reopened Hebrew words isn't saved as right-to-left. + let s = session("לפני אחרי", true); + let bytes = crate::io::save_bytes("x.docx", &s.doc).unwrap(); + let mut s = Session::new(crate::io::open_bytes("x.docx", &bytes).unwrap()); + s.sel = crate::Selection::caret(Pos::new(StoryRef::Body, wordcraft_doc::Path::top(0), "לפני ".len())); + run(&mut s, "insert.link", json!({"url": "https://example.com", "text": "WordCraft site"})); + let runs = saved_runs(&s); + assert!(runs.contains(&(false, "WordCraft site".to_string())), "{runs:?}"); + // The caret's own formatting, as Reveal Formatting shows it, still says right to left. + s.sel = crate::Selection::caret(Pos::new(StoryRef::Body, wordcraft_doc::Path::top(0), "לפ".len())); + s.pending = None; // moving the caret by hand doesn't drop the link's pending formatting + assert_eq!(s.caret_props().rtl, Some(true)); + assert_eq!(s.typing_props().rtl, None); + assert_eq!(s.typing_props().lang_bidi.as_deref(), Some("he-IL"), "the bidi language stays"); +} diff --git a/crates/engine/src/tests/czech_test_dict.rs b/crates/engine/src/tests/czech_test_dict.rs index 382e95d25f..6cc59759e6 100644 --- a/crates/engine/src/tests/czech_test_dict.rs +++ b/crates/engine/src/tests/czech_test_dict.rs @@ -1,59 +1,59 @@ -//! Hand-written synthetic Czech test data. No words, rules or suggestion tables were copied -//! from a system dictionary. This is a small fixture, not a redistributable Czech dictionary. - -pub const AFF: &str = "SET UTF-8 -FLAG UTF-8 -FORBIDDENWORD F -TRY aeiouěščřžýáíéůúntklmp -KEY xn|qř -MAP 5 -MAP eěé -MAP cč -MAP rř -MAP uůú -MAP ií -REP 1 -REP mňe mě -SFX A Y 2 # synthetic declension -SFX A o a o -SFX A o ě o -SFX B N 1 -SFX B 0 e/C . -SFX C N 1 -SFX C e ejší e -SFX D N 1 -SFX D 0 itel/E . -SFX E N 1 -SFX E 0 é . -SFX P Y 1 -SFX P ha ze ha -PFX X Y 1 -PFX X 0 ne . -"; - -pub const DIC: &str = "20 -město/AX -rychl/B -uč/D -Praha/P -škola -počítač -příliš -žluťoučký -kůň -úpěl -ďábelské -ódy -zpráva -zprava -děkujeme -mě -nůž -si -to -zqzakázané/F -"; - -pub fn install() { - super::install_dictionary(AFF, DIC).unwrap(); -} +//! Hand-written synthetic Czech test data. No words, rules or suggestion tables were copied +//! from a system dictionary. This is a small fixture, not a redistributable Czech dictionary. + +pub const AFF: &str = "SET UTF-8 +FLAG UTF-8 +FORBIDDENWORD F +TRY aeiouěščřžýáíéůúntklmp +KEY xn|qř +MAP 5 +MAP eěé +MAP cč +MAP rř +MAP uůú +MAP ií +REP 1 +REP mňe mě +SFX A Y 2 # synthetic declension +SFX A o a o +SFX A o ě o +SFX B N 1 +SFX B 0 e/C . +SFX C N 1 +SFX C e ejší e +SFX D N 1 +SFX D 0 itel/E . +SFX E N 1 +SFX E 0 é . +SFX P Y 1 +SFX P ha ze ha +PFX X Y 1 +PFX X 0 ne . +"; + +pub const DIC: &str = "20 +město/AX +rychl/B +uč/D +Praha/P +škola +počítač +příliš +žluťoučký +kůň +úpěl +ďábelské +ódy +zpráva +zprava +děkujeme +mě +nůž +si +to +zqzakázané/F +"; + +pub fn install() { + super::install_dictionary(AFF, DIC).unwrap(); +} diff --git a/crates/engine/src/tests/documents.rs b/crates/engine/src/tests/documents.rs index 2f0151b33b..cdeeff5135 100644 --- a/crates/engine/src/tests/documents.rs +++ b/crates/engine/src/tests/documents.rs @@ -1,410 +1,410 @@ -//! Several open documents in one session: File › New and Open open beside the others, each -//! document keeps its own text, selection, undo and find state, and work addressed to a document -//! by id lands in that document whichever one is active. - -use serde_json::{Value, json}; -use wordcraft_doc::StoryRef; - -use crate::Session; - -fn s() -> Session { - Session::new(wordcraft_doc::Document::new()) -} - -fn run(s: &mut Session, id: &str, v: Value) -> Value { - s.run(id, &v).unwrap_or_else(|e| panic!("{id}: {e}")) -} - -fn text(s: &Session) -> String { - s.doc.plain_text(StoryRef::Body) -} - -fn ids(s: &Session) -> Vec { - s.documents().iter().map(|d| d.id).collect() -} - -fn temp_dir(name: &str) -> std::path::PathBuf { - let dir = std::env::temp_dir().join(format!("wordcraft-engine-docs-{name}-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - dir -} - -#[test] -fn new_opens_another_document_and_each_keeps_its_own_text_and_undo() { - let mut s = s(); - let a = s.document_id(); - run(&mut s, "text.insert", json!({"text": "Alpha"})); - run(&mut s, "file.new", json!({})); - let b = s.document_id(); - assert_ne!(a, b); - assert_eq!(ids(&s), vec![a, b], "opening order"); - let docs = s.documents(); - assert_eq!(docs.iter().map(|d| (d.title.as_str(), d.active)).collect::>(), vec![("Document1", false), ("Document2", true)]); - assert!(docs[0].dirty && !docs[1].dirty); - assert_eq!(text(&s), "", "the new document is active"); - run(&mut s, "text.insert", json!({"text": "Beta"})); - - s.activate(a).unwrap(); - assert_eq!(text(&s), "Alpha"); - assert_eq!(s.document_id(), a, "ids stay with their documents"); - s.activate(a).unwrap(); - s.activate(b).unwrap(); - assert_eq!(text(&s), "Beta"); - run(&mut s, "file.new", json!({})); - assert_eq!(s.documents()[2].title, "Document3"); -} - -/// Undo goes with its document: edit A, edit B, back to A, Undo takes A's edit back and leaves -/// B alone; Redo puts it back. Find state stays with its document too. -#[test] -fn undo_and_find_stay_with_their_document_across_switches() { - let mut s = s(); - let a = s.document_id(); - run(&mut s, "text.insert", json!({"text": "Alpha"})); - s.find.query = "Alpha".into(); - let b = s.add_document(wordcraft_doc::Document::new()); - assert_eq!(s.find.query, "", "a new document has its own find state"); - run(&mut s, "text.insert", json!({"text": "Beta"})); - s.find.query = "Beta".into(); - - s.activate(a).unwrap(); - assert_eq!(s.find.query, "Alpha"); - run(&mut s, "edit.undo", json!({})); - assert_eq!(text(&s), ""); - assert_eq!(s.with_document(b, |s| (text(s), s.undo_depth(), s.find.query.clone())).unwrap(), ("Beta".to_string(), 1, "Beta".to_string())); - run(&mut s, "edit.redo", json!({})); - assert_eq!(text(&s), "Alpha"); - s.activate(b).unwrap(); - run(&mut s, "edit.undo", json!({})); - assert_eq!(text(&s), ""); - assert_eq!(s.with_document(a, |s| text(s)).unwrap(), "Alpha"); -} - -#[cfg(not(target_arch = "wasm32"))] -#[test] -fn open_replaces_an_untouched_blank_document_and_otherwise_opens_beside() { - let dir = temp_dir("open"); - let one = dir.join("one.txt"); - let two = dir.join("two.txt"); - std::fs::write(&one, "One").unwrap(); - std::fs::write(&two, "Two").unwrap(); - - let mut s = s(); - assert!(s.is_untouched_blank()); - let r = run(&mut s, "file.open", json!({"path": one.to_string_lossy()})); - assert_eq!(r["alreadyOpen"], false); - assert_eq!(s.documents().len(), 1, "the untouched blank document was replaced"); - let first = s.document_id(); - assert_eq!(s.documents()[0].title, "one"); - - run(&mut s, "file.open", json!({"path": two.to_string_lossy()})); - assert_eq!(s.documents().len(), 2, "a document with a file stays open"); - let second = s.document_id(); - assert_eq!(text(&s), "Two"); - - // The same file again, by another spelling: it becomes the active document. - let again = dir.join(".").join("one.txt"); - let r = run(&mut s, "file.open", json!({"path": again.to_string_lossy()})); - assert_eq!((r["alreadyOpen"].as_bool(), r["id"].as_u64()), (Some(true), Some(first))); - assert_eq!((s.document_id(), s.documents().len()), (first, 2)); - assert_eq!(s.find_open(&two), Some(second)); - - // A failed open changes nothing. - assert!(s.run("file.open", &json!({"path": dir.join("missing.docx").to_string_lossy()})).is_err()); - assert_eq!((ids(&s), s.document_id()), (vec![first, second], first)); - - // An edited blank document isn't replaced. - let mut s = self::s(); - run(&mut s, "text.insert", json!({"text": "x"})); - run(&mut s, "text.backspace", json!({})); - assert!(!s.is_untouched_blank()); - run(&mut s, "file.open", json!({"path": one.to_string_lossy()})); - assert_eq!(s.documents().len(), 2); - let _ = std::fs::remove_dir_all(&dir); -} - -#[test] -fn close_activates_the_previous_document_and_the_last_leaves_a_blank_one() { - let mut s = s(); - let a = s.document_id(); - let b = s.add_document(wordcraft_doc::Document::from_text("B")); - let c = s.add_document(wordcraft_doc::Document::from_text("C")); - s.activate(b).unwrap(); - s.close_document(b).unwrap(); - assert_eq!((ids(&s), s.document_id()), (vec![a, c], a), "the one opened before it"); - s.close_document(c).unwrap(); - s.close_document(a).unwrap(); - assert_eq!(s.documents().len(), 1); - assert!(![a, b, c].contains(&s.document_id())); - assert!(s.is_untouched_blank()); - assert!(s.close_document(a).is_err(), "closed documents are gone"); - - // Closing the first makes the next one active. - let d = s.document_id(); - let e = s.add_document(wordcraft_doc::Document::new()); - s.activate(d).unwrap(); - s.close_document(d).unwrap(); - assert_eq!(s.document_id(), e); -} - -#[test] -fn close_document_refuses_unsaved_changes_unless_forced() { - let mut s = s(); - let a = s.document_id(); - run(&mut s, "text.insert", json!({"text": "Draft"})); - let b = s.add_document(wordcraft_doc::Document::new()); - assert!(s.run("file.closeDocument", &json!({"id": a})).is_err()); - assert_eq!(ids(&s), vec![a, b], "still open"); - let r = run(&mut s, "file.closeDocument", json!({})); - assert_eq!((r["closed"].as_u64(), r["active"].as_u64()), (Some(b), Some(a))); - let r = run(&mut s, "file.closeDocument", json!({"id": a, "force": true})); - assert_eq!(r["documents"].as_array().map(Vec::len), Some(1)); - assert!(s.is_untouched_blank()); - for bad in [json!({"id": -1}), json!({"id": "x"}), json!({"id": u64::MAX}), json!({"id": 1.5}), json!({"id": []})] { - assert!(s.run("file.closeDocument", &bad).is_err(), "{bad}"); - } - assert_eq!(s.documents().len(), 1); -} - -/// Work addressed to a document by id lands in it, whichever document is active, and the active -/// one is left as it was. -#[test] -fn with_document_works_on_the_given_document_and_keeps_the_active_one() { - let mut s = s(); - let a = s.document_id(); - run(&mut s, "text.insert", json!({"text": "Alpha"})); - let b = s.add_document(wordcraft_doc::Document::new()); - run(&mut s, "text.insert", json!({"text": "Beta"})); - let (b_rev, b_undo) = (s.rev(), s.undo_depth()); - - let r = s.with_document(a, |s| { - s.run("text.insert", &json!({"text": " more"})).unwrap(); - s.run("edit.advancedFind", &json!({})).ok(); - s.document_id() - }); - assert_eq!(r.unwrap(), a); - assert_eq!(s.document_id(), b); - assert_eq!((text(&s), s.rev(), s.undo_depth()), ("Beta".to_string(), b_rev, b_undo), "B untouched"); - assert_eq!(s.with_document(a, |s| text(s)).unwrap(), "Alpha more"); - assert!(s.ui_requests.iter().all(|q| q.get("open").is_none() || q["document"] == a), "requests name their document: {:?}", s.ui_requests); - assert!(s.with_document(u64::MAX, |_| ()).is_err()); - - // Closing the active document inside leaves the one it ran on active. - s.with_document(a, |s| s.close_document(b)).unwrap().unwrap(); - assert_eq!(ids(&s), vec![a]); - assert_eq!(s.document_id(), a); -} - -/// A Read Aloud reading names its document: switching documents stops it, and turning citation -/// skipping on in another document doesn't restart it at the first document's positions there. -#[test] -fn read_aloud_belongs_to_its_document() { - let mut s = s(); - let a = s.document_id(); - run(&mut s, "text.insert", json!({"text": "One sentence. Another one."})); - run(&mut s, "caret.docStart", json!({})); - run(&mut s, "review.readAloud", json!({})); - assert_eq!(s.read_aloud.document, Some(a)); - let b = s.add_document(wordcraft_doc::Document::from_text("Other text.")); - assert_eq!(s.read_aloud.state(), crate::speech::State::Stopped, "the reading of A stopped"); - run(&mut s, "readAloud.skipCitations", json!({"value": false})); - assert_eq!(s.read_aloud.document, Some(a), "nothing restarted in B"); - run(&mut s, "review.readAloud", json!({})); - assert_eq!(s.read_aloud.document, Some(b)); -} - -/// Tests that `view.switchWindows` lists open documents, activates by document ID, and requests switching by window ID. -#[test] -fn switch_windows_lists_and_activates() { - let mut s = s(); - let a = s.document_id(); - let b = s.add_document(wordcraft_doc::Document::new()); - let r = run(&mut s, "view.switchWindows", json!({})); - let list = r["documents"].as_array().unwrap(); - assert_eq!( - list.iter().map(|d| (d["id"].as_u64(), d["title"].as_str(), d["active"].as_bool())).collect::>(), - vec![(Some(a), Some("Document1"), Some(false)), (Some(b), Some("Document2"), Some(true))] - ); - assert!(list[0].get("path").is_some() && list[0].get("dirty").is_some()); - run(&mut s, "view.switchWindows", json!({"id": a})); - assert_eq!(s.document_id(), a); - s.ui_requests.clear(); - run(&mut s, "view.switchWindows", json!({"window": 1})); - assert_eq!(s.ui_requests, vec![json!({"windows": "switchWindow", "window": 1})]); - s.windows.available = true; - s.windows.current = Some(1); - s.windows.others = vec![crate::cmd::view::OtherWindow { id: 2, title: "Second".into() }]; - s.ui_requests.clear(); - run(&mut s, "view.switchWindows", json!({"window": 2})); - assert_eq!(s.ui_requests, vec![json!({"windows": "switchWindow", "window": 2})]); - assert!(s.run("view.switchWindows", &json!({"window": 3})).is_err(), "unknown window key fails when windows are available"); - for bad in [json!({"id": 0}), json!({"id": u64::MAX}), json!({"id": -5}), json!({"id": "1"}), json!({"id": 1e300}), json!({"id": {}})] { - assert!(s.run("view.switchWindows", &bad).is_err(), "{bad}"); - assert_eq!(s.document_id(), a); - } - for bad in [json!({"window": "1"}), json!({"window": -1}), json!({"window": {}})] { - assert!(s.run("view.switchWindows", &bad).is_err(), "{bad}"); - } - let junk = [json!(null), json!([]), json!({"id": null}), json!({"text": 5})]; - for id in ["view.switchWindows", "file.closeDocument", "file.new"] { - for j in &junk { - let _ = s.run(id, j); - s.clamp_selection(); - let _ = s.layout(); - } - } - assert!(!s.documents().is_empty()); -} - -#[test] -fn document_ids_are_unique_across_sessions_and_replacements() { - let mut one = s(); - let two = s(); - assert_ne!(one.document_id(), two.document_id()); - let before = one.document_id(); - one.set_document(wordcraft_doc::Document::new()); - assert_ne!(one.document_id(), before); - assert_eq!(ids(&one), vec![one.document_id()], "the replacement takes its place in the list"); -} - -/// A Start screen's stand-in document gives back its name: the first document a person makes is -/// `Document1`, the next `Document2`, and after closing the last one, New makes `Document2` -/// again only if no other document has taken it. -#[test] -fn a_stand_in_document_gives_back_its_untitled_name() { - let mut s = s(); - let placeholder = s.document_id(); - s.release_untitled_name(); - run(&mut s, "file.new", json!({})); - s.close_document(placeholder).unwrap(); - assert_eq!(s.documents()[0].title, "Document1"); - run(&mut s, "file.new", json!({})); - assert_eq!(s.documents()[1].title, "Document2"); - // A document with work keeps its name. - run(&mut s, "text.insert", json!({"text": "Work"})); - s.release_untitled_name(); - run(&mut s, "file.new", json!({})); - assert_eq!(s.documents()[2].title, "Document3"); -} - -/// Two windows on one document: each keeps its own caret and zoom; a caret inside text another -/// window deleted is clamped, so typing there lands at the end of what is left; Undo in either -/// window undoes the document's last edit, and typing in one window never joins the other's -/// undo step. -#[test] -fn two_views_of_one_document_keep_their_own_caret_and_share_undo() { - let mut s = s(); - run(&mut s, "text.insert", json!({"text": "Hello world"})); - let mut b = s.copy_view(); - let a_caret = s.sel.focus.clone(); - - // Window B: zoom in; its caret is after "world". - let mut a = s.take_view(); - s.put_view(b); - run(&mut s, "view.zoom", json!({"value": 150})); - b = s.take_view(); - s.put_view(a); - assert_eq!(s.view.zoom, 1.0, "window A keeps its zoom"); - - // Window A deletes " world", where B's caret is. - run(&mut s, "select.text", json!({"text": " world"})); - run(&mut s, "text.delete", json!({})); - assert_eq!(text(&s).trim(), "Hello"); - a = s.take_view(); - s.put_view(b); - assert!((s.view.zoom - 1.5).abs() < 1e-6, "window B keeps its zoom"); - assert_eq!(s.sel.focus.off, 5, "B's caret is clamped into what is left"); - run(&mut s, "text.insert", json!({"text": "!"})); - assert_eq!(text(&s).trim(), "Hello!"); - - // Undo in A undoes B's typing; Undo in B then undoes A's delete. - b = s.take_view(); - s.put_view(a); - run(&mut s, "edit.undo", json!({})); - assert_eq!(text(&s).trim(), "Hello"); - a = s.take_view(); - s.put_view(b); - run(&mut s, "edit.undo", json!({})); - assert_eq!(text(&s).trim(), "Hello world"); - - // Typing in A, then B, then A again: three undo steps. - b = s.take_view(); - s.put_view(a); - s.sel = crate::Selection::caret(a_caret); - run(&mut s, "text.insert", json!({"text": "x"})); - a = s.take_view(); - s.put_view(b); - run(&mut s, "text.insert", json!({"text": "y"})); - b = s.take_view(); - s.put_view(a); - run(&mut s, "text.insert", json!({"text": "z"})); - run(&mut s, "edit.undo", json!({})); - assert!(!text(&s).contains('z') && text(&s).contains('x') && text(&s).contains('y'), "{}", text(&s)); - drop(b); -} - -/// An input method's promise that the next insertion joins its replacement belongs to the -/// window that made it: another window on the document typing at the same place gets an undo -/// step of its own. -#[test] -fn input_method_joins_stay_with_their_window() { - let mut s = s(); - run(&mut s, "text.insert", json!({"text": "Hello"})); - s.join_next_ime_text_replacement(); - let b = s.copy_view(); - let a = s.take_view(); - s.put_view(b); - run(&mut s, "text.insert", json!({"text": "y"})); - run(&mut s, "edit.undo", json!({})); - assert_eq!(text(&s).trim(), "Hello", "B's typing is its own undo step"); - drop(a); -} - -/// Check spelling as you type is one setting for the app, not one per open document (#5740): -/// turned off (File › Options › Proofing, or Review's command) with one document active, it is off -/// in the others too, and switching to another document doesn't turn it back on, so what is saved -/// for the next run is what was chosen, whichever document is active then. -#[test] -fn check_spelling_is_one_setting_for_every_open_document() { - let mut s = s(); - let a = s.document_id(); - run(&mut s, "file.new", json!({})); - let b = s.document_id(); - run(&mut s, "file.new", json!({})); - let c = s.document_id(); - assert!(s.view.proofing); - s.activate(a).unwrap(); - run(&mut s, "file.options", json!({"checkSpelling": false})); - for id in [b, c, a] { - s.activate(id).unwrap(); - assert!(!s.view.proofing, "off in document {id}"); - assert!(!s.prefs.check_spelling); - } - run(&mut s, "review.proofing", json!({"value": true})); - for id in [b, c] { - s.activate(id).unwrap(); - assert!(s.view.proofing, "back on in document {id}"); - } - // A document opened now starts with the setting as it is. - run(&mut s, "review.proofing", json!({"value": false})); - run(&mut s, "file.new", json!({})); - assert!(!s.view.proofing); -} - -/// A window's view put back after Check spelling as you type changed in another window takes the -/// setting as it is now, not as it was when the window's view was taken (#5740): every view made -/// active follows the saved preference. -#[test] -fn a_window_view_put_back_follows_the_spelling_setting() { - let mut s = s(); - assert!(s.view.proofing); - let held = s.take_view(); - run(&mut s, "file.options", json!({"checkSpelling": false})); - s.put_view(held); - assert!(!s.view.proofing, "the view taken with spelling on comes back with it off"); - let held = s.take_view(); - run(&mut s, "review.proofing", json!({"value": true})); - s.put_view(held); - assert!(s.view.proofing); -} +//! Several open documents in one session: File › New and Open open beside the others, each +//! document keeps its own text, selection, undo and find state, and work addressed to a document +//! by id lands in that document whichever one is active. + +use serde_json::{Value, json}; +use wordcraft_doc::StoryRef; + +use crate::Session; + +fn s() -> Session { + Session::new(wordcraft_doc::Document::new()) +} + +fn run(s: &mut Session, id: &str, v: Value) -> Value { + s.run(id, &v).unwrap_or_else(|e| panic!("{id}: {e}")) +} + +fn text(s: &Session) -> String { + s.doc.plain_text(StoryRef::Body) +} + +fn ids(s: &Session) -> Vec { + s.documents().iter().map(|d| d.id).collect() +} + +fn temp_dir(name: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("wordcraft-engine-docs-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +#[test] +fn new_opens_another_document_and_each_keeps_its_own_text_and_undo() { + let mut s = s(); + let a = s.document_id(); + run(&mut s, "text.insert", json!({"text": "Alpha"})); + run(&mut s, "file.new", json!({})); + let b = s.document_id(); + assert_ne!(a, b); + assert_eq!(ids(&s), vec![a, b], "opening order"); + let docs = s.documents(); + assert_eq!(docs.iter().map(|d| (d.title.as_str(), d.active)).collect::>(), vec![("Document1", false), ("Document2", true)]); + assert!(docs[0].dirty && !docs[1].dirty); + assert_eq!(text(&s), "", "the new document is active"); + run(&mut s, "text.insert", json!({"text": "Beta"})); + + s.activate(a).unwrap(); + assert_eq!(text(&s), "Alpha"); + assert_eq!(s.document_id(), a, "ids stay with their documents"); + s.activate(a).unwrap(); + s.activate(b).unwrap(); + assert_eq!(text(&s), "Beta"); + run(&mut s, "file.new", json!({})); + assert_eq!(s.documents()[2].title, "Document3"); +} + +/// Undo goes with its document: edit A, edit B, back to A, Undo takes A's edit back and leaves +/// B alone; Redo puts it back. Find state stays with its document too. +#[test] +fn undo_and_find_stay_with_their_document_across_switches() { + let mut s = s(); + let a = s.document_id(); + run(&mut s, "text.insert", json!({"text": "Alpha"})); + s.find.query = "Alpha".into(); + let b = s.add_document(wordcraft_doc::Document::new()); + assert_eq!(s.find.query, "", "a new document has its own find state"); + run(&mut s, "text.insert", json!({"text": "Beta"})); + s.find.query = "Beta".into(); + + s.activate(a).unwrap(); + assert_eq!(s.find.query, "Alpha"); + run(&mut s, "edit.undo", json!({})); + assert_eq!(text(&s), ""); + assert_eq!(s.with_document(b, |s| (text(s), s.undo_depth(), s.find.query.clone())).unwrap(), ("Beta".to_string(), 1, "Beta".to_string())); + run(&mut s, "edit.redo", json!({})); + assert_eq!(text(&s), "Alpha"); + s.activate(b).unwrap(); + run(&mut s, "edit.undo", json!({})); + assert_eq!(text(&s), ""); + assert_eq!(s.with_document(a, |s| text(s)).unwrap(), "Alpha"); +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn open_replaces_an_untouched_blank_document_and_otherwise_opens_beside() { + let dir = temp_dir("open"); + let one = dir.join("one.txt"); + let two = dir.join("two.txt"); + std::fs::write(&one, "One").unwrap(); + std::fs::write(&two, "Two").unwrap(); + + let mut s = s(); + assert!(s.is_untouched_blank()); + let r = run(&mut s, "file.open", json!({"path": one.to_string_lossy()})); + assert_eq!(r["alreadyOpen"], false); + assert_eq!(s.documents().len(), 1, "the untouched blank document was replaced"); + let first = s.document_id(); + assert_eq!(s.documents()[0].title, "one"); + + run(&mut s, "file.open", json!({"path": two.to_string_lossy()})); + assert_eq!(s.documents().len(), 2, "a document with a file stays open"); + let second = s.document_id(); + assert_eq!(text(&s), "Two"); + + // The same file again, by another spelling: it becomes the active document. + let again = dir.join(".").join("one.txt"); + let r = run(&mut s, "file.open", json!({"path": again.to_string_lossy()})); + assert_eq!((r["alreadyOpen"].as_bool(), r["id"].as_u64()), (Some(true), Some(first))); + assert_eq!((s.document_id(), s.documents().len()), (first, 2)); + assert_eq!(s.find_open(&two), Some(second)); + + // A failed open changes nothing. + assert!(s.run("file.open", &json!({"path": dir.join("missing.docx").to_string_lossy()})).is_err()); + assert_eq!((ids(&s), s.document_id()), (vec![first, second], first)); + + // An edited blank document isn't replaced. + let mut s = self::s(); + run(&mut s, "text.insert", json!({"text": "x"})); + run(&mut s, "text.backspace", json!({})); + assert!(!s.is_untouched_blank()); + run(&mut s, "file.open", json!({"path": one.to_string_lossy()})); + assert_eq!(s.documents().len(), 2); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn close_activates_the_previous_document_and_the_last_leaves_a_blank_one() { + let mut s = s(); + let a = s.document_id(); + let b = s.add_document(wordcraft_doc::Document::from_text("B")); + let c = s.add_document(wordcraft_doc::Document::from_text("C")); + s.activate(b).unwrap(); + s.close_document(b).unwrap(); + assert_eq!((ids(&s), s.document_id()), (vec![a, c], a), "the one opened before it"); + s.close_document(c).unwrap(); + s.close_document(a).unwrap(); + assert_eq!(s.documents().len(), 1); + assert!(![a, b, c].contains(&s.document_id())); + assert!(s.is_untouched_blank()); + assert!(s.close_document(a).is_err(), "closed documents are gone"); + + // Closing the first makes the next one active. + let d = s.document_id(); + let e = s.add_document(wordcraft_doc::Document::new()); + s.activate(d).unwrap(); + s.close_document(d).unwrap(); + assert_eq!(s.document_id(), e); +} + +#[test] +fn close_document_refuses_unsaved_changes_unless_forced() { + let mut s = s(); + let a = s.document_id(); + run(&mut s, "text.insert", json!({"text": "Draft"})); + let b = s.add_document(wordcraft_doc::Document::new()); + assert!(s.run("file.closeDocument", &json!({"id": a})).is_err()); + assert_eq!(ids(&s), vec![a, b], "still open"); + let r = run(&mut s, "file.closeDocument", json!({})); + assert_eq!((r["closed"].as_u64(), r["active"].as_u64()), (Some(b), Some(a))); + let r = run(&mut s, "file.closeDocument", json!({"id": a, "force": true})); + assert_eq!(r["documents"].as_array().map(Vec::len), Some(1)); + assert!(s.is_untouched_blank()); + for bad in [json!({"id": -1}), json!({"id": "x"}), json!({"id": u64::MAX}), json!({"id": 1.5}), json!({"id": []})] { + assert!(s.run("file.closeDocument", &bad).is_err(), "{bad}"); + } + assert_eq!(s.documents().len(), 1); +} + +/// Work addressed to a document by id lands in it, whichever document is active, and the active +/// one is left as it was. +#[test] +fn with_document_works_on_the_given_document_and_keeps_the_active_one() { + let mut s = s(); + let a = s.document_id(); + run(&mut s, "text.insert", json!({"text": "Alpha"})); + let b = s.add_document(wordcraft_doc::Document::new()); + run(&mut s, "text.insert", json!({"text": "Beta"})); + let (b_rev, b_undo) = (s.rev(), s.undo_depth()); + + let r = s.with_document(a, |s| { + s.run("text.insert", &json!({"text": " more"})).unwrap(); + s.run("edit.advancedFind", &json!({})).ok(); + s.document_id() + }); + assert_eq!(r.unwrap(), a); + assert_eq!(s.document_id(), b); + assert_eq!((text(&s), s.rev(), s.undo_depth()), ("Beta".to_string(), b_rev, b_undo), "B untouched"); + assert_eq!(s.with_document(a, |s| text(s)).unwrap(), "Alpha more"); + assert!(s.ui_requests.iter().all(|q| q.get("open").is_none() || q["document"] == a), "requests name their document: {:?}", s.ui_requests); + assert!(s.with_document(u64::MAX, |_| ()).is_err()); + + // Closing the active document inside leaves the one it ran on active. + s.with_document(a, |s| s.close_document(b)).unwrap().unwrap(); + assert_eq!(ids(&s), vec![a]); + assert_eq!(s.document_id(), a); +} + +/// A Read Aloud reading names its document: switching documents stops it, and turning citation +/// skipping on in another document doesn't restart it at the first document's positions there. +#[test] +fn read_aloud_belongs_to_its_document() { + let mut s = s(); + let a = s.document_id(); + run(&mut s, "text.insert", json!({"text": "One sentence. Another one."})); + run(&mut s, "caret.docStart", json!({})); + run(&mut s, "review.readAloud", json!({})); + assert_eq!(s.read_aloud.document, Some(a)); + let b = s.add_document(wordcraft_doc::Document::from_text("Other text.")); + assert_eq!(s.read_aloud.state(), crate::speech::State::Stopped, "the reading of A stopped"); + run(&mut s, "readAloud.skipCitations", json!({"value": false})); + assert_eq!(s.read_aloud.document, Some(a), "nothing restarted in B"); + run(&mut s, "review.readAloud", json!({})); + assert_eq!(s.read_aloud.document, Some(b)); +} + +/// Tests that `view.switchWindows` lists open documents, activates by document ID, and requests switching by window ID. +#[test] +fn switch_windows_lists_and_activates() { + let mut s = s(); + let a = s.document_id(); + let b = s.add_document(wordcraft_doc::Document::new()); + let r = run(&mut s, "view.switchWindows", json!({})); + let list = r["documents"].as_array().unwrap(); + assert_eq!( + list.iter().map(|d| (d["id"].as_u64(), d["title"].as_str(), d["active"].as_bool())).collect::>(), + vec![(Some(a), Some("Document1"), Some(false)), (Some(b), Some("Document2"), Some(true))] + ); + assert!(list[0].get("path").is_some() && list[0].get("dirty").is_some()); + run(&mut s, "view.switchWindows", json!({"id": a})); + assert_eq!(s.document_id(), a); + s.ui_requests.clear(); + run(&mut s, "view.switchWindows", json!({"window": 1})); + assert_eq!(s.ui_requests, vec![json!({"windows": "switchWindow", "window": 1})]); + s.windows.available = true; + s.windows.current = Some(1); + s.windows.others = vec![crate::cmd::view::OtherWindow { id: 2, title: "Second".into() }]; + s.ui_requests.clear(); + run(&mut s, "view.switchWindows", json!({"window": 2})); + assert_eq!(s.ui_requests, vec![json!({"windows": "switchWindow", "window": 2})]); + assert!(s.run("view.switchWindows", &json!({"window": 3})).is_err(), "unknown window key fails when windows are available"); + for bad in [json!({"id": 0}), json!({"id": u64::MAX}), json!({"id": -5}), json!({"id": "1"}), json!({"id": 1e300}), json!({"id": {}})] { + assert!(s.run("view.switchWindows", &bad).is_err(), "{bad}"); + assert_eq!(s.document_id(), a); + } + for bad in [json!({"window": "1"}), json!({"window": -1}), json!({"window": {}})] { + assert!(s.run("view.switchWindows", &bad).is_err(), "{bad}"); + } + let junk = [json!(null), json!([]), json!({"id": null}), json!({"text": 5})]; + for id in ["view.switchWindows", "file.closeDocument", "file.new"] { + for j in &junk { + let _ = s.run(id, j); + s.clamp_selection(); + let _ = s.layout(); + } + } + assert!(!s.documents().is_empty()); +} + +#[test] +fn document_ids_are_unique_across_sessions_and_replacements() { + let mut one = s(); + let two = s(); + assert_ne!(one.document_id(), two.document_id()); + let before = one.document_id(); + one.set_document(wordcraft_doc::Document::new()); + assert_ne!(one.document_id(), before); + assert_eq!(ids(&one), vec![one.document_id()], "the replacement takes its place in the list"); +} + +/// A Start screen's stand-in document gives back its name: the first document a person makes is +/// `Document1`, the next `Document2`, and after closing the last one, New makes `Document2` +/// again only if no other document has taken it. +#[test] +fn a_stand_in_document_gives_back_its_untitled_name() { + let mut s = s(); + let placeholder = s.document_id(); + s.release_untitled_name(); + run(&mut s, "file.new", json!({})); + s.close_document(placeholder).unwrap(); + assert_eq!(s.documents()[0].title, "Document1"); + run(&mut s, "file.new", json!({})); + assert_eq!(s.documents()[1].title, "Document2"); + // A document with work keeps its name. + run(&mut s, "text.insert", json!({"text": "Work"})); + s.release_untitled_name(); + run(&mut s, "file.new", json!({})); + assert_eq!(s.documents()[2].title, "Document3"); +} + +/// Two windows on one document: each keeps its own caret and zoom; a caret inside text another +/// window deleted is clamped, so typing there lands at the end of what is left; Undo in either +/// window undoes the document's last edit, and typing in one window never joins the other's +/// undo step. +#[test] +fn two_views_of_one_document_keep_their_own_caret_and_share_undo() { + let mut s = s(); + run(&mut s, "text.insert", json!({"text": "Hello world"})); + let mut b = s.copy_view(); + let a_caret = s.sel.focus.clone(); + + // Window B: zoom in; its caret is after "world". + let mut a = s.take_view(); + s.put_view(b); + run(&mut s, "view.zoom", json!({"value": 150})); + b = s.take_view(); + s.put_view(a); + assert_eq!(s.view.zoom, 1.0, "window A keeps its zoom"); + + // Window A deletes " world", where B's caret is. + run(&mut s, "select.text", json!({"text": " world"})); + run(&mut s, "text.delete", json!({})); + assert_eq!(text(&s).trim(), "Hello"); + a = s.take_view(); + s.put_view(b); + assert!((s.view.zoom - 1.5).abs() < 1e-6, "window B keeps its zoom"); + assert_eq!(s.sel.focus.off, 5, "B's caret is clamped into what is left"); + run(&mut s, "text.insert", json!({"text": "!"})); + assert_eq!(text(&s).trim(), "Hello!"); + + // Undo in A undoes B's typing; Undo in B then undoes A's delete. + b = s.take_view(); + s.put_view(a); + run(&mut s, "edit.undo", json!({})); + assert_eq!(text(&s).trim(), "Hello"); + a = s.take_view(); + s.put_view(b); + run(&mut s, "edit.undo", json!({})); + assert_eq!(text(&s).trim(), "Hello world"); + + // Typing in A, then B, then A again: three undo steps. + b = s.take_view(); + s.put_view(a); + s.sel = crate::Selection::caret(a_caret); + run(&mut s, "text.insert", json!({"text": "x"})); + a = s.take_view(); + s.put_view(b); + run(&mut s, "text.insert", json!({"text": "y"})); + b = s.take_view(); + s.put_view(a); + run(&mut s, "text.insert", json!({"text": "z"})); + run(&mut s, "edit.undo", json!({})); + assert!(!text(&s).contains('z') && text(&s).contains('x') && text(&s).contains('y'), "{}", text(&s)); + drop(b); +} + +/// An input method's promise that the next insertion joins its replacement belongs to the +/// window that made it: another window on the document typing at the same place gets an undo +/// step of its own. +#[test] +fn input_method_joins_stay_with_their_window() { + let mut s = s(); + run(&mut s, "text.insert", json!({"text": "Hello"})); + s.join_next_ime_text_replacement(); + let b = s.copy_view(); + let a = s.take_view(); + s.put_view(b); + run(&mut s, "text.insert", json!({"text": "y"})); + run(&mut s, "edit.undo", json!({})); + assert_eq!(text(&s).trim(), "Hello", "B's typing is its own undo step"); + drop(a); +} + +/// Check spelling as you type is one setting for the app, not one per open document (#5740): +/// turned off (File › Options › Proofing, or Review's command) with one document active, it is off +/// in the others too, and switching to another document doesn't turn it back on, so what is saved +/// for the next run is what was chosen, whichever document is active then. +#[test] +fn check_spelling_is_one_setting_for_every_open_document() { + let mut s = s(); + let a = s.document_id(); + run(&mut s, "file.new", json!({})); + let b = s.document_id(); + run(&mut s, "file.new", json!({})); + let c = s.document_id(); + assert!(s.view.proofing); + s.activate(a).unwrap(); + run(&mut s, "file.options", json!({"checkSpelling": false})); + for id in [b, c, a] { + s.activate(id).unwrap(); + assert!(!s.view.proofing, "off in document {id}"); + assert!(!s.prefs.check_spelling); + } + run(&mut s, "review.proofing", json!({"value": true})); + for id in [b, c] { + s.activate(id).unwrap(); + assert!(s.view.proofing, "back on in document {id}"); + } + // A document opened now starts with the setting as it is. + run(&mut s, "review.proofing", json!({"value": false})); + run(&mut s, "file.new", json!({})); + assert!(!s.view.proofing); +} + +/// A window's view put back after Check spelling as you type changed in another window takes the +/// setting as it is now, not as it was when the window's view was taken (#5740): every view made +/// active follows the saved preference. +#[test] +fn a_window_view_put_back_follows_the_spelling_setting() { + let mut s = s(); + assert!(s.view.proofing); + let held = s.take_view(); + run(&mut s, "file.options", json!({"checkSpelling": false})); + s.put_view(held); + assert!(!s.view.proofing, "the view taken with spelling on comes back with it off"); + let held = s.take_view(); + run(&mut s, "review.proofing", json!({"value": true})); + s.put_view(held); + assert!(s.view.proofing); +} diff --git a/crates/engine/src/tests/find_apostrophes.rs b/crates/engine/src/tests/find_apostrophes.rs index 7e4dac65e5..c06bb381fe 100644 --- a/crates/engine/src/tests/find_apostrophes.rs +++ b/crates/engine/src/tests/find_apostrophes.rs @@ -1,48 +1,48 @@ -use super::*; - -/// Keyboard smart quotes stay searchable, and replacement keeps their original spelling. -#[test] -fn keyboard_apostrophe_finds_smart_quoted_typing() { - let mut s = s(); - for ch in "Tau'va".chars() { - run(&mut s, "text.insert", json!({"text": ch.to_string()})); - } - assert_eq!(text(&s), "Tau’va"); - for query in ["Tau'", "Tau'v", "Tau'va", "tau'VA"] { - assert_eq!(run(&mut s, "edit.find", json!({"text": query}))["count"], 1, "{query}"); - } - assert_eq!(run(&mut s, "edit.find", json!({"text": "tau'va", "matchCase": true}))["count"], 0); - assert_eq!(run(&mut s, "edit.replaceAll", json!({"text": "Tau'va", "with": "[^&]", "matchCase": false}))["replaced"], 1); - assert_eq!(text(&s), "[Tau’va]"); - run(&mut s, "edit.undo", json!({})); - assert_eq!(text(&s), "Tau’va"); - run(&mut s, "edit.redo", json!({})); - assert_eq!(text(&s), "[Tau’va]"); -} - -/// Normal search uses raw UTF-8 ranges while explicit syntax and modifier letters stay exact. -#[test] -fn normal_apostrophe_search_keeps_exact_modes_and_original_byte_ranges() { - let mut s = s(); - let original = "🙂 Tau'va Tau’va Tau‘va Tauʼva"; - run(&mut s, "document.setText", json!({"text": original})); - let found = run(&mut s, "edit.find", json!({"text": "Tau'va", "wholeWord": true})); - assert_eq!(found["count"], 3); - let ranges: Vec<_> = - found["matches"].as_array().unwrap().iter().map(|m| (m["start"]["off"].as_u64().unwrap(), m["end"]["off"].as_u64().unwrap())).collect(); - assert_eq!(ranges, [(5, 11), (12, 20), (21, 29)]); - for options in [json!({"special": false}), json!({"wildcards": true}), json!({"regex": true})] { - let mut exact = super::s(); - run(&mut exact, "document.setText", json!({"text": original})); - let mut params = options; - params["text"] = json!("Tau'va"); - assert_eq!(run(&mut exact, "edit.find", params)["count"], 1); - } - for query in ["Tau^u39va", "Tau^u8217va", "Tau’va", "Tau‘va", "Tauʼva"] { - assert_eq!(run(&mut s, "edit.find", json!({"text": query}))["count"], 1, "{query}"); - } - assert_eq!(run(&mut s, "edit.replaceAll", json!({"text": "Tau'va", "with": "<^&>"}))["replaced"], 3); - assert_eq!(text(&s), "🙂 Tauʼva"); - run(&mut s, "edit.undo", json!({})); - assert_eq!(text(&s), original); -} +use super::*; + +/// Keyboard smart quotes stay searchable, and replacement keeps their original spelling. +#[test] +fn keyboard_apostrophe_finds_smart_quoted_typing() { + let mut s = s(); + for ch in "Tau'va".chars() { + run(&mut s, "text.insert", json!({"text": ch.to_string()})); + } + assert_eq!(text(&s), "Tau’va"); + for query in ["Tau'", "Tau'v", "Tau'va", "tau'VA"] { + assert_eq!(run(&mut s, "edit.find", json!({"text": query}))["count"], 1, "{query}"); + } + assert_eq!(run(&mut s, "edit.find", json!({"text": "tau'va", "matchCase": true}))["count"], 0); + assert_eq!(run(&mut s, "edit.replaceAll", json!({"text": "Tau'va", "with": "[^&]", "matchCase": false}))["replaced"], 1); + assert_eq!(text(&s), "[Tau’va]"); + run(&mut s, "edit.undo", json!({})); + assert_eq!(text(&s), "Tau’va"); + run(&mut s, "edit.redo", json!({})); + assert_eq!(text(&s), "[Tau’va]"); +} + +/// Normal search uses raw UTF-8 ranges while explicit syntax and modifier letters stay exact. +#[test] +fn normal_apostrophe_search_keeps_exact_modes_and_original_byte_ranges() { + let mut s = s(); + let original = "🙂 Tau'va Tau’va Tau‘va Tauʼva"; + run(&mut s, "document.setText", json!({"text": original})); + let found = run(&mut s, "edit.find", json!({"text": "Tau'va", "wholeWord": true})); + assert_eq!(found["count"], 3); + let ranges: Vec<_> = + found["matches"].as_array().unwrap().iter().map(|m| (m["start"]["off"].as_u64().unwrap(), m["end"]["off"].as_u64().unwrap())).collect(); + assert_eq!(ranges, [(5, 11), (12, 20), (21, 29)]); + for options in [json!({"special": false}), json!({"wildcards": true}), json!({"regex": true})] { + let mut exact = super::s(); + run(&mut exact, "document.setText", json!({"text": original})); + let mut params = options; + params["text"] = json!("Tau'va"); + assert_eq!(run(&mut exact, "edit.find", params)["count"], 1); + } + for query in ["Tau^u39va", "Tau^u8217va", "Tau’va", "Tau‘va", "Tauʼva"] { + assert_eq!(run(&mut s, "edit.find", json!({"text": query}))["count"], 1, "{query}"); + } + assert_eq!(run(&mut s, "edit.replaceAll", json!({"text": "Tau'va", "with": "<^&>"}))["replaced"], 3); + assert_eq!(text(&s), "🙂 Tauʼva"); + run(&mut s, "edit.undo", json!({})); + assert_eq!(text(&s), original); +} diff --git a/crates/engine/src/tests/fit_one_page.rs b/crates/engine/src/tests/fit_one_page.rs index 0e58feb43c..a7458bf8a2 100644 --- a/crates/engine/src/tests/fit_one_page.rs +++ b/crates/engine/src/tests/fit_one_page.rs @@ -1,1150 +1,1150 @@ -use serde_json::json; -use wordcraft_doc::Document; -use wordcraft_doc::para::PAGE_BREAK; -use wordcraft_doc::props::CharProps; - -use crate::Session; - -/// Creates a document with `lines` lines of text. -fn create_multiline_doc(lines: usize) -> Session { - let mut text = String::new(); - for i in 1..=lines { - text.push_str(&format!("Line {i}: The quick brown fox jumps over the lazy dog.\n")); - } - Session::new(Document::from_text(&text)) -} - -#[test] -fn fit_one_page_fails_on_single_page() { - let mut s = create_multiline_doc(5); - let before = s.doc.clone(); - let res = s.run("layout.fitOnePage", &json!({})); - assert!(res.is_err()); - let err = res.unwrap_err().to_string(); - assert!(err.contains("already fits on one page"), "unexpected error: {err}"); - assert_eq!(s.doc, before, "document must not change on failure"); -} - -#[test] -fn fit_one_page_fails_on_hard_page_break() { - let text = format!("Page 1 content\n{PAGE_BREAK}Page 2 content\n"); - let mut s = Session::new(Document::from_text(&text)); - let before = s.doc.clone(); - let res = s.run("layout.fitOnePage", &json!({})); - assert!(res.is_err()); - let err = res.unwrap_err().to_string(); - assert!(err.contains("explicit page or section breaks"), "unexpected error: {err}"); - assert_eq!(s.doc, before, "document must not change on failure"); -} - -#[test] -fn fit_one_page_refuses_page_break_before_before_search() { - let mut s = create_multiline_doc(27); - assert_eq!(s.layout().pages.len(), 2); - if let wordcraft_doc::Block::Para(p) = std::sync::Arc::make_mut(&mut s.doc.body[1]) { - p.props.page_break_before = Some(true); - } - s.touch(); - s.relayout(); - assert_eq!(s.layout().pages.len(), 2); - let before = s.doc.clone(); - - let res = s.run("layout.fitOnePage", &json!({})); - assert!(res.is_err()); - let err = res.unwrap_err().to_string(); - assert!(err.contains("explicit page or section breaks"), "page_break_before must be reported as an explicit break: {err}"); - assert_eq!(s.doc, before, "document must not change on refusal"); -} - -#[test] -fn fit_one_page_fails_on_next_page_section_break() { - let mut s = Session::new(Document::from_text("First section body text")); - s.run("layout.break", &json!({"kind": "nextPage"})).unwrap(); - s.run("text.insert", &json!({"text": "Second section text on next page"})).unwrap(); - assert_eq!(s.layout().pages.len(), 2, "break must cause 2 pages"); - - let before = s.doc.clone(); - let res = s.run("layout.fitOnePage", &json!({})); - assert!(res.is_err()); - let err = res.unwrap_err().to_string(); - assert!(err.contains("explicit page or section breaks"), "unexpected error: {err}"); - assert_eq!(s.doc, before, "document must not change on failure"); -} - -#[test] -fn fit_one_page_condenses_slight_overflow_and_undoes() { - // Find the boundary where the document just spills onto page 2 - let mut lines = 30; - let mut s = create_multiline_doc(lines); - while s.layout().pages.len() < 2 && lines < 80 { - lines += 2; - s = create_multiline_doc(lines); - } - assert_eq!(s.layout().pages.len(), 2, "must start as a 2-page document with {lines} lines"); - - let doc_before = s.doc.clone(); - let res = s.run("layout.fitOnePage", &json!({})); - assert!(res.is_ok(), "fit should succeed on slight overflow: {res:?}"); - assert_eq!(s.layout().pages.len(), 1, "document must now fit on 1 page"); - assert!(s.status.contains("Document fitted to 1 page"), "status was: {}", s.status); - - // Test single atomic undo - let undo_res = s.run("edit.undo", &json!({})); - assert!(undo_res.is_ok(), "undo must succeed"); - assert_eq!(s.layout().pages.len(), 2, "undo must restore 2-page layout"); - assert_eq!(s.doc, doc_before, "undo must restore exact pre-fit document state"); - - // Test redo - let redo_res = s.run("edit.redo", &json!({})); - assert!(redo_res.is_ok(), "redo must succeed"); - assert_eq!(s.layout().pages.len(), 1, "redo must restore 1-page layout"); -} - -#[test] -fn fit_one_page_respects_max_delta() { - let mut s = create_multiline_doc(27); - assert_eq!(s.layout().pages.len(), 2, "must start as 2-page document"); - - let doc_backup = s.doc.clone(); - let ok_res = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.5, "adjustSpacing": false})); - assert!(ok_res.is_ok(), "must succeed when positive font delta 0.5 is permitted: {ok_res:?}"); - assert_eq!(s.layout().pages.len(), 1); - - s.doc = doc_backup; - let res = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.0, "adjustSpacing": false})); - assert!(res.is_err(), "must fail when font delta is capped at 0.0 and spacing is disabled"); -} - -#[test] -fn fit_one_page_respects_step_pt() { - let mut text = String::new(); - for i in 1..=28 { - text.push_str(&format!("Line {i}: The quick brown fox jumps over the lazy dog.\n")); - } - let mut s = Session::new(Document::from_text(&text)); - for b in &mut s.doc.body { - if let wordcraft_doc::Block::Para(p) = std::sync::Arc::make_mut(b) { - p.mark.size = Some(11.4); - for r in &mut p.runs { - r.props.size = Some(11.4); - } - } - } - assert_eq!(s.layout().pages.len(), 2, "must start as 2-page document at 11.4 pt"); - - // Both calls hold maxDeltaPt constant at 0.4 and disable spacing adjustments. - // Default 0.5 pt step generates no candidates <= 0.4 pt, so it must fail. - let doc_backup = s.doc.clone(); - let default_res = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.4, "adjustSpacing": false})); - assert!(default_res.is_err(), "default step 0.5 must skip all candidates when maxDelta is 0.4"); - - // With stepPt = 0.1 under the same maxDeltaPt = 0.4, fine-grained steps (0.1..0.4) succeed (11.4 -> 11.0 pt). - s.doc = doc_backup; - let res = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.4, "stepPt": 0.1, "adjustSpacing": false})); - assert!(res.is_ok(), "stepPt=0.1 must generate fine-grained candidates under maxDelta 0.4: {res:?}"); - assert_eq!(s.layout().pages.len(), 1); -} - -#[test] -fn fit_one_page_allows_spacing_only_at_font_floor() { - let mut lines = 35; - let mut s = create_multiline_doc(lines); - let set_font = |s: &mut Session| { - for b in &mut s.doc.body { - if let wordcraft_doc::Block::Para(p) = std::sync::Arc::make_mut(b) { - for r in &mut p.runs { - r.props = CharProps { size: Some(8.5), ..Default::default() }; - } - } - } - s.touch(); - s.relayout(); - }; - set_font(&mut s); - while s.layout().pages.len() < 2 && lines < 120 { - lines += 1; - s = create_multiline_doc(lines); - set_font(&mut s); - } - assert_eq!(s.layout().pages.len(), 2); - - // Spacing-only candidate must be permitted to run and succeed - let res = s.run("layout.fitOnePage", &json!({})); - assert!(res.is_ok(), "spacing-only candidate must succeed at font floor: {res:?}"); - assert_eq!(s.layout().pages.len(), 1); - assert!(s.status.contains("spacing: "), "status: {}", s.status); - assert!(!s.status.contains("font: -"), "font must remain untouched: {}", s.status); -} - -#[test] -fn fit_one_page_fails_when_body_font_already_at_minimum_readable_size() { - let mut lines = 35; - let mut s = create_multiline_doc(lines); - let set_font = |s: &mut Session| { - for b in &mut s.doc.body { - if let wordcraft_doc::Block::Para(p) = std::sync::Arc::make_mut(b) { - for r in &mut p.runs { - r.props = CharProps { size: Some(8.0), ..Default::default() }; - } - } - } - s.touch(); - s.relayout(); - }; - set_font(&mut s); - while s.layout().pages.len() < 2 && lines < 120 { - lines += 1; - s = create_multiline_doc(lines); - set_font(&mut s); - } - assert_eq!(s.layout().pages.len(), 2); - - // When spacing adjustment is disabled, no candidate can run without reducing font - let res = s.run("layout.fitOnePage", &json!({"adjustSpacing": false})); - assert!(res.is_err(), "must refuse to shrink font when already <= 8.5 pt"); - let err = res.unwrap_err().to_string(); - assert!(err.contains("Unable to shrink document further without reducing text beyond readability limits."), "unexpected error message: {err}"); -} - -#[test] -fn fit_one_page_bounds_fine_step_loop() { - let mut lines = 28; - let mut s = create_multiline_doc(lines); - while s.layout().pages.len() < 2 && lines < 80 { - lines += 1; - s = create_multiline_doc(lines); - } - assert_eq!(s.layout().pages.len(), 2, "fixture must reach candidate generation on 2 pages"); - - // With stepPt = 0.001 and maxDeltaPt = 2.0 (ratio = 2000), fine-step candidate count - // is clamped by MAX_FINE_STEPS (20), executing quickly without doing thousands of iterations. - let res = s.run("layout.fitOnePage", &json!({"stepPt": 0.001, "maxDeltaPt": 2.0, "adjustSpacing": false})); - assert!(res.is_ok(), "fitOnePage with bounded fine steps succeeds or terminates gracefully: {res:?}"); -} - -#[test] -fn fit_one_page_supports_max_delta_above_2pt() { - let mut lines = 28; - let mut s = create_multiline_doc(lines); - while s.layout().pages.len() < 2 && lines < 80 { - lines += 1; - s = create_multiline_doc(lines); - } - // Add 8 lines of spill (within the 12-line spill limit) - for i in 1..=8 { - s.run("text.insert", &json!({"text": format!("Spill paragraph line {i}\n")})).unwrap(); - } - assert_eq!(s.layout().pages.len(), 2); - - // With maxDeltaPt = 3.0, candidate above 2 pt fits the document - let res = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 3.0})); - assert!(res.is_ok(), "fitOnePage with maxDeltaPt=3.0 must succeed: {res:?}"); - assert_eq!(s.layout().pages.len(), 1, "must fit on 1 page"); -} - -#[test] -fn fit_one_page_anti_compound_guard_prevents_repeated_invocations() { - let mut lines = 28; - let mut s = create_multiline_doc(lines); - loop { - for b in &mut s.doc.body { - if let wordcraft_doc::Block::Para(p) = std::sync::Arc::make_mut(b) { - p.props.line_spacing = Some(wordcraft_doc::props::LineSpacing::Multiple(0.94)); - for r in &mut p.runs { - r.props.size = Some(11.0); - } - } - } - s.touch(); - s.relayout(); - if s.layout().pages.len() >= 2 || lines >= 80 { - break; - } - lines += 1; - s = create_multiline_doc(lines); - } - assert_eq!(s.layout().pages.len(), 2); - - // Simulate a document that already used the full 1.0 pt font and 0.94 spacing budgets. - s.doc.fit_state = Some(wordcraft_doc::FitState { - cum_delta: 1.0, - cum_space: 0.94, - original_font_sizes: vec![12.0], - original_line_spacings: vec![wordcraft_doc::props::LineSpacing::Multiple(1.0)], - ..Default::default() - }); - s.touch(); - s.relayout(); - let doc_before = s.doc.clone(); - - let res = s.run("layout.fitOnePage", &json!({})); - assert!(res.is_err(), "exhausted budgets must be rejected"); - let err = res.unwrap_err().to_string(); - assert!(err.contains("Unable to shrink document further without reducing text beyond readability limits."), "unexpected error message: {err}"); - assert_eq!(s.doc, doc_before, "document must not be mutated on rejected invocation"); -} - -#[test] -fn fit_one_page_already_fits_wins_over_exhausted_budget() { - let mut s = create_multiline_doc(5); - assert_eq!(s.layout().pages.len(), 1); - for b in &mut s.doc.body { - if let wordcraft_doc::Block::Para(p) = std::sync::Arc::make_mut(b) { - p.props.line_spacing = Some(wordcraft_doc::props::LineSpacing::Multiple(0.94)); - for r in &mut p.runs { - r.props.size = Some(11.0); - } - } - } - s.doc.fit_state = Some(wordcraft_doc::FitState { - cum_delta: 1.0, - cum_space: 0.94, - original_font_sizes: vec![12.0], - original_line_spacings: vec![wordcraft_doc::props::LineSpacing::Multiple(1.0)], - ..Default::default() - }); - s.touch(); - s.relayout(); - - let res = s.run("layout.fitOnePage", &json!({})); - assert!(res.is_err()); - let err = res.unwrap_err().to_string(); - assert!(err.contains("already fits on one page"), "already-fits must take precedence over exhausted budgets: {err}"); -} - -#[test] -fn fit_one_page_checks_complex_script_font_floor() { - let mut lines = 28; - let mut s = create_multiline_doc(lines); - while s.layout().pages.len() < 2 && lines < 80 { - lines += 1; - s = create_multiline_doc(lines); - } - assert_eq!(s.layout().pages.len(), 2); - - // Insert an Arabic run with size 12.0 pt but size_cs 9.0 pt - let arabic_p = wordcraft_doc::Paragraph::with_text( - "نص تجريبي باللغة العربية", - CharProps { size: Some(12.0), size_cs: Some(9.0), cs: Some(true), ..Default::default() }, - ); - s.doc.body.insert(0, wordcraft_doc::para_block(arabic_p)); - assert_eq!(s.layout().pages.len(), 2); - - // A 1.0 pt reduction would reduce size_cs from 9.0 to 8.0 (< 8.5 pt floor). - // The trial must not commit. - let _ = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 1.0, "adjustSpacing": false})); - let p = s.doc.body[0].as_para().unwrap(); - let cs_size = p.runs[0].props.size_cs.unwrap_or(9.0); - assert!(cs_size >= 8.5, "complex-script size must remain >= 8.5 pt, was {cs_size}"); -} - -#[test] -fn fit_one_page_checks_editable_text_box_font_floor() { - let mut lines = 28; - let mut s = create_multiline_doc(lines); - while s.layout().pages.len() < 2 && lines < 80 { - lines += 1; - s = create_multiline_doc(lines); - } - assert_eq!(s.layout().pages.len(), 2); - - // Add a body-anchored text box with 9.0 pt font - let box_id = s.doc.add_part( - wordcraft_doc::PartKind::TextBox, - vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text( - "Text box body text", - CharProps { size: Some(9.0), ..Default::default() }, - ))], - ); - // Link it into body - let mut shape_p = wordcraft_doc::Paragraph::with_text("Before box ", CharProps::default()); - let shape = wordcraft_doc::InlineObject::Shape { - kind: wordcraft_doc::para::ShapeKind::TextBox, - w: 100.0, - h: 50.0, - fill: None, - stroke: None, - stroke_width: 0.5, - float: Default::default(), - story: Some(box_id), - freeform: None, - effects: Default::default(), - extra: Default::default(), - }; - shape_p.objects.push(shape); - s.doc.body.insert(0, wordcraft_doc::para_block(shape_p)); - assert_eq!(s.layout().pages.len(), 2); - - // With maxDeltaPt = 1.0 and adjustSpacing = false, 9.0 pt would reduce to 8.0 pt. - // The trial must not be committed. - let _ = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 1.0, "adjustSpacing": false})); - let part = s.doc.parts.get(&box_id).unwrap(); - let box_p = part.blocks[0].as_para().unwrap(); - let sz = box_p.runs[0].props.size.unwrap(); - assert!(sz >= 8.5, "editable text box font must remain >= 8.5 pt, was {sz}"); -} - -#[test] -fn fit_one_page_fails_when_overflow_exceeds_limits() { - let mut lines = 28; - let mut s = create_multiline_doc(lines); - while s.layout().pages.len() < 2 { - lines += 1; - s = create_multiline_doc(lines); - } - // Add 16 extra lines to page 2: this overflow is too large to fit into page 1 with maxDeltaPt = 0.5 - for i in 1..=16 { - s.run("text.insert", &json!({"text": format!("Overflow line {i}\n")})).unwrap(); - } - assert_eq!(s.layout().pages.len(), 2, "must be a 2-page document"); - - let res = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.5})); - assert!(res.is_err(), "must refuse when overflow exceeds aesthetic reduction limits"); - let err = res.unwrap_err().to_string(); - assert!(err.contains("exceeds limits for aesthetic page reduction"), "unexpected error message: {err}"); -} - -#[test] -fn fit_one_page_validates_effective_font_in_tables_before_committing() { - let mut s = Session::new(Document::new()); - let mut table = wordcraft_doc::Table::new(1, 1, 400.0); - let cell_p = wordcraft_doc::Paragraph::with_text("Cell text", CharProps { size: Some(9.0), ..Default::default() }); - table.rows[0].cells[0].blocks = vec![wordcraft_doc::para_block(cell_p)]; - s.doc.body = vec![std::sync::Arc::new(wordcraft_doc::Block::Table(table))]; - - let mut text = String::new(); - for i in 1..=45 { - text.push_str(&format!("Line {i} following table: The quick brown fox jumps over the lazy dog.\n")); - } - s.doc.body.push(wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(&text, CharProps::default()))); - assert_eq!(s.layout().pages.len(), 2); - - // If maxDeltaPt is 1.0, 9.0 pt would reduce to 8.0 pt (violating 8.5 pt floor). - // The candidate must not be committed. - let _ = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 1.0, "adjustSpacing": false})); - if let Some(tbl) = s.doc.body[0].as_table() { - let p = tbl.rows[0].cells[0].blocks[0].as_para().unwrap(); - let sz = p.runs[0].props.size.unwrap(); - assert!(sz >= 8.5, "table cell font size must remain >= 8.5 pt, was {sz}"); - } -} - -#[test] -fn fit_one_page_validates_effective_font_in_table_styles_before_committing() { - let mut s = Session::new(Document::new()); - // Create a custom table style with 9.0 pt font - let st = wordcraft_doc::styles::Style { - id: "CustomTableStyle".into(), - name: "CustomTableStyle".into(), - kind: wordcraft_doc::styles::StyleKind::Table, - chr: CharProps { size: Some(9.0), ..Default::default() }, - table: Some(wordcraft_doc::styles::TableStyleParts::default()), - ..Default::default() - }; - std::sync::Arc::make_mut(&mut s.doc.styles).upsert(st); - - let mut table = wordcraft_doc::Table::new(1, 1, 400.0); - table.props.style = Some("CustomTableStyle".to_string()); - let cell_p = wordcraft_doc::Paragraph::with_text("Cell text inheriting table style", CharProps::default()); - table.rows[0].cells[0].blocks = vec![wordcraft_doc::para_block(cell_p)]; - s.doc.body = vec![std::sync::Arc::new(wordcraft_doc::Block::Table(table))]; - - let mut text = String::new(); - for i in 1..=45 { - text.push_str(&format!("Line {i} following table: The quick brown fox jumps over the lazy dog.\n")); - } - s.doc.body.push(wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(&text, CharProps::default()))); - assert_eq!(s.layout().pages.len(), 2); - - // If maxDeltaPt is 1.0, 9.0 pt in the table style would reduce to 8.0 pt (violating 8.5 pt floor). - // The candidate must not be committed. - let _ = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 1.0, "adjustSpacing": false})); - let style = s.doc.styles.table_style("CustomTableStyle").unwrap(); - let sz = style.chr.size.unwrap(); - assert!(sz >= 8.5, "table style font size must remain >= 8.5 pt, was {sz}"); -} - -#[test] -fn fit_one_page_measures_only_final_page_placed_lines() { - let mut s = Session::new(Document::new()); - let words = "The quick brown fox jumps over the lazy dog and runs across the wide open fields in the sunshine. "; - let mut reps = 20; - loop { - let text = words.repeat(reps); - s.doc.body = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(&text, CharProps::default()))]; - s.touch(); - s.relayout(); - if s.layout().pages.len() >= 2 { - break; - } - reps += 1; - } - assert_eq!(s.layout().pages.len(), 2); - - let res = s.run("layout.fitOnePage", &json!({})); - assert!(res.is_ok(), "fitOnePage must succeed when single paragraph slightly spills onto page 2: {res:?}"); - assert_eq!(s.layout().pages.len(), 1); -} - -#[test] -fn fit_one_page_exempts_style_inherited_superscript() { - let mut s = Session::new(Document::new()); - // Create a Character style with Superscript alignment and 7.0 pt font - let st = wordcraft_doc::styles::Style { - id: "SuperStyle".into(), - name: "SuperStyle".into(), - kind: wordcraft_doc::styles::StyleKind::Character, - chr: CharProps { vert_align: Some(wordcraft_doc::props::VertAlign::Superscript), size: Some(7.0), ..Default::default() }, - ..Default::default() - }; - std::sync::Arc::make_mut(&mut s.doc.styles).upsert(st); - - let words = "Regular readable text that fills up lines in the document body. "; - let mut reps = 20; - loop { - let mut p = wordcraft_doc::Paragraph::with_text(&words.repeat(reps), CharProps { size: Some(11.0), ..Default::default() }); - // Add a run with style-inherited superscript - p.runs.push(wordcraft_doc::Run { len: 3, props: CharProps { style: Some("SuperStyle".into()), ..Default::default() } }); - p.text.push_str("123"); - s.doc.body = vec![wordcraft_doc::para_block(p)]; - s.touch(); - s.relayout(); - if s.layout().pages.len() >= 2 { - break; - } - reps += 1; - } - assert_eq!(s.layout().pages.len(), 2); - - // fitOnePage must not be blocked by the 7.0 pt superscript run because it is exempt - let res = s.run("layout.fitOnePage", &json!({})); - assert!(res.is_ok(), "style-inherited superscript must be exempt from font floor: {res:?}"); - assert_eq!(s.layout().pages.len(), 1); -} - -#[test] -fn fit_one_page_refused_when_protect_formatting_enabled() { - let mut s = Session::new(Document::new()); - s.doc.settings.protection = Some("none".into()); - s.doc.settings.protect_formatting = true; - let res = s.run("layout.fitOnePage", &json!({})); - assert!(res.is_err()); - let err = res.unwrap_err().to_string(); - assert!(err.contains("formatting is limited to the document's styles"), "unexpected error: {err}"); -} - -#[test] -fn fit_one_page_zero_max_delta_never_reduces_font() { - let mut s = Session::new(Document::new()); - let words = "Content line with fractional font size for zero delta testing. "; - let mut reps = 20; - loop { - let text = words.repeat(reps); - s.doc.body = - vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(&text, CharProps { size: Some(10.3), ..Default::default() }))]; - s.touch(); - s.relayout(); - if s.layout().pages.len() >= 2 { - break; - } - reps += 1; - } - assert_eq!(s.layout().pages.len(), 2); - - // Call fitOnePage with maxDeltaPt = 0.0 and a tiny stepPt (which should not produce any positive candidate) - let _ = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.0, "stepPt": 0.001})); - let p = s.doc.body[0].as_para().unwrap(); - assert_eq!(p.runs[0].props.size, Some(10.3), "font size must remain completely unchanged when maxDeltaPt is 0"); -} - -#[test] -fn fit_one_page_fails_with_failed_cmd_error() { - let mut s = create_multiline_doc(5); - let res = s.run("layout.fitOnePage", &json!({})); - assert!(matches!(res, Err(crate::CmdError::Failed(_))), "expected CmdError::Failed, got {res:?}"); -} - -#[test] -fn fit_one_page_never_populates_custom_props() { - let mut lines = 30; - let mut s = create_multiline_doc(lines); - while s.layout().pages.len() < 2 && lines < 80 { - lines += 2; - s = create_multiline_doc(lines); - } - assert!(s.doc.custom_props.is_empty()); - let res = s.run("layout.fitOnePage", &json!({})); - assert!(res.is_ok()); - assert!(s.doc.custom_props.is_empty(), "custom_props must not be populated with internal fit state"); -} - -#[test] -fn fit_one_page_allows_subsequent_fit_after_typography_reset() { - use std::sync::Arc; - - let mut lines = 28; - let mut s = create_multiline_doc(lines); - while s.layout().pages.len() < 2 && lines < 80 { - lines += 1; - s = create_multiline_doc(lines); - } - assert_eq!(s.layout().pages.len(), 2); - - // First invocation succeeds - let res1 = s.run("layout.fitOnePage", &json!({})); - assert!(res1.is_ok()); - assert_eq!(s.layout().pages.len(), 1); - - // Reset styles back to Normal with 12 pt font and 1.15 line spacing - if let Some(st) = Arc::make_mut(&mut s.doc.styles).styles.iter_mut().find(|st| st.id == "Normal") { - st.chr.size = Some(12.0); - st.para.line_spacing = Some(wordcraft_doc::props::LineSpacing::Multiple(1.15)); - } - for b in &mut s.doc.body { - if let wordcraft_doc::Block::Para(p) = Arc::make_mut(b) { - p.props.line_spacing = None; - for r in &mut p.runs { - r.props.size = None; - } - } - } - s.touch(); - s.relayout(); - - // Now add lines to make it 2 pages again - while s.layout().pages.len() < 2 { - s.run("text.insert", &json!({"text": "Additional fresh paragraph after style reset.\n"})).unwrap(); - } - assert_eq!(s.layout().pages.len(), 2); - - // A subsequent fitOnePage run must NOT be permanently blocked and should succeed - let res2 = s.run("layout.fitOnePage", &json!({})); - assert!(res2.is_ok(), "fitOnePage must succeed after typography reset: {res2:?}"); - assert_eq!(s.layout().pages.len(), 1); -} - -#[test] -fn fit_one_page_small_paragraph_mark_with_runs_never_blocks_fit() { - let mut lines = 30; - let mut s = create_multiline_doc(lines); - while s.layout().pages.len() < 2 && lines < 80 { - lines += 2; - s = create_multiline_doc(lines); - } - // Set a 1.0 pt paragraph mark on a body paragraph that has normal 11 pt text runs - if let wordcraft_doc::Block::Para(p) = std::sync::Arc::make_mut(&mut s.doc.body[0]) { - p.mark.size = Some(1.0); - } - s.touch(); - s.relayout(); - assert_eq!(s.layout().pages.len(), 2); - - let res = s.run("layout.fitOnePage", &json!({})); - assert!(res.is_ok(), "small mark with normal text runs must not block fit: {res:?}"); - assert_eq!(s.layout().pages.len(), 1); -} - -#[test] -fn fit_one_page_small_comment_font_never_blocks_body_fit() { - let mut lines = 30; - let mut s = create_multiline_doc(lines); - while s.layout().pages.len() < 2 && lines < 80 { - lines += 2; - s = create_multiline_doc(lines); - } - // Add a comment part with 6.0 pt text - let comment_p = wordcraft_doc::Paragraph::with_text("Small comment text", CharProps { size: Some(6.0), ..Default::default() }); - s.doc.add_part(wordcraft_doc::PartKind::Comment, vec![wordcraft_doc::para_block(comment_p)]); - s.touch(); - s.relayout(); - assert_eq!(s.layout().pages.len(), 2); - - let res = s.run("layout.fitOnePage", &json!({})); - assert!(res.is_ok(), "small comment font must not block body fit: {res:?}"); - assert_eq!(s.layout().pages.len(), 1); -} - -#[test] -fn fit_one_page_anti_compound_caps_total_reduction_across_runs() { - let mut s = create_multiline_doc(27); - assert_eq!(s.layout().pages.len(), 2); - - // Initial fit with -0.5 pt font reduction (spacing disabled so font budget is exercised) - let res1 = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.5, "adjustSpacing": false})); - assert!(res1.is_ok()); - assert_eq!(s.layout().pages.len(), 1); - assert_eq!(s.doc.fit_state.as_ref().map(|f| f.cum_delta), Some(0.5)); - - // Add content to spill to 2 pages again - for i in 1..=12 { - s.run("text.insert", &json!({"text": format!("Spill paragraph line {i}\n")})).unwrap(); - } - assert_eq!(s.layout().pages.len(), 2); - - // Candidate requiring > 0.5 pt more (e.g. total > 1.0 pt) must be rejected - let res2 = s.run("layout.fitOnePage", &json!({})); - assert!(res2.is_err(), "second run must not exceed cumulative 1.0 pt reduction budget"); -} - -#[test] -fn fit_one_page_tracks_reduced_text_provenance_without_global_minimum() { - let mut s = create_multiline_doc(27); - // Add a 9.0 pt run to the first paragraph alongside 11.0 pt runs - if let wordcraft_doc::Block::Para(p) = std::sync::Arc::make_mut(&mut s.doc.body[0]) { - p.runs.push(wordcraft_doc::para::Run { len: 5, props: CharProps { size: Some(9.0), ..Default::default() } }); - p.text.push_str("extra"); - } - s.touch(); - s.relayout(); - assert_eq!(s.layout().pages.len(), 2); - - // Initial fit with -0.5 pt: 9.0 pt -> 8.5 pt, 11.0 pt -> 10.5 pt (spacing disabled) - let res1 = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.5, "adjustSpacing": false})); - assert!(res1.is_ok()); - assert_eq!(s.layout().pages.len(), 1); - assert_eq!(s.doc.fit_state.as_ref().map(|f| f.cum_delta), Some(0.5)); - - // Reset only the 8.5 pt run back to 12.0 pt (so current global minimum font becomes 10.5 pt) - if let wordcraft_doc::Block::Para(p) = std::sync::Arc::make_mut(&mut s.doc.body[0]) { - p.runs.last_mut().unwrap().props.size = Some(12.0); - } - s.touch(); - s.relayout(); - - // Add content to spill to 2 pages again - for i in 1..=12 { - s.run("text.insert", &json!({"text": format!("Spill paragraph line {i}\n")})).unwrap(); - } - assert_eq!(s.layout().pages.len(), 2); - - // Because the 10.5 pt runs are still reduced, fit state must NOT be discarded, - // and a candidate requiring > 0.5 pt more font reduction must be rejected by the 1.0 pt cumulative cap - let res2 = s.run("layout.fitOnePage", &json!({})); - assert!(res2.is_err(), "cumulative budget must still hold even when the original minimum font was changed"); -} - -#[test] -fn fit_one_page_allows_spill_with_bottom_placed_footnote() { - let mut lines = 28; - let mut s = create_multiline_doc(lines); - while s.layout().pages.len() < 2 && lines < 80 { - lines += 1; - s = create_multiline_doc(lines); - } - assert_eq!(s.layout().pages.len(), 2); - - // Add a single-line footnote to the document (reference on page 1) - let note_p = wordcraft_doc::Paragraph::with_text("Short footnote text.", CharProps::default()); - let note_id = s.doc.add_part(wordcraft_doc::PartKind::Footnote, vec![wordcraft_doc::para_block(note_p)]); - let noteref = wordcraft_doc::para::InlineObject::NoteRef { kind: wordcraft_doc::para::NoteKind::Footnote, id: note_id, custom: String::new() }; - s.doc.insert_object(&wordcraft_doc::Pos::body(0, 5), noteref, &Default::default()).unwrap(); - s.touch(); - s.relayout(); - assert_eq!(s.layout().pages.len(), 2); - - // Even though the footnote is placed at the bottom of the page (large y), - // fill_ratio must measure only actual content height so fitOnePage is not falsely refused - let res = s.run("layout.fitOnePage", &json!({})); - assert!(res.is_ok(), "bottom-placed footnote must not drive fill ratio above 30%: {res:?}"); - assert_eq!(s.layout().pages.len(), 1); -} - -#[test] -fn fit_one_page_with_final_page_footnote() { - let mut lines = 28; - let mut s = create_multiline_doc(lines); - while s.layout().pages.len() < 2 && lines < 80 { - lines += 1; - s = create_multiline_doc(lines); - } - assert_eq!(s.layout().pages.len(), 2); - - // Insert note reference into a paragraph on the final page (page 2) - let last_para_idx = s.doc.body.len().saturating_sub(1); - let note_p = wordcraft_doc::Paragraph::with_text("Final page footnote text.", CharProps::default()); - let note_id = s.doc.add_part(wordcraft_doc::PartKind::Footnote, vec![wordcraft_doc::para_block(note_p)]); - let noteref = wordcraft_doc::para::InlineObject::NoteRef { kind: wordcraft_doc::para::NoteKind::Footnote, id: note_id, custom: String::new() }; - s.doc.insert_object(&wordcraft_doc::Pos::body(last_para_idx, 0), noteref, &Default::default()).unwrap(); - s.touch(); - s.relayout(); - assert_eq!(s.layout().pages.len(), 2); - - // Verify footnote is assigned to the final page (page index 1) - let page2_has_footnote = s.layout().pages[1] - .items - .iter() - .any(|item| matches!(item, wordcraft_layout::Placed::Lines { story: wordcraft_doc::StoryRef::Part(id), .. } if *id == note_id)); - assert!(page2_has_footnote, "footnote must be assigned to page 2"); - - // With modest spill and small footnote within the 30% fill threshold, fitOnePage succeeds - let res = s.run("layout.fitOnePage", &json!({})); - assert!(res.is_ok(), "final-page footnote within 30% fill limit must allow fitOnePage to succeed: {res:?}"); - assert_eq!(s.layout().pages.len(), 1); -} - -#[test] -fn fit_one_page_skips_deleted_text_when_markup_hidden() { - let mut s = create_multiline_doc(27); - assert_eq!(s.layout().pages.len(), 2); - - // Insert a deleted text run with small font (e.g. 6.0 pt, below the 8.5 pt floor) - if let wordcraft_doc::Block::Para(p) = std::sync::Arc::make_mut(&mut s.doc.body[0]) { - let mut del_props = CharProps { size: Some(6.0), ..Default::default() }; - del_props.del = Some(1); - p.runs.push(wordcraft_doc::para::Run { len: 8, props: del_props }); - p.text.push_str("deleted!"); - } - s.touch(); - s.relayout(); - - // When show_markup is false (the default in print view), deleted run is hidden - // and skipped during font-floor scan, allowing fitOnePage to succeed - s.view.show_markup = false; - let doc_backup = s.doc.clone(); - let res = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.5, "adjustSpacing": false})); - assert!(res.is_ok(), "deleted text below floor must be skipped when markup is hidden: {res:?}"); - assert_eq!(s.layout().pages.len(), 1); - - // When show_markup is true, deleted run is drawn and evaluated in font-floor scan, - // blocking font reduction because effective font is below 8.5 pt floor - s.doc = doc_backup; - s.view.show_markup = true; - let res_markup = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.5, "adjustSpacing": false})); - assert!(res_markup.is_err(), "deleted text below floor must block font reduction when markup is shown"); -} - -#[test] -fn fit_one_page_tracks_reduced_complex_script_font_sizes() { - let mut s = create_multiline_doc(27); - assert_eq!(s.layout().pages.len(), 2); - let styles_before = s.doc.styles.clone(); - - // Set complex script font size on body run while keeping standard font size default (12 pt) - if let wordcraft_doc::Block::Para(p) = std::sync::Arc::make_mut(&mut s.doc.body[0]) { - p.runs[0].props.size_cs = Some(14.0); - } - s.touch(); - s.relayout(); - - // First fit reduces font size by 0.5 pt (size -> 11.5, size_cs -> 13.5) - let res = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.5, "adjustSpacing": false})); - assert!(res.is_ok(), "first fit must succeed: {res:?}"); - assert_eq!(s.doc.fit_state.as_ref().map(|f| f.cum_delta), Some(0.5)); - - // User restores every other size (the styles the fit rescaled), leaving only this run's - // explicit complex-script size reduced at 13.5 pt. - s.doc.styles = styles_before; - s.touch(); - s.relayout(); - assert_eq!(s.layout().pages.len(), 2); - - // Because size_cs is still reduced, cumulative tracking retains the 0.5 pt delta provenance - assert_eq!(s.doc.fit_state.as_ref().map(|f| f.cum_delta), Some(0.5)); - - // Second fit within remaining 0.5 pt budget succeeds up to 1.0 pt cap - let res2 = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.5, "adjustSpacing": false})); - assert!(res2.is_ok(), "second fit within remaining budget must succeed: {res2:?}"); - assert_eq!(s.doc.fit_state.as_ref().map(|f| f.cum_delta), Some(1.0)); - - // Third fit exceeding cumulative 1.0 limit must be refused - let res3 = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.5, "adjustSpacing": false})); - assert!(res3.is_err(), "fit exceeding cumulative limit must be refused"); -} - -#[test] -fn fit_one_page_preserves_budget_across_document_serialization() { - let mut s = create_multiline_doc(27); - assert_eq!(s.layout().pages.len(), 2); - - // Apply fit - let res = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.5, "adjustSpacing": false})); - assert!(res.is_ok(), "fit must succeed: {res:?}"); - assert_eq!(s.doc.fit_state.as_ref().map(|f| f.cum_delta), Some(0.5)); - - // Serialize and deserialize document (e.g. saving and reopening) - let json_bytes = serde_json::to_vec(&s.doc).expect("serialization must succeed"); - let reopened_doc: wordcraft_doc::Document = serde_json::from_slice(&json_bytes).expect("deserialization must succeed"); - - // Verify fit_state is preserved outside custom properties - assert_eq!(reopened_doc.fit_state.as_ref().map(|f| f.cum_delta), Some(0.5)); - assert!(reopened_doc.custom_props.is_empty(), "custom_props must remain empty"); - - // Reopen into a new session with additional spill - let mut s2 = Session::new(reopened_doc); - let extra_p = wordcraft_doc::Paragraph::with_text("Extra overflow paragraph.", CharProps::default()); - s2.doc.body.push(wordcraft_doc::para_block(extra_p)); - s2.touch(); - s2.relayout(); - - // Second fit on reopened document preserves previous 0.5 delta, allowing only remaining 0.5 pt up to 1.0 cap - let res2 = s2.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.5, "adjustSpacing": false})); - assert!(res2.is_ok(), "second fit within remaining budget must succeed"); - assert_eq!(s2.doc.fit_state.as_ref().map(|f| f.cum_delta), Some(1.0)); - - // Third fit exceeding the cumulative 1.0 limit must be refused - let res3 = s2.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.5, "adjustSpacing": false})); - assert!(res3.is_err(), "fit exceeding cumulative limit must be refused"); -} - -#[test] -fn fit_one_page_enforces_durable_limit_when_fit_state_missing() { - // A document with body font already at 8.5 pt readability floor and fit_state == None - let set_font = |s: &mut Session| { - for b in &mut s.doc.body { - if let wordcraft_doc::Block::Para(p) = std::sync::Arc::make_mut(b) { - for r in &mut p.runs { - r.props.size = Some(8.5); - } - } - } - s.touch(); - s.relayout(); - }; - let mut lines = 27; - let mut s = create_multiline_doc(lines); - set_font(&mut s); - while s.layout().pages.len() < 2 && lines < 120 { - lines += 1; - s = create_multiline_doc(lines); - set_font(&mut s); - } - assert_eq!(s.layout().pages.len(), 2); - s.doc.fit_state = None; - s.touch(); - s.relayout(); - let before = s.doc.clone(); - - // With adjustSpacing = false, font reduction is bounded by the durable 8.5 pt floor and refused - let res = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.5, "adjustSpacing": false})); - assert!(res.is_err(), "fit must be refused when typography is at durable readability floor"); - let err = res.unwrap_err().to_string(); - assert!(err.contains("Unable to shrink document further without reducing text beyond readability limits."), "unexpected error: {err}"); - assert_eq!(s.doc, before, "a refused fit must leave the document unchanged"); -} - -#[test] -fn fit_one_page_skips_hidden_text_when_hidden_marks_off() { - let mut s = create_multiline_doc(27); - assert_eq!(s.layout().pages.len(), 2); - - // Insert a hidden text run with small font (6.0 pt, below the 8.5 pt floor) into an existing paragraph - if let wordcraft_doc::Block::Para(p) = std::sync::Arc::make_mut(&mut s.doc.body[0]) { - let mut hidden_props = CharProps { size: Some(6.0), ..Default::default() }; - hidden_props.hidden = Some(true); - p.runs.push(wordcraft_doc::para::Run { len: 7, props: hidden_props }); - p.text.push_str("secret!"); - } - s.touch(); - s.relayout(); - - // When marks are off, hidden text is skipped from layout and floor checks - s.view.marks = false; - s.prefs.marks.hidden = false; - let doc_backup = s.doc.clone(); - - let res = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.5, "adjustSpacing": false})); - assert!(res.is_ok(), "hidden text with small font must be skipped when hidden marks are off: {res:?}"); - assert_eq!(s.layout().pages.len(), 1); - - // Reset document and test with marks turned on - s.doc = doc_backup; - s.view.marks = true; - s.touch(); - s.relayout(); - - let res_with_marks = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.5, "adjustSpacing": false})); - assert!(res_with_marks.is_err(), "hidden text with small font must block fit when marks are visible"); -} - -#[test] -fn fit_one_page_accounts_for_vertical_alignment_padding() { - let mut s = create_multiline_doc(26); - let extra_p = wordcraft_doc::Paragraph::with_text("Spill line on page 2.", CharProps::default()); - s.doc.body.push(wordcraft_doc::para_block(extra_p)); - - // Set page vertical alignment to Bottom - s.doc.last_section.valign = wordcraft_doc::section::PageVAlign::Bottom; - s.touch(); - s.relayout(); - assert_eq!(s.layout().pages.len(), 2); - - // Fit must succeed because the actual spill height (1 line) is well under 30% of page height, - // even though the line was translated to the bottom of page 2 by valign padding. - let res = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.5, "adjustSpacing": false})); - assert!(res.is_ok(), "fit must succeed with vertical alignment padding: {res:?}"); - assert_eq!(s.layout().pages.len(), 1); -} - -#[test] -fn fit_one_page_accounts_for_note_separator_decor_story() { - let mut lines = 28; - let mut s = create_multiline_doc(lines); - while s.layout().pages.len() < 2 && lines < 80 { - lines += 1; - s = create_multiline_doc(lines); - } - assert_eq!(s.layout().pages.len(), 2); - - // Set custom footnote separator story so layout produces DECOR_STORY - s.doc.footnote_separators.separator = Some(vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text("---", CharProps::default()))]); - - // Insert note reference into a paragraph on the final page (page 2) - let last_para_idx = s.doc.body.len().saturating_sub(1); - let note_p = wordcraft_doc::Paragraph::with_text("Final page footnote text.", CharProps::default()); - let note_id = s.doc.add_part(wordcraft_doc::PartKind::Footnote, vec![wordcraft_doc::para_block(note_p)]); - let noteref = wordcraft_doc::para::InlineObject::NoteRef { kind: wordcraft_doc::para::NoteKind::Footnote, id: note_id, custom: String::new() }; - s.doc.insert_object(&wordcraft_doc::Pos::body(last_para_idx, 0), noteref, &Default::default()).unwrap(); - s.touch(); - s.relayout(); - assert_eq!(s.layout().pages.len(), 2); - - // Verify layout produces DECOR_STORY on page 2 - let page2 = &s.layout().pages[1]; - let has_decor = - page2.items.iter().any(|item| matches!(item, wordcraft_layout::Placed::Lines { story, .. } if *story == wordcraft_layout::DECOR_STORY)); - assert!(has_decor, "footnote on page 2 must generate DECOR_STORY separator"); - - // Fit must succeed without DECOR_STORY inflating body_max_y - let res = s.run("layout.fitOnePage", &json!({})); - assert!(res.is_ok(), "fit with final page footnote and DECOR_STORY must succeed: {res:?}"); - assert_eq!(s.layout().pages.len(), 1); -} - -#[test] -fn fit_one_page_tracks_fixed_spacing_independent_of_paragraph_mark_size() { - let mut lines = 28; - let mut s = create_multiline_doc(lines); - let set_spacing = |s: &mut Session| { - for b in &mut s.doc.body { - if let wordcraft_doc::Block::Para(p) = std::sync::Arc::make_mut(b) { - p.props.line_spacing = Some(wordcraft_doc::props::LineSpacing::Exactly(16.0)); - } - } - s.touch(); - s.relayout(); - }; - set_spacing(&mut s); - while s.layout().pages.len() < 2 && lines < 80 { - lines += 1; - s = create_multiline_doc(lines); - set_spacing(&mut s); - } - assert_eq!(s.layout().pages.len(), 2); - - // Fit with spacing reduction - let res = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.0, "adjustSpacing": true})); - assert!(res.is_ok(), "spacing reduction must succeed: {res:?}"); - assert!(s.doc.fit_state.as_ref().is_some_and(|f| f.cum_space < 1.0)); - - // Change paragraph mark font size on paragraphs (e.g. from 12 pt to 24 pt) - for b in &mut s.doc.body { - if let wordcraft_doc::Block::Para(p) = std::sync::Arc::make_mut(b) { - p.mark.size = Some(24.0); - } - } - s.touch(); - s.relayout(); - - // Second fit with spacing only must be refused because spacing floor 0.94 was reached - // and tracked provenance was retained despite paragraph mark size change - let res2 = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.0, "adjustSpacing": true})); - assert!(res2.is_err(), "anti-compound guard must detect reduced fixed spacing and reject repeat compression"); -} - -#[test] -fn fit_one_page_reports_search_exhaustion_when_candidates_cannot_remove_page() { - let mut s = create_multiline_doc(27); - assert_eq!(s.layout().pages.len(), 2); - - // Calling with zero maxDeltaPt and adjustSpacing: false produces no candidate steps - let res = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.0, "adjustSpacing": false})); - assert!(res.is_err()); - let err = res.unwrap_err().to_string(); - assert!( - err.contains("no permitted typography adjustment could remove a page"), - "error message must distinguish search exhaustion from overflow limits: {err}" - ); -} - -#[test] -fn fit_one_page_excludes_line_number_decorations_from_final_page_limits() { - let mut lines = 33; - let enable_line_numbers = |s: &mut Session| { - s.doc.last_section.line_numbers = Some(wordcraft_doc::section::LineNumbering::default()); - s.touch(); - s.relayout(); - }; - let mut s = create_multiline_doc(lines); - enable_line_numbers(&mut s); - while s.layout().pages.len() < 2 && lines < 60 { - lines += 1; - s = create_multiline_doc(lines); - enable_line_numbers(&mut s); - } - assert_eq!(s.layout().pages.len(), 2); - - // Every body line on the final page also places a line-number decoration. Counting those as - // content would double the final-page line count and refuse before any candidate is tried. - let decorated = s.layout().pages[1] - .items - .iter() - .filter(|item| matches!(item, wordcraft_layout::Placed::Lines { story, .. } if *story == wordcraft_layout::DECOR_STORY)) - .count(); - assert!(decorated > 0, "the final page must carry line-number decorations"); - - let res = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 4.0})); - assert!(res.is_ok(), "line-number decorations must not count as final-page content: {res:?}"); - assert_eq!(s.layout().pages.len(), 1); -} - -#[test] -fn fit_one_page_counts_side_by_side_footnote_cells_once() { - let mut s = create_multiline_doc(27); - assert_eq!(s.layout().pages.len(), 2); - - // A footnote table whose row's cells sit side by side: their text occupies one vertical - // interval, so their heights must not be summed as if stacked. - let mut table = wordcraft_doc::Table::new(1, 3, 420.0); - let cell_text = "The quick brown fox jumps over the lazy dog again and again."; - for cell in &mut table.rows[0].cells { - cell.blocks = - vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(cell_text, CharProps { size: Some(21.0), ..Default::default() }))]; - } - let note_blocks: Vec> = vec![std::sync::Arc::new(wordcraft_doc::Block::Table(table))]; - let note_id = s.doc.add_part(wordcraft_doc::PartKind::Footnote, note_blocks); - - let last_para_idx = s.doc.body.len().saturating_sub(1); - let noteref = wordcraft_doc::para::InlineObject::NoteRef { kind: wordcraft_doc::para::NoteKind::Footnote, id: note_id, custom: String::new() }; - s.doc.insert_object(&wordcraft_doc::Pos::body(last_para_idx, 0), noteref, &Default::default()).unwrap(); - s.touch(); - s.relayout(); - assert_eq!(s.layout().pages.len(), 2); - - let note_cells = s.layout().pages[1] - .items - .iter() - .filter(|item| matches!(item, wordcraft_layout::Placed::Cell { story: wordcraft_doc::StoryRef::Part(id), .. } if *id == note_id)) - .count(); - assert!(note_cells >= 2, "the footnote table must place its cells side by side on the final page"); - - let res = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 4.0})); - assert!(res.is_ok(), "side-by-side footnote cells must count once in the fill height: {res:?}"); - assert_eq!(s.layout().pages.len(), 1); -} - -#[test] -fn fit_one_page_refuses_when_formatting_changes_cannot_be_tracked() { - let mut s = create_multiline_doc(27); - assert_eq!(s.layout().pages.len(), 2); - s.doc.settings.track_changes = true; - s.prefs.markup.track_formatting = true; - let before = s.doc.clone(); - - // Style reductions cannot be recorded as formatting revisions, so the command must refuse - // rather than change typography that rejecting all changes cannot undo. - let res = s.run("layout.fitOnePage", &json!({})); - assert!(res.is_err(), "layout.fitOnePage must refuse while formatting is tracked"); - let err = res.unwrap_err().to_string(); - assert!(err.contains("tracks formatting changes"), "unexpected error: {err}"); - assert_eq!(s.doc, before, "a refused fit must leave the document unchanged"); -} +use serde_json::json; +use wordcraft_doc::Document; +use wordcraft_doc::para::PAGE_BREAK; +use wordcraft_doc::props::CharProps; + +use crate::Session; + +/// Creates a document with `lines` lines of text. +fn create_multiline_doc(lines: usize) -> Session { + let mut text = String::new(); + for i in 1..=lines { + text.push_str(&format!("Line {i}: The quick brown fox jumps over the lazy dog.\n")); + } + Session::new(Document::from_text(&text)) +} + +#[test] +fn fit_one_page_fails_on_single_page() { + let mut s = create_multiline_doc(5); + let before = s.doc.clone(); + let res = s.run("layout.fitOnePage", &json!({})); + assert!(res.is_err()); + let err = res.unwrap_err().to_string(); + assert!(err.contains("already fits on one page"), "unexpected error: {err}"); + assert_eq!(s.doc, before, "document must not change on failure"); +} + +#[test] +fn fit_one_page_fails_on_hard_page_break() { + let text = format!("Page 1 content\n{PAGE_BREAK}Page 2 content\n"); + let mut s = Session::new(Document::from_text(&text)); + let before = s.doc.clone(); + let res = s.run("layout.fitOnePage", &json!({})); + assert!(res.is_err()); + let err = res.unwrap_err().to_string(); + assert!(err.contains("explicit page or section breaks"), "unexpected error: {err}"); + assert_eq!(s.doc, before, "document must not change on failure"); +} + +#[test] +fn fit_one_page_refuses_page_break_before_before_search() { + let mut s = create_multiline_doc(27); + assert_eq!(s.layout().pages.len(), 2); + if let wordcraft_doc::Block::Para(p) = std::sync::Arc::make_mut(&mut s.doc.body[1]) { + p.props.page_break_before = Some(true); + } + s.touch(); + s.relayout(); + assert_eq!(s.layout().pages.len(), 2); + let before = s.doc.clone(); + + let res = s.run("layout.fitOnePage", &json!({})); + assert!(res.is_err()); + let err = res.unwrap_err().to_string(); + assert!(err.contains("explicit page or section breaks"), "page_break_before must be reported as an explicit break: {err}"); + assert_eq!(s.doc, before, "document must not change on refusal"); +} + +#[test] +fn fit_one_page_fails_on_next_page_section_break() { + let mut s = Session::new(Document::from_text("First section body text")); + s.run("layout.break", &json!({"kind": "nextPage"})).unwrap(); + s.run("text.insert", &json!({"text": "Second section text on next page"})).unwrap(); + assert_eq!(s.layout().pages.len(), 2, "break must cause 2 pages"); + + let before = s.doc.clone(); + let res = s.run("layout.fitOnePage", &json!({})); + assert!(res.is_err()); + let err = res.unwrap_err().to_string(); + assert!(err.contains("explicit page or section breaks"), "unexpected error: {err}"); + assert_eq!(s.doc, before, "document must not change on failure"); +} + +#[test] +fn fit_one_page_condenses_slight_overflow_and_undoes() { + // Find the boundary where the document just spills onto page 2 + let mut lines = 30; + let mut s = create_multiline_doc(lines); + while s.layout().pages.len() < 2 && lines < 80 { + lines += 2; + s = create_multiline_doc(lines); + } + assert_eq!(s.layout().pages.len(), 2, "must start as a 2-page document with {lines} lines"); + + let doc_before = s.doc.clone(); + let res = s.run("layout.fitOnePage", &json!({})); + assert!(res.is_ok(), "fit should succeed on slight overflow: {res:?}"); + assert_eq!(s.layout().pages.len(), 1, "document must now fit on 1 page"); + assert!(s.status.contains("Document fitted to 1 page"), "status was: {}", s.status); + + // Test single atomic undo + let undo_res = s.run("edit.undo", &json!({})); + assert!(undo_res.is_ok(), "undo must succeed"); + assert_eq!(s.layout().pages.len(), 2, "undo must restore 2-page layout"); + assert_eq!(s.doc, doc_before, "undo must restore exact pre-fit document state"); + + // Test redo + let redo_res = s.run("edit.redo", &json!({})); + assert!(redo_res.is_ok(), "redo must succeed"); + assert_eq!(s.layout().pages.len(), 1, "redo must restore 1-page layout"); +} + +#[test] +fn fit_one_page_respects_max_delta() { + let mut s = create_multiline_doc(27); + assert_eq!(s.layout().pages.len(), 2, "must start as 2-page document"); + + let doc_backup = s.doc.clone(); + let ok_res = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.5, "adjustSpacing": false})); + assert!(ok_res.is_ok(), "must succeed when positive font delta 0.5 is permitted: {ok_res:?}"); + assert_eq!(s.layout().pages.len(), 1); + + s.doc = doc_backup; + let res = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.0, "adjustSpacing": false})); + assert!(res.is_err(), "must fail when font delta is capped at 0.0 and spacing is disabled"); +} + +#[test] +fn fit_one_page_respects_step_pt() { + let mut text = String::new(); + for i in 1..=28 { + text.push_str(&format!("Line {i}: The quick brown fox jumps over the lazy dog.\n")); + } + let mut s = Session::new(Document::from_text(&text)); + for b in &mut s.doc.body { + if let wordcraft_doc::Block::Para(p) = std::sync::Arc::make_mut(b) { + p.mark.size = Some(11.4); + for r in &mut p.runs { + r.props.size = Some(11.4); + } + } + } + assert_eq!(s.layout().pages.len(), 2, "must start as 2-page document at 11.4 pt"); + + // Both calls hold maxDeltaPt constant at 0.4 and disable spacing adjustments. + // Default 0.5 pt step generates no candidates <= 0.4 pt, so it must fail. + let doc_backup = s.doc.clone(); + let default_res = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.4, "adjustSpacing": false})); + assert!(default_res.is_err(), "default step 0.5 must skip all candidates when maxDelta is 0.4"); + + // With stepPt = 0.1 under the same maxDeltaPt = 0.4, fine-grained steps (0.1..0.4) succeed (11.4 -> 11.0 pt). + s.doc = doc_backup; + let res = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.4, "stepPt": 0.1, "adjustSpacing": false})); + assert!(res.is_ok(), "stepPt=0.1 must generate fine-grained candidates under maxDelta 0.4: {res:?}"); + assert_eq!(s.layout().pages.len(), 1); +} + +#[test] +fn fit_one_page_allows_spacing_only_at_font_floor() { + let mut lines = 35; + let mut s = create_multiline_doc(lines); + let set_font = |s: &mut Session| { + for b in &mut s.doc.body { + if let wordcraft_doc::Block::Para(p) = std::sync::Arc::make_mut(b) { + for r in &mut p.runs { + r.props = CharProps { size: Some(8.5), ..Default::default() }; + } + } + } + s.touch(); + s.relayout(); + }; + set_font(&mut s); + while s.layout().pages.len() < 2 && lines < 120 { + lines += 1; + s = create_multiline_doc(lines); + set_font(&mut s); + } + assert_eq!(s.layout().pages.len(), 2); + + // Spacing-only candidate must be permitted to run and succeed + let res = s.run("layout.fitOnePage", &json!({})); + assert!(res.is_ok(), "spacing-only candidate must succeed at font floor: {res:?}"); + assert_eq!(s.layout().pages.len(), 1); + assert!(s.status.contains("spacing: "), "status: {}", s.status); + assert!(!s.status.contains("font: -"), "font must remain untouched: {}", s.status); +} + +#[test] +fn fit_one_page_fails_when_body_font_already_at_minimum_readable_size() { + let mut lines = 35; + let mut s = create_multiline_doc(lines); + let set_font = |s: &mut Session| { + for b in &mut s.doc.body { + if let wordcraft_doc::Block::Para(p) = std::sync::Arc::make_mut(b) { + for r in &mut p.runs { + r.props = CharProps { size: Some(8.0), ..Default::default() }; + } + } + } + s.touch(); + s.relayout(); + }; + set_font(&mut s); + while s.layout().pages.len() < 2 && lines < 120 { + lines += 1; + s = create_multiline_doc(lines); + set_font(&mut s); + } + assert_eq!(s.layout().pages.len(), 2); + + // When spacing adjustment is disabled, no candidate can run without reducing font + let res = s.run("layout.fitOnePage", &json!({"adjustSpacing": false})); + assert!(res.is_err(), "must refuse to shrink font when already <= 8.5 pt"); + let err = res.unwrap_err().to_string(); + assert!(err.contains("Unable to shrink document further without reducing text beyond readability limits."), "unexpected error message: {err}"); +} + +#[test] +fn fit_one_page_bounds_fine_step_loop() { + let mut lines = 28; + let mut s = create_multiline_doc(lines); + while s.layout().pages.len() < 2 && lines < 80 { + lines += 1; + s = create_multiline_doc(lines); + } + assert_eq!(s.layout().pages.len(), 2, "fixture must reach candidate generation on 2 pages"); + + // With stepPt = 0.001 and maxDeltaPt = 2.0 (ratio = 2000), fine-step candidate count + // is clamped by MAX_FINE_STEPS (20), executing quickly without doing thousands of iterations. + let res = s.run("layout.fitOnePage", &json!({"stepPt": 0.001, "maxDeltaPt": 2.0, "adjustSpacing": false})); + assert!(res.is_ok(), "fitOnePage with bounded fine steps succeeds or terminates gracefully: {res:?}"); +} + +#[test] +fn fit_one_page_supports_max_delta_above_2pt() { + let mut lines = 28; + let mut s = create_multiline_doc(lines); + while s.layout().pages.len() < 2 && lines < 80 { + lines += 1; + s = create_multiline_doc(lines); + } + // Add 8 lines of spill (within the 12-line spill limit) + for i in 1..=8 { + s.run("text.insert", &json!({"text": format!("Spill paragraph line {i}\n")})).unwrap(); + } + assert_eq!(s.layout().pages.len(), 2); + + // With maxDeltaPt = 3.0, candidate above 2 pt fits the document + let res = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 3.0})); + assert!(res.is_ok(), "fitOnePage with maxDeltaPt=3.0 must succeed: {res:?}"); + assert_eq!(s.layout().pages.len(), 1, "must fit on 1 page"); +} + +#[test] +fn fit_one_page_anti_compound_guard_prevents_repeated_invocations() { + let mut lines = 28; + let mut s = create_multiline_doc(lines); + loop { + for b in &mut s.doc.body { + if let wordcraft_doc::Block::Para(p) = std::sync::Arc::make_mut(b) { + p.props.line_spacing = Some(wordcraft_doc::props::LineSpacing::Multiple(0.94)); + for r in &mut p.runs { + r.props.size = Some(11.0); + } + } + } + s.touch(); + s.relayout(); + if s.layout().pages.len() >= 2 || lines >= 80 { + break; + } + lines += 1; + s = create_multiline_doc(lines); + } + assert_eq!(s.layout().pages.len(), 2); + + // Simulate a document that already used the full 1.0 pt font and 0.94 spacing budgets. + s.doc.fit_state = Some(wordcraft_doc::FitState { + cum_delta: 1.0, + cum_space: 0.94, + original_font_sizes: vec![12.0], + original_line_spacings: vec![wordcraft_doc::props::LineSpacing::Multiple(1.0)], + ..Default::default() + }); + s.touch(); + s.relayout(); + let doc_before = s.doc.clone(); + + let res = s.run("layout.fitOnePage", &json!({})); + assert!(res.is_err(), "exhausted budgets must be rejected"); + let err = res.unwrap_err().to_string(); + assert!(err.contains("Unable to shrink document further without reducing text beyond readability limits."), "unexpected error message: {err}"); + assert_eq!(s.doc, doc_before, "document must not be mutated on rejected invocation"); +} + +#[test] +fn fit_one_page_already_fits_wins_over_exhausted_budget() { + let mut s = create_multiline_doc(5); + assert_eq!(s.layout().pages.len(), 1); + for b in &mut s.doc.body { + if let wordcraft_doc::Block::Para(p) = std::sync::Arc::make_mut(b) { + p.props.line_spacing = Some(wordcraft_doc::props::LineSpacing::Multiple(0.94)); + for r in &mut p.runs { + r.props.size = Some(11.0); + } + } + } + s.doc.fit_state = Some(wordcraft_doc::FitState { + cum_delta: 1.0, + cum_space: 0.94, + original_font_sizes: vec![12.0], + original_line_spacings: vec![wordcraft_doc::props::LineSpacing::Multiple(1.0)], + ..Default::default() + }); + s.touch(); + s.relayout(); + + let res = s.run("layout.fitOnePage", &json!({})); + assert!(res.is_err()); + let err = res.unwrap_err().to_string(); + assert!(err.contains("already fits on one page"), "already-fits must take precedence over exhausted budgets: {err}"); +} + +#[test] +fn fit_one_page_checks_complex_script_font_floor() { + let mut lines = 28; + let mut s = create_multiline_doc(lines); + while s.layout().pages.len() < 2 && lines < 80 { + lines += 1; + s = create_multiline_doc(lines); + } + assert_eq!(s.layout().pages.len(), 2); + + // Insert an Arabic run with size 12.0 pt but size_cs 9.0 pt + let arabic_p = wordcraft_doc::Paragraph::with_text( + "نص تجريبي باللغة العربية", + CharProps { size: Some(12.0), size_cs: Some(9.0), cs: Some(true), ..Default::default() }, + ); + s.doc.body.insert(0, wordcraft_doc::para_block(arabic_p)); + assert_eq!(s.layout().pages.len(), 2); + + // A 1.0 pt reduction would reduce size_cs from 9.0 to 8.0 (< 8.5 pt floor). + // The trial must not commit. + let _ = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 1.0, "adjustSpacing": false})); + let p = s.doc.body[0].as_para().unwrap(); + let cs_size = p.runs[0].props.size_cs.unwrap_or(9.0); + assert!(cs_size >= 8.5, "complex-script size must remain >= 8.5 pt, was {cs_size}"); +} + +#[test] +fn fit_one_page_checks_editable_text_box_font_floor() { + let mut lines = 28; + let mut s = create_multiline_doc(lines); + while s.layout().pages.len() < 2 && lines < 80 { + lines += 1; + s = create_multiline_doc(lines); + } + assert_eq!(s.layout().pages.len(), 2); + + // Add a body-anchored text box with 9.0 pt font + let box_id = s.doc.add_part( + wordcraft_doc::PartKind::TextBox, + vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text( + "Text box body text", + CharProps { size: Some(9.0), ..Default::default() }, + ))], + ); + // Link it into body + let mut shape_p = wordcraft_doc::Paragraph::with_text("Before box ", CharProps::default()); + let shape = wordcraft_doc::InlineObject::Shape { + kind: wordcraft_doc::para::ShapeKind::TextBox, + w: 100.0, + h: 50.0, + fill: None, + stroke: None, + stroke_width: 0.5, + float: Default::default(), + story: Some(box_id), + freeform: None, + effects: Default::default(), + extra: Default::default(), + }; + shape_p.objects.push(shape); + s.doc.body.insert(0, wordcraft_doc::para_block(shape_p)); + assert_eq!(s.layout().pages.len(), 2); + + // With maxDeltaPt = 1.0 and adjustSpacing = false, 9.0 pt would reduce to 8.0 pt. + // The trial must not be committed. + let _ = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 1.0, "adjustSpacing": false})); + let part = s.doc.parts.get(&box_id).unwrap(); + let box_p = part.blocks[0].as_para().unwrap(); + let sz = box_p.runs[0].props.size.unwrap(); + assert!(sz >= 8.5, "editable text box font must remain >= 8.5 pt, was {sz}"); +} + +#[test] +fn fit_one_page_fails_when_overflow_exceeds_limits() { + let mut lines = 28; + let mut s = create_multiline_doc(lines); + while s.layout().pages.len() < 2 { + lines += 1; + s = create_multiline_doc(lines); + } + // Add 16 extra lines to page 2: this overflow is too large to fit into page 1 with maxDeltaPt = 0.5 + for i in 1..=16 { + s.run("text.insert", &json!({"text": format!("Overflow line {i}\n")})).unwrap(); + } + assert_eq!(s.layout().pages.len(), 2, "must be a 2-page document"); + + let res = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.5})); + assert!(res.is_err(), "must refuse when overflow exceeds aesthetic reduction limits"); + let err = res.unwrap_err().to_string(); + assert!(err.contains("exceeds limits for aesthetic page reduction"), "unexpected error message: {err}"); +} + +#[test] +fn fit_one_page_validates_effective_font_in_tables_before_committing() { + let mut s = Session::new(Document::new()); + let mut table = wordcraft_doc::Table::new(1, 1, 400.0); + let cell_p = wordcraft_doc::Paragraph::with_text("Cell text", CharProps { size: Some(9.0), ..Default::default() }); + table.rows[0].cells[0].blocks = vec![wordcraft_doc::para_block(cell_p)]; + s.doc.body = vec![std::sync::Arc::new(wordcraft_doc::Block::Table(table))]; + + let mut text = String::new(); + for i in 1..=45 { + text.push_str(&format!("Line {i} following table: The quick brown fox jumps over the lazy dog.\n")); + } + s.doc.body.push(wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(&text, CharProps::default()))); + assert_eq!(s.layout().pages.len(), 2); + + // If maxDeltaPt is 1.0, 9.0 pt would reduce to 8.0 pt (violating 8.5 pt floor). + // The candidate must not be committed. + let _ = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 1.0, "adjustSpacing": false})); + if let Some(tbl) = s.doc.body[0].as_table() { + let p = tbl.rows[0].cells[0].blocks[0].as_para().unwrap(); + let sz = p.runs[0].props.size.unwrap(); + assert!(sz >= 8.5, "table cell font size must remain >= 8.5 pt, was {sz}"); + } +} + +#[test] +fn fit_one_page_validates_effective_font_in_table_styles_before_committing() { + let mut s = Session::new(Document::new()); + // Create a custom table style with 9.0 pt font + let st = wordcraft_doc::styles::Style { + id: "CustomTableStyle".into(), + name: "CustomTableStyle".into(), + kind: wordcraft_doc::styles::StyleKind::Table, + chr: CharProps { size: Some(9.0), ..Default::default() }, + table: Some(wordcraft_doc::styles::TableStyleParts::default()), + ..Default::default() + }; + std::sync::Arc::make_mut(&mut s.doc.styles).upsert(st); + + let mut table = wordcraft_doc::Table::new(1, 1, 400.0); + table.props.style = Some("CustomTableStyle".to_string()); + let cell_p = wordcraft_doc::Paragraph::with_text("Cell text inheriting table style", CharProps::default()); + table.rows[0].cells[0].blocks = vec![wordcraft_doc::para_block(cell_p)]; + s.doc.body = vec![std::sync::Arc::new(wordcraft_doc::Block::Table(table))]; + + let mut text = String::new(); + for i in 1..=45 { + text.push_str(&format!("Line {i} following table: The quick brown fox jumps over the lazy dog.\n")); + } + s.doc.body.push(wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(&text, CharProps::default()))); + assert_eq!(s.layout().pages.len(), 2); + + // If maxDeltaPt is 1.0, 9.0 pt in the table style would reduce to 8.0 pt (violating 8.5 pt floor). + // The candidate must not be committed. + let _ = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 1.0, "adjustSpacing": false})); + let style = s.doc.styles.table_style("CustomTableStyle").unwrap(); + let sz = style.chr.size.unwrap(); + assert!(sz >= 8.5, "table style font size must remain >= 8.5 pt, was {sz}"); +} + +#[test] +fn fit_one_page_measures_only_final_page_placed_lines() { + let mut s = Session::new(Document::new()); + let words = "The quick brown fox jumps over the lazy dog and runs across the wide open fields in the sunshine. "; + let mut reps = 20; + loop { + let text = words.repeat(reps); + s.doc.body = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(&text, CharProps::default()))]; + s.touch(); + s.relayout(); + if s.layout().pages.len() >= 2 { + break; + } + reps += 1; + } + assert_eq!(s.layout().pages.len(), 2); + + let res = s.run("layout.fitOnePage", &json!({})); + assert!(res.is_ok(), "fitOnePage must succeed when single paragraph slightly spills onto page 2: {res:?}"); + assert_eq!(s.layout().pages.len(), 1); +} + +#[test] +fn fit_one_page_exempts_style_inherited_superscript() { + let mut s = Session::new(Document::new()); + // Create a Character style with Superscript alignment and 7.0 pt font + let st = wordcraft_doc::styles::Style { + id: "SuperStyle".into(), + name: "SuperStyle".into(), + kind: wordcraft_doc::styles::StyleKind::Character, + chr: CharProps { vert_align: Some(wordcraft_doc::props::VertAlign::Superscript), size: Some(7.0), ..Default::default() }, + ..Default::default() + }; + std::sync::Arc::make_mut(&mut s.doc.styles).upsert(st); + + let words = "Regular readable text that fills up lines in the document body. "; + let mut reps = 20; + loop { + let mut p = wordcraft_doc::Paragraph::with_text(&words.repeat(reps), CharProps { size: Some(11.0), ..Default::default() }); + // Add a run with style-inherited superscript + p.runs.push(wordcraft_doc::Run { len: 3, props: CharProps { style: Some("SuperStyle".into()), ..Default::default() } }); + p.text.push_str("123"); + s.doc.body = vec![wordcraft_doc::para_block(p)]; + s.touch(); + s.relayout(); + if s.layout().pages.len() >= 2 { + break; + } + reps += 1; + } + assert_eq!(s.layout().pages.len(), 2); + + // fitOnePage must not be blocked by the 7.0 pt superscript run because it is exempt + let res = s.run("layout.fitOnePage", &json!({})); + assert!(res.is_ok(), "style-inherited superscript must be exempt from font floor: {res:?}"); + assert_eq!(s.layout().pages.len(), 1); +} + +#[test] +fn fit_one_page_refused_when_protect_formatting_enabled() { + let mut s = Session::new(Document::new()); + s.doc.settings.protection = Some("none".into()); + s.doc.settings.protect_formatting = true; + let res = s.run("layout.fitOnePage", &json!({})); + assert!(res.is_err()); + let err = res.unwrap_err().to_string(); + assert!(err.contains("formatting is limited to the document's styles"), "unexpected error: {err}"); +} + +#[test] +fn fit_one_page_zero_max_delta_never_reduces_font() { + let mut s = Session::new(Document::new()); + let words = "Content line with fractional font size for zero delta testing. "; + let mut reps = 20; + loop { + let text = words.repeat(reps); + s.doc.body = + vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(&text, CharProps { size: Some(10.3), ..Default::default() }))]; + s.touch(); + s.relayout(); + if s.layout().pages.len() >= 2 { + break; + } + reps += 1; + } + assert_eq!(s.layout().pages.len(), 2); + + // Call fitOnePage with maxDeltaPt = 0.0 and a tiny stepPt (which should not produce any positive candidate) + let _ = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.0, "stepPt": 0.001})); + let p = s.doc.body[0].as_para().unwrap(); + assert_eq!(p.runs[0].props.size, Some(10.3), "font size must remain completely unchanged when maxDeltaPt is 0"); +} + +#[test] +fn fit_one_page_fails_with_failed_cmd_error() { + let mut s = create_multiline_doc(5); + let res = s.run("layout.fitOnePage", &json!({})); + assert!(matches!(res, Err(crate::CmdError::Failed(_))), "expected CmdError::Failed, got {res:?}"); +} + +#[test] +fn fit_one_page_never_populates_custom_props() { + let mut lines = 30; + let mut s = create_multiline_doc(lines); + while s.layout().pages.len() < 2 && lines < 80 { + lines += 2; + s = create_multiline_doc(lines); + } + assert!(s.doc.custom_props.is_empty()); + let res = s.run("layout.fitOnePage", &json!({})); + assert!(res.is_ok()); + assert!(s.doc.custom_props.is_empty(), "custom_props must not be populated with internal fit state"); +} + +#[test] +fn fit_one_page_allows_subsequent_fit_after_typography_reset() { + use std::sync::Arc; + + let mut lines = 28; + let mut s = create_multiline_doc(lines); + while s.layout().pages.len() < 2 && lines < 80 { + lines += 1; + s = create_multiline_doc(lines); + } + assert_eq!(s.layout().pages.len(), 2); + + // First invocation succeeds + let res1 = s.run("layout.fitOnePage", &json!({})); + assert!(res1.is_ok()); + assert_eq!(s.layout().pages.len(), 1); + + // Reset styles back to Normal with 12 pt font and 1.15 line spacing + if let Some(st) = Arc::make_mut(&mut s.doc.styles).styles.iter_mut().find(|st| st.id == "Normal") { + st.chr.size = Some(12.0); + st.para.line_spacing = Some(wordcraft_doc::props::LineSpacing::Multiple(1.15)); + } + for b in &mut s.doc.body { + if let wordcraft_doc::Block::Para(p) = Arc::make_mut(b) { + p.props.line_spacing = None; + for r in &mut p.runs { + r.props.size = None; + } + } + } + s.touch(); + s.relayout(); + + // Now add lines to make it 2 pages again + while s.layout().pages.len() < 2 { + s.run("text.insert", &json!({"text": "Additional fresh paragraph after style reset.\n"})).unwrap(); + } + assert_eq!(s.layout().pages.len(), 2); + + // A subsequent fitOnePage run must NOT be permanently blocked and should succeed + let res2 = s.run("layout.fitOnePage", &json!({})); + assert!(res2.is_ok(), "fitOnePage must succeed after typography reset: {res2:?}"); + assert_eq!(s.layout().pages.len(), 1); +} + +#[test] +fn fit_one_page_small_paragraph_mark_with_runs_never_blocks_fit() { + let mut lines = 30; + let mut s = create_multiline_doc(lines); + while s.layout().pages.len() < 2 && lines < 80 { + lines += 2; + s = create_multiline_doc(lines); + } + // Set a 1.0 pt paragraph mark on a body paragraph that has normal 11 pt text runs + if let wordcraft_doc::Block::Para(p) = std::sync::Arc::make_mut(&mut s.doc.body[0]) { + p.mark.size = Some(1.0); + } + s.touch(); + s.relayout(); + assert_eq!(s.layout().pages.len(), 2); + + let res = s.run("layout.fitOnePage", &json!({})); + assert!(res.is_ok(), "small mark with normal text runs must not block fit: {res:?}"); + assert_eq!(s.layout().pages.len(), 1); +} + +#[test] +fn fit_one_page_small_comment_font_never_blocks_body_fit() { + let mut lines = 30; + let mut s = create_multiline_doc(lines); + while s.layout().pages.len() < 2 && lines < 80 { + lines += 2; + s = create_multiline_doc(lines); + } + // Add a comment part with 6.0 pt text + let comment_p = wordcraft_doc::Paragraph::with_text("Small comment text", CharProps { size: Some(6.0), ..Default::default() }); + s.doc.add_part(wordcraft_doc::PartKind::Comment, vec![wordcraft_doc::para_block(comment_p)]); + s.touch(); + s.relayout(); + assert_eq!(s.layout().pages.len(), 2); + + let res = s.run("layout.fitOnePage", &json!({})); + assert!(res.is_ok(), "small comment font must not block body fit: {res:?}"); + assert_eq!(s.layout().pages.len(), 1); +} + +#[test] +fn fit_one_page_anti_compound_caps_total_reduction_across_runs() { + let mut s = create_multiline_doc(27); + assert_eq!(s.layout().pages.len(), 2); + + // Initial fit with -0.5 pt font reduction (spacing disabled so font budget is exercised) + let res1 = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.5, "adjustSpacing": false})); + assert!(res1.is_ok()); + assert_eq!(s.layout().pages.len(), 1); + assert_eq!(s.doc.fit_state.as_ref().map(|f| f.cum_delta), Some(0.5)); + + // Add content to spill to 2 pages again + for i in 1..=12 { + s.run("text.insert", &json!({"text": format!("Spill paragraph line {i}\n")})).unwrap(); + } + assert_eq!(s.layout().pages.len(), 2); + + // Candidate requiring > 0.5 pt more (e.g. total > 1.0 pt) must be rejected + let res2 = s.run("layout.fitOnePage", &json!({})); + assert!(res2.is_err(), "second run must not exceed cumulative 1.0 pt reduction budget"); +} + +#[test] +fn fit_one_page_tracks_reduced_text_provenance_without_global_minimum() { + let mut s = create_multiline_doc(27); + // Add a 9.0 pt run to the first paragraph alongside 11.0 pt runs + if let wordcraft_doc::Block::Para(p) = std::sync::Arc::make_mut(&mut s.doc.body[0]) { + p.runs.push(wordcraft_doc::para::Run { len: 5, props: CharProps { size: Some(9.0), ..Default::default() } }); + p.text.push_str("extra"); + } + s.touch(); + s.relayout(); + assert_eq!(s.layout().pages.len(), 2); + + // Initial fit with -0.5 pt: 9.0 pt -> 8.5 pt, 11.0 pt -> 10.5 pt (spacing disabled) + let res1 = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.5, "adjustSpacing": false})); + assert!(res1.is_ok()); + assert_eq!(s.layout().pages.len(), 1); + assert_eq!(s.doc.fit_state.as_ref().map(|f| f.cum_delta), Some(0.5)); + + // Reset only the 8.5 pt run back to 12.0 pt (so current global minimum font becomes 10.5 pt) + if let wordcraft_doc::Block::Para(p) = std::sync::Arc::make_mut(&mut s.doc.body[0]) { + p.runs.last_mut().unwrap().props.size = Some(12.0); + } + s.touch(); + s.relayout(); + + // Add content to spill to 2 pages again + for i in 1..=12 { + s.run("text.insert", &json!({"text": format!("Spill paragraph line {i}\n")})).unwrap(); + } + assert_eq!(s.layout().pages.len(), 2); + + // Because the 10.5 pt runs are still reduced, fit state must NOT be discarded, + // and a candidate requiring > 0.5 pt more font reduction must be rejected by the 1.0 pt cumulative cap + let res2 = s.run("layout.fitOnePage", &json!({})); + assert!(res2.is_err(), "cumulative budget must still hold even when the original minimum font was changed"); +} + +#[test] +fn fit_one_page_allows_spill_with_bottom_placed_footnote() { + let mut lines = 28; + let mut s = create_multiline_doc(lines); + while s.layout().pages.len() < 2 && lines < 80 { + lines += 1; + s = create_multiline_doc(lines); + } + assert_eq!(s.layout().pages.len(), 2); + + // Add a single-line footnote to the document (reference on page 1) + let note_p = wordcraft_doc::Paragraph::with_text("Short footnote text.", CharProps::default()); + let note_id = s.doc.add_part(wordcraft_doc::PartKind::Footnote, vec![wordcraft_doc::para_block(note_p)]); + let noteref = wordcraft_doc::para::InlineObject::NoteRef { kind: wordcraft_doc::para::NoteKind::Footnote, id: note_id, custom: String::new() }; + s.doc.insert_object(&wordcraft_doc::Pos::body(0, 5), noteref, &Default::default()).unwrap(); + s.touch(); + s.relayout(); + assert_eq!(s.layout().pages.len(), 2); + + // Even though the footnote is placed at the bottom of the page (large y), + // fill_ratio must measure only actual content height so fitOnePage is not falsely refused + let res = s.run("layout.fitOnePage", &json!({})); + assert!(res.is_ok(), "bottom-placed footnote must not drive fill ratio above 30%: {res:?}"); + assert_eq!(s.layout().pages.len(), 1); +} + +#[test] +fn fit_one_page_with_final_page_footnote() { + let mut lines = 28; + let mut s = create_multiline_doc(lines); + while s.layout().pages.len() < 2 && lines < 80 { + lines += 1; + s = create_multiline_doc(lines); + } + assert_eq!(s.layout().pages.len(), 2); + + // Insert note reference into a paragraph on the final page (page 2) + let last_para_idx = s.doc.body.len().saturating_sub(1); + let note_p = wordcraft_doc::Paragraph::with_text("Final page footnote text.", CharProps::default()); + let note_id = s.doc.add_part(wordcraft_doc::PartKind::Footnote, vec![wordcraft_doc::para_block(note_p)]); + let noteref = wordcraft_doc::para::InlineObject::NoteRef { kind: wordcraft_doc::para::NoteKind::Footnote, id: note_id, custom: String::new() }; + s.doc.insert_object(&wordcraft_doc::Pos::body(last_para_idx, 0), noteref, &Default::default()).unwrap(); + s.touch(); + s.relayout(); + assert_eq!(s.layout().pages.len(), 2); + + // Verify footnote is assigned to the final page (page index 1) + let page2_has_footnote = s.layout().pages[1] + .items + .iter() + .any(|item| matches!(item, wordcraft_layout::Placed::Lines { story: wordcraft_doc::StoryRef::Part(id), .. } if *id == note_id)); + assert!(page2_has_footnote, "footnote must be assigned to page 2"); + + // With modest spill and small footnote within the 30% fill threshold, fitOnePage succeeds + let res = s.run("layout.fitOnePage", &json!({})); + assert!(res.is_ok(), "final-page footnote within 30% fill limit must allow fitOnePage to succeed: {res:?}"); + assert_eq!(s.layout().pages.len(), 1); +} + +#[test] +fn fit_one_page_skips_deleted_text_when_markup_hidden() { + let mut s = create_multiline_doc(27); + assert_eq!(s.layout().pages.len(), 2); + + // Insert a deleted text run with small font (e.g. 6.0 pt, below the 8.5 pt floor) + if let wordcraft_doc::Block::Para(p) = std::sync::Arc::make_mut(&mut s.doc.body[0]) { + let mut del_props = CharProps { size: Some(6.0), ..Default::default() }; + del_props.del = Some(1); + p.runs.push(wordcraft_doc::para::Run { len: 8, props: del_props }); + p.text.push_str("deleted!"); + } + s.touch(); + s.relayout(); + + // When show_markup is false (the default in print view), deleted run is hidden + // and skipped during font-floor scan, allowing fitOnePage to succeed + s.view.show_markup = false; + let doc_backup = s.doc.clone(); + let res = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.5, "adjustSpacing": false})); + assert!(res.is_ok(), "deleted text below floor must be skipped when markup is hidden: {res:?}"); + assert_eq!(s.layout().pages.len(), 1); + + // When show_markup is true, deleted run is drawn and evaluated in font-floor scan, + // blocking font reduction because effective font is below 8.5 pt floor + s.doc = doc_backup; + s.view.show_markup = true; + let res_markup = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.5, "adjustSpacing": false})); + assert!(res_markup.is_err(), "deleted text below floor must block font reduction when markup is shown"); +} + +#[test] +fn fit_one_page_tracks_reduced_complex_script_font_sizes() { + let mut s = create_multiline_doc(27); + assert_eq!(s.layout().pages.len(), 2); + let styles_before = s.doc.styles.clone(); + + // Set complex script font size on body run while keeping standard font size default (12 pt) + if let wordcraft_doc::Block::Para(p) = std::sync::Arc::make_mut(&mut s.doc.body[0]) { + p.runs[0].props.size_cs = Some(14.0); + } + s.touch(); + s.relayout(); + + // First fit reduces font size by 0.5 pt (size -> 11.5, size_cs -> 13.5) + let res = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.5, "adjustSpacing": false})); + assert!(res.is_ok(), "first fit must succeed: {res:?}"); + assert_eq!(s.doc.fit_state.as_ref().map(|f| f.cum_delta), Some(0.5)); + + // User restores every other size (the styles the fit rescaled), leaving only this run's + // explicit complex-script size reduced at 13.5 pt. + s.doc.styles = styles_before; + s.touch(); + s.relayout(); + assert_eq!(s.layout().pages.len(), 2); + + // Because size_cs is still reduced, cumulative tracking retains the 0.5 pt delta provenance + assert_eq!(s.doc.fit_state.as_ref().map(|f| f.cum_delta), Some(0.5)); + + // Second fit within remaining 0.5 pt budget succeeds up to 1.0 pt cap + let res2 = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.5, "adjustSpacing": false})); + assert!(res2.is_ok(), "second fit within remaining budget must succeed: {res2:?}"); + assert_eq!(s.doc.fit_state.as_ref().map(|f| f.cum_delta), Some(1.0)); + + // Third fit exceeding cumulative 1.0 limit must be refused + let res3 = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.5, "adjustSpacing": false})); + assert!(res3.is_err(), "fit exceeding cumulative limit must be refused"); +} + +#[test] +fn fit_one_page_preserves_budget_across_document_serialization() { + let mut s = create_multiline_doc(27); + assert_eq!(s.layout().pages.len(), 2); + + // Apply fit + let res = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.5, "adjustSpacing": false})); + assert!(res.is_ok(), "fit must succeed: {res:?}"); + assert_eq!(s.doc.fit_state.as_ref().map(|f| f.cum_delta), Some(0.5)); + + // Serialize and deserialize document (e.g. saving and reopening) + let json_bytes = serde_json::to_vec(&s.doc).expect("serialization must succeed"); + let reopened_doc: wordcraft_doc::Document = serde_json::from_slice(&json_bytes).expect("deserialization must succeed"); + + // Verify fit_state is preserved outside custom properties + assert_eq!(reopened_doc.fit_state.as_ref().map(|f| f.cum_delta), Some(0.5)); + assert!(reopened_doc.custom_props.is_empty(), "custom_props must remain empty"); + + // Reopen into a new session with additional spill + let mut s2 = Session::new(reopened_doc); + let extra_p = wordcraft_doc::Paragraph::with_text("Extra overflow paragraph.", CharProps::default()); + s2.doc.body.push(wordcraft_doc::para_block(extra_p)); + s2.touch(); + s2.relayout(); + + // Second fit on reopened document preserves previous 0.5 delta, allowing only remaining 0.5 pt up to 1.0 cap + let res2 = s2.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.5, "adjustSpacing": false})); + assert!(res2.is_ok(), "second fit within remaining budget must succeed"); + assert_eq!(s2.doc.fit_state.as_ref().map(|f| f.cum_delta), Some(1.0)); + + // Third fit exceeding the cumulative 1.0 limit must be refused + let res3 = s2.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.5, "adjustSpacing": false})); + assert!(res3.is_err(), "fit exceeding cumulative limit must be refused"); +} + +#[test] +fn fit_one_page_enforces_durable_limit_when_fit_state_missing() { + // A document with body font already at 8.5 pt readability floor and fit_state == None + let set_font = |s: &mut Session| { + for b in &mut s.doc.body { + if let wordcraft_doc::Block::Para(p) = std::sync::Arc::make_mut(b) { + for r in &mut p.runs { + r.props.size = Some(8.5); + } + } + } + s.touch(); + s.relayout(); + }; + let mut lines = 27; + let mut s = create_multiline_doc(lines); + set_font(&mut s); + while s.layout().pages.len() < 2 && lines < 120 { + lines += 1; + s = create_multiline_doc(lines); + set_font(&mut s); + } + assert_eq!(s.layout().pages.len(), 2); + s.doc.fit_state = None; + s.touch(); + s.relayout(); + let before = s.doc.clone(); + + // With adjustSpacing = false, font reduction is bounded by the durable 8.5 pt floor and refused + let res = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.5, "adjustSpacing": false})); + assert!(res.is_err(), "fit must be refused when typography is at durable readability floor"); + let err = res.unwrap_err().to_string(); + assert!(err.contains("Unable to shrink document further without reducing text beyond readability limits."), "unexpected error: {err}"); + assert_eq!(s.doc, before, "a refused fit must leave the document unchanged"); +} + +#[test] +fn fit_one_page_skips_hidden_text_when_hidden_marks_off() { + let mut s = create_multiline_doc(27); + assert_eq!(s.layout().pages.len(), 2); + + // Insert a hidden text run with small font (6.0 pt, below the 8.5 pt floor) into an existing paragraph + if let wordcraft_doc::Block::Para(p) = std::sync::Arc::make_mut(&mut s.doc.body[0]) { + let mut hidden_props = CharProps { size: Some(6.0), ..Default::default() }; + hidden_props.hidden = Some(true); + p.runs.push(wordcraft_doc::para::Run { len: 7, props: hidden_props }); + p.text.push_str("secret!"); + } + s.touch(); + s.relayout(); + + // When marks are off, hidden text is skipped from layout and floor checks + s.view.marks = false; + s.prefs.marks.hidden = false; + let doc_backup = s.doc.clone(); + + let res = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.5, "adjustSpacing": false})); + assert!(res.is_ok(), "hidden text with small font must be skipped when hidden marks are off: {res:?}"); + assert_eq!(s.layout().pages.len(), 1); + + // Reset document and test with marks turned on + s.doc = doc_backup; + s.view.marks = true; + s.touch(); + s.relayout(); + + let res_with_marks = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.5, "adjustSpacing": false})); + assert!(res_with_marks.is_err(), "hidden text with small font must block fit when marks are visible"); +} + +#[test] +fn fit_one_page_accounts_for_vertical_alignment_padding() { + let mut s = create_multiline_doc(26); + let extra_p = wordcraft_doc::Paragraph::with_text("Spill line on page 2.", CharProps::default()); + s.doc.body.push(wordcraft_doc::para_block(extra_p)); + + // Set page vertical alignment to Bottom + s.doc.last_section.valign = wordcraft_doc::section::PageVAlign::Bottom; + s.touch(); + s.relayout(); + assert_eq!(s.layout().pages.len(), 2); + + // Fit must succeed because the actual spill height (1 line) is well under 30% of page height, + // even though the line was translated to the bottom of page 2 by valign padding. + let res = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.5, "adjustSpacing": false})); + assert!(res.is_ok(), "fit must succeed with vertical alignment padding: {res:?}"); + assert_eq!(s.layout().pages.len(), 1); +} + +#[test] +fn fit_one_page_accounts_for_note_separator_decor_story() { + let mut lines = 28; + let mut s = create_multiline_doc(lines); + while s.layout().pages.len() < 2 && lines < 80 { + lines += 1; + s = create_multiline_doc(lines); + } + assert_eq!(s.layout().pages.len(), 2); + + // Set custom footnote separator story so layout produces DECOR_STORY + s.doc.footnote_separators.separator = Some(vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text("---", CharProps::default()))]); + + // Insert note reference into a paragraph on the final page (page 2) + let last_para_idx = s.doc.body.len().saturating_sub(1); + let note_p = wordcraft_doc::Paragraph::with_text("Final page footnote text.", CharProps::default()); + let note_id = s.doc.add_part(wordcraft_doc::PartKind::Footnote, vec![wordcraft_doc::para_block(note_p)]); + let noteref = wordcraft_doc::para::InlineObject::NoteRef { kind: wordcraft_doc::para::NoteKind::Footnote, id: note_id, custom: String::new() }; + s.doc.insert_object(&wordcraft_doc::Pos::body(last_para_idx, 0), noteref, &Default::default()).unwrap(); + s.touch(); + s.relayout(); + assert_eq!(s.layout().pages.len(), 2); + + // Verify layout produces DECOR_STORY on page 2 + let page2 = &s.layout().pages[1]; + let has_decor = + page2.items.iter().any(|item| matches!(item, wordcraft_layout::Placed::Lines { story, .. } if *story == wordcraft_layout::DECOR_STORY)); + assert!(has_decor, "footnote on page 2 must generate DECOR_STORY separator"); + + // Fit must succeed without DECOR_STORY inflating body_max_y + let res = s.run("layout.fitOnePage", &json!({})); + assert!(res.is_ok(), "fit with final page footnote and DECOR_STORY must succeed: {res:?}"); + assert_eq!(s.layout().pages.len(), 1); +} + +#[test] +fn fit_one_page_tracks_fixed_spacing_independent_of_paragraph_mark_size() { + let mut lines = 28; + let mut s = create_multiline_doc(lines); + let set_spacing = |s: &mut Session| { + for b in &mut s.doc.body { + if let wordcraft_doc::Block::Para(p) = std::sync::Arc::make_mut(b) { + p.props.line_spacing = Some(wordcraft_doc::props::LineSpacing::Exactly(16.0)); + } + } + s.touch(); + s.relayout(); + }; + set_spacing(&mut s); + while s.layout().pages.len() < 2 && lines < 80 { + lines += 1; + s = create_multiline_doc(lines); + set_spacing(&mut s); + } + assert_eq!(s.layout().pages.len(), 2); + + // Fit with spacing reduction + let res = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.0, "adjustSpacing": true})); + assert!(res.is_ok(), "spacing reduction must succeed: {res:?}"); + assert!(s.doc.fit_state.as_ref().is_some_and(|f| f.cum_space < 1.0)); + + // Change paragraph mark font size on paragraphs (e.g. from 12 pt to 24 pt) + for b in &mut s.doc.body { + if let wordcraft_doc::Block::Para(p) = std::sync::Arc::make_mut(b) { + p.mark.size = Some(24.0); + } + } + s.touch(); + s.relayout(); + + // Second fit with spacing only must be refused because spacing floor 0.94 was reached + // and tracked provenance was retained despite paragraph mark size change + let res2 = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.0, "adjustSpacing": true})); + assert!(res2.is_err(), "anti-compound guard must detect reduced fixed spacing and reject repeat compression"); +} + +#[test] +fn fit_one_page_reports_search_exhaustion_when_candidates_cannot_remove_page() { + let mut s = create_multiline_doc(27); + assert_eq!(s.layout().pages.len(), 2); + + // Calling with zero maxDeltaPt and adjustSpacing: false produces no candidate steps + let res = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 0.0, "adjustSpacing": false})); + assert!(res.is_err()); + let err = res.unwrap_err().to_string(); + assert!( + err.contains("no permitted typography adjustment could remove a page"), + "error message must distinguish search exhaustion from overflow limits: {err}" + ); +} + +#[test] +fn fit_one_page_excludes_line_number_decorations_from_final_page_limits() { + let mut lines = 33; + let enable_line_numbers = |s: &mut Session| { + s.doc.last_section.line_numbers = Some(wordcraft_doc::section::LineNumbering::default()); + s.touch(); + s.relayout(); + }; + let mut s = create_multiline_doc(lines); + enable_line_numbers(&mut s); + while s.layout().pages.len() < 2 && lines < 60 { + lines += 1; + s = create_multiline_doc(lines); + enable_line_numbers(&mut s); + } + assert_eq!(s.layout().pages.len(), 2); + + // Every body line on the final page also places a line-number decoration. Counting those as + // content would double the final-page line count and refuse before any candidate is tried. + let decorated = s.layout().pages[1] + .items + .iter() + .filter(|item| matches!(item, wordcraft_layout::Placed::Lines { story, .. } if *story == wordcraft_layout::DECOR_STORY)) + .count(); + assert!(decorated > 0, "the final page must carry line-number decorations"); + + let res = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 4.0})); + assert!(res.is_ok(), "line-number decorations must not count as final-page content: {res:?}"); + assert_eq!(s.layout().pages.len(), 1); +} + +#[test] +fn fit_one_page_counts_side_by_side_footnote_cells_once() { + let mut s = create_multiline_doc(27); + assert_eq!(s.layout().pages.len(), 2); + + // A footnote table whose row's cells sit side by side: their text occupies one vertical + // interval, so their heights must not be summed as if stacked. + let mut table = wordcraft_doc::Table::new(1, 3, 420.0); + let cell_text = "The quick brown fox jumps over the lazy dog again and again."; + for cell in &mut table.rows[0].cells { + cell.blocks = + vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(cell_text, CharProps { size: Some(21.0), ..Default::default() }))]; + } + let note_blocks: Vec> = vec![std::sync::Arc::new(wordcraft_doc::Block::Table(table))]; + let note_id = s.doc.add_part(wordcraft_doc::PartKind::Footnote, note_blocks); + + let last_para_idx = s.doc.body.len().saturating_sub(1); + let noteref = wordcraft_doc::para::InlineObject::NoteRef { kind: wordcraft_doc::para::NoteKind::Footnote, id: note_id, custom: String::new() }; + s.doc.insert_object(&wordcraft_doc::Pos::body(last_para_idx, 0), noteref, &Default::default()).unwrap(); + s.touch(); + s.relayout(); + assert_eq!(s.layout().pages.len(), 2); + + let note_cells = s.layout().pages[1] + .items + .iter() + .filter(|item| matches!(item, wordcraft_layout::Placed::Cell { story: wordcraft_doc::StoryRef::Part(id), .. } if *id == note_id)) + .count(); + assert!(note_cells >= 2, "the footnote table must place its cells side by side on the final page"); + + let res = s.run("layout.fitOnePage", &json!({"maxDeltaPt": 4.0})); + assert!(res.is_ok(), "side-by-side footnote cells must count once in the fill height: {res:?}"); + assert_eq!(s.layout().pages.len(), 1); +} + +#[test] +fn fit_one_page_refuses_when_formatting_changes_cannot_be_tracked() { + let mut s = create_multiline_doc(27); + assert_eq!(s.layout().pages.len(), 2); + s.doc.settings.track_changes = true; + s.prefs.markup.track_formatting = true; + let before = s.doc.clone(); + + // Style reductions cannot be recorded as formatting revisions, so the command must refuse + // rather than change typography that rejecting all changes cannot undo. + let res = s.run("layout.fitOnePage", &json!({})); + assert!(res.is_err(), "layout.fitOnePage must refuse while formatting is tracked"); + let err = res.unwrap_err().to_string(); + assert!(err.contains("tracks formatting changes"), "unexpected error: {err}"); + assert_eq!(s.doc, before, "a refused fit must leave the document unchanged"); +} diff --git a/crates/engine/src/tests/font_sizes.rs b/crates/engine/src/tests/font_sizes.rs index 8db1332851..ad337534d6 100644 --- a/crates/engine/src/tests/font_sizes.rs +++ b/crates/engine/src/tests/font_sizes.rs @@ -1,230 +1,230 @@ -//! Relative font sizes keep each selected run's size (#4903). - -use serde_json::json; -use wordcraft_doc::{Document, Pos}; - -use crate::Session; - -fn mixed() -> Session { - let mut doc = Document::from_text("Large heading\nSmall detail"); - for (block, size) in [(0, 20.0), (1, 10.0)] { - let end = doc.para_at(&Pos::body(block, 0)).unwrap().len(); - doc.format_range(&Pos::body(block, 0), &Pos::body(block, end), &|c| { - c.size = Some(size); - c.size_cs = Some(size); - }) - .unwrap(); - } - let mut s = Session::new(doc); - s.run("select.all", &json!({})).unwrap(); - s -} - -fn sizes(s: &Session) -> Vec { - (0..2) - .map(|block| { - let p = s.doc.para_at(&Pos::body(block, 0)).unwrap(); - s.doc.styles.resolve_char(p.props.style.as_deref(), p.props_of_char(0)).size - }) - .collect() -} - -#[test] -fn relative_font_sizes_preserve_mixed_runs_and_one_undo() { - for (command, expected) in [ - ("format.growFont", [22.0, 10.5]), - ("format.shrinkFont", [18.0, 9.0]), - ("format.growFont1", [21.0, 11.0]), - ("format.shrinkFont1", [19.0, 9.0]), - ] { - let mut s = mixed(); - let before = s.doc.clone(); - let selection = s.sel.clone(); - let depth = s.undo_depth(); - s.run(command, &json!({})).unwrap(); - assert_eq!(sizes(&s), expected, "{command}"); - assert_eq!(s.sel, selection); - assert_eq!(s.undo_depth(), depth + 1); - let after = s.doc.clone(); - s.run("edit.undo", &json!({})).unwrap(); - assert_eq!(s.doc, before); - s.run("edit.redo", &json!({})).unwrap(); - assert_eq!(s.doc, after); - } -} - -#[test] -fn relative_font_sizes_resolve_styles_and_preserve_other_properties() { - use wordcraft_doc::props::CharProps; - use wordcraft_doc::styles::{Style, StyleKind}; - - let mut s = mixed(); - let styles = std::sync::Arc::make_mut(&mut s.doc.styles); - styles.default_chr.size_cs = Some(30.0); - styles.upsert(Style { - id: "Large".into(), - name: "Large".into(), - based_on: Some("Normal".into()), - chr: CharProps { size: Some(20.0), bold: Some(true), ..Default::default() }, - ..Default::default() - }); - styles.upsert(Style { - id: "Small".into(), - name: "Small".into(), - kind: StyleKind::Character, - chr: CharProps { size: Some(10.0), italic: Some(true), ..Default::default() }, - ..Default::default() - }); - for block in 0..2 { - let p = s.doc.para_mut(wordcraft_doc::StoryRef::Body, &wordcraft_doc::Path::top(block)).unwrap(); - p.props.style = Some(if block == 0 { "Large" } else { "Normal" }.into()); - for c in std::iter::once(&mut p.mark).chain(p.runs.iter_mut().map(|r| &mut r.props)) { - c.size = None; - c.size_cs = None; - if block == 1 { - c.style = Some("Small".into()); - } - } - } - s.run("format.growFont", &json!({})).unwrap(); - assert_eq!(sizes(&s), [22.0, 10.5]); - for (block, size) in [(0, 22.0), (1, 10.5)] { - let p = s.doc.para_at(&Pos::body(block, 0)).unwrap(); - assert_eq!(p.mark.size, Some(size)); - for (_, c) in p.run_ranges() { - assert_eq!((c.size, c.size_cs), (Some(size), Some(size)), "both scripts still get the explicit override"); - let resolved = s.doc.styles.resolve_char(p.props.style.as_deref(), c); - assert!(if block == 0 { resolved.bold } else { resolved.italic }); - } - } -} - -#[test] -fn relative_font_sizes_change_only_the_selected_unicode_pieces() { - let mut s = Session::new(Document::from_text("éA中Bz")); - s.doc.format_range(&Pos::body(0, 2), &Pos::body(0, 3), &|c| c.size = Some(20.0)).unwrap(); - s.doc.format_range(&Pos::body(0, 3), &Pos::body(0, 6), &|c| c.size = Some(10.0)).unwrap(); - let before = s.doc.clone(); - s.run("select.range", &json!({"anchor": Pos::body(0, 2), "focus": Pos::body(0, 6)})).unwrap(); - s.run("format.growFont1", &json!({})).unwrap(); - let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); - let original = before.para_at(&Pos::body(0, 0)).unwrap(); - assert_eq!(p.text, "éA中Bz"); - assert_eq!(p.props_of_char(2).size, Some(21.0)); - assert_eq!(p.props_of_char(3).size, Some(11.0)); - assert_eq!(p.props_of_char(0), original.props_of_char(0)); - assert_eq!(p.props_of_char(6), original.props_of_char(6)); - assert_eq!(p.mark, original.mark, "a partial selection leaves the paragraph mark alone"); -} - -#[test] -fn relative_font_sizes_keep_the_caret_and_word_targets() { - let mut s = Session::new(Document::new()); - s.run("format.size", &json!({"size": 10})).unwrap(); - for size in [11.0, 12.0] { - s.run("format.growFont1", &json!({})).unwrap(); - assert_eq!(s.pending.as_ref().unwrap().size, Some(size)); - assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().mark.size, Some(size)); - } - s.run("text.insert", &json!({"text": "abcd ef"})).unwrap(); - s.doc.format_range(&Pos::body(0, 0), &Pos::body(0, 2), &|c| c.size = Some(20.0)).unwrap(); - s.doc.format_range(&Pos::body(0, 2), &Pos::body(0, 4), &|c| c.size = Some(10.0)).unwrap(); - s.run("caret.set", &json!({"pos": Pos::body(0, 2)})).unwrap(); - let selection = s.sel.clone(); - s.run("format.growFont1", &json!({})).unwrap(); - assert_eq!(s.sel, selection); - let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); - assert_eq!((p.props_of_char(0).size, p.props_of_char(2).size, p.props_of_char(5).size), (Some(21.0), Some(11.0), Some(12.0))); -} - -#[test] -fn relative_font_sizes_keep_column_cell_and_text_box_targets() { - use wordcraft_doc::{Path, StoryRef}; - - let mut s = super::column_doc(); - super::block(&mut s); - for (block, size) in [(0, 20.0), (1, 10.0), (2, 14.0)] { - s.doc.format_range(&Pos::body(block, 0), &Pos::body(block, 6), &|c| c.size = Some(size)).unwrap(); - } - s.run("format.growFont1", &json!({})).unwrap(); - assert!(s.column_segments().is_some()); - for (block, size) in [(0, 20.0), (1, 10.0), (2, 14.0)] { - let p = s.doc.para_at(&Pos::body(block, 0)).unwrap(); - assert_eq!((p.props_of_char(1).size, p.props_of_char(2).size, p.props_of_char(4).size), (Some(size), Some(size + 1.0), Some(size))); - assert_eq!(p.mark.size, Some(size)); - } - - let mut s = Session::new(Document::new()); - s.run("insert.table", &json!({"rows": 1, "cols": 2})).unwrap(); - let paths = [Path(vec![0, 0, 0, 0]), Path(vec![0, 0, 1, 0])]; - for (path, size) in paths.iter().zip([20.0, 10.0]) { - s.doc.para_mut(StoryRef::Body, path).unwrap().mark.size = Some(size); - } - s.run("table.selectTable", &json!({})).unwrap(); - let _ = s.layout(); - s.run("format.growFont1", &json!({})).unwrap(); - for (path, size) in paths.iter().zip([21.0, 11.0]) { - assert_eq!(s.doc.para(StoryRef::Body, path).unwrap().mark.size, Some(size), "empty cells retain distinct sizes"); - } - - let mut s = Session::new(Document::from_text("Body")); - let id = s.run("insert.textBox", &json!({"text": "AB"})).unwrap()["story"].as_u64().unwrap() as u32; - let story = StoryRef::Part(id); - let at = |off| Pos { story, path: Path::top(0), off }; - for (a, b, size) in [(0, 1, 20.0), (1, 2, 10.0)] { - s.doc.format_range(&at(a), &at(b), &|c| c.size = Some(size)).unwrap(); - } - s.run("select.range", &json!({"anchor": Pos::body(0, 0), "focus": Pos::body(0, "\u{fffc}".len())})).unwrap(); - let before = s.doc.para_at(&Pos::body(0, 0)).unwrap().clone(); - s.run("format.growFont1", &json!({})).unwrap(); - assert_eq!(*s.doc.para_at(&Pos::body(0, 0)).unwrap(), before); - let p = s.doc.para(story, &Path::top(0)).unwrap(); - assert_eq!((p.props_of_char(0).size, p.props_of_char(1).size), (Some(21.0), Some(11.0))); -} - -#[test] -fn relative_font_sizes_keep_bounds_protection_and_saved_sizes() { - for (command, values, expected) in [ - ("format.growFont", [72.0, 1638.0], [80.0, 1638.0]), - ("format.shrinkFont", [1.0, 8.0], [1.0, 1.0]), - ("format.growFont1", [1.0, 1638.0], [2.0, 1638.0]), - ("format.shrinkFont1", [1.0, 1638.0], [1.0, 1637.0]), - ] { - let mut s = mixed(); - for (block, size) in values.into_iter().enumerate() { - let end = s.doc.para_at(&Pos::body(block, 0)).unwrap().len(); - s.doc.format_range(&Pos::body(block, 0), &Pos::body(block, end), &|c| c.size = Some(size)).unwrap(); - } - s.run(command, &json!({})).unwrap(); - assert_eq!(sizes(&s), expected, "{command}"); - let bytes = crate::io::save_bytes("sizes.docx", &s.doc).unwrap(); - let reopened = Session::new(crate::io::open_bytes("sizes.docx", &bytes).unwrap()); - assert_eq!(sizes(&reopened), expected, "saved {command}"); - s.run("review.restrict", &json!({"mode": "readOnly"})).unwrap(); - let before = s.doc.clone(); - let depth = s.undo_depth(); - assert!(s.run(command, &json!({})).is_err()); - assert_eq!(s.doc, before); - assert_eq!(s.undo_depth(), depth); - } -} - -#[test] -fn relative_font_sizes_keep_paragraph_mark_link_filtering() { - let mut s = mixed(); - for (block, style) in [(0, "Hyperlink"), (1, "CustomLink")] { - let p = s.doc.para_mut(wordcraft_doc::StoryRef::Body, &wordcraft_doc::Path::top(block)).unwrap(); - p.mark.link = Some("https://example.test/mark".into()); - p.mark.style = Some(style.into()); - p.runs[0].props.link = Some("https://example.test/text".into()); - p.runs[0].props.style = Some("Hyperlink".into()); - } - s.run("format.growFont", &json!({})).unwrap(); - assert_eq!(sizes(&s), [22.0, 10.5]); - for block in 0..2 { - let p = s.doc.para_at(&Pos::body(block, 0)).unwrap(); - assert_eq!(p.mark.link, None); - assert_eq!(p.mark.style.as_deref(), if block == 0 { None } else { Some("CustomLink") }); - assert!(p.run_ranges().all(|(_, c)| c.link.as_deref() == Some("https://example.test/text") && c.style.as_deref() == Some("Hyperlink"))); - } -} +//! Relative font sizes keep each selected run's size (#4903). + +use serde_json::json; +use wordcraft_doc::{Document, Pos}; + +use crate::Session; + +fn mixed() -> Session { + let mut doc = Document::from_text("Large heading\nSmall detail"); + for (block, size) in [(0, 20.0), (1, 10.0)] { + let end = doc.para_at(&Pos::body(block, 0)).unwrap().len(); + doc.format_range(&Pos::body(block, 0), &Pos::body(block, end), &|c| { + c.size = Some(size); + c.size_cs = Some(size); + }) + .unwrap(); + } + let mut s = Session::new(doc); + s.run("select.all", &json!({})).unwrap(); + s +} + +fn sizes(s: &Session) -> Vec { + (0..2) + .map(|block| { + let p = s.doc.para_at(&Pos::body(block, 0)).unwrap(); + s.doc.styles.resolve_char(p.props.style.as_deref(), p.props_of_char(0)).size + }) + .collect() +} + +#[test] +fn relative_font_sizes_preserve_mixed_runs_and_one_undo() { + for (command, expected) in [ + ("format.growFont", [22.0, 10.5]), + ("format.shrinkFont", [18.0, 9.0]), + ("format.growFont1", [21.0, 11.0]), + ("format.shrinkFont1", [19.0, 9.0]), + ] { + let mut s = mixed(); + let before = s.doc.clone(); + let selection = s.sel.clone(); + let depth = s.undo_depth(); + s.run(command, &json!({})).unwrap(); + assert_eq!(sizes(&s), expected, "{command}"); + assert_eq!(s.sel, selection); + assert_eq!(s.undo_depth(), depth + 1); + let after = s.doc.clone(); + s.run("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc, before); + s.run("edit.redo", &json!({})).unwrap(); + assert_eq!(s.doc, after); + } +} + +#[test] +fn relative_font_sizes_resolve_styles_and_preserve_other_properties() { + use wordcraft_doc::props::CharProps; + use wordcraft_doc::styles::{Style, StyleKind}; + + let mut s = mixed(); + let styles = std::sync::Arc::make_mut(&mut s.doc.styles); + styles.default_chr.size_cs = Some(30.0); + styles.upsert(Style { + id: "Large".into(), + name: "Large".into(), + based_on: Some("Normal".into()), + chr: CharProps { size: Some(20.0), bold: Some(true), ..Default::default() }, + ..Default::default() + }); + styles.upsert(Style { + id: "Small".into(), + name: "Small".into(), + kind: StyleKind::Character, + chr: CharProps { size: Some(10.0), italic: Some(true), ..Default::default() }, + ..Default::default() + }); + for block in 0..2 { + let p = s.doc.para_mut(wordcraft_doc::StoryRef::Body, &wordcraft_doc::Path::top(block)).unwrap(); + p.props.style = Some(if block == 0 { "Large" } else { "Normal" }.into()); + for c in std::iter::once(&mut p.mark).chain(p.runs.iter_mut().map(|r| &mut r.props)) { + c.size = None; + c.size_cs = None; + if block == 1 { + c.style = Some("Small".into()); + } + } + } + s.run("format.growFont", &json!({})).unwrap(); + assert_eq!(sizes(&s), [22.0, 10.5]); + for (block, size) in [(0, 22.0), (1, 10.5)] { + let p = s.doc.para_at(&Pos::body(block, 0)).unwrap(); + assert_eq!(p.mark.size, Some(size)); + for (_, c) in p.run_ranges() { + assert_eq!((c.size, c.size_cs), (Some(size), Some(size)), "both scripts still get the explicit override"); + let resolved = s.doc.styles.resolve_char(p.props.style.as_deref(), c); + assert!(if block == 0 { resolved.bold } else { resolved.italic }); + } + } +} + +#[test] +fn relative_font_sizes_change_only_the_selected_unicode_pieces() { + let mut s = Session::new(Document::from_text("éA中Bz")); + s.doc.format_range(&Pos::body(0, 2), &Pos::body(0, 3), &|c| c.size = Some(20.0)).unwrap(); + s.doc.format_range(&Pos::body(0, 3), &Pos::body(0, 6), &|c| c.size = Some(10.0)).unwrap(); + let before = s.doc.clone(); + s.run("select.range", &json!({"anchor": Pos::body(0, 2), "focus": Pos::body(0, 6)})).unwrap(); + s.run("format.growFont1", &json!({})).unwrap(); + let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); + let original = before.para_at(&Pos::body(0, 0)).unwrap(); + assert_eq!(p.text, "éA中Bz"); + assert_eq!(p.props_of_char(2).size, Some(21.0)); + assert_eq!(p.props_of_char(3).size, Some(11.0)); + assert_eq!(p.props_of_char(0), original.props_of_char(0)); + assert_eq!(p.props_of_char(6), original.props_of_char(6)); + assert_eq!(p.mark, original.mark, "a partial selection leaves the paragraph mark alone"); +} + +#[test] +fn relative_font_sizes_keep_the_caret_and_word_targets() { + let mut s = Session::new(Document::new()); + s.run("format.size", &json!({"size": 10})).unwrap(); + for size in [11.0, 12.0] { + s.run("format.growFont1", &json!({})).unwrap(); + assert_eq!(s.pending.as_ref().unwrap().size, Some(size)); + assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().mark.size, Some(size)); + } + s.run("text.insert", &json!({"text": "abcd ef"})).unwrap(); + s.doc.format_range(&Pos::body(0, 0), &Pos::body(0, 2), &|c| c.size = Some(20.0)).unwrap(); + s.doc.format_range(&Pos::body(0, 2), &Pos::body(0, 4), &|c| c.size = Some(10.0)).unwrap(); + s.run("caret.set", &json!({"pos": Pos::body(0, 2)})).unwrap(); + let selection = s.sel.clone(); + s.run("format.growFont1", &json!({})).unwrap(); + assert_eq!(s.sel, selection); + let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); + assert_eq!((p.props_of_char(0).size, p.props_of_char(2).size, p.props_of_char(5).size), (Some(21.0), Some(11.0), Some(12.0))); +} + +#[test] +fn relative_font_sizes_keep_column_cell_and_text_box_targets() { + use wordcraft_doc::{Path, StoryRef}; + + let mut s = super::column_doc(); + super::block(&mut s); + for (block, size) in [(0, 20.0), (1, 10.0), (2, 14.0)] { + s.doc.format_range(&Pos::body(block, 0), &Pos::body(block, 6), &|c| c.size = Some(size)).unwrap(); + } + s.run("format.growFont1", &json!({})).unwrap(); + assert!(s.column_segments().is_some()); + for (block, size) in [(0, 20.0), (1, 10.0), (2, 14.0)] { + let p = s.doc.para_at(&Pos::body(block, 0)).unwrap(); + assert_eq!((p.props_of_char(1).size, p.props_of_char(2).size, p.props_of_char(4).size), (Some(size), Some(size + 1.0), Some(size))); + assert_eq!(p.mark.size, Some(size)); + } + + let mut s = Session::new(Document::new()); + s.run("insert.table", &json!({"rows": 1, "cols": 2})).unwrap(); + let paths = [Path(vec![0, 0, 0, 0]), Path(vec![0, 0, 1, 0])]; + for (path, size) in paths.iter().zip([20.0, 10.0]) { + s.doc.para_mut(StoryRef::Body, path).unwrap().mark.size = Some(size); + } + s.run("table.selectTable", &json!({})).unwrap(); + let _ = s.layout(); + s.run("format.growFont1", &json!({})).unwrap(); + for (path, size) in paths.iter().zip([21.0, 11.0]) { + assert_eq!(s.doc.para(StoryRef::Body, path).unwrap().mark.size, Some(size), "empty cells retain distinct sizes"); + } + + let mut s = Session::new(Document::from_text("Body")); + let id = s.run("insert.textBox", &json!({"text": "AB"})).unwrap()["story"].as_u64().unwrap() as u32; + let story = StoryRef::Part(id); + let at = |off| Pos { story, path: Path::top(0), off }; + for (a, b, size) in [(0, 1, 20.0), (1, 2, 10.0)] { + s.doc.format_range(&at(a), &at(b), &|c| c.size = Some(size)).unwrap(); + } + s.run("select.range", &json!({"anchor": Pos::body(0, 0), "focus": Pos::body(0, "\u{fffc}".len())})).unwrap(); + let before = s.doc.para_at(&Pos::body(0, 0)).unwrap().clone(); + s.run("format.growFont1", &json!({})).unwrap(); + assert_eq!(*s.doc.para_at(&Pos::body(0, 0)).unwrap(), before); + let p = s.doc.para(story, &Path::top(0)).unwrap(); + assert_eq!((p.props_of_char(0).size, p.props_of_char(1).size), (Some(21.0), Some(11.0))); +} + +#[test] +fn relative_font_sizes_keep_bounds_protection_and_saved_sizes() { + for (command, values, expected) in [ + ("format.growFont", [72.0, 1638.0], [80.0, 1638.0]), + ("format.shrinkFont", [1.0, 8.0], [1.0, 1.0]), + ("format.growFont1", [1.0, 1638.0], [2.0, 1638.0]), + ("format.shrinkFont1", [1.0, 1638.0], [1.0, 1637.0]), + ] { + let mut s = mixed(); + for (block, size) in values.into_iter().enumerate() { + let end = s.doc.para_at(&Pos::body(block, 0)).unwrap().len(); + s.doc.format_range(&Pos::body(block, 0), &Pos::body(block, end), &|c| c.size = Some(size)).unwrap(); + } + s.run(command, &json!({})).unwrap(); + assert_eq!(sizes(&s), expected, "{command}"); + let bytes = crate::io::save_bytes("sizes.docx", &s.doc).unwrap(); + let reopened = Session::new(crate::io::open_bytes("sizes.docx", &bytes).unwrap()); + assert_eq!(sizes(&reopened), expected, "saved {command}"); + s.run("review.restrict", &json!({"mode": "readOnly"})).unwrap(); + let before = s.doc.clone(); + let depth = s.undo_depth(); + assert!(s.run(command, &json!({})).is_err()); + assert_eq!(s.doc, before); + assert_eq!(s.undo_depth(), depth); + } +} + +#[test] +fn relative_font_sizes_keep_paragraph_mark_link_filtering() { + let mut s = mixed(); + for (block, style) in [(0, "Hyperlink"), (1, "CustomLink")] { + let p = s.doc.para_mut(wordcraft_doc::StoryRef::Body, &wordcraft_doc::Path::top(block)).unwrap(); + p.mark.link = Some("https://example.test/mark".into()); + p.mark.style = Some(style.into()); + p.runs[0].props.link = Some("https://example.test/text".into()); + p.runs[0].props.style = Some("Hyperlink".into()); + } + s.run("format.growFont", &json!({})).unwrap(); + assert_eq!(sizes(&s), [22.0, 10.5]); + for block in 0..2 { + let p = s.doc.para_at(&Pos::body(block, 0)).unwrap(); + assert_eq!(p.mark.link, None); + assert_eq!(p.mark.style.as_deref(), if block == 0 { None } else { Some("CustomLink") }); + assert!(p.run_ranges().all(|(_, c)| c.link.as_deref() == Some("https://example.test/text") && c.style.as_deref() == Some("Hyperlink"))); + } +} diff --git a/crates/engine/src/tests/fonts.rs b/crates/engine/src/tests/fonts.rs index 33f74d81ee..960c56329f 100644 --- a/crates/engine/src/tests/fonts.rs +++ b/crates/engine/src/tests/fonts.rs @@ -1,511 +1,511 @@ -//! Fonts added at run time (#582): `font.add`, `font.list`, `font.remove`. - -use serde_json::json; -use wordcraft_doc::Pos; - -use crate::Session; -use crate::cmd::insert::base64_encode; - -/// The bundled face `file` of family `original` renamed `family` (as long as `original`), so it -/// is a font the database doesn't have. -fn renamed(file: &str, original: &str, family: &str) -> Vec { - assert_eq!(family.len(), original.len()); - let mut data = std::fs::read(std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets/fonts").join(file)).unwrap(); - let utf16 = |s: &str| s.encode_utf16().flat_map(u16::to_be_bytes).collect::>(); - for (from, to) in [(utf16(original), utf16(family)), (original.as_bytes().to_vec(), family.as_bytes().to_vec())] { - let mut i = 0; - while let Some(at) = data[i..].windows(from.len()).position(|w| w == from) { - data[i + at..i + at + to.len()].copy_from_slice(&to); - i += at + to.len(); - } - } - data -} - -/// The caret's x after `text` set in `font`. -fn text_end_x(s: &mut Session, font: &str, text: &str) -> f32 { - s.run("document.setText", &json!({"text": text})).unwrap(); - s.run("select.all", &json!({})).unwrap(); - s.run("format.font", &json!({"name": font})).unwrap(); - s.layout().caret_on(&Pos::body(0, text.len()), 0).unwrap().x -} - -#[test] -fn an_added_font_is_listed_and_lays_out_text_already_set_in_it() { - // A monospaced face, so it measures unlike whatever proportional font the system falls back - // to (Source Serif 4 itself where no system fonts are installed, as on the FreeBSD runner). - // The name has no Serif/Sans/Mono in it, which would pick a fallback of that kind. - const FAMILY: &str = "Cmdfont Face 4"; - // Narrow letters: set monospaced they take far more room than in any proportional font. - const TEXT: &str = "illicit little lilies fill tiny jill"; - let mut s = Session::new(wordcraft_doc::Document::new()); - let mono = text_end_x(&mut s, "JetBrains Mono", TEXT); - // Not installed yet: the text falls back to another font. - let missing = text_end_x(&mut s, FAMILY, TEXT); - assert!((missing - mono).abs() > 1.0, "the fallback measures differently: {missing} vs {mono}"); - - let data = base64_encode(&renamed("JetBrainsMono-Regular.ttf", "JetBrains Mono", FAMILY)); - let r = s.run("font.add", &json!({"data": data, "name": "CmdfontFace4-Regular.ttf"})).unwrap(); - assert_eq!(r["families"], json!([FAMILY])); - assert_eq!(r["faces"][0]["style"], "Regular"); - // The text already set in it re-lays out with it, without touching the document. - let x = s.layout().caret_on(&Pos::body(0, TEXT.len()), 0).unwrap().x; - assert!((x - mono).abs() < 0.01, "measured with the added font: {x} vs {mono} (missing {missing})"); - - let list = s.run("font.list", &json!({"all": true})).unwrap(); - assert!(list["fonts"].as_array().unwrap().iter().any(|f| f["families"] == json!([FAMILY]))); - assert!(list["families"].as_array().unwrap().iter().any(|f| f == FAMILY)); - // It exports to PDF like any other font. - assert!(crate::io::save_bytes("out.pdf", &s.doc).unwrap().starts_with(b"%PDF")); - - // Adding it again changes nothing. - let again = s.run("font.add", &json!({"data": data, "name": "again.ttf"})); - assert!(again.unwrap_err().to_string().contains("already available")); - - let removed = s.run("font.remove", &json!({"family": FAMILY})).unwrap(); - assert_eq!(removed["removed"].as_array().unwrap().len(), 1); - let x = s.layout().caret_on(&Pos::body(0, TEXT.len()), 0).unwrap().x; - assert!((x - missing).abs() < 0.01, "falls back again once removed"); - assert!(s.run("font.remove", &json!({"family": FAMILY})).is_err()); -} - -#[test] -fn adding_a_font_never_changes_text_that_does_not_use_it() { - const FAMILY: &str = "Metric Serif 4"; - let mut s = Session::new(crate::sample::sample_document()); - let before = s.layout(); - let positions = |s: &mut Session| { - let l = s.layout(); - (0..s.doc.body.len().min(12)).filter_map(|b| l.caret_on(&Pos::body(b, 0), 0).map(|c| (c.page, c.x, c.top))).collect::>() - }; - let a = positions(&mut s); - s.run("font.add", &json!({"data": base64_encode(&renamed("SourceSerif4-Bold.ttf", "Source Serif 4", FAMILY)), "name": "m.ttf"})).unwrap(); - assert_eq!(positions(&mut s), a); - assert_eq!(s.layout().pages.len(), before.pages.len()); -} - -#[test] -fn bad_font_input_is_an_error() { - let mut s = Session::new(wordcraft_doc::Document::new()); - for p in [ - json!({"data": base64_encode(b"definitely not a font")}), - json!({"data": "%%% not base64"}), - json!({"data": base64_encode(b"wOF2\0\x01\0\0\xff\xff\xff\xff")}), - json!({"path": "/nonexistent/font.ttf"}), - json!({"path": env!("CARGO_MANIFEST_DIR")}), - json!({"path": concat!(env!("CARGO_MANIFEST_DIR"), "/Cargo.toml")}), - json!({"url": "https://fonts.googleapis.com/css2?family=Roboto"}), - ] { - assert!(s.run("font.add", &p).is_err(), "{p}"); - } - assert!(s.run("font.remove", &json!({})).is_err()); - assert!(s.run("font.remove", &json!({"family": "Source Sans 3"})).is_err(), "the bundled fonts stay"); - // No file: the UI is asked for a picker. - let r = s.run("font.add", &json!({})).unwrap(); - assert_eq!(r["pending"], true); - assert!(s.ui_requests.iter().any(|r| r["open"] == "addFont")); - - let r = s.run("font.loadSystem", &json!({})).unwrap(); - assert_eq!(r["pending"], true); - assert!(s.ui_requests.iter().any(|r| r["open"] == "loadSystemFonts")); -} - -/// A .docx whose text is set in `family`, embedding `font` through the font table (#1171): as an -/// obfuscated `.odttf` when `key` is a real GUID, as a plain `.ttf` when it is all zeros. -fn docx_embedding(family: &str, font: Option<(&[u8], &str)>, text: &str) -> Vec { - const W: &str = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"; - const R: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; - const PR: &str = "http://schemas.openxmlformats.org/package/2006/relationships"; - let rels = |items: &[(&str, &str)]| { - let list: String = items.iter().map(|(t, g)| format!(r#""#)).collect(); - format!(r#"{list}"#) - }; - let mut files: Vec<(String, Vec)> = vec![ - ( - "[Content_Types].xml".into(), - br#""#.to_vec(), - ), - ("_rels/.rels".into(), rels(&[("officeDocument", "word/document.xml")]).into_bytes()), - ("word/_rels/document.xml.rels".into(), rels(&[("fontTable", "fontTable.xml")]).into_bytes()), - ( - "word/document.xml".into(), - format!(r#"{text}"#).into_bytes(), - ), - ]; - let mut table = format!(r#""#); - if let Some((data, key)) = font { - let digits = key.replace(['{', '}', '-'], ""); - let hex: Vec = (0..16).map(|i| u8::from_str_radix(&digits[i * 2..i * 2 + 2], 16).unwrap()).collect(); - let zero = hex.iter().all(|b| *b == 0); - let mut data = data.to_vec(); - // Obfuscation (ECMA-376 Part 1, 17.8.1): the first 32 bytes XOR the key's bytes, reversed. - for i in 0..32 { - data[i] ^= hex[15 - i % 16]; - } - let ext = if zero { "ttf" } else { "odttf" }; - table.push_str(&format!(r#""#)); - files.push(("word/_rels/fontTable.xml.rels".into(), rels(&[("font", &format!("fonts/font1.{ext}"))]).into_bytes())); - files.push((format!("word/fonts/font1.{ext}"), data)); - } - table.push_str(""); - files.push(("word/fontTable.xml".into(), table.into_bytes())); - let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); - for (name, bytes) in files { - zip.start_file(name, zip::write::SimpleFileOptions::default()).unwrap(); - std::io::Write::write_all(&mut zip, &bytes).unwrap(); - } - zip.finish().unwrap().into_inner() -} - -#[test] -fn fonts_embedded_in_a_docx_lay_out_its_text() { - const TEXT: &str = "illicit little lilies fill tiny jill"; - let end_x = - |bytes: &[u8]| Session::new(crate::io::open_bytes("e.docx", bytes).unwrap()).layout().caret_on(&Pos::body(0, TEXT.len()), 0).unwrap().x; - let mono = end_x(&docx_embedding("JetBrains Mono", None, TEXT)); - // Both forms Word and other producers write: an obfuscated .odttf with its key, a plain .ttf - // with the all-zero key. Each is a family no other test or system has. - for (family, key) in [("JetBrains Odtt", "{9F1C2A4E-7B3D-4C58-A1E6-0D2B8F5C3E77}"), ("JetBrains Zero", "{00000000-0000-0000-0000-000000000000}")] - { - let font = renamed("JetBrainsMono-Regular.ttf", "JetBrains Mono", family); - let missing = end_x(&docx_embedding(family, None, TEXT)); - assert!((missing - mono).abs() > 1.0, "{family}: not embedded, the text falls back: {missing} vs {mono}"); - let x = end_x(&docx_embedding(family, Some((&font, key)), TEXT)); - assert!((x - mono).abs() < 0.01, "{family}: measured with the embedded font: {x} vs {mono} (missing {missing})"); - } -} - -/// A font Word embeds as a subset ("embed only the characters used") goes by a made-up family -/// inside (`___WRD_EMBED_SUB_46`), and the text asks for it by the font table's name -/// (`OBRBWW+Century Schoolbook`): the text is measured with it all the same (#2240). It fell back -/// to a substitute, as if nothing were embedded. -#[test] -fn a_subset_font_word_embeds_lays_out_text_set_in_its_table_name() { - const TEXT: &str = "illicit little lilies fill tiny jill"; - const FAMILY: &str = "QWERTY+JetBrains Sub"; - let end_x = - |bytes: &[u8]| Session::new(crate::io::open_bytes("e.docx", bytes).unwrap()).layout().caret_on(&Pos::body(0, TEXT.len()), 0).unwrap().x; - let mono = end_x(&docx_embedding("JetBrains Mono", None, TEXT)); - let missing = end_x(&docx_embedding(FAMILY, None, TEXT)); - assert!((missing - mono).abs() > 1.0, "not embedded, the text falls back: {missing} vs {mono}"); - let subset = renamed("JetBrainsMono-Regular.ttf", "JetBrains Mono", "___WRD_EMBED_7"); - let x = end_x(&docx_embedding(FAMILY, Some((&subset, "{6A0E3B57-1C2D-4E8F-9A7B-3D5C1E0F2B64}")), TEXT)); - assert!((x - mono).abs() < 0.01, "measured with the embedded subset: {x} vs {mono} (missing {missing})"); -} - -/// `font.missing`'s fonts, as "font>shownAs". -fn missing_list(s: &mut Session) -> Vec { - let m = s.run("font.missing", &json!({})).unwrap(); - m["missing"] - .as_array() - .unwrap() - .iter() - .map(|f| { - let shown: Vec<&str> = f["shownAs"].as_array().unwrap().iter().map(|x| x.as_str().unwrap()).collect(); - format!("{}>{}", f["font"].as_str().unwrap(), shown.join(",")) - }) - .collect() -} - -/// The missing fonts of `doc` as laid out. -fn missing_in(doc: wordcraft_doc::Document) -> Vec { - let mut s = Session::new(doc); - let layout = s.layout(); - crate::cmd::font::missing_fonts(&s.doc, &layout) -} - -/// A paragraph of `text` set in `font`. -fn para_in(text: &str, font: &str) -> wordcraft_doc::Paragraph { - wordcraft_doc::Paragraph::with_text(text, wordcraft_doc::CharProps { font: Some(font.into()), ..Default::default() }) -} - -/// A document of these paragraphs. -fn doc_of(paras: Vec) -> wordcraft_doc::Document { - let mut doc = wordcraft_doc::Document::new(); - doc.body = paras.into_iter().map(wordcraft_doc::para_block).collect(); - doc -} - -#[test] -fn fonts_that_are_not_installed_are_reported_with_the_font_shown_instead() { - // A family no system has, with no Serif/Mono in its name: it's drawn in a sans substitute. - const MISSING: &str = "Wordcraft Nowhere Face"; - let mut s = Session::new(wordcraft_doc::Document::new()); - s.run("document.setText", &json!({"text": "Bundled\nMissing"})).unwrap(); - s.run("select.all", &json!({})).unwrap(); - s.run("format.font", &json!({"name": "JetBrains Mono"})).unwrap(); - assert!(s.run("format.state", &json!({})).unwrap()["fontShownAs"].is_null(), "a bundled font is installed"); - assert_eq!(missing_list(&mut s), Vec::::new()); - - s.run("select.text", &json!({"text": "Missing"})).unwrap(); - s.run("format.font", &json!({"name": MISSING})).unwrap(); - let st = s.run("format.state", &json!({})).unwrap(); - assert_eq!(st["font"], MISSING); - let shown = st["fontShownAs"].as_str().unwrap_or_default().to_string(); - assert!(!shown.is_empty() && shown != MISSING, "the font box learns the substitute: {st}"); - assert_eq!(missing_list(&mut s), [format!("{MISSING}>{shown}")], "only the missing font, once"); - - // A mixed selection has no single font, so nothing to flag in the font box. - s.run("select.all", &json!({})).unwrap(); - assert!(s.run("format.state", &json!({})).unwrap()["fontShownAs"].is_null()); -} - -#[test] -fn a_font_only_a_style_names_is_not_reported_missing() { - let mut s = Session::new(wordcraft_doc::Document::new()); - s.run("document.setText", &json!({"text": "Body"})).unwrap(); - s.run("styles.create", &json!({"name": "Unused Look"})).unwrap(); - s.run("styles.modify", &json!({"style": "Unused Look", "chr": {"font": "Wordcraft Style Only Face"}})).unwrap(); - // Creating a style applies it, as in Word: put the text back in Normal. - s.run("para.style", &json!({"style": "Normal"})).unwrap(); - s.run("select.all", &json!({})).unwrap(); - s.run("format.font", &json!({"name": "JetBrains Mono"})).unwrap(); - assert_eq!(missing_list(&mut s), Vec::::new(), "no text uses the style"); - // Text in the style, with no font of its own, is. - let mut s = Session::new(wordcraft_doc::Document::new()); - s.run("document.setText", &json!({"text": "Body"})).unwrap(); - s.run("styles.create", &json!({"name": "Used Look"})).unwrap(); - s.run("styles.modify", &json!({"style": "Used Look", "chr": {"font": "Wordcraft Style Only Face"}})).unwrap(); - s.run("para.style", &json!({"style": "Used Look"})).unwrap(); - let list = missing_list(&mut s); - assert!(list.iter().any(|f| f.starts_with("Wordcraft Style Only Face>")), "{list:?}"); -} - -#[test] -fn missing_fonts_are_what_layout_drew_in_shown_text() { - let mut doc = doc_of(vec![ - para_in("one", "Wordcraft Nowhere Face"), - para_in("two", "wordcraft nowhere face"), - para_in("three", "JetBrains Mono"), - // Spaces, a picture's placeholder and zero-width characters show no font. - para_in(" \u{FFFC}\t\u{200B}\u{FEFF}\u{FE0F}\u{3164}\u{034F}", "Wordcraft Blank Face"), - ]); - // Hidden text isn't laid out. - doc.body.push(wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text( - "secret", - wordcraft_doc::CharProps { font: Some("Wordcraft Hidden Face".into()), hidden: Some(true), ..Default::default() }, - ))); - // A text box story nothing shows any more (deleted, not yet pruned). - doc.parts.insert( - 77, - wordcraft_doc::Part { - kind: wordcraft_doc::PartKind::TextBox, - blocks: vec![wordcraft_doc::para_block(para_in("gone", "Wordcraft Orphan Face"))], - body: Default::default(), - }, - ); - let names: Vec = missing_in(doc).into_iter().map(|f| f.font).collect(); - assert_eq!(names.len(), 1, "one font whatever its case, and nothing else: {names:?}"); - assert!(names[0].eq_ignore_ascii_case("Wordcraft Nowhere Face")); -} - -#[test] -fn metric_twins_are_listed_but_flagged_compatible() { - // A twin by name, installed or not here: "Liberation Sans" stands in for Arial wherever it's - // the substitute; a made-up family's substitute is never its twin. - let m = missing_in(doc_of(vec![para_in("Text 😀", "Arial"), para_in("Text", "Wordcraft Nowhere Face")])); - let nowhere = m.iter().find(|f| f.font == "Wordcraft Nowhere Face").expect("listed"); - assert!(!nowhere.metric_compatible, "{nowhere:?}"); - match crate::cmd::font::shown_as(&wordcraft_doc::Document::new(), "Arial") { - // Arial is installed: not listed. - None => assert!(!m.iter().any(|f| f.font == "Arial"), "{m:?}"), - Some(sub) => { - let arial = m.iter().find(|f| f.font == "Arial").expect("listed"); - // An emoji drawn in a fallback face doesn't change the verdict on the substitute. - assert_eq!(arial.metric_compatible, wordcraft_fonts::word::metric_compatible("Arial", &sub), "{arial:?}"); - } - } -} - -#[test] -fn a_missing_roman_font_drawn_in_cambria_is_reported() { - // Word draws a missing font the font table classes as roman in Cambria (#1613): installed or - // not, Cambria isn't the font the document asks for. - const ROMAN: &str = "Wordcraft Roman Gone"; - let mut doc = doc_of(vec![para_in("Text", ROMAN)]); - doc.settings.roman_fonts = vec![ROMAN.into()]; - let m = missing_in(doc.clone()); - assert_eq!(m.iter().map(|f| f.font.as_str()).collect::>(), [ROMAN], "{m:?}"); - assert!(!m[0].metric_compatible, "{m:?}"); - // The font box names the face layout draws, not the generic substitute. - let cambria = wordcraft_fonts::word::resolve("Cambria", false, false).face.get().family.clone(); - assert_eq!(m[0].shown_as, std::slice::from_ref(&cambria)); - let mut s = Session::new(doc); - s.run("select.all", &json!({})).unwrap(); - assert_eq!(s.run("format.state", &json!({})).unwrap()["fontShownAs"], cambria.as_str()); -} - -#[test] -fn a_missing_east_asian_font_is_reported_for_cjk_text_only() { - let props = - wordcraft_doc::CharProps { font: Some("JetBrains Mono".into()), font_ea: Some("Wordcraft Nowhere Mincho".into()), ..Default::default() }; - let m = missing_in(doc_of(vec![wordcraft_doc::Paragraph::with_text("abc 日本語", props.clone())])); - assert_eq!(m.len(), 1, "{m:?}"); - assert_eq!(m[0].font, "Wordcraft Nowhere Mincho"); - assert!(!m[0].shown_as.is_empty() && !m[0].metric_compatible, "{m:?}"); - // Without CJK text the East Asian font draws nothing. - assert!(missing_in(doc_of(vec![wordcraft_doc::Paragraph::with_text("abc", props)])).is_empty()); -} - -#[test] -fn symbol_fonts_are_shown_as_unicode_without_a_missing_font() { - let mut s = Session::new(wordcraft_doc::Document::new()); - s.run("document.setText", &json!({"text": "\u{F0FC}"})).unwrap(); - s.run("select.all", &json!({})).unwrap(); - s.run("format.font", &json!({"name": "Wingdings"})).unwrap(); - // Not installed, so it's drawn in another face, but on purpose: nothing to report. - if wordcraft_fonts::word::resolve("Wingdings", false, false).substituted { - assert!(s.run("format.state", &json!({})).unwrap()["fontShownAs"].is_string()); - } - let layout = s.layout(); - assert!(crate::cmd::font::missing_fonts(&s.doc, &layout).is_empty()); -} - -#[test] -fn missing_fonts_from_command_search_say_so_in_the_status_line() { - let mut s = Session::new(doc_of(vec![para_in("Text", "Wordcraft Nowhere Face")])); - s.run("font.missing", &json!({})).unwrap(); - assert!(s.status.contains("Wordcraft Nowhere Face"), "{}", s.status); - // Thousands of missing fonts: a few named, the rest counted. - let mut s = Session::new(doc_of((0..40).map(|i| para_in("x", &format!("Wordcraft Many Face {i}"))).collect())); - s.run("font.missing", &json!({})).unwrap(); - assert!(s.status.ends_with(" and 35 more") && s.status.len() < 300, "{}", s.status); -} - -#[test] -fn a_huge_font_name_is_kept_short() { - let name = format!("Wordcraft {}", "é".repeat(100_000)); - let m = missing_in(doc_of(vec![para_in("Text", &name)])); - assert_eq!(m.len(), 1); - assert!(m[0].font.chars().count() <= 256, "{}", m[0].font.len()); -} - -#[test] -fn symbol_fonts_drawn_as_unicode_are_not_reported() { - // Word's bullets are Symbol characters; WordCraft draws them as their Unicode equivalents. - let m = missing_in(doc_of(vec![para_in("\u{F0B7}", "Symbol"), para_in("\u{F0FC}", "Wingdings")])); - assert!(m.is_empty(), "{m:?}"); -} - -#[test] -fn cjk_text_is_reported_under_its_east_asian_font_not_the_latin_one() { - let props = - wordcraft_doc::CharProps { font: Some("Wordcraft Latin Gone".into()), font_ea: Some("Wordcraft Asian Gone".into()), ..Default::default() }; - let names = |text: &str| -> Vec { - missing_in(doc_of(vec![wordcraft_doc::Paragraph::with_text(text, props.clone())])).into_iter().map(|f| f.font).collect() - }; - assert_eq!(names("日本語"), ["Wordcraft Asian Gone"], "no character asks for the Latin font"); - assert_eq!(names("abc 日本語"), ["Wordcraft Asian Gone", "Wordcraft Latin Gone"]); - // Without an East Asian font the CJK text asks for the Latin one. - let latin_only = wordcraft_doc::CharProps { font: Some("Wordcraft Latin Gone".into()), ..Default::default() }; - let m = missing_in(doc_of(vec![wordcraft_doc::Paragraph::with_text("日本語", latin_only)])); - assert_eq!(m.iter().map(|f| f.font.as_str()).collect::>(), ["Wordcraft Latin Gone"]); -} - -#[test] -fn a_symbol_font_named_for_complex_script_text_is_not_reported() { - let props = wordcraft_doc::CharProps { font: Some("JetBrains Mono".into()), font_cs: Some("Symbol".into()), ..Default::default() }; - assert!(missing_in(doc_of(vec![wordcraft_doc::Paragraph::with_text("سلام", props)])).is_empty()); -} - -#[test] -fn a_hostile_number_of_missing_fonts_stays_bounded() { - let paras = (0..3000).map(|i| para_in("x", &format!("Wordcraft Hostile Face {i}"))).collect(); - let m = missing_in(doc_of(paras)); - assert!(!m.is_empty() && m.len() <= wordcraft_layout::MAX_SUBSTITUTED, "{}", m.len()); -} - -#[test] -fn a_font_key_matches_the_listed_name_however_long_or_padded() { - // Leading spaces and more than 256 characters: layout keeps 256, the list trims them. - let name = format!(" Wordcraft {}", "x".repeat(400)); - let m = missing_in(doc_of(vec![para_in("Text", &name)])); - assert_eq!(m.len(), 1); - assert_eq!(crate::cmd::font::font_key(&name), crate::cmd::font::font_key(&m[0].font)); - // The status line shortens such names. - let mut s = Session::new(doc_of(vec![para_in("Text", &name)])); - s.run("font.missing", &json!({})).unwrap(); - assert!(s.status.chars().count() < 80, "{}", s.status); -} - -#[test] -fn only_characters_that_draw_count() { - use wordcraft_layout::para::draws_something; - for c in ['a', '日', '€', '\u{1F600}'] { - assert!(draws_something(c), "{c:?}"); - } - for c in [' ', '\t', '\u{FFFC}', '\u{00AD}', '\u{200B}', '\u{FE0F}', '\u{3164}', '\u{FFF0}', '\u{1BCA0}', '\u{E0041}'] { - assert!(!draws_something(c), "{c:?}"); - } -} - -#[test] -fn a_blank_font_name_is_not_a_missing_font() { - // A file's `w:ascii=" "`: the text is drawn in a substitute, but there's no font to name. - assert!(missing_in(doc_of(vec![para_in("Text", " "), para_in("More", "\t")])).is_empty()); -} - -#[test] -fn a_padded_name_is_judged_by_the_face_its_text_is_drawn_in() { - // Layout looks "Arial " up spaces and all, so its text is in a substitute even where Arial is - // installed; one of Arial's twins (Helvetica on a Mac) measures like it. - let m = missing_in(doc_of(vec![para_in("Text", "Arial ")])); - let Some(arial) = m.iter().find(|f| f.font == "Arial") else { return }; - let drawn = arial.shown_as.first().expect("drawn in a face"); - assert_eq!(arial.metric_compatible, wordcraft_fonts::word::metric_compatible("Arial", drawn), "{arial:?}"); -} - -#[test] -fn an_east_asian_font_is_shown_as_the_face_its_text_is_drawn_in() { - // MS Mincho is roman in many font tables, but Cambria is the run's font's substitute: an - // East Asian font's text goes to the run's font or a fallback face, as the list says. - const MINCHO: &str = "Wordcraft Mincho Gone"; - let props = wordcraft_doc::CharProps { font: Some("JetBrains Mono".into()), font_ea: Some(MINCHO.into()), ..Default::default() }; - let mut doc = doc_of(vec![wordcraft_doc::Paragraph::with_text("日", props)]); - doc.settings.roman_fonts = vec![MINCHO.into()]; - let shown = crate::cmd::font::east_asian_shown_as(&doc, MINCHO, "JetBrains Mono").expect("not installed"); - let m = missing_in(doc.clone()); - assert_eq!(m.iter().map(|f| f.font.as_str()).collect::>(), [MINCHO], "{m:?}"); - assert_eq!(m[0].shown_as, [shown], "{m:?}"); - assert!(crate::cmd::font::east_asian_shown_as(&doc, "JetBrains Mono", "JetBrains Mono").is_none(), "installed"); -} - -/// A semicolon-separated LibreOffice font fallback list is not reported as missing when any -/// candidate is installed, and substitutes by candidate family when none is. -#[test] -fn libreoffice_font_fallback_lists_are_not_reported_as_missing_when_installed() { - let db = wordcraft_fonts::FontDb::global(); - let doc = doc_of(vec![para_in("Text", "Liberation Sans;Arial")]); - if db.has_family("Liberation Sans") || db.has_family("Arial") { - let m = missing_in(doc.clone()); - assert!(m.is_empty(), "expected not to be reported as missing: {m:?}"); - } - let bundled_fallback = doc_of(vec![para_in("Text", "NonExistentSans123;Source Sans 3")]); - assert!(missing_in(bundled_fallback).is_empty(), "bundled fallback candidate is installed"); - - // Roundtrip through DOCX export and import: - let docx_bytes = crate::io::save_bytes("out.docx", &doc).unwrap(); - let loaded = wordcraft_docx::read(&docx_bytes).unwrap(); - assert_eq!(loaded.body[0].as_para().unwrap().runs[0].props.font.as_deref(), Some("Liberation Sans;Arial")); - if db.has_family("Liberation Sans") || db.has_family("Arial") { - let m = missing_in(loaded); - assert!(m.is_empty(), "expected not to report installed fallback font as missing: {m:?}"); - } - - // When none of the fonts is installed, missing_fonts flags it with an appropriate substitute: - if !db.has_family("NonExistentSans123") && !db.has_family("Times New Roman") { - let m = missing_in(doc_of(vec![para_in("Text", "NonExistentSans123;Times New Roman")])); - assert_eq!(m.len(), 1); - assert_eq!(m[0].font, "NonExistentSans123;Times New Roman"); - let drawn = m[0].shown_as.first().expect("drawn face"); - assert!(drawn.contains("Serif") || drawn == "Times" || drawn == "Tinos", "drawn: {drawn}"); - } - let m = missing_in(doc_of(vec![para_in("Text", "NonExistentSans123;NonExistentSerif123")])); - assert_eq!(m.len(), 1); - assert_eq!(m[0].font, "NonExistentSans123;NonExistentSerif123"); - let drawn = m[0].shown_as.first().expect("drawn face"); - assert!( - drawn.contains("Serif") || drawn == "Times" || drawn == "Times New Roman" || drawn == "Tinos" || drawn == "Caladea" || drawn == "Georgia", - "drawn: {drawn}" - ); -} +//! Fonts added at run time (#582): `font.add`, `font.list`, `font.remove`. + +use serde_json::json; +use wordcraft_doc::Pos; + +use crate::Session; +use crate::cmd::insert::base64_encode; + +/// The bundled face `file` of family `original` renamed `family` (as long as `original`), so it +/// is a font the database doesn't have. +fn renamed(file: &str, original: &str, family: &str) -> Vec { + assert_eq!(family.len(), original.len()); + let mut data = std::fs::read(std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets/fonts").join(file)).unwrap(); + let utf16 = |s: &str| s.encode_utf16().flat_map(u16::to_be_bytes).collect::>(); + for (from, to) in [(utf16(original), utf16(family)), (original.as_bytes().to_vec(), family.as_bytes().to_vec())] { + let mut i = 0; + while let Some(at) = data[i..].windows(from.len()).position(|w| w == from) { + data[i + at..i + at + to.len()].copy_from_slice(&to); + i += at + to.len(); + } + } + data +} + +/// The caret's x after `text` set in `font`. +fn text_end_x(s: &mut Session, font: &str, text: &str) -> f32 { + s.run("document.setText", &json!({"text": text})).unwrap(); + s.run("select.all", &json!({})).unwrap(); + s.run("format.font", &json!({"name": font})).unwrap(); + s.layout().caret_on(&Pos::body(0, text.len()), 0).unwrap().x +} + +#[test] +fn an_added_font_is_listed_and_lays_out_text_already_set_in_it() { + // A monospaced face, so it measures unlike whatever proportional font the system falls back + // to (Source Serif 4 itself where no system fonts are installed, as on the FreeBSD runner). + // The name has no Serif/Sans/Mono in it, which would pick a fallback of that kind. + const FAMILY: &str = "Cmdfont Face 4"; + // Narrow letters: set monospaced they take far more room than in any proportional font. + const TEXT: &str = "illicit little lilies fill tiny jill"; + let mut s = Session::new(wordcraft_doc::Document::new()); + let mono = text_end_x(&mut s, "JetBrains Mono", TEXT); + // Not installed yet: the text falls back to another font. + let missing = text_end_x(&mut s, FAMILY, TEXT); + assert!((missing - mono).abs() > 1.0, "the fallback measures differently: {missing} vs {mono}"); + + let data = base64_encode(&renamed("JetBrainsMono-Regular.ttf", "JetBrains Mono", FAMILY)); + let r = s.run("font.add", &json!({"data": data, "name": "CmdfontFace4-Regular.ttf"})).unwrap(); + assert_eq!(r["families"], json!([FAMILY])); + assert_eq!(r["faces"][0]["style"], "Regular"); + // The text already set in it re-lays out with it, without touching the document. + let x = s.layout().caret_on(&Pos::body(0, TEXT.len()), 0).unwrap().x; + assert!((x - mono).abs() < 0.01, "measured with the added font: {x} vs {mono} (missing {missing})"); + + let list = s.run("font.list", &json!({"all": true})).unwrap(); + assert!(list["fonts"].as_array().unwrap().iter().any(|f| f["families"] == json!([FAMILY]))); + assert!(list["families"].as_array().unwrap().iter().any(|f| f == FAMILY)); + // It exports to PDF like any other font. + assert!(crate::io::save_bytes("out.pdf", &s.doc).unwrap().starts_with(b"%PDF")); + + // Adding it again changes nothing. + let again = s.run("font.add", &json!({"data": data, "name": "again.ttf"})); + assert!(again.unwrap_err().to_string().contains("already available")); + + let removed = s.run("font.remove", &json!({"family": FAMILY})).unwrap(); + assert_eq!(removed["removed"].as_array().unwrap().len(), 1); + let x = s.layout().caret_on(&Pos::body(0, TEXT.len()), 0).unwrap().x; + assert!((x - missing).abs() < 0.01, "falls back again once removed"); + assert!(s.run("font.remove", &json!({"family": FAMILY})).is_err()); +} + +#[test] +fn adding_a_font_never_changes_text_that_does_not_use_it() { + const FAMILY: &str = "Metric Serif 4"; + let mut s = Session::new(crate::sample::sample_document()); + let before = s.layout(); + let positions = |s: &mut Session| { + let l = s.layout(); + (0..s.doc.body.len().min(12)).filter_map(|b| l.caret_on(&Pos::body(b, 0), 0).map(|c| (c.page, c.x, c.top))).collect::>() + }; + let a = positions(&mut s); + s.run("font.add", &json!({"data": base64_encode(&renamed("SourceSerif4-Bold.ttf", "Source Serif 4", FAMILY)), "name": "m.ttf"})).unwrap(); + assert_eq!(positions(&mut s), a); + assert_eq!(s.layout().pages.len(), before.pages.len()); +} + +#[test] +fn bad_font_input_is_an_error() { + let mut s = Session::new(wordcraft_doc::Document::new()); + for p in [ + json!({"data": base64_encode(b"definitely not a font")}), + json!({"data": "%%% not base64"}), + json!({"data": base64_encode(b"wOF2\0\x01\0\0\xff\xff\xff\xff")}), + json!({"path": "/nonexistent/font.ttf"}), + json!({"path": env!("CARGO_MANIFEST_DIR")}), + json!({"path": concat!(env!("CARGO_MANIFEST_DIR"), "/Cargo.toml")}), + json!({"url": "https://fonts.googleapis.com/css2?family=Roboto"}), + ] { + assert!(s.run("font.add", &p).is_err(), "{p}"); + } + assert!(s.run("font.remove", &json!({})).is_err()); + assert!(s.run("font.remove", &json!({"family": "Source Sans 3"})).is_err(), "the bundled fonts stay"); + // No file: the UI is asked for a picker. + let r = s.run("font.add", &json!({})).unwrap(); + assert_eq!(r["pending"], true); + assert!(s.ui_requests.iter().any(|r| r["open"] == "addFont")); + + let r = s.run("font.loadSystem", &json!({})).unwrap(); + assert_eq!(r["pending"], true); + assert!(s.ui_requests.iter().any(|r| r["open"] == "loadSystemFonts")); +} + +/// A .docx whose text is set in `family`, embedding `font` through the font table (#1171): as an +/// obfuscated `.odttf` when `key` is a real GUID, as a plain `.ttf` when it is all zeros. +fn docx_embedding(family: &str, font: Option<(&[u8], &str)>, text: &str) -> Vec { + const W: &str = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"; + const R: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; + const PR: &str = "http://schemas.openxmlformats.org/package/2006/relationships"; + let rels = |items: &[(&str, &str)]| { + let list: String = items.iter().map(|(t, g)| format!(r#""#)).collect(); + format!(r#"{list}"#) + }; + let mut files: Vec<(String, Vec)> = vec![ + ( + "[Content_Types].xml".into(), + br#""#.to_vec(), + ), + ("_rels/.rels".into(), rels(&[("officeDocument", "word/document.xml")]).into_bytes()), + ("word/_rels/document.xml.rels".into(), rels(&[("fontTable", "fontTable.xml")]).into_bytes()), + ( + "word/document.xml".into(), + format!(r#"{text}"#).into_bytes(), + ), + ]; + let mut table = format!(r#""#); + if let Some((data, key)) = font { + let digits = key.replace(['{', '}', '-'], ""); + let hex: Vec = (0..16).map(|i| u8::from_str_radix(&digits[i * 2..i * 2 + 2], 16).unwrap()).collect(); + let zero = hex.iter().all(|b| *b == 0); + let mut data = data.to_vec(); + // Obfuscation (ECMA-376 Part 1, 17.8.1): the first 32 bytes XOR the key's bytes, reversed. + for i in 0..32 { + data[i] ^= hex[15 - i % 16]; + } + let ext = if zero { "ttf" } else { "odttf" }; + table.push_str(&format!(r#""#)); + files.push(("word/_rels/fontTable.xml.rels".into(), rels(&[("font", &format!("fonts/font1.{ext}"))]).into_bytes())); + files.push((format!("word/fonts/font1.{ext}"), data)); + } + table.push_str(""); + files.push(("word/fontTable.xml".into(), table.into_bytes())); + let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + for (name, bytes) in files { + zip.start_file(name, zip::write::SimpleFileOptions::default()).unwrap(); + std::io::Write::write_all(&mut zip, &bytes).unwrap(); + } + zip.finish().unwrap().into_inner() +} + +#[test] +fn fonts_embedded_in_a_docx_lay_out_its_text() { + const TEXT: &str = "illicit little lilies fill tiny jill"; + let end_x = + |bytes: &[u8]| Session::new(crate::io::open_bytes("e.docx", bytes).unwrap()).layout().caret_on(&Pos::body(0, TEXT.len()), 0).unwrap().x; + let mono = end_x(&docx_embedding("JetBrains Mono", None, TEXT)); + // Both forms Word and other producers write: an obfuscated .odttf with its key, a plain .ttf + // with the all-zero key. Each is a family no other test or system has. + for (family, key) in [("JetBrains Odtt", "{9F1C2A4E-7B3D-4C58-A1E6-0D2B8F5C3E77}"), ("JetBrains Zero", "{00000000-0000-0000-0000-000000000000}")] + { + let font = renamed("JetBrainsMono-Regular.ttf", "JetBrains Mono", family); + let missing = end_x(&docx_embedding(family, None, TEXT)); + assert!((missing - mono).abs() > 1.0, "{family}: not embedded, the text falls back: {missing} vs {mono}"); + let x = end_x(&docx_embedding(family, Some((&font, key)), TEXT)); + assert!((x - mono).abs() < 0.01, "{family}: measured with the embedded font: {x} vs {mono} (missing {missing})"); + } +} + +/// A font Word embeds as a subset ("embed only the characters used") goes by a made-up family +/// inside (`___WRD_EMBED_SUB_46`), and the text asks for it by the font table's name +/// (`OBRBWW+Century Schoolbook`): the text is measured with it all the same (#2240). It fell back +/// to a substitute, as if nothing were embedded. +#[test] +fn a_subset_font_word_embeds_lays_out_text_set_in_its_table_name() { + const TEXT: &str = "illicit little lilies fill tiny jill"; + const FAMILY: &str = "QWERTY+JetBrains Sub"; + let end_x = + |bytes: &[u8]| Session::new(crate::io::open_bytes("e.docx", bytes).unwrap()).layout().caret_on(&Pos::body(0, TEXT.len()), 0).unwrap().x; + let mono = end_x(&docx_embedding("JetBrains Mono", None, TEXT)); + let missing = end_x(&docx_embedding(FAMILY, None, TEXT)); + assert!((missing - mono).abs() > 1.0, "not embedded, the text falls back: {missing} vs {mono}"); + let subset = renamed("JetBrainsMono-Regular.ttf", "JetBrains Mono", "___WRD_EMBED_7"); + let x = end_x(&docx_embedding(FAMILY, Some((&subset, "{6A0E3B57-1C2D-4E8F-9A7B-3D5C1E0F2B64}")), TEXT)); + assert!((x - mono).abs() < 0.01, "measured with the embedded subset: {x} vs {mono} (missing {missing})"); +} + +/// `font.missing`'s fonts, as "font>shownAs". +fn missing_list(s: &mut Session) -> Vec { + let m = s.run("font.missing", &json!({})).unwrap(); + m["missing"] + .as_array() + .unwrap() + .iter() + .map(|f| { + let shown: Vec<&str> = f["shownAs"].as_array().unwrap().iter().map(|x| x.as_str().unwrap()).collect(); + format!("{}>{}", f["font"].as_str().unwrap(), shown.join(",")) + }) + .collect() +} + +/// The missing fonts of `doc` as laid out. +fn missing_in(doc: wordcraft_doc::Document) -> Vec { + let mut s = Session::new(doc); + let layout = s.layout(); + crate::cmd::font::missing_fonts(&s.doc, &layout) +} + +/// A paragraph of `text` set in `font`. +fn para_in(text: &str, font: &str) -> wordcraft_doc::Paragraph { + wordcraft_doc::Paragraph::with_text(text, wordcraft_doc::CharProps { font: Some(font.into()), ..Default::default() }) +} + +/// A document of these paragraphs. +fn doc_of(paras: Vec) -> wordcraft_doc::Document { + let mut doc = wordcraft_doc::Document::new(); + doc.body = paras.into_iter().map(wordcraft_doc::para_block).collect(); + doc +} + +#[test] +fn fonts_that_are_not_installed_are_reported_with_the_font_shown_instead() { + // A family no system has, with no Serif/Mono in its name: it's drawn in a sans substitute. + const MISSING: &str = "Wordcraft Nowhere Face"; + let mut s = Session::new(wordcraft_doc::Document::new()); + s.run("document.setText", &json!({"text": "Bundled\nMissing"})).unwrap(); + s.run("select.all", &json!({})).unwrap(); + s.run("format.font", &json!({"name": "JetBrains Mono"})).unwrap(); + assert!(s.run("format.state", &json!({})).unwrap()["fontShownAs"].is_null(), "a bundled font is installed"); + assert_eq!(missing_list(&mut s), Vec::::new()); + + s.run("select.text", &json!({"text": "Missing"})).unwrap(); + s.run("format.font", &json!({"name": MISSING})).unwrap(); + let st = s.run("format.state", &json!({})).unwrap(); + assert_eq!(st["font"], MISSING); + let shown = st["fontShownAs"].as_str().unwrap_or_default().to_string(); + assert!(!shown.is_empty() && shown != MISSING, "the font box learns the substitute: {st}"); + assert_eq!(missing_list(&mut s), [format!("{MISSING}>{shown}")], "only the missing font, once"); + + // A mixed selection has no single font, so nothing to flag in the font box. + s.run("select.all", &json!({})).unwrap(); + assert!(s.run("format.state", &json!({})).unwrap()["fontShownAs"].is_null()); +} + +#[test] +fn a_font_only_a_style_names_is_not_reported_missing() { + let mut s = Session::new(wordcraft_doc::Document::new()); + s.run("document.setText", &json!({"text": "Body"})).unwrap(); + s.run("styles.create", &json!({"name": "Unused Look"})).unwrap(); + s.run("styles.modify", &json!({"style": "Unused Look", "chr": {"font": "Wordcraft Style Only Face"}})).unwrap(); + // Creating a style applies it, as in Word: put the text back in Normal. + s.run("para.style", &json!({"style": "Normal"})).unwrap(); + s.run("select.all", &json!({})).unwrap(); + s.run("format.font", &json!({"name": "JetBrains Mono"})).unwrap(); + assert_eq!(missing_list(&mut s), Vec::::new(), "no text uses the style"); + // Text in the style, with no font of its own, is. + let mut s = Session::new(wordcraft_doc::Document::new()); + s.run("document.setText", &json!({"text": "Body"})).unwrap(); + s.run("styles.create", &json!({"name": "Used Look"})).unwrap(); + s.run("styles.modify", &json!({"style": "Used Look", "chr": {"font": "Wordcraft Style Only Face"}})).unwrap(); + s.run("para.style", &json!({"style": "Used Look"})).unwrap(); + let list = missing_list(&mut s); + assert!(list.iter().any(|f| f.starts_with("Wordcraft Style Only Face>")), "{list:?}"); +} + +#[test] +fn missing_fonts_are_what_layout_drew_in_shown_text() { + let mut doc = doc_of(vec![ + para_in("one", "Wordcraft Nowhere Face"), + para_in("two", "wordcraft nowhere face"), + para_in("three", "JetBrains Mono"), + // Spaces, a picture's placeholder and zero-width characters show no font. + para_in(" \u{FFFC}\t\u{200B}\u{FEFF}\u{FE0F}\u{3164}\u{034F}", "Wordcraft Blank Face"), + ]); + // Hidden text isn't laid out. + doc.body.push(wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text( + "secret", + wordcraft_doc::CharProps { font: Some("Wordcraft Hidden Face".into()), hidden: Some(true), ..Default::default() }, + ))); + // A text box story nothing shows any more (deleted, not yet pruned). + doc.parts.insert( + 77, + wordcraft_doc::Part { + kind: wordcraft_doc::PartKind::TextBox, + blocks: vec![wordcraft_doc::para_block(para_in("gone", "Wordcraft Orphan Face"))], + body: Default::default(), + }, + ); + let names: Vec = missing_in(doc).into_iter().map(|f| f.font).collect(); + assert_eq!(names.len(), 1, "one font whatever its case, and nothing else: {names:?}"); + assert!(names[0].eq_ignore_ascii_case("Wordcraft Nowhere Face")); +} + +#[test] +fn metric_twins_are_listed_but_flagged_compatible() { + // A twin by name, installed or not here: "Liberation Sans" stands in for Arial wherever it's + // the substitute; a made-up family's substitute is never its twin. + let m = missing_in(doc_of(vec![para_in("Text 😀", "Arial"), para_in("Text", "Wordcraft Nowhere Face")])); + let nowhere = m.iter().find(|f| f.font == "Wordcraft Nowhere Face").expect("listed"); + assert!(!nowhere.metric_compatible, "{nowhere:?}"); + match crate::cmd::font::shown_as(&wordcraft_doc::Document::new(), "Arial") { + // Arial is installed: not listed. + None => assert!(!m.iter().any(|f| f.font == "Arial"), "{m:?}"), + Some(sub) => { + let arial = m.iter().find(|f| f.font == "Arial").expect("listed"); + // An emoji drawn in a fallback face doesn't change the verdict on the substitute. + assert_eq!(arial.metric_compatible, wordcraft_fonts::word::metric_compatible("Arial", &sub), "{arial:?}"); + } + } +} + +#[test] +fn a_missing_roman_font_drawn_in_cambria_is_reported() { + // Word draws a missing font the font table classes as roman in Cambria (#1613): installed or + // not, Cambria isn't the font the document asks for. + const ROMAN: &str = "Wordcraft Roman Gone"; + let mut doc = doc_of(vec![para_in("Text", ROMAN)]); + doc.settings.roman_fonts = vec![ROMAN.into()]; + let m = missing_in(doc.clone()); + assert_eq!(m.iter().map(|f| f.font.as_str()).collect::>(), [ROMAN], "{m:?}"); + assert!(!m[0].metric_compatible, "{m:?}"); + // The font box names the face layout draws, not the generic substitute. + let cambria = wordcraft_fonts::word::resolve("Cambria", false, false).face.get().family.clone(); + assert_eq!(m[0].shown_as, std::slice::from_ref(&cambria)); + let mut s = Session::new(doc); + s.run("select.all", &json!({})).unwrap(); + assert_eq!(s.run("format.state", &json!({})).unwrap()["fontShownAs"], cambria.as_str()); +} + +#[test] +fn a_missing_east_asian_font_is_reported_for_cjk_text_only() { + let props = + wordcraft_doc::CharProps { font: Some("JetBrains Mono".into()), font_ea: Some("Wordcraft Nowhere Mincho".into()), ..Default::default() }; + let m = missing_in(doc_of(vec![wordcraft_doc::Paragraph::with_text("abc 日本語", props.clone())])); + assert_eq!(m.len(), 1, "{m:?}"); + assert_eq!(m[0].font, "Wordcraft Nowhere Mincho"); + assert!(!m[0].shown_as.is_empty() && !m[0].metric_compatible, "{m:?}"); + // Without CJK text the East Asian font draws nothing. + assert!(missing_in(doc_of(vec![wordcraft_doc::Paragraph::with_text("abc", props)])).is_empty()); +} + +#[test] +fn symbol_fonts_are_shown_as_unicode_without_a_missing_font() { + let mut s = Session::new(wordcraft_doc::Document::new()); + s.run("document.setText", &json!({"text": "\u{F0FC}"})).unwrap(); + s.run("select.all", &json!({})).unwrap(); + s.run("format.font", &json!({"name": "Wingdings"})).unwrap(); + // Not installed, so it's drawn in another face, but on purpose: nothing to report. + if wordcraft_fonts::word::resolve("Wingdings", false, false).substituted { + assert!(s.run("format.state", &json!({})).unwrap()["fontShownAs"].is_string()); + } + let layout = s.layout(); + assert!(crate::cmd::font::missing_fonts(&s.doc, &layout).is_empty()); +} + +#[test] +fn missing_fonts_from_command_search_say_so_in_the_status_line() { + let mut s = Session::new(doc_of(vec![para_in("Text", "Wordcraft Nowhere Face")])); + s.run("font.missing", &json!({})).unwrap(); + assert!(s.status.contains("Wordcraft Nowhere Face"), "{}", s.status); + // Thousands of missing fonts: a few named, the rest counted. + let mut s = Session::new(doc_of((0..40).map(|i| para_in("x", &format!("Wordcraft Many Face {i}"))).collect())); + s.run("font.missing", &json!({})).unwrap(); + assert!(s.status.ends_with(" and 35 more") && s.status.len() < 300, "{}", s.status); +} + +#[test] +fn a_huge_font_name_is_kept_short() { + let name = format!("Wordcraft {}", "é".repeat(100_000)); + let m = missing_in(doc_of(vec![para_in("Text", &name)])); + assert_eq!(m.len(), 1); + assert!(m[0].font.chars().count() <= 256, "{}", m[0].font.len()); +} + +#[test] +fn symbol_fonts_drawn_as_unicode_are_not_reported() { + // Word's bullets are Symbol characters; WordCraft draws them as their Unicode equivalents. + let m = missing_in(doc_of(vec![para_in("\u{F0B7}", "Symbol"), para_in("\u{F0FC}", "Wingdings")])); + assert!(m.is_empty(), "{m:?}"); +} + +#[test] +fn cjk_text_is_reported_under_its_east_asian_font_not_the_latin_one() { + let props = + wordcraft_doc::CharProps { font: Some("Wordcraft Latin Gone".into()), font_ea: Some("Wordcraft Asian Gone".into()), ..Default::default() }; + let names = |text: &str| -> Vec { + missing_in(doc_of(vec![wordcraft_doc::Paragraph::with_text(text, props.clone())])).into_iter().map(|f| f.font).collect() + }; + assert_eq!(names("日本語"), ["Wordcraft Asian Gone"], "no character asks for the Latin font"); + assert_eq!(names("abc 日本語"), ["Wordcraft Asian Gone", "Wordcraft Latin Gone"]); + // Without an East Asian font the CJK text asks for the Latin one. + let latin_only = wordcraft_doc::CharProps { font: Some("Wordcraft Latin Gone".into()), ..Default::default() }; + let m = missing_in(doc_of(vec![wordcraft_doc::Paragraph::with_text("日本語", latin_only)])); + assert_eq!(m.iter().map(|f| f.font.as_str()).collect::>(), ["Wordcraft Latin Gone"]); +} + +#[test] +fn a_symbol_font_named_for_complex_script_text_is_not_reported() { + let props = wordcraft_doc::CharProps { font: Some("JetBrains Mono".into()), font_cs: Some("Symbol".into()), ..Default::default() }; + assert!(missing_in(doc_of(vec![wordcraft_doc::Paragraph::with_text("سلام", props)])).is_empty()); +} + +#[test] +fn a_hostile_number_of_missing_fonts_stays_bounded() { + let paras = (0..3000).map(|i| para_in("x", &format!("Wordcraft Hostile Face {i}"))).collect(); + let m = missing_in(doc_of(paras)); + assert!(!m.is_empty() && m.len() <= wordcraft_layout::MAX_SUBSTITUTED, "{}", m.len()); +} + +#[test] +fn a_font_key_matches_the_listed_name_however_long_or_padded() { + // Leading spaces and more than 256 characters: layout keeps 256, the list trims them. + let name = format!(" Wordcraft {}", "x".repeat(400)); + let m = missing_in(doc_of(vec![para_in("Text", &name)])); + assert_eq!(m.len(), 1); + assert_eq!(crate::cmd::font::font_key(&name), crate::cmd::font::font_key(&m[0].font)); + // The status line shortens such names. + let mut s = Session::new(doc_of(vec![para_in("Text", &name)])); + s.run("font.missing", &json!({})).unwrap(); + assert!(s.status.chars().count() < 80, "{}", s.status); +} + +#[test] +fn only_characters_that_draw_count() { + use wordcraft_layout::para::draws_something; + for c in ['a', '日', '€', '\u{1F600}'] { + assert!(draws_something(c), "{c:?}"); + } + for c in [' ', '\t', '\u{FFFC}', '\u{00AD}', '\u{200B}', '\u{FE0F}', '\u{3164}', '\u{FFF0}', '\u{1BCA0}', '\u{E0041}'] { + assert!(!draws_something(c), "{c:?}"); + } +} + +#[test] +fn a_blank_font_name_is_not_a_missing_font() { + // A file's `w:ascii=" "`: the text is drawn in a substitute, but there's no font to name. + assert!(missing_in(doc_of(vec![para_in("Text", " "), para_in("More", "\t")])).is_empty()); +} + +#[test] +fn a_padded_name_is_judged_by_the_face_its_text_is_drawn_in() { + // Layout looks "Arial " up spaces and all, so its text is in a substitute even where Arial is + // installed; one of Arial's twins (Helvetica on a Mac) measures like it. + let m = missing_in(doc_of(vec![para_in("Text", "Arial ")])); + let Some(arial) = m.iter().find(|f| f.font == "Arial") else { return }; + let drawn = arial.shown_as.first().expect("drawn in a face"); + assert_eq!(arial.metric_compatible, wordcraft_fonts::word::metric_compatible("Arial", drawn), "{arial:?}"); +} + +#[test] +fn an_east_asian_font_is_shown_as_the_face_its_text_is_drawn_in() { + // MS Mincho is roman in many font tables, but Cambria is the run's font's substitute: an + // East Asian font's text goes to the run's font or a fallback face, as the list says. + const MINCHO: &str = "Wordcraft Mincho Gone"; + let props = wordcraft_doc::CharProps { font: Some("JetBrains Mono".into()), font_ea: Some(MINCHO.into()), ..Default::default() }; + let mut doc = doc_of(vec![wordcraft_doc::Paragraph::with_text("日", props)]); + doc.settings.roman_fonts = vec![MINCHO.into()]; + let shown = crate::cmd::font::east_asian_shown_as(&doc, MINCHO, "JetBrains Mono").expect("not installed"); + let m = missing_in(doc.clone()); + assert_eq!(m.iter().map(|f| f.font.as_str()).collect::>(), [MINCHO], "{m:?}"); + assert_eq!(m[0].shown_as, [shown], "{m:?}"); + assert!(crate::cmd::font::east_asian_shown_as(&doc, "JetBrains Mono", "JetBrains Mono").is_none(), "installed"); +} + +/// A semicolon-separated LibreOffice font fallback list is not reported as missing when any +/// candidate is installed, and substitutes by candidate family when none is. +#[test] +fn libreoffice_font_fallback_lists_are_not_reported_as_missing_when_installed() { + let db = wordcraft_fonts::FontDb::global(); + let doc = doc_of(vec![para_in("Text", "Liberation Sans;Arial")]); + if db.has_family("Liberation Sans") || db.has_family("Arial") { + let m = missing_in(doc.clone()); + assert!(m.is_empty(), "expected not to be reported as missing: {m:?}"); + } + let bundled_fallback = doc_of(vec![para_in("Text", "NonExistentSans123;Source Sans 3")]); + assert!(missing_in(bundled_fallback).is_empty(), "bundled fallback candidate is installed"); + + // Roundtrip through DOCX export and import: + let docx_bytes = crate::io::save_bytes("out.docx", &doc).unwrap(); + let loaded = wordcraft_docx::read(&docx_bytes).unwrap(); + assert_eq!(loaded.body[0].as_para().unwrap().runs[0].props.font.as_deref(), Some("Liberation Sans;Arial")); + if db.has_family("Liberation Sans") || db.has_family("Arial") { + let m = missing_in(loaded); + assert!(m.is_empty(), "expected not to report installed fallback font as missing: {m:?}"); + } + + // When none of the fonts is installed, missing_fonts flags it with an appropriate substitute: + if !db.has_family("NonExistentSans123") && !db.has_family("Times New Roman") { + let m = missing_in(doc_of(vec![para_in("Text", "NonExistentSans123;Times New Roman")])); + assert_eq!(m.len(), 1); + assert_eq!(m[0].font, "NonExistentSans123;Times New Roman"); + let drawn = m[0].shown_as.first().expect("drawn face"); + assert!(drawn.contains("Serif") || drawn == "Times" || drawn == "Tinos", "drawn: {drawn}"); + } + let m = missing_in(doc_of(vec![para_in("Text", "NonExistentSans123;NonExistentSerif123")])); + assert_eq!(m.len(), 1); + assert_eq!(m[0].font, "NonExistentSans123;NonExistentSerif123"); + let drawn = m[0].shown_as.first().expect("drawn face"); + assert!( + drawn.contains("Serif") || drawn == "Times" || drawn == "Times New Roman" || drawn == "Tinos" || drawn == "Caladea" || drawn == "Georgia", + "drawn: {drawn}" + ); +} diff --git a/crates/engine/src/tests/ime_noop.rs b/crates/engine/src/tests/ime_noop.rs index d0350e0811..73956e68dc 100644 --- a/crates/engine/src/tests/ime_noop.rs +++ b/crates/engine/src/tests/ime_noop.rs @@ -1,122 +1,122 @@ -//! Regressions where main's "a command that changes nothing is not an edit" (#23) meets the -//! migration's input-method application-change and state-revision tracking. -//! -//! Kept in its own module so the merged `tests.rs` is not touched. - -use serde_json::json; - -use crate::Session; - -/// Creates an empty-document session for the IME no-op regressions. -fn s() -> Session { - Session::new(wordcraft_doc::Document::new()) -} - -/// Runs a command in the test session, panicking with the command id on error. -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}")) -} - -/// An *unclassified* no-op (`text.backspace` at the very start of the body) is not an edit and -/// must not look like one to the input method: no effect flags, no application-change signal, no -/// settled-revision move, and no history/dirty/revision side effects. -#[test] -fn a_no_op_command_is_not_an_ime_state_change() { - let mut s = s(); - run(&mut s, "text.insert", json!({"text": "x"})); - run(&mut s, "caret.docStart", json!({})); - assert!(s.pending.is_none(), "caret movement dropped any pending formatting"); - s.take_pending_ime_application_change(); - let revision = s.ime_state_revision().expect("an IME revision"); - let rev = s.rev(); - let depth = s.undo_depth(); - let dirty = s.dirty; - - run(&mut s, "text.backspace", json!({})); - - let outcome = s.ime_edit_outcome(); - assert!(!outcome.classified, "the handler leaves this no-op unclassified: {outcome:?}"); - assert!(!outcome.content_changed && !outcome.state_changed, "{outcome:?}"); - assert!(!s.take_pending_ime_application_change(), "a no-op is not an application change"); - assert_eq!(s.ime_state_revision(), Some(revision), "the settled IME revision did not move"); - assert_eq!((s.undo_depth(), s.dirty, s.rev()), (depth, dirty, rev)); -} - -/// `document.setText` with identical text replaces the body under the same document id, so the -/// IME replacement epoch must advance — but because nothing observable changed it is not an edit -/// and must not signal an application-origin change (main's #23 no-op rule). -#[test] -fn a_same_text_set_text_advances_the_body_epoch_without_an_application_change() { - let mut s = s(); - run(&mut s, "document.setText", json!({"text": "Same body"})); - s.take_pending_ime_application_change(); - let epoch = s.ime_body_epoch(); - let document_id = s.document_id(); - let rev = s.rev(); - let depth = s.undo_depth(); - - run(&mut s, "document.setText", json!({"text": "Same body"})); - - assert!(s.ime_body_epoch() > epoch, "the body is replaced again under the same document id"); - assert_eq!(s.document_id(), document_id, "the document id itself is unchanged"); - assert!(!s.take_pending_ime_application_change(), "an identical setText is not an application change"); - assert_eq!((s.undo_depth(), s.rev()), (depth, rev)); -} - -/// The IME body replacement promise (`DeleteSurrounding`, then the commit that replaces the -/// erased text) joins only a `text.insert` at the selection the deletion left. Tab, an ordinary -/// command, consumes it: it is no IME replacement receipt itself, and the commit after it owns a -/// new Undo step instead of joining the deletion. -#[test] -fn tab_between_an_ime_replacement_and_its_commit_keeps_the_steps_apart() { - let text = |s: &Session| s.doc.plain_text(wordcraft_doc::StoryRef::Body); - - // The promise itself joins the commit at the unchanged selection. - let mut s = self::s(); - run(&mut s, "text.insert", json!({"text": "ab"})); - run(&mut s, "text.backspace", json!({})); - assert_eq!(text(&s), "a", "DeleteSurrounding erased the b"); - s.join_next_ime_text_replacement(); - let depth = s.undo_depth(); - run(&mut s, "text.insert", json!({"text": "c"})); - assert_eq!(text(&s), "ac"); - assert_eq!(s.undo_depth(), depth, "the commit joins the deletion's step"); - run(&mut s, "edit.undo", json!({})); - assert_eq!(text(&s), "ab", "one Undo restores the deletion and its commit"); - - // Tab consumes the promise: the commit no longer joins the deletion. - let mut s = self::s(); - run(&mut s, "text.insert", json!({"text": "ab"})); - run(&mut s, "text.backspace", json!({})); - s.join_next_ime_text_replacement(); - let depth = s.undo_depth(); - run(&mut s, "text.tab", json!({})); - let outcome = s.ime_edit_outcome(); - assert!(!outcome.classified, "Tab is not an IME replacement receipt: {outcome:?}"); - assert_eq!(s.undo_depth(), depth + 1, "Tab owns its Undo step"); - run(&mut s, "text.insert", json!({"text": "c"})); - assert_eq!(text(&s), "a\tc"); - assert_eq!(s.undo_depth(), depth + 2, "the commit after Tab starts its own Undo step"); - run(&mut s, "edit.undo", json!({})); - assert_eq!(text(&s), "a\t", "Undo takes back only the commit"); - run(&mut s, "edit.undo", json!({})); - assert_eq!(text(&s), "a", "then the Tab"); - run(&mut s, "edit.undo", json!({})); - assert_eq!(text(&s), "ab", "and the IME deletion on its own"); - - // Returning directly to the promised selection must not resurrect a promise consumed by - // Tab. Do not use a caret command here: that command would itself consume a leaked promise. - let mut s = self::s(); - run(&mut s, "text.insert", json!({"text": "ab"})); - run(&mut s, "text.backspace", json!({})); - s.join_next_ime_text_replacement(); - let promised_selection = s.sel.clone(); - run(&mut s, "text.tab", json!({})); - let depth = s.undo_depth(); - s.sel = promised_selection; - run(&mut s, "text.insert", json!({"text": "c"})); - assert_eq!(text(&s), "ac\t"); - assert_eq!(s.undo_depth(), depth + 1, "returning to the old selection cannot reuse the consumed promise"); - run(&mut s, "edit.undo", json!({})); - assert_eq!(text(&s), "a\t", "only the new insert is undone"); -} +//! Regressions where main's "a command that changes nothing is not an edit" (#23) meets the +//! migration's input-method application-change and state-revision tracking. +//! +//! Kept in its own module so the merged `tests.rs` is not touched. + +use serde_json::json; + +use crate::Session; + +/// Creates an empty-document session for the IME no-op regressions. +fn s() -> Session { + Session::new(wordcraft_doc::Document::new()) +} + +/// Runs a command in the test session, panicking with the command id on error. +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}")) +} + +/// An *unclassified* no-op (`text.backspace` at the very start of the body) is not an edit and +/// must not look like one to the input method: no effect flags, no application-change signal, no +/// settled-revision move, and no history/dirty/revision side effects. +#[test] +fn a_no_op_command_is_not_an_ime_state_change() { + let mut s = s(); + run(&mut s, "text.insert", json!({"text": "x"})); + run(&mut s, "caret.docStart", json!({})); + assert!(s.pending.is_none(), "caret movement dropped any pending formatting"); + s.take_pending_ime_application_change(); + let revision = s.ime_state_revision().expect("an IME revision"); + let rev = s.rev(); + let depth = s.undo_depth(); + let dirty = s.dirty; + + run(&mut s, "text.backspace", json!({})); + + let outcome = s.ime_edit_outcome(); + assert!(!outcome.classified, "the handler leaves this no-op unclassified: {outcome:?}"); + assert!(!outcome.content_changed && !outcome.state_changed, "{outcome:?}"); + assert!(!s.take_pending_ime_application_change(), "a no-op is not an application change"); + assert_eq!(s.ime_state_revision(), Some(revision), "the settled IME revision did not move"); + assert_eq!((s.undo_depth(), s.dirty, s.rev()), (depth, dirty, rev)); +} + +/// `document.setText` with identical text replaces the body under the same document id, so the +/// IME replacement epoch must advance — but because nothing observable changed it is not an edit +/// and must not signal an application-origin change (main's #23 no-op rule). +#[test] +fn a_same_text_set_text_advances_the_body_epoch_without_an_application_change() { + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "Same body"})); + s.take_pending_ime_application_change(); + let epoch = s.ime_body_epoch(); + let document_id = s.document_id(); + let rev = s.rev(); + let depth = s.undo_depth(); + + run(&mut s, "document.setText", json!({"text": "Same body"})); + + assert!(s.ime_body_epoch() > epoch, "the body is replaced again under the same document id"); + assert_eq!(s.document_id(), document_id, "the document id itself is unchanged"); + assert!(!s.take_pending_ime_application_change(), "an identical setText is not an application change"); + assert_eq!((s.undo_depth(), s.rev()), (depth, rev)); +} + +/// The IME body replacement promise (`DeleteSurrounding`, then the commit that replaces the +/// erased text) joins only a `text.insert` at the selection the deletion left. Tab, an ordinary +/// command, consumes it: it is no IME replacement receipt itself, and the commit after it owns a +/// new Undo step instead of joining the deletion. +#[test] +fn tab_between_an_ime_replacement_and_its_commit_keeps_the_steps_apart() { + let text = |s: &Session| s.doc.plain_text(wordcraft_doc::StoryRef::Body); + + // The promise itself joins the commit at the unchanged selection. + let mut s = self::s(); + run(&mut s, "text.insert", json!({"text": "ab"})); + run(&mut s, "text.backspace", json!({})); + assert_eq!(text(&s), "a", "DeleteSurrounding erased the b"); + s.join_next_ime_text_replacement(); + let depth = s.undo_depth(); + run(&mut s, "text.insert", json!({"text": "c"})); + assert_eq!(text(&s), "ac"); + assert_eq!(s.undo_depth(), depth, "the commit joins the deletion's step"); + run(&mut s, "edit.undo", json!({})); + assert_eq!(text(&s), "ab", "one Undo restores the deletion and its commit"); + + // Tab consumes the promise: the commit no longer joins the deletion. + let mut s = self::s(); + run(&mut s, "text.insert", json!({"text": "ab"})); + run(&mut s, "text.backspace", json!({})); + s.join_next_ime_text_replacement(); + let depth = s.undo_depth(); + run(&mut s, "text.tab", json!({})); + let outcome = s.ime_edit_outcome(); + assert!(!outcome.classified, "Tab is not an IME replacement receipt: {outcome:?}"); + assert_eq!(s.undo_depth(), depth + 1, "Tab owns its Undo step"); + run(&mut s, "text.insert", json!({"text": "c"})); + assert_eq!(text(&s), "a\tc"); + assert_eq!(s.undo_depth(), depth + 2, "the commit after Tab starts its own Undo step"); + run(&mut s, "edit.undo", json!({})); + assert_eq!(text(&s), "a\t", "Undo takes back only the commit"); + run(&mut s, "edit.undo", json!({})); + assert_eq!(text(&s), "a", "then the Tab"); + run(&mut s, "edit.undo", json!({})); + assert_eq!(text(&s), "ab", "and the IME deletion on its own"); + + // Returning directly to the promised selection must not resurrect a promise consumed by + // Tab. Do not use a caret command here: that command would itself consume a leaked promise. + let mut s = self::s(); + run(&mut s, "text.insert", json!({"text": "ab"})); + run(&mut s, "text.backspace", json!({})); + s.join_next_ime_text_replacement(); + let promised_selection = s.sel.clone(); + run(&mut s, "text.tab", json!({})); + let depth = s.undo_depth(); + s.sel = promised_selection; + run(&mut s, "text.insert", json!({"text": "c"})); + assert_eq!(text(&s), "ac\t"); + assert_eq!(s.undo_depth(), depth + 1, "returning to the old selection cannot reuse the consumed promise"); + run(&mut s, "edit.undo", json!({})); + assert_eq!(text(&s), "a\t", "only the new insert is undone"); +} diff --git a/crates/engine/src/tests/para_tools.rs b/crates/engine/src/tests/para_tools.rs index 5456be07b2..bca60f06ec 100644 --- a/crates/engine/src/tests/para_tools.rs +++ b/crates/engine/src/tests/para_tools.rs @@ -1,484 +1,484 @@ -//! Advanced paragraph tools (#5043): conversion and cleanup over the selection or the whole -//! document, one undo step, Track Changes respected. - -use serde_json::json; -use wordcraft_doc::{InlineObject, Path, Pos, RevisionKind, SectionProps, StoryRef}; - -use crate::Session; - -fn s() -> Session { - Session::new(wordcraft_doc::Document::new()) -} - -fn run(s: &mut Session, id: &str, v: 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) -} - -/// Select the whole document (Mod+A's command). -fn select_all(s: &mut Session) { - run(s, "select.all", json!({})); -} - -/// One paragraph with `\n` as manual line breaks, typed as text. -fn type_breaks(s: &mut Session, t: &str) { - run(s, "text.insert", json!({"text": t})); -} - -#[test] -fn line_breaks_become_paragraphs() { - let mut s = s(); - type_breaks(&mut s, "one\ntwo\nthree"); - assert_eq!(s.doc.para_paths(StoryRef::Body).len(), 1); - select_all(&mut s); - run(&mut s, "para.convertBreaks", json!({"scope": "all"})); - assert_eq!(text(&s), "one\ntwo\nthree", "the breaks are paragraph marks, not text"); - assert_eq!(s.doc.para_paths(StoryRef::Body).len(), 3); - // One undo step brings the manual line breaks back. - assert!(s.undo()); - let p0 = s.doc.para(StoryRef::Body, &Path::top(0)).unwrap(); - assert_eq!(p0.text, "one\ntwo\nthree", "back to one paragraph with its breaks"); -} - -#[test] -fn blank_paragraphs_go_in_between() { - let mut s = s(); - run(&mut s, "document.setText", json!({"text": "a\nb\nc"})); - select_all(&mut s); - run(&mut s, "para.blankBetween", json!({"scope": "all"})); - assert_eq!(text(&s), "a\n\nb\n\nc"); -} - -#[test] -fn blank_paragraphs_go_after_too() { - let mut s = s(); - run(&mut s, "document.setText", json!({"text": "a\nb\nc"})); - select_all(&mut s); - run(&mut s, "para.blankBetween", json!({"scope": "all", "after": true})); - assert_eq!(text(&s), "a\n\nb\n\nc\n"); -} - -#[test] -fn separate_paragraphs_blanks_after_every_one() { - let mut s = s(); - run(&mut s, "document.setText", json!({"text": "a\nb\nc"})); - select_all(&mut s); - run(&mut s, "para.separateParagraphs", json!({"scope": "all"})); - assert_eq!(text(&s), "a\n\nb\n\nc\n"); -} - -#[test] -fn blank_paragraphs_are_deleted() { - let mut s = s(); - run(&mut s, "document.setText", json!({"text": "a\n\nb\n\n\nc"})); - select_all(&mut s); - run(&mut s, "para.removeBlank", json!({"scope": "all"})); - assert_eq!(text(&s), "a\nb\nc"); -} - -#[test] -fn a_section_break_paragraph_stays() { - let mut s = s(); - run(&mut s, "document.setText", json!({"text": "a\nb"})); - // The second paragraph carries a section break and is emptied: removing it would drop the - // section, so it stays. - let second = Path::top(1); - let p = s.doc.para_mut(StoryRef::Body, &second).unwrap(); - p.text.clear(); - p.runs.clear(); - p.section = Some(Box::new(SectionProps::default())); - p.touch(); - select_all(&mut s); - run(&mut s, "para.removeBlank", json!({"scope": "all"})); - assert_eq!(s.doc.para_paths(StoryRef::Body).len(), 2, "the section-break paragraph stays"); -} - -#[test] -fn leading_spaces_are_removed() { - let mut s = s(); - run(&mut s, "document.setText", json!({"text": " a\nb\n c"})); - select_all(&mut s); - run(&mut s, "para.trimLeadingSpaces", json!({"scope": "all"})); - assert_eq!(text(&s), "a\nb\nc"); -} - -#[test] -fn extra_spaces_collapse_to_one() { - let mut s = s(); - run(&mut s, "document.setText", json!({"text": "a b c d \n e"})); - select_all(&mut s); - run(&mut s, "para.removeExtraSpaces", json!({"scope": "all"})); - assert_eq!(text(&s), "a b c d \n e", "inner runs collapse; the leading spaces of the second paragraph stay"); -} - -#[test] -fn a_space_run_at_the_end_collapses() { - let mut s = s(); - run(&mut s, "document.setText", json!({"text": "a "})); - select_all(&mut s); - run(&mut s, "para.removeExtraSpaces", json!({"scope": "all"})); - assert_eq!(text(&s), "a "); -} - -#[test] -fn extra_spaces_collapse_without_removing_tabs_or_page_breaks() { - use wordcraft_doc::para::PAGE_BREAK; - - let mut s = s(); - run(&mut s, "document.setText", json!({"text": format!("a \t b {PAGE_BREAK} c\t ")})); - select_all(&mut s); - run(&mut s, "para.removeExtraSpaces", json!({"scope": "all"})); - assert_eq!(text(&s), format!("a \t b {PAGE_BREAK} c\t "), "only repeated spaces collapse"); -} - -#[test] -fn indented_spaces_become_an_indent() { - let mut s = s(); - run(&mut s, "document.setText", json!({"text": " a\nb"})); - select_all(&mut s); - run(&mut s, "para.spacesToIndent", json!({"scope": "all"})); - assert_eq!(text(&s), "a\nb"); - let p = s.doc.para(StoryRef::Body, &Path::top(0)).unwrap(); - assert_eq!(p.props.indent_first, Some(216.0), "three spaces are 3 x 72 pt"); - assert_eq!(s.doc.para(StoryRef::Body, &Path::top(1)).unwrap().props.indent_first, None, "b has no spaces"); -} - -#[test] -fn spaces_to_indent_keeps_spaces_the_indent_limit_cannot_represent() { - let mut many = s(); - run(&mut many, "document.setText", json!({"text": format!("{}a", " ".repeat(24))})); - select_all(&mut many); - run(&mut many, "para.spacesToIndent", json!({"scope": "all"})); - assert_eq!(text(&many), " a", "22 spaces fit in the 1584 pt maximum; two stay as text"); - assert_eq!(many.doc.para(StoryRef::Body, &Path::top(0)).unwrap().props.indent_first, Some(1584.0)); - - let mut nearly_full = s(); - run(&mut nearly_full, "document.setText", json!({"text": " a"})); - run(&mut nearly_full, "para.indents", json!({"firstLine": 1548.0})); - select_all(&mut nearly_full); - run(&mut nearly_full, "para.spacesToIndent", json!({"scope": "all"})); - assert_eq!(text(&nearly_full), " a", "less than 72 pt of room cannot replace one space"); - assert_eq!(nearly_full.doc.para(StoryRef::Body, &Path::top(0)).unwrap().props.indent_first, Some(1548.0)); -} - -#[test] -fn indents_become_leading_spaces() { - let mut s = s(); - run(&mut s, "document.setText", json!({"text": "a"})); - run(&mut s, "para.indents", json!({"left": 72.0, "firstLine": 144.0})); - select_all(&mut s); - run(&mut s, "para.indentToSpaces", json!({"scope": "all"})); - assert_eq!(text(&s), " a", "72 + 144 pt become three spaces"); - let p = s.doc.para(StoryRef::Body, &Path::top(0)).unwrap(); - assert_eq!((p.props.indent_left, p.props.indent_first), (Some(0.0), Some(0.0))); -} - -#[test] -fn a_hanging_indent_that_sums_to_zero_is_still_removed() { - let mut s = s(); - run(&mut s, "document.setText", json!({"text": "a"})); - run(&mut s, "para.indents", json!({"left": 72.0, "firstLine": -72.0})); - select_all(&mut s); - run(&mut s, "para.indentToSpaces", json!({"scope": "all"})); - assert_eq!(text(&s), "a", "the cancelling indents need no leading spaces"); - let p = s.doc.para(StoryRef::Body, &Path::top(0)).unwrap(); - assert_eq!((p.props.indent_left, p.props.indent_first), (Some(0.0), Some(0.0))); - - let mut tracked = Session::new(wordcraft_doc::Document::new()); - run(&mut tracked, "document.setText", json!({"text": "a"})); - run(&mut tracked, "para.indents", json!({"left": 72.0, "firstLine": -72.0})); - run(&mut tracked, "review.trackChanges", json!({"value": true})); - select_all(&mut tracked); - run(&mut tracked, "para.indentToSpaces", json!({"scope": "all"})); - assert!(!tracked.doc.revisions.iter().any(|r| r.kind == RevisionKind::Insert), "no inserted text means no insert revision"); - assert!(tracked.doc.revisions.iter().any(|r| r.kind == RevisionKind::Format), "the removed hanging indent is tracked as formatting"); -} - -#[test] -fn cleanup_keeps_fields_footnote_anchors_and_mixed_formatting() { - let mut s = s(); - run(&mut s, "document.setText", json!({"text": "aa bb "})); - s.sel = crate::Selection { anchor: Pos::body(0, 4), focus: Pos::body(0, 6) }; - run(&mut s, "format.bold", json!({"value": true})); - s.sel = crate::Selection::caret(s.doc.end_of(StoryRef::Body)); - run(&mut s, "insert.field", json!({"instr": "AUTHOR", "result": "Ada"})); - let note = s.run("references.footnote", &json!({"text": "keep this note"})).unwrap()["id"].as_u64().unwrap() as u32; - s.sel = crate::Selection::caret(s.doc.end_of(StoryRef::Body)); - run(&mut s, "text.insert", json!({"text": " tail"})); - - select_all(&mut s); - run(&mut s, "para.removeExtraSpaces", json!({"scope": "all"})); - - let p = s.doc.para(StoryRef::Body, &Path::top(0)).unwrap(); - assert_eq!((p.props_of_char(3).bold, p.props_of_char(4).bold), (Some(true), Some(true)), "the bold run moved but kept its formatting"); - assert!(p.object_offsets().iter().any(|off| matches!(p.object_at(*off), Some(InlineObject::Field { .. }))), "the field stays"); - assert!(p.object_offsets().iter().any(|off| matches!(p.object_at(*off), Some(InlineObject::NoteRef { .. }))), "the footnote anchor stays"); - assert!(s.doc.parts.contains_key(¬e), "the anchored footnote story stays"); -} - -#[test] -fn cleanup_inside_a_table_keeps_the_table_and_its_cell() { - let mut s = s(); - run(&mut s, "insert.table", json!({"rows": 1, "cols": 1})); - run(&mut s, "text.insert", json!({"text": "a b"})); - select_all(&mut s); - run(&mut s, "para.removeExtraSpaces", json!({"scope": "all"})); - let table = s.doc.body.iter().find_map(|block| block.as_table()).expect("the table stays"); - let cell = table.rows[0].cells[0].blocks.iter().find_map(|block| block.as_para()).expect("the cell paragraph stays"); - assert_eq!(cell.text, "a b"); -} - -#[test] -fn whole_document_scope_from_a_table_cell_reaches_the_body() { - let mut s = s(); - run(&mut s, "document.setText", json!({"text": "body text"})); - s.sel = crate::Selection::caret(s.doc.end_of(StoryRef::Body)); - run(&mut s, "insert.table", json!({"rows": 1, "cols": 2})); - let table_index = s.doc.body.iter().position(|block| block.as_table().is_some()).expect("the table was inserted") as u32; - s.sel = crate::Selection::caret(Pos { story: StoryRef::Body, path: Path(vec![table_index, 0, 0, 0]), off: 0 }); - run(&mut s, "text.insert", json!({"text": "cell text"})); - assert!(s.sel.focus.path.depth() > 0, "the caret is inside the table"); - run(&mut s, "para.removeExtraSpaces", json!({"scope": "all"})); - assert!(text(&s).contains("body text"), "body text outside the table was cleaned too: {}", text(&s)); - let table = s.doc.body.iter().find_map(|block| block.as_table()).expect("the table stays"); - let cell = table.rows[0].cells[0].blocks.iter().find_map(|block| block.as_para()).expect("the cell stays"); - assert_eq!(cell.text, "cell text"); -} - -#[test] -fn paragraph_tools_refuse_content_locked_controls() { - for (text, command) in [("a b", "para.removeExtraSpaces"), ("a\nb", "para.convertBreaks")] { - let mut s = s(); - type_breaks(&mut s, text); - select_all(&mut s); - run(&mut s, "developer.richText", json!({"tag": "locked"})); - run(&mut s, "developer.properties", json!({"control": "locked", "lockEdit": true})); - select_all(&mut s); - let before = s.doc.clone(); - let error = s.run(command, &json!({"scope": "all"})).unwrap_err(); - assert!(error.to_string().contains("can't be edited"), "{command}: {error}"); - assert_eq!(s.doc, before, "{command} changed locked content"); - } -} - -#[test] -fn line_breaks_are_removed() { - let mut s = s(); - type_breaks(&mut s, "one\ntwo\nthree"); - select_all(&mut s); - run(&mut s, "para.removeLineBreaks", json!({"scope": "all"})); - assert_eq!(text(&s), "onetwothree"); -} - -#[test] -fn the_selection_is_the_scope() { - let mut s = s(); - run(&mut s, "document.setText", json!({"text": "a b\nc d"})); - // Only the first paragraph: select from its start to its end. - s.sel = crate::Selection { anchor: Pos::body(0, 0), focus: Pos::body(0, 4) }; - run(&mut s, "para.removeExtraSpaces", json!({})); - assert_eq!(text(&s), "a b\nc d", "the second paragraph is untouched"); -} - -#[test] -fn structural_tools_do_not_cross_the_selection() { - let mut breaks = s(); - type_breaks(&mut breaks, "one\ntwo"); - run(&mut breaks, "text.newParagraph", json!({})); - run(&mut breaks, "text.insert", json!({"text": "outside"})); - breaks.sel = crate::Selection { anchor: Pos::body(0, 0), focus: Pos::body(0, 7) }; - run(&mut breaks, "para.convertBreaks", json!({})); - let last = breaks.doc.para_paths(StoryRef::Body).last().cloned().unwrap(); - assert_eq!(breaks.doc.para(StoryRef::Body, &last).unwrap().text, "outside"); - - let mut between = s(); - run(&mut between, "document.setText", json!({"text": "a\nb\nc"})); - between.sel = crate::Selection { anchor: Pos::body(0, 0), focus: Pos::body(1, 1) }; - run(&mut between, "para.blankBetween", json!({})); - assert_eq!(text(&between), "a\n\nb\nc", "only a and b get a blank between them"); - - let mut separate = s(); - run(&mut separate, "document.setText", json!({"text": "a\nb\nc"})); - separate.sel = crate::Selection { anchor: Pos::body(0, 0), focus: Pos::body(1, 1) }; - run(&mut separate, "para.separateParagraphs", json!({})); - assert_eq!(text(&separate), "a\n\nb\n\nc", "the blank after b stays before the unselected c"); - - let mut blank = s(); - run(&mut blank, "document.setText", json!({"text": "a\n\nb\n\nc"})); - blank.sel = crate::Selection::caret(Pos::body(1, 0)); - run(&mut blank, "para.removeBlank", json!({})); - assert_eq!(text(&blank), "a\nb\n\nc", "the other blank paragraph is outside the selection"); -} - -#[test] -fn indent_space_conversions_are_reversible_track_changes() { - let mut to_indent = s(); - run(&mut to_indent, "document.setText", json!({"text": " a"})); - run(&mut to_indent, "review.trackChanges", json!({"value": true})); - select_all(&mut to_indent); - run(&mut to_indent, "para.spacesToIndent", json!({"scope": "all"})); - assert_eq!(to_indent.doc.para(StoryRef::Body, &Path::top(0)).unwrap().props.indent_first, Some(144.0)); - run(&mut to_indent, "review.rejectAll", json!({})); - let p = to_indent.doc.para(StoryRef::Body, &Path::top(0)).unwrap(); - assert_eq!((p.text.as_str(), p.props.indent_first), (" a", None)); - - let mut to_spaces = s(); - run(&mut to_spaces, "document.setText", json!({"text": "a"})); - run(&mut to_spaces, "para.indents", json!({"firstLine": 144.0})); - run(&mut to_spaces, "review.trackChanges", json!({"value": true})); - select_all(&mut to_spaces); - run(&mut to_spaces, "para.indentToSpaces", json!({"scope": "all"})); - run(&mut to_spaces, "review.rejectAll", json!({})); - let p = to_spaces.doc.para(StoryRef::Body, &Path::top(0)).unwrap(); - assert_eq!((p.text.as_str(), p.props.indent_first), ("a", Some(144.0))); - - let mut accepted = s(); - run(&mut accepted, "document.setText", json!({"text": "a"})); - run(&mut accepted, "para.indents", json!({"firstLine": 144.0})); - run(&mut accepted, "review.trackChanges", json!({"value": true})); - select_all(&mut accepted); - run(&mut accepted, "para.indentToSpaces", json!({"scope": "all"})); - run(&mut accepted, "review.acceptAll", json!({})); - let p = accepted.doc.para(StoryRef::Body, &Path::top(0)).unwrap(); - assert_eq!((p.text.as_str(), p.props.indent_first), (" a", Some(0.0))); -} - -#[test] -fn indent_to_spaces_does_not_inherit_revision_marks() { - let mut s = s(); - run(&mut s, "document.setText", json!({"text": "a"})); - run(&mut s, "review.trackChanges", json!({"value": true})); - s.sel = crate::Selection { anchor: Pos::body(0, 0), focus: Pos::body(0, 1) }; - run(&mut s, "format.bold", json!({"value": true})); - run(&mut s, "text.delete", json!({})); - run(&mut s, "review.trackChanges", json!({"value": false})); - run(&mut s, "para.indents", json!({"firstLine": 72.0})); - select_all(&mut s); - run(&mut s, "para.indentToSpaces", json!({"scope": "all"})); - let space = s.doc.para(StoryRef::Body, &Path::top(0)).unwrap().props_of_char(0); - assert_eq!((space.ins, space.del, space.fmt_change.as_ref().map(|_| ())), (None, None, None)); -} - -#[test] -fn converting_too_many_line_breaks_is_atomic() { - let original = format!("{}end", "x\n".repeat(10_001)); - let mut s = s(); - type_breaks(&mut s, &original); - select_all(&mut s); - let before = s.doc.clone(); - let error = s.run("para.convertBreaks", &json!({"scope": "all"})).unwrap_err(); - assert!(error.to_string().contains("at most"), "{error}"); - assert_eq!(s.doc, before, "the refused conversion is all-or-nothing"); -} - -#[test] -fn repeating_a_tracked_deletion_creates_no_empty_revision() { - let mut s = s(); - run(&mut s, "document.setText", json!({"text": " a"})); - run(&mut s, "review.trackChanges", json!({"value": true})); - select_all(&mut s); - run(&mut s, "para.trimLeadingSpaces", json!({"scope": "all"})); - let revisions = s.doc.revisions.len(); - run(&mut s, "para.trimLeadingSpaces", json!({"scope": "all"})); - assert_eq!(s.doc.revisions.len(), revisions, "already-deleted spaces create no revision"); -} - -#[test] -fn deletions_are_recorded_under_track_changes() { - let mut s = s(); - run(&mut s, "document.setText", json!({"text": "a b\n c"})); - run(&mut s, "review.trackChanges", json!({"value": true})); - select_all(&mut s); - run(&mut s, "para.trimLeadingSpaces", json!({"scope": "all"})); - // The text stays marked deleted, not gone, as Word records the deletion. - assert_eq!(text(&s), "a b\n c", "tracked deletions keep their text"); - assert!(s.doc.revisions.iter().any(|r| r.kind == RevisionKind::Delete)); - run(&mut s, "review.acceptAll", json!({})); - assert_eq!(text(&s), "a b\nc", "accepting removes the leading space"); -} - -/// Tracked-delete the bytes `a..b` of the first body paragraph (Track Changes must be on). -fn track_delete_in_first(s: &mut Session, a: usize, b: usize) { - let path = s.doc.para_paths(StoryRef::Body)[0].clone(); - let at = |off: usize| Pos { story: StoryRef::Body, path: path.clone(), off }; - crate::cmd::track_delete(s, &at(a), &at(b)).expect("tracked delete"); -} - -fn first_indent(s: &Session) -> Option { - let path = s.doc.para_paths(StoryRef::Body)[0].clone(); - s.doc.para(StoryRef::Body, &path).expect("paragraph").props.indent_first -} - -fn first_deleted(s: &Session, off: usize) -> bool { - let path = s.doc.para_paths(StoryRef::Body)[0].clone(); - s.doc.para(StoryRef::Body, &path).expect("paragraph").props_of_char(off).del.is_some() -} - -#[test] -fn remove_extra_spaces_skips_tracked_deleted_spaces() { - let mut s = s(); - run(&mut s, "document.setText", json!({"text": "a b"})); - run(&mut s, "review.trackChanges", json!({"value": true})); - track_delete_in_first(&mut s, 2, 3); // the second space is already deleted - select_all(&mut s); - run(&mut s, "para.removeExtraSpaces", json!({"scope": "all"})); - run(&mut s, "review.acceptAll", json!({})); - assert_eq!(text(&s), "a b", "the one live space survives"); - - // Three spaces, the middle one already deleted: of the two live ones the last is kept. - let mut s = self::s(); - run(&mut s, "document.setText", json!({"text": "a b"})); - run(&mut s, "review.trackChanges", json!({"value": true})); - track_delete_in_first(&mut s, 2, 3); - select_all(&mut s); - run(&mut s, "para.removeExtraSpaces", json!({"scope": "all"})); - assert!(first_deleted(&s, 1) && first_deleted(&s, 2), "the first and the already deleted space are deleted"); - assert!(!first_deleted(&s, 3), "the last live space stays"); -} - -#[test] -fn spaces_to_indent_twice_does_not_count_deleted_spaces_again() { - let mut s = s(); - run(&mut s, "document.setText", json!({"text": " x"})); - run(&mut s, "review.trackChanges", json!({"value": true})); - select_all(&mut s); - run(&mut s, "para.spacesToIndent", json!({"scope": "all"})); - assert_eq!(first_indent(&s), Some(144.0)); - run(&mut s, "para.spacesToIndent", json!({"scope": "all"})); - assert_eq!(first_indent(&s), Some(144.0), "the deleted spaces are not indented a second time"); -} - -#[test] -fn convert_breaks_leaves_a_tracked_deleted_break_alone() { - let mut s = s(); - type_breaks(&mut s, "a\nb\nc"); - run(&mut s, "review.trackChanges", json!({"value": true})); - track_delete_in_first(&mut s, 1, 2); - select_all(&mut s); - run(&mut s, "para.convertBreaks", json!({"scope": "all"})); - assert_eq!(s.doc.para_paths(StoryRef::Body).len(), 2, "only the live break becomes a paragraph break"); -} - -#[test] -fn junk_scope_is_refused() { - let mut s = s(); - run(&mut s, "document.setText", json!({"text": "a b"})); - select_all(&mut s); - let e = s.run("para.removeExtraSpaces", &json!({"scope": "junk"})).unwrap_err(); - assert!(format!("{e}").contains("scope"), "{e}"); - assert_eq!(text(&s), "a b", "the document is unchanged"); -} - -#[test] -fn too_many_paragraphs_are_refused_before_any_change() { - let text = "x\n".repeat(15_000); - let mut s = Session::new(wordcraft_doc::Document::from_text(&text)); - select_all(&mut s); - let e = s.run("para.blankBetween", &json!({"scope": "all"})).unwrap_err(); - assert!(format!("{e}").contains("at most"), "{e}"); - let kept = s.doc.para_paths(StoryRef::Body).len(); - assert!((15_000..=15_001).contains(&kept), "nothing changed, {kept}"); -} +//! Advanced paragraph tools (#5043): conversion and cleanup over the selection or the whole +//! document, one undo step, Track Changes respected. + +use serde_json::json; +use wordcraft_doc::{InlineObject, Path, Pos, RevisionKind, SectionProps, StoryRef}; + +use crate::Session; + +fn s() -> Session { + Session::new(wordcraft_doc::Document::new()) +} + +fn run(s: &mut Session, id: &str, v: 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) +} + +/// Select the whole document (Mod+A's command). +fn select_all(s: &mut Session) { + run(s, "select.all", json!({})); +} + +/// One paragraph with `\n` as manual line breaks, typed as text. +fn type_breaks(s: &mut Session, t: &str) { + run(s, "text.insert", json!({"text": t})); +} + +#[test] +fn line_breaks_become_paragraphs() { + let mut s = s(); + type_breaks(&mut s, "one\ntwo\nthree"); + assert_eq!(s.doc.para_paths(StoryRef::Body).len(), 1); + select_all(&mut s); + run(&mut s, "para.convertBreaks", json!({"scope": "all"})); + assert_eq!(text(&s), "one\ntwo\nthree", "the breaks are paragraph marks, not text"); + assert_eq!(s.doc.para_paths(StoryRef::Body).len(), 3); + // One undo step brings the manual line breaks back. + assert!(s.undo()); + let p0 = s.doc.para(StoryRef::Body, &Path::top(0)).unwrap(); + assert_eq!(p0.text, "one\ntwo\nthree", "back to one paragraph with its breaks"); +} + +#[test] +fn blank_paragraphs_go_in_between() { + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "a\nb\nc"})); + select_all(&mut s); + run(&mut s, "para.blankBetween", json!({"scope": "all"})); + assert_eq!(text(&s), "a\n\nb\n\nc"); +} + +#[test] +fn blank_paragraphs_go_after_too() { + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "a\nb\nc"})); + select_all(&mut s); + run(&mut s, "para.blankBetween", json!({"scope": "all", "after": true})); + assert_eq!(text(&s), "a\n\nb\n\nc\n"); +} + +#[test] +fn separate_paragraphs_blanks_after_every_one() { + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "a\nb\nc"})); + select_all(&mut s); + run(&mut s, "para.separateParagraphs", json!({"scope": "all"})); + assert_eq!(text(&s), "a\n\nb\n\nc\n"); +} + +#[test] +fn blank_paragraphs_are_deleted() { + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "a\n\nb\n\n\nc"})); + select_all(&mut s); + run(&mut s, "para.removeBlank", json!({"scope": "all"})); + assert_eq!(text(&s), "a\nb\nc"); +} + +#[test] +fn a_section_break_paragraph_stays() { + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "a\nb"})); + // The second paragraph carries a section break and is emptied: removing it would drop the + // section, so it stays. + let second = Path::top(1); + let p = s.doc.para_mut(StoryRef::Body, &second).unwrap(); + p.text.clear(); + p.runs.clear(); + p.section = Some(Box::new(SectionProps::default())); + p.touch(); + select_all(&mut s); + run(&mut s, "para.removeBlank", json!({"scope": "all"})); + assert_eq!(s.doc.para_paths(StoryRef::Body).len(), 2, "the section-break paragraph stays"); +} + +#[test] +fn leading_spaces_are_removed() { + let mut s = s(); + run(&mut s, "document.setText", json!({"text": " a\nb\n c"})); + select_all(&mut s); + run(&mut s, "para.trimLeadingSpaces", json!({"scope": "all"})); + assert_eq!(text(&s), "a\nb\nc"); +} + +#[test] +fn extra_spaces_collapse_to_one() { + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "a b c d \n e"})); + select_all(&mut s); + run(&mut s, "para.removeExtraSpaces", json!({"scope": "all"})); + assert_eq!(text(&s), "a b c d \n e", "inner runs collapse; the leading spaces of the second paragraph stay"); +} + +#[test] +fn a_space_run_at_the_end_collapses() { + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "a "})); + select_all(&mut s); + run(&mut s, "para.removeExtraSpaces", json!({"scope": "all"})); + assert_eq!(text(&s), "a "); +} + +#[test] +fn extra_spaces_collapse_without_removing_tabs_or_page_breaks() { + use wordcraft_doc::para::PAGE_BREAK; + + let mut s = s(); + run(&mut s, "document.setText", json!({"text": format!("a \t b {PAGE_BREAK} c\t ")})); + select_all(&mut s); + run(&mut s, "para.removeExtraSpaces", json!({"scope": "all"})); + assert_eq!(text(&s), format!("a \t b {PAGE_BREAK} c\t "), "only repeated spaces collapse"); +} + +#[test] +fn indented_spaces_become_an_indent() { + let mut s = s(); + run(&mut s, "document.setText", json!({"text": " a\nb"})); + select_all(&mut s); + run(&mut s, "para.spacesToIndent", json!({"scope": "all"})); + assert_eq!(text(&s), "a\nb"); + let p = s.doc.para(StoryRef::Body, &Path::top(0)).unwrap(); + assert_eq!(p.props.indent_first, Some(216.0), "three spaces are 3 x 72 pt"); + assert_eq!(s.doc.para(StoryRef::Body, &Path::top(1)).unwrap().props.indent_first, None, "b has no spaces"); +} + +#[test] +fn spaces_to_indent_keeps_spaces_the_indent_limit_cannot_represent() { + let mut many = s(); + run(&mut many, "document.setText", json!({"text": format!("{}a", " ".repeat(24))})); + select_all(&mut many); + run(&mut many, "para.spacesToIndent", json!({"scope": "all"})); + assert_eq!(text(&many), " a", "22 spaces fit in the 1584 pt maximum; two stay as text"); + assert_eq!(many.doc.para(StoryRef::Body, &Path::top(0)).unwrap().props.indent_first, Some(1584.0)); + + let mut nearly_full = s(); + run(&mut nearly_full, "document.setText", json!({"text": " a"})); + run(&mut nearly_full, "para.indents", json!({"firstLine": 1548.0})); + select_all(&mut nearly_full); + run(&mut nearly_full, "para.spacesToIndent", json!({"scope": "all"})); + assert_eq!(text(&nearly_full), " a", "less than 72 pt of room cannot replace one space"); + assert_eq!(nearly_full.doc.para(StoryRef::Body, &Path::top(0)).unwrap().props.indent_first, Some(1548.0)); +} + +#[test] +fn indents_become_leading_spaces() { + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "a"})); + run(&mut s, "para.indents", json!({"left": 72.0, "firstLine": 144.0})); + select_all(&mut s); + run(&mut s, "para.indentToSpaces", json!({"scope": "all"})); + assert_eq!(text(&s), " a", "72 + 144 pt become three spaces"); + let p = s.doc.para(StoryRef::Body, &Path::top(0)).unwrap(); + assert_eq!((p.props.indent_left, p.props.indent_first), (Some(0.0), Some(0.0))); +} + +#[test] +fn a_hanging_indent_that_sums_to_zero_is_still_removed() { + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "a"})); + run(&mut s, "para.indents", json!({"left": 72.0, "firstLine": -72.0})); + select_all(&mut s); + run(&mut s, "para.indentToSpaces", json!({"scope": "all"})); + assert_eq!(text(&s), "a", "the cancelling indents need no leading spaces"); + let p = s.doc.para(StoryRef::Body, &Path::top(0)).unwrap(); + assert_eq!((p.props.indent_left, p.props.indent_first), (Some(0.0), Some(0.0))); + + let mut tracked = Session::new(wordcraft_doc::Document::new()); + run(&mut tracked, "document.setText", json!({"text": "a"})); + run(&mut tracked, "para.indents", json!({"left": 72.0, "firstLine": -72.0})); + run(&mut tracked, "review.trackChanges", json!({"value": true})); + select_all(&mut tracked); + run(&mut tracked, "para.indentToSpaces", json!({"scope": "all"})); + assert!(!tracked.doc.revisions.iter().any(|r| r.kind == RevisionKind::Insert), "no inserted text means no insert revision"); + assert!(tracked.doc.revisions.iter().any(|r| r.kind == RevisionKind::Format), "the removed hanging indent is tracked as formatting"); +} + +#[test] +fn cleanup_keeps_fields_footnote_anchors_and_mixed_formatting() { + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "aa bb "})); + s.sel = crate::Selection { anchor: Pos::body(0, 4), focus: Pos::body(0, 6) }; + run(&mut s, "format.bold", json!({"value": true})); + s.sel = crate::Selection::caret(s.doc.end_of(StoryRef::Body)); + run(&mut s, "insert.field", json!({"instr": "AUTHOR", "result": "Ada"})); + let note = s.run("references.footnote", &json!({"text": "keep this note"})).unwrap()["id"].as_u64().unwrap() as u32; + s.sel = crate::Selection::caret(s.doc.end_of(StoryRef::Body)); + run(&mut s, "text.insert", json!({"text": " tail"})); + + select_all(&mut s); + run(&mut s, "para.removeExtraSpaces", json!({"scope": "all"})); + + let p = s.doc.para(StoryRef::Body, &Path::top(0)).unwrap(); + assert_eq!((p.props_of_char(3).bold, p.props_of_char(4).bold), (Some(true), Some(true)), "the bold run moved but kept its formatting"); + assert!(p.object_offsets().iter().any(|off| matches!(p.object_at(*off), Some(InlineObject::Field { .. }))), "the field stays"); + assert!(p.object_offsets().iter().any(|off| matches!(p.object_at(*off), Some(InlineObject::NoteRef { .. }))), "the footnote anchor stays"); + assert!(s.doc.parts.contains_key(¬e), "the anchored footnote story stays"); +} + +#[test] +fn cleanup_inside_a_table_keeps_the_table_and_its_cell() { + let mut s = s(); + run(&mut s, "insert.table", json!({"rows": 1, "cols": 1})); + run(&mut s, "text.insert", json!({"text": "a b"})); + select_all(&mut s); + run(&mut s, "para.removeExtraSpaces", json!({"scope": "all"})); + let table = s.doc.body.iter().find_map(|block| block.as_table()).expect("the table stays"); + let cell = table.rows[0].cells[0].blocks.iter().find_map(|block| block.as_para()).expect("the cell paragraph stays"); + assert_eq!(cell.text, "a b"); +} + +#[test] +fn whole_document_scope_from_a_table_cell_reaches_the_body() { + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "body text"})); + s.sel = crate::Selection::caret(s.doc.end_of(StoryRef::Body)); + run(&mut s, "insert.table", json!({"rows": 1, "cols": 2})); + let table_index = s.doc.body.iter().position(|block| block.as_table().is_some()).expect("the table was inserted") as u32; + s.sel = crate::Selection::caret(Pos { story: StoryRef::Body, path: Path(vec![table_index, 0, 0, 0]), off: 0 }); + run(&mut s, "text.insert", json!({"text": "cell text"})); + assert!(s.sel.focus.path.depth() > 0, "the caret is inside the table"); + run(&mut s, "para.removeExtraSpaces", json!({"scope": "all"})); + assert!(text(&s).contains("body text"), "body text outside the table was cleaned too: {}", text(&s)); + let table = s.doc.body.iter().find_map(|block| block.as_table()).expect("the table stays"); + let cell = table.rows[0].cells[0].blocks.iter().find_map(|block| block.as_para()).expect("the cell stays"); + assert_eq!(cell.text, "cell text"); +} + +#[test] +fn paragraph_tools_refuse_content_locked_controls() { + for (text, command) in [("a b", "para.removeExtraSpaces"), ("a\nb", "para.convertBreaks")] { + let mut s = s(); + type_breaks(&mut s, text); + select_all(&mut s); + run(&mut s, "developer.richText", json!({"tag": "locked"})); + run(&mut s, "developer.properties", json!({"control": "locked", "lockEdit": true})); + select_all(&mut s); + let before = s.doc.clone(); + let error = s.run(command, &json!({"scope": "all"})).unwrap_err(); + assert!(error.to_string().contains("can't be edited"), "{command}: {error}"); + assert_eq!(s.doc, before, "{command} changed locked content"); + } +} + +#[test] +fn line_breaks_are_removed() { + let mut s = s(); + type_breaks(&mut s, "one\ntwo\nthree"); + select_all(&mut s); + run(&mut s, "para.removeLineBreaks", json!({"scope": "all"})); + assert_eq!(text(&s), "onetwothree"); +} + +#[test] +fn the_selection_is_the_scope() { + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "a b\nc d"})); + // Only the first paragraph: select from its start to its end. + s.sel = crate::Selection { anchor: Pos::body(0, 0), focus: Pos::body(0, 4) }; + run(&mut s, "para.removeExtraSpaces", json!({})); + assert_eq!(text(&s), "a b\nc d", "the second paragraph is untouched"); +} + +#[test] +fn structural_tools_do_not_cross_the_selection() { + let mut breaks = s(); + type_breaks(&mut breaks, "one\ntwo"); + run(&mut breaks, "text.newParagraph", json!({})); + run(&mut breaks, "text.insert", json!({"text": "outside"})); + breaks.sel = crate::Selection { anchor: Pos::body(0, 0), focus: Pos::body(0, 7) }; + run(&mut breaks, "para.convertBreaks", json!({})); + let last = breaks.doc.para_paths(StoryRef::Body).last().cloned().unwrap(); + assert_eq!(breaks.doc.para(StoryRef::Body, &last).unwrap().text, "outside"); + + let mut between = s(); + run(&mut between, "document.setText", json!({"text": "a\nb\nc"})); + between.sel = crate::Selection { anchor: Pos::body(0, 0), focus: Pos::body(1, 1) }; + run(&mut between, "para.blankBetween", json!({})); + assert_eq!(text(&between), "a\n\nb\nc", "only a and b get a blank between them"); + + let mut separate = s(); + run(&mut separate, "document.setText", json!({"text": "a\nb\nc"})); + separate.sel = crate::Selection { anchor: Pos::body(0, 0), focus: Pos::body(1, 1) }; + run(&mut separate, "para.separateParagraphs", json!({})); + assert_eq!(text(&separate), "a\n\nb\n\nc", "the blank after b stays before the unselected c"); + + let mut blank = s(); + run(&mut blank, "document.setText", json!({"text": "a\n\nb\n\nc"})); + blank.sel = crate::Selection::caret(Pos::body(1, 0)); + run(&mut blank, "para.removeBlank", json!({})); + assert_eq!(text(&blank), "a\nb\n\nc", "the other blank paragraph is outside the selection"); +} + +#[test] +fn indent_space_conversions_are_reversible_track_changes() { + let mut to_indent = s(); + run(&mut to_indent, "document.setText", json!({"text": " a"})); + run(&mut to_indent, "review.trackChanges", json!({"value": true})); + select_all(&mut to_indent); + run(&mut to_indent, "para.spacesToIndent", json!({"scope": "all"})); + assert_eq!(to_indent.doc.para(StoryRef::Body, &Path::top(0)).unwrap().props.indent_first, Some(144.0)); + run(&mut to_indent, "review.rejectAll", json!({})); + let p = to_indent.doc.para(StoryRef::Body, &Path::top(0)).unwrap(); + assert_eq!((p.text.as_str(), p.props.indent_first), (" a", None)); + + let mut to_spaces = s(); + run(&mut to_spaces, "document.setText", json!({"text": "a"})); + run(&mut to_spaces, "para.indents", json!({"firstLine": 144.0})); + run(&mut to_spaces, "review.trackChanges", json!({"value": true})); + select_all(&mut to_spaces); + run(&mut to_spaces, "para.indentToSpaces", json!({"scope": "all"})); + run(&mut to_spaces, "review.rejectAll", json!({})); + let p = to_spaces.doc.para(StoryRef::Body, &Path::top(0)).unwrap(); + assert_eq!((p.text.as_str(), p.props.indent_first), ("a", Some(144.0))); + + let mut accepted = s(); + run(&mut accepted, "document.setText", json!({"text": "a"})); + run(&mut accepted, "para.indents", json!({"firstLine": 144.0})); + run(&mut accepted, "review.trackChanges", json!({"value": true})); + select_all(&mut accepted); + run(&mut accepted, "para.indentToSpaces", json!({"scope": "all"})); + run(&mut accepted, "review.acceptAll", json!({})); + let p = accepted.doc.para(StoryRef::Body, &Path::top(0)).unwrap(); + assert_eq!((p.text.as_str(), p.props.indent_first), (" a", Some(0.0))); +} + +#[test] +fn indent_to_spaces_does_not_inherit_revision_marks() { + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "a"})); + run(&mut s, "review.trackChanges", json!({"value": true})); + s.sel = crate::Selection { anchor: Pos::body(0, 0), focus: Pos::body(0, 1) }; + run(&mut s, "format.bold", json!({"value": true})); + run(&mut s, "text.delete", json!({})); + run(&mut s, "review.trackChanges", json!({"value": false})); + run(&mut s, "para.indents", json!({"firstLine": 72.0})); + select_all(&mut s); + run(&mut s, "para.indentToSpaces", json!({"scope": "all"})); + let space = s.doc.para(StoryRef::Body, &Path::top(0)).unwrap().props_of_char(0); + assert_eq!((space.ins, space.del, space.fmt_change.as_ref().map(|_| ())), (None, None, None)); +} + +#[test] +fn converting_too_many_line_breaks_is_atomic() { + let original = format!("{}end", "x\n".repeat(10_001)); + let mut s = s(); + type_breaks(&mut s, &original); + select_all(&mut s); + let before = s.doc.clone(); + let error = s.run("para.convertBreaks", &json!({"scope": "all"})).unwrap_err(); + assert!(error.to_string().contains("at most"), "{error}"); + assert_eq!(s.doc, before, "the refused conversion is all-or-nothing"); +} + +#[test] +fn repeating_a_tracked_deletion_creates_no_empty_revision() { + let mut s = s(); + run(&mut s, "document.setText", json!({"text": " a"})); + run(&mut s, "review.trackChanges", json!({"value": true})); + select_all(&mut s); + run(&mut s, "para.trimLeadingSpaces", json!({"scope": "all"})); + let revisions = s.doc.revisions.len(); + run(&mut s, "para.trimLeadingSpaces", json!({"scope": "all"})); + assert_eq!(s.doc.revisions.len(), revisions, "already-deleted spaces create no revision"); +} + +#[test] +fn deletions_are_recorded_under_track_changes() { + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "a b\n c"})); + run(&mut s, "review.trackChanges", json!({"value": true})); + select_all(&mut s); + run(&mut s, "para.trimLeadingSpaces", json!({"scope": "all"})); + // The text stays marked deleted, not gone, as Word records the deletion. + assert_eq!(text(&s), "a b\n c", "tracked deletions keep their text"); + assert!(s.doc.revisions.iter().any(|r| r.kind == RevisionKind::Delete)); + run(&mut s, "review.acceptAll", json!({})); + assert_eq!(text(&s), "a b\nc", "accepting removes the leading space"); +} + +/// Tracked-delete the bytes `a..b` of the first body paragraph (Track Changes must be on). +fn track_delete_in_first(s: &mut Session, a: usize, b: usize) { + let path = s.doc.para_paths(StoryRef::Body)[0].clone(); + let at = |off: usize| Pos { story: StoryRef::Body, path: path.clone(), off }; + crate::cmd::track_delete(s, &at(a), &at(b)).expect("tracked delete"); +} + +fn first_indent(s: &Session) -> Option { + let path = s.doc.para_paths(StoryRef::Body)[0].clone(); + s.doc.para(StoryRef::Body, &path).expect("paragraph").props.indent_first +} + +fn first_deleted(s: &Session, off: usize) -> bool { + let path = s.doc.para_paths(StoryRef::Body)[0].clone(); + s.doc.para(StoryRef::Body, &path).expect("paragraph").props_of_char(off).del.is_some() +} + +#[test] +fn remove_extra_spaces_skips_tracked_deleted_spaces() { + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "a b"})); + run(&mut s, "review.trackChanges", json!({"value": true})); + track_delete_in_first(&mut s, 2, 3); // the second space is already deleted + select_all(&mut s); + run(&mut s, "para.removeExtraSpaces", json!({"scope": "all"})); + run(&mut s, "review.acceptAll", json!({})); + assert_eq!(text(&s), "a b", "the one live space survives"); + + // Three spaces, the middle one already deleted: of the two live ones the last is kept. + let mut s = self::s(); + run(&mut s, "document.setText", json!({"text": "a b"})); + run(&mut s, "review.trackChanges", json!({"value": true})); + track_delete_in_first(&mut s, 2, 3); + select_all(&mut s); + run(&mut s, "para.removeExtraSpaces", json!({"scope": "all"})); + assert!(first_deleted(&s, 1) && first_deleted(&s, 2), "the first and the already deleted space are deleted"); + assert!(!first_deleted(&s, 3), "the last live space stays"); +} + +#[test] +fn spaces_to_indent_twice_does_not_count_deleted_spaces_again() { + let mut s = s(); + run(&mut s, "document.setText", json!({"text": " x"})); + run(&mut s, "review.trackChanges", json!({"value": true})); + select_all(&mut s); + run(&mut s, "para.spacesToIndent", json!({"scope": "all"})); + assert_eq!(first_indent(&s), Some(144.0)); + run(&mut s, "para.spacesToIndent", json!({"scope": "all"})); + assert_eq!(first_indent(&s), Some(144.0), "the deleted spaces are not indented a second time"); +} + +#[test] +fn convert_breaks_leaves_a_tracked_deleted_break_alone() { + let mut s = s(); + type_breaks(&mut s, "a\nb\nc"); + run(&mut s, "review.trackChanges", json!({"value": true})); + track_delete_in_first(&mut s, 1, 2); + select_all(&mut s); + run(&mut s, "para.convertBreaks", json!({"scope": "all"})); + assert_eq!(s.doc.para_paths(StoryRef::Body).len(), 2, "only the live break becomes a paragraph break"); +} + +#[test] +fn junk_scope_is_refused() { + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "a b"})); + select_all(&mut s); + let e = s.run("para.removeExtraSpaces", &json!({"scope": "junk"})).unwrap_err(); + assert!(format!("{e}").contains("scope"), "{e}"); + assert_eq!(text(&s), "a b", "the document is unchanged"); +} + +#[test] +fn too_many_paragraphs_are_refused_before_any_change() { + let text = "x\n".repeat(15_000); + let mut s = Session::new(wordcraft_doc::Document::from_text(&text)); + select_all(&mut s); + let e = s.run("para.blankBetween", &json!({"scope": "all"})).unwrap_err(); + assert!(format!("{e}").contains("at most"), "{e}"); + let kept = s.doc.para_paths(StoryRef::Body).len(); + assert!((15_000..=15_001).contains(&kept), "nothing changed, {kept}"); +} diff --git a/crates/engine/src/tests/revision_checks.rs b/crates/engine/src/tests/revision_checks.rs index c87d98bef4..fe3e616036 100644 --- a/crates/engine/src/tests/revision_checks.rs +++ b/crates/engine/src/tests/revision_checks.rs @@ -1,474 +1,474 @@ -//! #1329: table deletion, Change Case and field unlink respect locked content controls and -//! Track Changes (Reject All restores what they changed). - -use serde_json::{Value, json}; -use wordcraft_doc::para::{InlineObject, OBJ}; -use wordcraft_doc::{Pos, StoryRef, Table}; - -use crate::{Selection, Session}; - -fn run(s: &mut Session, id: &str, v: Value) -> Value { - s.run(id, &v).unwrap_or_else(|e| panic!("{id}: {e}")) -} - -/// The first paragraph's text without its tracked deletions. -fn visible(s: &Session) -> String { - let Some(p) = s.doc.para_at(&Pos::body(0, 0)) else { return String::new() }; - p.run_ranges().filter(|(_, c)| c.del.is_none()).filter_map(|(r, _)| p.text.get(r)).collect::().replace(OBJ, "") -} - -fn table(s: &Session) -> Option<&Table> { - s.doc.body.iter().find_map(|b| b.as_table()) -} - -/// Every cell's text, row by row (deleted text included). -fn cells(s: &Session) -> Vec> { - table(s) - .map(|t| { - t.rows - .iter() - .map(|r| r.cells.iter().map(|c| c.blocks.iter().filter_map(|b| b.as_para()).map(|p| p.plain_text()).collect()).collect()) - .collect() - }) - .unwrap_or_default() -} - -/// Whether every character of every cell in `which` is a tracked deletion. -fn cells_deleted(s: &Session, which: &[(usize, usize)]) -> bool { - let Some(t) = table(s) else { return false }; - which.iter().all(|(r, c)| { - let cell = &t.rows[*r].cells[*c]; - cell.blocks.iter().filter_map(|b| b.as_para()).all(|p| p.run_ranges().all(|(_, props)| props.del.is_some())) - }) -} - -/// A 2×2 table holding A1 B1 / A2 B2, the caret in A1 (at its start). -fn table_session() -> Session { - let mut s = Session::new(wordcraft_doc::Document::new()); - run(&mut s, "insert.table", json!({"rows": 2, "cols": 2})); - for (i, t) in ["A1", "B1", "A2", "B2"].iter().enumerate() { - if i > 0 { - run(&mut s, "text.tab", json!({})); - } - run(&mut s, "text.insert", json!({"text": t})); - } - caret_in(&mut s, 0, 0, 0); - s -} - -fn caret_in(s: &mut Session, r: u32, c: u32, off: usize) { - s.sel = Selection::caret(Pos { story: StoryRef::Body, path: wordcraft_doc::Path(vec![0, r, c, 0]), off }); -} - -/// [`table_session`] with "A1" in a content control tagged `ctl`, locked with `lock`. -fn locked_table(lock: &str) -> Session { - let mut s = table_session(); - let at = |off| Pos { story: StoryRef::Body, path: wordcraft_doc::Path(vec![0, 0, 0, 0]), off }; - // A partial selection inside the paragraph (not the whole paragraph) wraps inline. - s.sel = Selection { anchor: at(0), focus: at(1) }; - run(&mut s, "developer.richText", json!({"tag": "ctl"})); - run(&mut s, "developer.properties", json!({"control": "ctl", lock: true})); - caret_in(&mut s, 0, 0, 0); - s -} - -#[test] -fn table_deletes_refuse_a_control_locked_against_deletion() { - for id in ["table.deleteTable", "table.deleteRow", "table.deleteColumn", "table.deleteCells"] { - let mut s = locked_table("lockDelete"); - let before = s.doc.clone(); - assert!(s.run(id, &json!({})).is_err(), "{id} deleted a locked control"); - assert_eq!(s.doc, before, "{id} changed the document"); - assert!(s.status.contains("can't be deleted"), "{id}: {}", s.status); - } -} - -#[test] -fn tracked_table_delete_marks_the_text_deleted_and_reject_all_restores_it() { - let mut s = table_session(); - run(&mut s, "review.trackChanges", json!({"value": true})); - run(&mut s, "table.deleteTable", json!({})); - // Like Word: the structure stays, its text is marked deleted. - assert!(table(&s).is_some(), "the table was removed under Track Changes"); - assert_eq!(cells(&s), [["A1", "B1"], ["A2", "B2"]]); - assert!(cells_deleted(&s, &[(0, 0), (0, 1), (1, 0), (1, 1)])); - run(&mut s, "review.rejectAll", json!({})); - assert_eq!(cells(&s), [["A1", "B1"], ["A2", "B2"]]); - assert!(!cells_deleted(&s, &[(0, 0)]) && s.doc.revisions.is_empty()); -} - -#[test] -fn tracked_row_column_and_cell_deletes_mark_their_text_deleted() { - for (id, gone, kept) in [ - ("table.deleteRow", vec![(0, 0), (0, 1)], vec![(1, 0), (1, 1)]), - ("table.deleteColumn", vec![(0, 0), (1, 0)], vec![(0, 1), (1, 1)]), - ("table.deleteCells", vec![(0, 0)], vec![(0, 1), (1, 0), (1, 1)]), - ] { - let mut s = table_session(); - run(&mut s, "review.trackChanges", json!({"value": true})); - run(&mut s, id, json!({})); - assert_eq!(cells(&s), [["A1", "B1"], ["A2", "B2"]], "{id} changed the structure"); - assert!(cells_deleted(&s, &gone), "{id}: text not marked deleted"); - for k in &kept { - assert!(!cells_deleted(&s, &[*k]), "{id}: {k:?} marked deleted"); - } - run(&mut s, "review.rejectAll", json!({})); - assert_eq!(cells(&s), [["A1", "B1"], ["A2", "B2"]]); - assert!(s.doc.revisions.is_empty(), "{id}"); - } -} - -#[test] -fn tracked_table_delete_over_tracked_insertions_rejects_back_to_the_original() { - let mut s = table_session(); - run(&mut s, "review.trackChanges", json!({"value": true})); - // One insertion by this author, one by another. - caret_in(&mut s, 0, 0, 2); - run(&mut s, "text.insert", json!({"text": "x"})); - let me = s.author.clone(); - s.author = "Other".into(); - caret_in(&mut s, 1, 1, 2); - run(&mut s, "text.insert", json!({"text": "y"})); - s.author = me; - caret_in(&mut s, 0, 0, 0); - run(&mut s, "table.deleteTable", json!({})); - assert!(table(&s).is_some()); - run(&mut s, "review.rejectAll", json!({})); - assert_eq!(cells(&s), [["A1", "B1"], ["A2", "B2"]]); - assert!(s.doc.revisions.is_empty()); -} - -/// "hello world" with "hello" in a rich text control tagged `ctl`. -fn hello_in_control(lock: Option<&str>) -> Session { - let mut s = Session::new(wordcraft_doc::Document::new()); - run(&mut s, "text.insert", json!({"text": "hello world"})); - s.sel = Selection { anchor: Pos::body(0, 0), focus: Pos::body(0, 5) }; - run(&mut s, "developer.richText", json!({"tag": "ctl"})); - if let Some(l) = lock { - run(&mut s, "developer.properties", json!({"control": "ctl", l: true})); - } - s -} - -#[test] -fn change_case_refuses_content_locked_against_editing() { - let mut s = hello_in_control(Some("lockEdit")); - let w = OBJ.len_utf8(); - // Just the control's content, and the whole paragraph. - for (a, b) in [(w, w + 5), (0, 11 + 2 * w)] { - s.sel = Selection { anchor: Pos::body(0, a), focus: Pos::body(0, b) }; - let before = s.doc.clone(); - assert!(s.run("format.changeCase", &json!({"mode": "upper"})).is_err(), "{a}..{b}"); - assert_eq!(s.doc, before); - } - // Locked against deletion only: the content may change. - let mut s = hello_in_control(Some("lockDelete")); - s.sel = Selection { anchor: Pos::body(0, 0), focus: Pos::body(0, 11 + 2 * w) }; - run(&mut s, "format.changeCase", json!({"mode": "upper"})); - assert_eq!(s.doc.plain_text(StoryRef::Body), "HELLO WORLD"); -} - -#[test] -fn tracked_change_case_rejects_back_to_the_original_case() { - let mut s = Session::new(wordcraft_doc::Document::new()); - run(&mut s, "text.insert", json!({"text": "hello World"})); - run(&mut s, "review.trackChanges", json!({"value": true})); - run(&mut s, "select.all", json!({})); - run(&mut s, "format.changeCase", json!({"mode": "upper"})); - assert_eq!(visible(&s), "HELLO WORLD"); - // The selection still covers the converted text. - let (a, b) = s.sel.ordered(); - assert_eq!((a.off, b.off), (0, s.doc.para_at(&a).map(|p| p.len()).unwrap_or(0))); - run(&mut s, "review.rejectAll", json!({})); - assert_eq!(s.doc.plain_text(StoryRef::Body), "hello World"); - assert!(s.doc.revisions.is_empty()); - // Accept All keeps the new case. - run(&mut s, "select.all", json!({})); - run(&mut s, "format.changeCase", json!({"mode": "upper"})); - run(&mut s, "review.acceptAll", json!({})); - assert_eq!(s.doc.plain_text(StoryRef::Body), "HELLO WORLD"); -} - -#[test] -fn tracked_change_case_of_own_insertion_stays_an_insertion() { - let mut s = Session::new(wordcraft_doc::Document::new()); - run(&mut s, "text.insert", json!({"text": "keep "})); - run(&mut s, "review.trackChanges", json!({"value": true})); - run(&mut s, "text.insert", json!({"text": "new"})); - s.sel = Selection { anchor: Pos::body(0, 5), focus: Pos::body(0, 8) }; - run(&mut s, "format.changeCase", json!({"mode": "upper"})); - assert_eq!(s.doc.plain_text(StoryRef::Body), "keep NEW", "no deleted copy of text that never existed"); - run(&mut s, "review.rejectAll", json!({})); - assert_eq!(s.doc.plain_text(StoryRef::Body), "keep "); -} - -/// "Made by {AUTHOR} on " with "by {AUTHOR} on" in a rich text control tagged `ctl`. -fn field_in_control(lock: &str) -> (Session, Pos) { - let mut s = Session::new(wordcraft_doc::Document::new()); - s.doc.core.creator = "Ada".into(); - run(&mut s, "text.insert", json!({"text": "Made by "})); - run(&mut s, "insert.field", json!({"instr": "AUTHOR", "result": "Ada"})); - run(&mut s, "text.insert", json!({"text": " on "})); - let w = OBJ.len_utf8(); - s.sel = Selection { anchor: Pos::body(0, 5), focus: Pos::body(0, 8 + w + 3) }; - run(&mut s, "developer.richText", json!({"tag": "ctl"})); - run(&mut s, "developer.properties", json!({"control": "ctl", lock: true})); - let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); - let off = p.object_offsets().into_iter().find(|o| matches!(p.object_at(*o), Some(InlineObject::Field { .. }))).unwrap(); - (s, Pos::body(0, off)) -} - -#[test] -fn unlink_refuses_a_field_in_content_locked_against_editing() { - let (mut s, at) = field_in_control("lockEdit"); - s.sel = Selection::caret(at); - let before = s.doc.clone(); - assert!(s.run("fields.unlink", &json!({})).is_err()); - assert_eq!(s.doc, before); - // Locked against deletion only: unlinking is an edit of the content, allowed. - let (mut s, at) = field_in_control("lockDelete"); - s.sel = Selection::caret(at); - run(&mut s, "fields.unlink", json!({})); - assert!(s.doc.plain_text(StoryRef::Body).contains("by Ada on")); -} - -#[test] -fn tracked_unlink_keeps_the_result_as_plain_text() { - let mut s = Session::new(wordcraft_doc::Document::new()); - s.doc.core.creator = "Ada".into(); - run(&mut s, "text.insert", json!({"text": "Made by "})); - run(&mut s, "insert.field", json!({"instr": "AUTHOR", "result": "Ada"})); - run(&mut s, "review.trackChanges", json!({"value": true})); - s.sel = Selection::caret(Pos::body(0, 8)); - run(&mut s, "fields.unlink", json!({})); - // Like Word, not tracked: the result is ordinary text, shown and kept by Reject All. - assert_eq!(visible(&s), "Made by Ada"); - assert!(s.doc.para_at(&Pos::body(0, 0)).unwrap().objects.is_empty()); - run(&mut s, "review.rejectAll", json!({})); - assert_eq!(s.doc.plain_text(StoryRef::Body), "Made by Ada"); -} - -#[test] -fn tracked_delete_table_removes_a_table_with_no_live_text() { - // Delete, Accept All (an empty grid is left), Delete again: the table goes. - let mut s = table_session(); - run(&mut s, "review.trackChanges", json!({"value": true})); - run(&mut s, "table.deleteTable", json!({})); - run(&mut s, "review.acceptAll", json!({})); - assert!(table(&s).is_some()); - caret_in(&mut s, 0, 0, 0); - run(&mut s, "table.deleteTable", json!({})); - assert!(table(&s).is_none(), "an empty table can't be deleted under Track Changes"); - assert!(s.doc.revisions.is_empty(), "no unused revision"); - // A fresh empty table too. - let mut s = Session::new(wordcraft_doc::Document::new()); - run(&mut s, "review.trackChanges", json!({"value": true})); - run(&mut s, "insert.table", json!({"rows": 2, "cols": 2})); - let revs = s.doc.revisions.len(); - run(&mut s, "table.deleteTable", json!({})); - assert!(table(&s).is_none()); - assert_eq!(s.doc.revisions.len(), revs); -} - -/// A 2×2 table whose first row is one cell "Title" across both columns; A | B below; caret in A. -fn merged_title_table(lock: Option<&str>) -> Session { - let mut s = Session::new(wordcraft_doc::Document::new()); - run(&mut s, "insert.table", json!({"rows": 2, "cols": 2})); - let at = |r, c| Pos { story: StoryRef::Body, path: wordcraft_doc::Path(vec![0, r, c, 0]), off: 0 }; - s.sel = Selection { anchor: at(0, 0), focus: at(0, 1) }; - run(&mut s, "table.merge", json!({})); - assert_eq!(table(&s).unwrap().rows[0].cells.len(), 1); - caret_in(&mut s, 0, 0, 0); - run(&mut s, "text.insert", json!({"text": "Title"})); - caret_in(&mut s, 1, 0, 0); - run(&mut s, "text.insert", json!({"text": "A"})); - caret_in(&mut s, 1, 1, 0); - run(&mut s, "text.insert", json!({"text": "B"})); - if let Some(l) = lock { - let p = |off| Pos { story: StoryRef::Body, path: wordcraft_doc::Path(vec![0, 0, 0, 0]), off }; - s.sel = Selection { anchor: p(0), focus: p(2) }; - run(&mut s, "developer.richText", json!({"tag": "ctl"})); - run(&mut s, "developer.properties", json!({"control": "ctl", l: true})); - } - caret_in(&mut s, 1, 0, 0); - s -} - -#[test] -fn delete_columns_leaves_a_merged_cell_that_keeps_a_column_alone() { - // Tracked: only A is marked deleted, not the merged title. - let mut s = merged_title_table(None); - run(&mut s, "review.trackChanges", json!({"value": true})); - run(&mut s, "table.deleteColumn", json!({})); - assert!(cells_deleted(&s, &[(1, 0)])); - assert!(!cells_deleted(&s, &[(0, 0)]), "the merged title was struck"); - assert!(!cells_deleted(&s, &[(1, 1)])); - // Untracked: a lockDelete control in the title doesn't stop the column going. - let mut s = merged_title_table(Some("lockDelete")); - run(&mut s, "table.deleteColumn", json!({})); - assert_eq!(table(&s).map(|t| t.grid.len()), Some(1)); - assert_eq!(s.doc.control_ranges(StoryRef::Body).len(), 1); -} - -#[test] -fn tracked_row_and_column_deletes_over_tracked_changes_reject_back_to_the_original() { - for id in ["table.deleteRow", "table.deleteColumn"] { - let mut s = table_session(); - run(&mut s, "review.trackChanges", json!({"value": true})); - caret_in(&mut s, 0, 0, 2); - run(&mut s, "text.insert", json!({"text": "x"})); - let me = s.author.clone(); - s.author = "Other".into(); - caret_in(&mut s, 0, 1, 2); - run(&mut s, "text.insert", json!({"text": "y"})); - caret_in(&mut s, 1, 0, 2); - run(&mut s, "text.insert", json!({"text": "z"})); - // And a deletion already there. - s.sel = Selection { - anchor: Pos { story: StoryRef::Body, path: wordcraft_doc::Path(vec![0, 0, 0, 0]), off: 0 }, - focus: Pos { story: StoryRef::Body, path: wordcraft_doc::Path(vec![0, 0, 0, 0]), off: 1 }, - }; - run(&mut s, "text.delete", json!({})); - s.author = me; - caret_in(&mut s, 0, 0, 0); - run(&mut s, id, json!({})); - run(&mut s, "review.rejectAll", json!({})); - assert_eq!(cells(&s), [["A1", "B1"], ["A2", "B2"]], "{id}"); - assert!(s.doc.revisions.is_empty(), "{id}"); - } -} - -fn tracked_hello() -> Session { - let mut s = Session::new(wordcraft_doc::Document::new()); - run(&mut s, "text.insert", json!({"text": "hello world"})); - run(&mut s, "review.trackChanges", json!({"value": true})); - run(&mut s, "select.all", json!({})); - run(&mut s, "format.changeCase", json!({"mode": "upper"})); - assert_eq!(visible(&s), "HELLO WORLD"); - s -} - -#[test] -fn tracked_change_case_twice_reads_only_the_live_text() { - let mut s = tracked_hello(); - run(&mut s, "format.changeCase", json!({"mode": "title"})); - assert_eq!(visible(&s), "Hello World"); - // Shift+F3 goes on from UPPERCASE as it does untracked. - let mut plain = Session::new(wordcraft_doc::Document::new()); - run(&mut plain, "text.insert", json!({"text": "HELLO WORLD"})); - run(&mut plain, "select.all", json!({})); - run(&mut plain, "format.changeCase", json!({})); - let mut s = tracked_hello(); - run(&mut s, "format.changeCase", json!({})); - assert_eq!(visible(&s), plain.doc.plain_text(StoryRef::Body)); - run(&mut s, "review.rejectAll", json!({})); - assert_eq!(s.doc.plain_text(StoryRef::Body), "hello world"); -} - -#[test] -fn tracked_change_case_handles_sharp_s_and_mixed_formatting() { - let mut s = Session::new(wordcraft_doc::Document::new()); - run(&mut s, "text.insert", json!({"text": "straße ab"})); - s.sel = Selection { anchor: Pos::body(0, 0), focus: Pos::body(0, 3) }; - run(&mut s, "format.bold", json!({})); - run(&mut s, "review.trackChanges", json!({"value": true})); - run(&mut s, "select.all", json!({})); - run(&mut s, "format.changeCase", json!({"mode": "upper"})); - assert_eq!(visible(&s), "STRASSE AB"); - // Each inserted stretch keeps its run's formatting. - let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); - let live: Vec<(String, bool)> = - p.run_ranges().filter(|(_, c)| c.del.is_none()).map(|(r, c)| (p.text[r].to_string(), c.bold == Some(true))).collect(); - let bold: String = live.iter().filter(|x| x.1).map(|x| x.0.as_str()).collect(); - assert_eq!(bold, "STR"); - run(&mut s, "review.rejectAll", json!({})); - assert_eq!(s.doc.plain_text(StoryRef::Body), "straße ab"); -} - -#[test] -fn tracked_delete_of_struck_table_text_keeps_it_for_reject_all() { - for id in ["table.deleteTable", "table.deleteRow", "table.deleteColumn", "table.deleteCells"] { - let mut s = table_session(); - run(&mut s, "review.trackChanges", json!({"value": true})); - run(&mut s, id, json!({})); - caret_in(&mut s, 0, 0, 0); - // Pressed again: the text is already struck, so nothing is removed outright. - let before = s.doc.clone(); - assert!(s.run(id, &json!({})).is_err(), "{id}"); - assert_eq!(s.doc, before, "{id}"); - assert!(s.status.contains("already marked deleted"), "{id}: {}", s.status); - run(&mut s, "review.rejectAll", json!({})); - assert_eq!(cells(&s), [["A1", "B1"], ["A2", "B2"]], "{id}"); - } -} - -#[derive(Clone, Copy, Debug)] -enum Slot { - FootnoteSeparator, - EndnoteSeparator, - FootnoteContinuationSeparator, - EndnoteContinuationNotice, -} - -const SLOTS: [Slot; 4] = [Slot::FootnoteSeparator, Slot::EndnoteSeparator, Slot::FootnoteContinuationSeparator, Slot::EndnoteContinuationNotice]; - -fn slot_mut(doc: &mut wordcraft_doc::Document, slot: Slot) -> &mut Option { - match slot { - Slot::FootnoteSeparator => &mut doc.footnote_separators.separator, - Slot::EndnoteSeparator => &mut doc.endnote_separators.separator, - Slot::FootnoteContinuationSeparator => &mut doc.footnote_separators.continuation_separator, - Slot::EndnoteContinuationNotice => &mut doc.endnote_separators.continuation_notice, - } -} - -/// A session whose note separator `slot` holds the text "SEP" as a tracked insertion (revision 0). -fn tracked_separator_session(slot: Slot) -> Session { - let mut doc = wordcraft_doc::Document::new(); - doc.revisions.push(wordcraft_doc::Revision { - kind: wordcraft_doc::RevisionKind::Insert, - author: "Alice".into(), - date: String::new(), - move_name: None, - }); - let props = wordcraft_doc::CharProps { ins: Some(0), ..Default::default() }; - let p = wordcraft_doc::Paragraph::with_text("SEP", props); - *slot_mut(&mut doc, slot) = Some(vec![wordcraft_doc::para_block(p)]); - Session::new(doc) -} - -fn separator_para(s: &mut Session, slot: Slot) -> wordcraft_doc::Paragraph { - slot_mut(&mut s.doc, slot).as_ref().and_then(|b| b.iter().find_map(|b| b.as_para())).expect("separator").clone() -} - -#[test] -fn accept_all_resolves_note_separator_revisions() { - for slot in SLOTS { - let mut s = tracked_separator_session(slot); - run(&mut s, "review.acceptAll", json!({})); - let p = separator_para(&mut s, slot); - assert_eq!(p.plain_text(), "SEP", "{slot:?}"); - assert!(p.run_ranges().all(|(_, c)| c.ins.is_none() && c.del.is_none()), "{slot:?}"); - } -} - -#[test] -fn reject_all_resolves_note_separator_revisions() { - for slot in SLOTS { - let mut s = tracked_separator_session(slot); - run(&mut s, "review.rejectAll", json!({})); - assert_eq!(separator_para(&mut s, slot).plain_text(), "", "{slot:?}"); - } -} - -#[test] -fn new_tracked_change_does_not_alias_separator_revision() { - for slot in SLOTS { - let mut s = tracked_separator_session(slot); - run(&mut s, "review.acceptAll", json!({})); - s.author = "Bob".into(); - run(&mut s, "review.trackChanges", json!({"value": true})); - run(&mut s, "text.insert", json!({"text": "NEW "})); - let p = separator_para(&mut s, slot); - assert!(p.run_ranges().all(|(_, c)| c.ins.is_none() && c.del.is_none()), "{slot:?}"); - } -} +//! #1329: table deletion, Change Case and field unlink respect locked content controls and +//! Track Changes (Reject All restores what they changed). + +use serde_json::{Value, json}; +use wordcraft_doc::para::{InlineObject, OBJ}; +use wordcraft_doc::{Pos, StoryRef, Table}; + +use crate::{Selection, Session}; + +fn run(s: &mut Session, id: &str, v: Value) -> Value { + s.run(id, &v).unwrap_or_else(|e| panic!("{id}: {e}")) +} + +/// The first paragraph's text without its tracked deletions. +fn visible(s: &Session) -> String { + let Some(p) = s.doc.para_at(&Pos::body(0, 0)) else { return String::new() }; + p.run_ranges().filter(|(_, c)| c.del.is_none()).filter_map(|(r, _)| p.text.get(r)).collect::().replace(OBJ, "") +} + +fn table(s: &Session) -> Option<&Table> { + s.doc.body.iter().find_map(|b| b.as_table()) +} + +/// Every cell's text, row by row (deleted text included). +fn cells(s: &Session) -> Vec> { + table(s) + .map(|t| { + t.rows + .iter() + .map(|r| r.cells.iter().map(|c| c.blocks.iter().filter_map(|b| b.as_para()).map(|p| p.plain_text()).collect()).collect()) + .collect() + }) + .unwrap_or_default() +} + +/// Whether every character of every cell in `which` is a tracked deletion. +fn cells_deleted(s: &Session, which: &[(usize, usize)]) -> bool { + let Some(t) = table(s) else { return false }; + which.iter().all(|(r, c)| { + let cell = &t.rows[*r].cells[*c]; + cell.blocks.iter().filter_map(|b| b.as_para()).all(|p| p.run_ranges().all(|(_, props)| props.del.is_some())) + }) +} + +/// A 2×2 table holding A1 B1 / A2 B2, the caret in A1 (at its start). +fn table_session() -> Session { + let mut s = Session::new(wordcraft_doc::Document::new()); + run(&mut s, "insert.table", json!({"rows": 2, "cols": 2})); + for (i, t) in ["A1", "B1", "A2", "B2"].iter().enumerate() { + if i > 0 { + run(&mut s, "text.tab", json!({})); + } + run(&mut s, "text.insert", json!({"text": t})); + } + caret_in(&mut s, 0, 0, 0); + s +} + +fn caret_in(s: &mut Session, r: u32, c: u32, off: usize) { + s.sel = Selection::caret(Pos { story: StoryRef::Body, path: wordcraft_doc::Path(vec![0, r, c, 0]), off }); +} + +/// [`table_session`] with "A1" in a content control tagged `ctl`, locked with `lock`. +fn locked_table(lock: &str) -> Session { + let mut s = table_session(); + let at = |off| Pos { story: StoryRef::Body, path: wordcraft_doc::Path(vec![0, 0, 0, 0]), off }; + // A partial selection inside the paragraph (not the whole paragraph) wraps inline. + s.sel = Selection { anchor: at(0), focus: at(1) }; + run(&mut s, "developer.richText", json!({"tag": "ctl"})); + run(&mut s, "developer.properties", json!({"control": "ctl", lock: true})); + caret_in(&mut s, 0, 0, 0); + s +} + +#[test] +fn table_deletes_refuse_a_control_locked_against_deletion() { + for id in ["table.deleteTable", "table.deleteRow", "table.deleteColumn", "table.deleteCells"] { + let mut s = locked_table("lockDelete"); + let before = s.doc.clone(); + assert!(s.run(id, &json!({})).is_err(), "{id} deleted a locked control"); + assert_eq!(s.doc, before, "{id} changed the document"); + assert!(s.status.contains("can't be deleted"), "{id}: {}", s.status); + } +} + +#[test] +fn tracked_table_delete_marks_the_text_deleted_and_reject_all_restores_it() { + let mut s = table_session(); + run(&mut s, "review.trackChanges", json!({"value": true})); + run(&mut s, "table.deleteTable", json!({})); + // Like Word: the structure stays, its text is marked deleted. + assert!(table(&s).is_some(), "the table was removed under Track Changes"); + assert_eq!(cells(&s), [["A1", "B1"], ["A2", "B2"]]); + assert!(cells_deleted(&s, &[(0, 0), (0, 1), (1, 0), (1, 1)])); + run(&mut s, "review.rejectAll", json!({})); + assert_eq!(cells(&s), [["A1", "B1"], ["A2", "B2"]]); + assert!(!cells_deleted(&s, &[(0, 0)]) && s.doc.revisions.is_empty()); +} + +#[test] +fn tracked_row_column_and_cell_deletes_mark_their_text_deleted() { + for (id, gone, kept) in [ + ("table.deleteRow", vec![(0, 0), (0, 1)], vec![(1, 0), (1, 1)]), + ("table.deleteColumn", vec![(0, 0), (1, 0)], vec![(0, 1), (1, 1)]), + ("table.deleteCells", vec![(0, 0)], vec![(0, 1), (1, 0), (1, 1)]), + ] { + let mut s = table_session(); + run(&mut s, "review.trackChanges", json!({"value": true})); + run(&mut s, id, json!({})); + assert_eq!(cells(&s), [["A1", "B1"], ["A2", "B2"]], "{id} changed the structure"); + assert!(cells_deleted(&s, &gone), "{id}: text not marked deleted"); + for k in &kept { + assert!(!cells_deleted(&s, &[*k]), "{id}: {k:?} marked deleted"); + } + run(&mut s, "review.rejectAll", json!({})); + assert_eq!(cells(&s), [["A1", "B1"], ["A2", "B2"]]); + assert!(s.doc.revisions.is_empty(), "{id}"); + } +} + +#[test] +fn tracked_table_delete_over_tracked_insertions_rejects_back_to_the_original() { + let mut s = table_session(); + run(&mut s, "review.trackChanges", json!({"value": true})); + // One insertion by this author, one by another. + caret_in(&mut s, 0, 0, 2); + run(&mut s, "text.insert", json!({"text": "x"})); + let me = s.author.clone(); + s.author = "Other".into(); + caret_in(&mut s, 1, 1, 2); + run(&mut s, "text.insert", json!({"text": "y"})); + s.author = me; + caret_in(&mut s, 0, 0, 0); + run(&mut s, "table.deleteTable", json!({})); + assert!(table(&s).is_some()); + run(&mut s, "review.rejectAll", json!({})); + assert_eq!(cells(&s), [["A1", "B1"], ["A2", "B2"]]); + assert!(s.doc.revisions.is_empty()); +} + +/// "hello world" with "hello" in a rich text control tagged `ctl`. +fn hello_in_control(lock: Option<&str>) -> Session { + let mut s = Session::new(wordcraft_doc::Document::new()); + run(&mut s, "text.insert", json!({"text": "hello world"})); + s.sel = Selection { anchor: Pos::body(0, 0), focus: Pos::body(0, 5) }; + run(&mut s, "developer.richText", json!({"tag": "ctl"})); + if let Some(l) = lock { + run(&mut s, "developer.properties", json!({"control": "ctl", l: true})); + } + s +} + +#[test] +fn change_case_refuses_content_locked_against_editing() { + let mut s = hello_in_control(Some("lockEdit")); + let w = OBJ.len_utf8(); + // Just the control's content, and the whole paragraph. + for (a, b) in [(w, w + 5), (0, 11 + 2 * w)] { + s.sel = Selection { anchor: Pos::body(0, a), focus: Pos::body(0, b) }; + let before = s.doc.clone(); + assert!(s.run("format.changeCase", &json!({"mode": "upper"})).is_err(), "{a}..{b}"); + assert_eq!(s.doc, before); + } + // Locked against deletion only: the content may change. + let mut s = hello_in_control(Some("lockDelete")); + s.sel = Selection { anchor: Pos::body(0, 0), focus: Pos::body(0, 11 + 2 * w) }; + run(&mut s, "format.changeCase", json!({"mode": "upper"})); + assert_eq!(s.doc.plain_text(StoryRef::Body), "HELLO WORLD"); +} + +#[test] +fn tracked_change_case_rejects_back_to_the_original_case() { + let mut s = Session::new(wordcraft_doc::Document::new()); + run(&mut s, "text.insert", json!({"text": "hello World"})); + run(&mut s, "review.trackChanges", json!({"value": true})); + run(&mut s, "select.all", json!({})); + run(&mut s, "format.changeCase", json!({"mode": "upper"})); + assert_eq!(visible(&s), "HELLO WORLD"); + // The selection still covers the converted text. + let (a, b) = s.sel.ordered(); + assert_eq!((a.off, b.off), (0, s.doc.para_at(&a).map(|p| p.len()).unwrap_or(0))); + run(&mut s, "review.rejectAll", json!({})); + assert_eq!(s.doc.plain_text(StoryRef::Body), "hello World"); + assert!(s.doc.revisions.is_empty()); + // Accept All keeps the new case. + run(&mut s, "select.all", json!({})); + run(&mut s, "format.changeCase", json!({"mode": "upper"})); + run(&mut s, "review.acceptAll", json!({})); + assert_eq!(s.doc.plain_text(StoryRef::Body), "HELLO WORLD"); +} + +#[test] +fn tracked_change_case_of_own_insertion_stays_an_insertion() { + let mut s = Session::new(wordcraft_doc::Document::new()); + run(&mut s, "text.insert", json!({"text": "keep "})); + run(&mut s, "review.trackChanges", json!({"value": true})); + run(&mut s, "text.insert", json!({"text": "new"})); + s.sel = Selection { anchor: Pos::body(0, 5), focus: Pos::body(0, 8) }; + run(&mut s, "format.changeCase", json!({"mode": "upper"})); + assert_eq!(s.doc.plain_text(StoryRef::Body), "keep NEW", "no deleted copy of text that never existed"); + run(&mut s, "review.rejectAll", json!({})); + assert_eq!(s.doc.plain_text(StoryRef::Body), "keep "); +} + +/// "Made by {AUTHOR} on " with "by {AUTHOR} on" in a rich text control tagged `ctl`. +fn field_in_control(lock: &str) -> (Session, Pos) { + let mut s = Session::new(wordcraft_doc::Document::new()); + s.doc.core.creator = "Ada".into(); + run(&mut s, "text.insert", json!({"text": "Made by "})); + run(&mut s, "insert.field", json!({"instr": "AUTHOR", "result": "Ada"})); + run(&mut s, "text.insert", json!({"text": " on "})); + let w = OBJ.len_utf8(); + s.sel = Selection { anchor: Pos::body(0, 5), focus: Pos::body(0, 8 + w + 3) }; + run(&mut s, "developer.richText", json!({"tag": "ctl"})); + run(&mut s, "developer.properties", json!({"control": "ctl", lock: true})); + let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); + let off = p.object_offsets().into_iter().find(|o| matches!(p.object_at(*o), Some(InlineObject::Field { .. }))).unwrap(); + (s, Pos::body(0, off)) +} + +#[test] +fn unlink_refuses_a_field_in_content_locked_against_editing() { + let (mut s, at) = field_in_control("lockEdit"); + s.sel = Selection::caret(at); + let before = s.doc.clone(); + assert!(s.run("fields.unlink", &json!({})).is_err()); + assert_eq!(s.doc, before); + // Locked against deletion only: unlinking is an edit of the content, allowed. + let (mut s, at) = field_in_control("lockDelete"); + s.sel = Selection::caret(at); + run(&mut s, "fields.unlink", json!({})); + assert!(s.doc.plain_text(StoryRef::Body).contains("by Ada on")); +} + +#[test] +fn tracked_unlink_keeps_the_result_as_plain_text() { + let mut s = Session::new(wordcraft_doc::Document::new()); + s.doc.core.creator = "Ada".into(); + run(&mut s, "text.insert", json!({"text": "Made by "})); + run(&mut s, "insert.field", json!({"instr": "AUTHOR", "result": "Ada"})); + run(&mut s, "review.trackChanges", json!({"value": true})); + s.sel = Selection::caret(Pos::body(0, 8)); + run(&mut s, "fields.unlink", json!({})); + // Like Word, not tracked: the result is ordinary text, shown and kept by Reject All. + assert_eq!(visible(&s), "Made by Ada"); + assert!(s.doc.para_at(&Pos::body(0, 0)).unwrap().objects.is_empty()); + run(&mut s, "review.rejectAll", json!({})); + assert_eq!(s.doc.plain_text(StoryRef::Body), "Made by Ada"); +} + +#[test] +fn tracked_delete_table_removes_a_table_with_no_live_text() { + // Delete, Accept All (an empty grid is left), Delete again: the table goes. + let mut s = table_session(); + run(&mut s, "review.trackChanges", json!({"value": true})); + run(&mut s, "table.deleteTable", json!({})); + run(&mut s, "review.acceptAll", json!({})); + assert!(table(&s).is_some()); + caret_in(&mut s, 0, 0, 0); + run(&mut s, "table.deleteTable", json!({})); + assert!(table(&s).is_none(), "an empty table can't be deleted under Track Changes"); + assert!(s.doc.revisions.is_empty(), "no unused revision"); + // A fresh empty table too. + let mut s = Session::new(wordcraft_doc::Document::new()); + run(&mut s, "review.trackChanges", json!({"value": true})); + run(&mut s, "insert.table", json!({"rows": 2, "cols": 2})); + let revs = s.doc.revisions.len(); + run(&mut s, "table.deleteTable", json!({})); + assert!(table(&s).is_none()); + assert_eq!(s.doc.revisions.len(), revs); +} + +/// A 2×2 table whose first row is one cell "Title" across both columns; A | B below; caret in A. +fn merged_title_table(lock: Option<&str>) -> Session { + let mut s = Session::new(wordcraft_doc::Document::new()); + run(&mut s, "insert.table", json!({"rows": 2, "cols": 2})); + let at = |r, c| Pos { story: StoryRef::Body, path: wordcraft_doc::Path(vec![0, r, c, 0]), off: 0 }; + s.sel = Selection { anchor: at(0, 0), focus: at(0, 1) }; + run(&mut s, "table.merge", json!({})); + assert_eq!(table(&s).unwrap().rows[0].cells.len(), 1); + caret_in(&mut s, 0, 0, 0); + run(&mut s, "text.insert", json!({"text": "Title"})); + caret_in(&mut s, 1, 0, 0); + run(&mut s, "text.insert", json!({"text": "A"})); + caret_in(&mut s, 1, 1, 0); + run(&mut s, "text.insert", json!({"text": "B"})); + if let Some(l) = lock { + let p = |off| Pos { story: StoryRef::Body, path: wordcraft_doc::Path(vec![0, 0, 0, 0]), off }; + s.sel = Selection { anchor: p(0), focus: p(2) }; + run(&mut s, "developer.richText", json!({"tag": "ctl"})); + run(&mut s, "developer.properties", json!({"control": "ctl", l: true})); + } + caret_in(&mut s, 1, 0, 0); + s +} + +#[test] +fn delete_columns_leaves_a_merged_cell_that_keeps_a_column_alone() { + // Tracked: only A is marked deleted, not the merged title. + let mut s = merged_title_table(None); + run(&mut s, "review.trackChanges", json!({"value": true})); + run(&mut s, "table.deleteColumn", json!({})); + assert!(cells_deleted(&s, &[(1, 0)])); + assert!(!cells_deleted(&s, &[(0, 0)]), "the merged title was struck"); + assert!(!cells_deleted(&s, &[(1, 1)])); + // Untracked: a lockDelete control in the title doesn't stop the column going. + let mut s = merged_title_table(Some("lockDelete")); + run(&mut s, "table.deleteColumn", json!({})); + assert_eq!(table(&s).map(|t| t.grid.len()), Some(1)); + assert_eq!(s.doc.control_ranges(StoryRef::Body).len(), 1); +} + +#[test] +fn tracked_row_and_column_deletes_over_tracked_changes_reject_back_to_the_original() { + for id in ["table.deleteRow", "table.deleteColumn"] { + let mut s = table_session(); + run(&mut s, "review.trackChanges", json!({"value": true})); + caret_in(&mut s, 0, 0, 2); + run(&mut s, "text.insert", json!({"text": "x"})); + let me = s.author.clone(); + s.author = "Other".into(); + caret_in(&mut s, 0, 1, 2); + run(&mut s, "text.insert", json!({"text": "y"})); + caret_in(&mut s, 1, 0, 2); + run(&mut s, "text.insert", json!({"text": "z"})); + // And a deletion already there. + s.sel = Selection { + anchor: Pos { story: StoryRef::Body, path: wordcraft_doc::Path(vec![0, 0, 0, 0]), off: 0 }, + focus: Pos { story: StoryRef::Body, path: wordcraft_doc::Path(vec![0, 0, 0, 0]), off: 1 }, + }; + run(&mut s, "text.delete", json!({})); + s.author = me; + caret_in(&mut s, 0, 0, 0); + run(&mut s, id, json!({})); + run(&mut s, "review.rejectAll", json!({})); + assert_eq!(cells(&s), [["A1", "B1"], ["A2", "B2"]], "{id}"); + assert!(s.doc.revisions.is_empty(), "{id}"); + } +} + +fn tracked_hello() -> Session { + let mut s = Session::new(wordcraft_doc::Document::new()); + run(&mut s, "text.insert", json!({"text": "hello world"})); + run(&mut s, "review.trackChanges", json!({"value": true})); + run(&mut s, "select.all", json!({})); + run(&mut s, "format.changeCase", json!({"mode": "upper"})); + assert_eq!(visible(&s), "HELLO WORLD"); + s +} + +#[test] +fn tracked_change_case_twice_reads_only_the_live_text() { + let mut s = tracked_hello(); + run(&mut s, "format.changeCase", json!({"mode": "title"})); + assert_eq!(visible(&s), "Hello World"); + // Shift+F3 goes on from UPPERCASE as it does untracked. + let mut plain = Session::new(wordcraft_doc::Document::new()); + run(&mut plain, "text.insert", json!({"text": "HELLO WORLD"})); + run(&mut plain, "select.all", json!({})); + run(&mut plain, "format.changeCase", json!({})); + let mut s = tracked_hello(); + run(&mut s, "format.changeCase", json!({})); + assert_eq!(visible(&s), plain.doc.plain_text(StoryRef::Body)); + run(&mut s, "review.rejectAll", json!({})); + assert_eq!(s.doc.plain_text(StoryRef::Body), "hello world"); +} + +#[test] +fn tracked_change_case_handles_sharp_s_and_mixed_formatting() { + let mut s = Session::new(wordcraft_doc::Document::new()); + run(&mut s, "text.insert", json!({"text": "straße ab"})); + s.sel = Selection { anchor: Pos::body(0, 0), focus: Pos::body(0, 3) }; + run(&mut s, "format.bold", json!({})); + run(&mut s, "review.trackChanges", json!({"value": true})); + run(&mut s, "select.all", json!({})); + run(&mut s, "format.changeCase", json!({"mode": "upper"})); + assert_eq!(visible(&s), "STRASSE AB"); + // Each inserted stretch keeps its run's formatting. + let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); + let live: Vec<(String, bool)> = + p.run_ranges().filter(|(_, c)| c.del.is_none()).map(|(r, c)| (p.text[r].to_string(), c.bold == Some(true))).collect(); + let bold: String = live.iter().filter(|x| x.1).map(|x| x.0.as_str()).collect(); + assert_eq!(bold, "STR"); + run(&mut s, "review.rejectAll", json!({})); + assert_eq!(s.doc.plain_text(StoryRef::Body), "straße ab"); +} + +#[test] +fn tracked_delete_of_struck_table_text_keeps_it_for_reject_all() { + for id in ["table.deleteTable", "table.deleteRow", "table.deleteColumn", "table.deleteCells"] { + let mut s = table_session(); + run(&mut s, "review.trackChanges", json!({"value": true})); + run(&mut s, id, json!({})); + caret_in(&mut s, 0, 0, 0); + // Pressed again: the text is already struck, so nothing is removed outright. + let before = s.doc.clone(); + assert!(s.run(id, &json!({})).is_err(), "{id}"); + assert_eq!(s.doc, before, "{id}"); + assert!(s.status.contains("already marked deleted"), "{id}: {}", s.status); + run(&mut s, "review.rejectAll", json!({})); + assert_eq!(cells(&s), [["A1", "B1"], ["A2", "B2"]], "{id}"); + } +} + +#[derive(Clone, Copy, Debug)] +enum Slot { + FootnoteSeparator, + EndnoteSeparator, + FootnoteContinuationSeparator, + EndnoteContinuationNotice, +} + +const SLOTS: [Slot; 4] = [Slot::FootnoteSeparator, Slot::EndnoteSeparator, Slot::FootnoteContinuationSeparator, Slot::EndnoteContinuationNotice]; + +fn slot_mut(doc: &mut wordcraft_doc::Document, slot: Slot) -> &mut Option { + match slot { + Slot::FootnoteSeparator => &mut doc.footnote_separators.separator, + Slot::EndnoteSeparator => &mut doc.endnote_separators.separator, + Slot::FootnoteContinuationSeparator => &mut doc.footnote_separators.continuation_separator, + Slot::EndnoteContinuationNotice => &mut doc.endnote_separators.continuation_notice, + } +} + +/// A session whose note separator `slot` holds the text "SEP" as a tracked insertion (revision 0). +fn tracked_separator_session(slot: Slot) -> Session { + let mut doc = wordcraft_doc::Document::new(); + doc.revisions.push(wordcraft_doc::Revision { + kind: wordcraft_doc::RevisionKind::Insert, + author: "Alice".into(), + date: String::new(), + move_name: None, + }); + let props = wordcraft_doc::CharProps { ins: Some(0), ..Default::default() }; + let p = wordcraft_doc::Paragraph::with_text("SEP", props); + *slot_mut(&mut doc, slot) = Some(vec![wordcraft_doc::para_block(p)]); + Session::new(doc) +} + +fn separator_para(s: &mut Session, slot: Slot) -> wordcraft_doc::Paragraph { + slot_mut(&mut s.doc, slot).as_ref().and_then(|b| b.iter().find_map(|b| b.as_para())).expect("separator").clone() +} + +#[test] +fn accept_all_resolves_note_separator_revisions() { + for slot in SLOTS { + let mut s = tracked_separator_session(slot); + run(&mut s, "review.acceptAll", json!({})); + let p = separator_para(&mut s, slot); + assert_eq!(p.plain_text(), "SEP", "{slot:?}"); + assert!(p.run_ranges().all(|(_, c)| c.ins.is_none() && c.del.is_none()), "{slot:?}"); + } +} + +#[test] +fn reject_all_resolves_note_separator_revisions() { + for slot in SLOTS { + let mut s = tracked_separator_session(slot); + run(&mut s, "review.rejectAll", json!({})); + assert_eq!(separator_para(&mut s, slot).plain_text(), "", "{slot:?}"); + } +} + +#[test] +fn new_tracked_change_does_not_alias_separator_revision() { + for slot in SLOTS { + let mut s = tracked_separator_session(slot); + run(&mut s, "review.acceptAll", json!({})); + s.author = "Bob".into(); + run(&mut s, "review.trackChanges", json!({"value": true})); + run(&mut s, "text.insert", json!({"text": "NEW "})); + let p = separator_para(&mut s, slot); + assert!(p.run_ranges().all(|(_, c)| c.ins.is_none() && c.del.is_none()), "{slot:?}"); + } +} diff --git a/crates/engine/src/tests/typing.rs b/crates/engine/src/tests/typing.rs index 6dd20ca12e..350f8872d6 100644 --- a/crates/engine/src/tests/typing.rs +++ b/crates/engine/src/tests/typing.rs @@ -1,4038 +1,4038 @@ -//! Typing behaviour checked side by side against Word: keystrokes go through the same commands -//! as the keyboard (one `text.insert` per character, Enter, Tab, Backspace), and the expected -//! results are what Word produces for the same keys. - -use serde_json::json; -use std::sync::Arc; -use wordcraft_doc::props::{BorderStyle, VertAlign}; -use wordcraft_doc::{Pos, StoryRef}; - -use crate::Session; - -/// Leaf #152: deleting the empty heading above body text must not promote that text to a heading. -/// Deleting the empty heading mark keeps the next paragraph as body text. -#[test] -fn deleting_a_preceding_empty_heading_keeps_the_body_paragraph_style() { - let mut s = Session::new(wordcraft_doc::Document::from_text("\nBody paragraph.")); - run(&mut s, "para.style", json!({"style": "Heading1"})); - run(&mut s, "text.delete", json!({})); - let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); - assert_eq!(p.text, "Body paragraph."); - assert_eq!(p.props.style, None); - assert_eq!(wordcraft_layout::outline::levels(&s.doc), [wordcraft_layout::outline::BODY]); -} - -/// Create an empty session for command-driven typing fixtures. -fn s() -> Session { - Session::new(wordcraft_doc::Document::new()) -} - -/// Execute a registry command and report its id on a test failure. -fn run(s: &mut Session, id: &str, v: serde_json::Value) { - s.run(id, &v).unwrap_or_else(|e| panic!("{id}: {e}")); -} - -/// Type like a person: `\n` is Enter, `\t` is Tab, `\u{8}` is Backspace, everything else one -/// character at a time. -fn typ(s: &mut Session, keys: &str) { - for c in keys.chars() { - match c { - '\n' => run(s, "text.newParagraph", json!({})), - '\t' => run(s, "text.tab", json!({})), - '\u{8}' => run(s, "text.backspace", json!({})), - c => run(s, "text.insert", json!({"text": c.to_string()})), - } - } -} - -/// Body paragraphs as (text, list level if in a list, in "List Paragraph"). -fn paras(s: &Session) -> Vec<(String, Option, bool)> { - s.doc - .body - .iter() - .filter_map(|b| b.as_para()) - .map(|p| (p.text.clone(), p.props.numbering.filter(|n| n.num != 0).map(|n| n.level), p.props.style.as_deref() == Some("ListParagraph"))) - .collect() -} - -/// Expected paragraph text and list membership for typing assertions. -fn para(text: &str, level: Option) -> (String, Option, bool) { - (text.to_string(), level, level.is_some()) -} - -/// Create a session and enter its fixture through the actual typing commands. -fn typed(keys: &str) -> Session { - let mut s = s(); - typ(&mut s, keys); - s -} - -#[test] -fn sample_text_formulas_expand_on_enter() { - for name in ["rand", "lorem"] { - for (args, paragraphs, sentences) in [ - ("", 5, 3), - ("2", 2, 3), - (" 2 ", 2, 3), - ("2,4", 2, 4), - ("2, 4", 2, 4), - (" 2 , 4 ", 2, 4), - ("1,1", 1, 1), - ("200,1", 200, 1), - ("1,200", 1, 200), - ] { - let mut s = typed(&format!("={name}({args})")); - let depth = s.undo_depth(); - typ(&mut s, "\n"); - let generated: Vec<_> = paras(&s).into_iter().map(|p| p.0).collect(); - assert_eq!(generated.len(), paragraphs + 1, "{name}({args})"); - for text in generated.iter().take(paragraphs) { - assert_eq!(text.matches('.').count(), sentences); - assert!(text.starts_with(if name == "lorem" { "Lorem ipsum" } else { "A blank page" }), "{name}({args}): {text:?}"); - } - assert_eq!(generated.last().unwrap(), ""); - assert_eq!(s.sel.focus, Pos::body(paragraphs, 0)); - assert_eq!(s.undo_depth(), depth + 1); - let expanded = s.doc.clone(); - run(&mut s, "edit.undo", json!({})); - assert_eq!(texts(&s), [format!("={name}({args})")]); - run(&mut s, "edit.redo", json!({})); - assert_eq!(s.doc, expanded); - } - } -} - -#[test] -fn sample_text_invalid_parameters_use_defaults() { - for name in ["rand", "lorem"] { - for args in ["0", "-1", "201", "abc", "1.5", "99999999999999999999999999999", "2,0", "0,4", "2,201", "2,no", "2,", ",4", "1,2,3"] { - let s = typed(&format!("={name}({args})\n")); - let texts = texts(&s); - assert_eq!(texts.len(), 6, "{name}({args})"); - assert!(texts.iter().take(5).all(|text| text.matches('.').count() == 3)); - } - } -} - -#[test] -fn sample_text_requires_an_exact_paragraph_and_caret_at_end() { - for formula in [" =rand()", "=rand() ", "prefix =rand()", "=lorem() suffix", "=other()", "=rand(", "=rand(())"] { - assert_eq!(texts(&typed(&format!("{formula}\n"))), [formula, ""]); - } - let mut s = typed("=rand()"); - run(&mut s, "caret.home", json!({})); - typ(&mut s, "\n"); - assert_eq!(texts(&s), ["", "=rand()"]); - let mut s = typed("=rand()suffix"); - s.sel = crate::Selection { anchor: Pos::body(0, 7), focus: Pos::body(0, 13) }; - typ(&mut s, "\n"); - assert_eq!(texts(&s), ["=rand()", ""]); -} - -#[test] -fn sample_text_preserves_typing_and_paragraph_formatting() { - let mut s = s(); - run(&mut s, "format.bold", json!({})); - run(&mut s, "para.alignCenter", json!({})); - typ(&mut s, "=rand(2,1)\n"); - for p in s.doc.body.iter().filter_map(|b| b.as_para()).take(2) { - assert_eq!(p.props.align, Some(wordcraft_doc::props::Align::Center)); - assert_eq!(p.props_at(0).bold, Some(true)); - } -} - -#[test] -fn sample_text_replacement_tracks_changes() { - let mut s = typed("=lorem(2,1)"); - run(&mut s, "review.trackChanges", json!({})); - typ(&mut s, "\n"); - assert!(s.doc.body.iter().filter_map(|b| b.as_para()).flat_map(|p| &p.runs).any(|r| r.props.ins.is_some())); - run(&mut s, "review.rejectAll", json!({})); - assert_eq!(texts(&s), ["=lorem(2,1)"]); - run(&mut s, "edit.undo", json!({})); - run(&mut s, "review.acceptAll", json!({})); - assert_eq!(texts(&s).len(), 3); - assert!(!s.doc.plain_text(StoryRef::Body).contains("=lorem")); -} - -#[test] -fn enter_twice_after_a_list_item_ends_the_list() { - let s = typed("* item\n\ntext"); - assert_eq!(paras(&s), vec![para("Item", Some(0)), para("text", None)]); -} - -#[test] -fn more_enters_after_a_list_make_blank_paragraphs() { - // The "stuck Enter" bug: after the list ended, further Enters did nothing. - let s = typed("* item\n\n\ntext"); - assert_eq!(paras(&s), vec![para("Item", Some(0)), para("", None), para("text", None)]); - let s = typed("1. item\n\n\n\nafter"); - assert_eq!(paras(&s), vec![para("Item", Some(0)), para("", None), para("", None), para("after", None)]); -} - -#[test] -fn the_paragraph_after_a_list_is_normal_text() { - let mut s = typed("* Apple\nBanana\n\nAfter list"); - let p = s.doc.para_at(&s.sel.focus).unwrap(); - assert_eq!(p.props.style, None, "back to Normal, not an indented List Paragraph"); - assert_eq!(p.props.numbering, None); - // And the next paragraph too. - typ(&mut s, "\nMore"); - assert_eq!(paras(&s).last(), Some(¶("More", None))); -} - -#[test] -fn enter_on_an_empty_nested_item_moves_it_up_a_level_first() { - let s = typed("* a\n\tb\n\ntext"); - assert_eq!(paras(&s), vec![para("A", Some(0)), para("B", Some(1)), para("text", Some(0))]); - let s = typed("* a\n\tb\n\n\ntext"); - assert_eq!(paras(&s), vec![para("A", Some(0)), para("B", Some(1)), para("text", None)]); - let s = typed("* a\n\tb\n\n\n\ntext"); - assert_eq!(paras(&s), vec![para("A", Some(0)), para("B", Some(1)), para("", None), para("text", None)]); - let s = typed("* a\n\tb\n\tc\n\n\n\ntext"); - assert_eq!(paras(&s), vec![para("A", Some(0)), para("B", Some(1)), para("C", Some(2)), para("text", None)]); - let s = typed("1. a\n\tb\n\n\ntext"); - assert_eq!(paras(&s), vec![para("A", Some(0)), para("B", Some(1)), para("text", None)]); -} - -/// #2143: in a Word file whose list comes from the paragraph styles (List Number, List Number 2 -/// with `w:numPr` in styles.xml, none on the paragraphs), Enter on an empty nested item moves it -/// up a level and then ends the list, as with direct numbering. -#[test] -fn enter_on_an_empty_item_numbered_by_its_style_moves_it_up_a_level() { - use std::io::Write; - let w = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main""#; - let style = |id: &str, name: &str, ilvl: u8| { - format!( - r#""# - ) - }; - let styles = format!( - r#"{}{}"#, - style("ListNumber", "List Number", 0), - style("ListNumber2", "List Number 2", 1) - ); - let numbering = format!( - r#""# - ); - let p = |st: &str, text: &str| format!(r#"{text}"#); - let body: String = - [p("ListNumber", "Lorem"), p("ListNumber", "Ipsum"), p("ListNumber2", "Latin"), p("ListNumber2", "Nonsense"), p("ListNumber2", "")].concat(); - let document = format!(r#"{body}"#); - let rel = |id: &str, kind: &str, target: &str| { - format!(r#""#) - }; - let rels_ns = r#"xmlns="http://schemas.openxmlformats.org/package/2006/relationships""#; - let main = format!("{}", rel("rId1", "officeDocument", "word/document.xml")); - let doc_rels = - format!("{}{}", rel("rId1", "styles", "styles.xml"), rel("rId2", "numbering", "numbering.xml")); - let ct = r#""#; - let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); - let o = zip::write::SimpleFileOptions::default(); - for (name, data) in [ - ("[Content_Types].xml", ct), - ("_rels/.rels", main.as_str()), - ("word/_rels/document.xml.rels", doc_rels.as_str()), - ("word/document.xml", document.as_str()), - ("word/styles.xml", styles.as_str()), - ("word/numbering.xml", numbering.as_str()), - ] { - z.start_file(name, o).unwrap(); - z.write_all(data.as_bytes()).unwrap(); - } - let doc = crate::io::open_bytes("t.docx", &z.finish().unwrap().into_inner()).unwrap(); - let mut s = Session::new(doc); - // The list level each paragraph shows, wherever its numbering comes from. - let levels = |s: &Session| -> Vec> { - s.doc - .body - .iter() - .filter_map(|b| b.as_para()) - .map(|p| s.doc.styles.resolve_para(&p.props).numbering.filter(|n| n.num != 0).map(|n| n.level)) - .collect() - }; - assert_eq!(levels(&s), [Some(0), Some(0), Some(1), Some(1), Some(1)]); - s.sel = crate::Selection::caret(Pos::body(4, 0)); - typ(&mut s, "\n"); - assert_eq!(levels(&s), [Some(0), Some(0), Some(1), Some(1), Some(0)]); - assert_eq!(s.sel.focus, Pos::body(4, 0)); - typ(&mut s, "\n"); - assert_eq!(levels(&s), [Some(0), Some(0), Some(1), Some(1), None]); - run(&mut s, "edit.undo", json!({})); - run(&mut s, "edit.undo", json!({})); - assert_eq!(levels(&s), [Some(0), Some(0), Some(1), Some(1), Some(1)]); -} - -#[test] -fn shift_tab_and_tab_change_list_levels() { - let mut s = typed("1. One\nTwo\n\tSub a\nSub b\n"); - run(&mut s, "text.backTab", json!({})); - typ(&mut s, "Three\n\nDone"); - assert_eq!( - paras(&s), - vec![para("One", Some(0)), para("Two", Some(0)), para("Sub a", Some(1)), para("Sub b", Some(1)), para("Three", Some(0)), para("Done", None)] - ); -} - -#[test] -fn backspace_at_the_start_of_an_item_removes_the_number_then_the_indent() { - let mut s = typed("* One\nTwo"); - run(&mut s, "caret.home", json!({})); - let text_x = |s: &mut Session| s.layout().caret(&Pos::body(1, 0)).map(|c| c.x); - let before = text_x(&mut s); - typ(&mut s, "\u{8}"); - // Number gone, still indented with the list: the text stays where it was. - assert_eq!(paras(&s)[1], ("Two".to_string(), None, true)); - assert_eq!(text_x(&mut s), before); - typ(&mut s, "\u{8}|"); - // Second Backspace: Normal paragraph, not joined to the one above. - assert_eq!(paras(&s), vec![para("One", Some(0)), para("|Two", None)]); -} - -#[test] -fn backspace_on_an_empty_item_keeps_the_paragraph() { - let s = typed("* One\n\u{8}Z"); - assert_eq!(paras(&s), vec![para("One", Some(0)), ("Z".to_string(), None, true)]); -} - -#[test] -fn enter_in_the_middle_or_at_the_start_of_an_item() { - let mut s = typed("* Hello world"); - for _ in 0..5 { - run(&mut s, "caret.left", json!({})); - } - typ(&mut s, "\nX"); - assert_eq!(paras(&s), vec![para("Hello ", Some(0)), para("Xworld", Some(0))]); - let mut s = typed("* first\nsecond"); - run(&mut s, "caret.home", json!({})); - typ(&mut s, "\nY"); - assert_eq!(paras(&s), vec![para("First", Some(0)), para("", Some(0)), para("Ysecond", Some(0))]); -} - -fn label_at(s: &Session, i: usize) -> String { - let n = s.doc.para_at(&Pos::body(i, 0)).and_then(|p| p.props.numbering).unwrap(); - s.doc.numbering.level(n.num, n.level).map(|l| l.text.clone()).unwrap_or_default() -} - -/// #3352 follow-up (CodeRabbit): a heading numbered only through its style's linked Multilevel -/// List (#1793) — `props.numbering` itself unset — isn't papered over by a fresh bullet when "- " -/// is typed at its start; the trigger stays as plain text and the heading's own number is kept. -#[test] -fn list_autoformat_does_not_override_a_style_linked_heading_number() { - let mut s = s(); - typ(&mut s, "One"); - run(&mut s, "para.heading1", json!({})); - run(&mut s, "list.multilevelPreset", json!({"preset": "headings"})); - typ(&mut s, "\nTwo"); - run(&mut s, "para.heading2", json!({})); - assert_eq!(numbers(&mut s), [num(Some("1"), "Heading1"), num(Some("1.1"), "Heading2")]); - assert_eq!(s.doc.para_at(&Pos::body(1, 0)).unwrap().props.numbering, None, "numbered through the style, not directly"); - run(&mut s, "caret.home", json!({})); - typ(&mut s, "- "); - assert_eq!(numbers(&mut s), [num(Some("1"), "Heading1"), num(Some("1.1"), "Heading2")], "the heading number survives"); - assert_eq!(paras(&s)[1].0, "- Two", "the trigger stays as typed text, not stripped into a bullet"); -} - -/// A heading numbered only through its style's linked Multilevel List keeps "---" and "+--+--+" -/// as text on Enter, as a paragraph numbered directly does: no border, no table, and at the top -/// of the document the heading keeps its number. -#[test] -fn border_and_table_lines_stay_text_in_a_style_numbered_heading() { - for (above, keys) in [("", "---"), ("Above\n", "---"), ("", "+--+--+"), ("Above\n", "+--+--+")] { - let mut s = s(); - typ(&mut s, &format!("{above}{keys}")); - run(&mut s, "para.heading1", json!({})); - run(&mut s, "list.multilevelPreset", json!({"preset": "headings"})); - let at = Pos::body(s.doc.body.len() - 1, 0); - assert_eq!(s.doc.para_at(&at).unwrap().props.numbering, None, "numbered through the style, not directly"); - typ(&mut s, "\nText"); - assert_eq!(s.doc.plain_text(StoryRef::Body), format!("{above}{keys}\nText"), "{above:?}{keys}"); - assert!(s.doc.body.iter().all(|b| b.as_para().is_some_and(|p| p.props.borders.is_none())), "{above:?}{keys}"); - let mut want = vec![num(Some("1"), "Heading1"), num(None, "Normal")]; - if !above.is_empty() { - want.insert(0, num(None, "Normal")); - } - assert_eq!(numbers(&mut s), want, "{above:?}{keys}"); - } -} - -#[test] -fn list_autoformat_triggers() { - let s = typed("- Item\nNext"); - assert_eq!(label_at(&s, 0), "-"); - assert_eq!(paras(&s), vec![para("Item", Some(0)), para("Next", Some(0))]); - let s = typed("1) a\nb"); - assert_eq!(label_at(&s, 0), "%1)"); - let s = typed("a. alpha\nbeta"); - assert_eq!(paras(&s), vec![para("Alpha", Some(0)), para("beta", Some(0))]); - assert_eq!(label_at(&s, 0), "%1."); - let n = s.doc.para_at(&Pos::body(0, 0)).and_then(|p| p.props.numbering).unwrap(); - assert_eq!(s.doc.numbering.level(n.num, 0).map(|l| l.format), Some(wordcraft_doc::section::NumFormat::LowerLetter)); -} - -/// #3352: typing the trigger at the start of an already-written paragraph (not only a fresh, -/// empty one) still turns it into a list item, keeping the rest of the paragraph's text — as in -/// Word, confirmed in Word for Windows 2026-10-21. -#[test] -fn list_autoformat_triggers_in_front_of_existing_text() { - let mut s = s(); - typ(&mut s, "On peut faire mieux"); - run(&mut s, "caret.home", json!({})); - typ(&mut s, "- "); - assert_eq!(paras(&s), vec![para("On peut faire mieux", Some(0))]); - assert_eq!(label_at(&s, 0), "-"); - // Undo right after takes only the AutoFormat back, as for a fresh paragraph. - run(&mut s, "edit.undo", json!({})); - assert_eq!(paras(&s), vec![("- On peut faire mieux".to_string(), None, false)]); -} - -#[test] -fn typing_1_dot_starts_numbering_again() { - let s = typed("1. a\nb\n\nmid\n1. c\nd"); - let num = |i: usize| s.doc.para_at(&Pos::body(i, 0)).and_then(|p| p.props.numbering).map(|n| n.num); - assert_eq!(num(0), num(1)); - assert_eq!(num(3), num(4)); - assert_ne!(num(0), num(3), "a new list restarts at 1"); -} - -#[test] -fn undo_right_after_autoformat_undoes_only_the_autoformat() { - let mut s = typed("* "); - assert!(paras(&s)[0].1.is_some()); - run(&mut s, "edit.undo", json!({})); - typ(&mut s, "x"); - assert_eq!(paras(&s), vec![("* x".to_string(), None, false)]); - // Same for AutoCorrect. - let mut s = typed("teh "); - run(&mut s, "edit.undo", json!({})); - assert_eq!(s.doc.plain_text(StoryRef::Body), "teh "); -} - -#[test] -fn autocorrect_as_you_type() { - let s = typed("teh cat said hello. world (c) \"quoted\" don't -- yes wait... i think so. e.g. this. "); - assert_eq!(s.doc.plain_text(StoryRef::Body), "The cat said hello. World © “quoted” don’t – yes wait… I think so. E.g. this. "); - // Abbreviations and initials don't end sentences. - let s = typed("see e.g. the map. Ask J. smith or Dr. jones "); - assert_eq!(s.doc.plain_text(StoryRef::Body), "See e.g. the map. Ask J. smith or Dr. jones "); - // Fractions; "iPhone" and file names stay as typed. - let s = typed("1/2 cup. iPhone. see readme.txt "); - assert_eq!(s.doc.plain_text(StoryRef::Body), "½ cup. iPhone. See readme.txt "); -} - -#[test] -fn smart_quotes_nested_and_opening_delimiters() { - let s = typed("\"'nested'\" '\"inner\"' (\"'parens'\")"); - assert_eq!(s.doc.plain_text(StoryRef::Body), "“‘nested’” ‘“inner”’ (“‘parens’”)"); - // Empty quotes preserve open then close - let s = typed("\"\" ''"); - assert_eq!(s.doc.plain_text(StoryRef::Body), "“” ‘’"); - // After opening guillemets and low quotes - let s = typed("«'french'» „'german'“"); - assert_eq!(s.doc.plain_text(StoryRef::Body), "«‘french’» „‘german’“"); -} - -/// #2223: the double-hyphen arrow is a replacement, not the existing arrow bullet. -#[test] -fn double_hyphen_arrow_autocorrect_preserves_typing_scope_and_undo() { - let mut s = typed("go --> "); - assert_eq!(s.doc.plain_text(StoryRef::Body), "Go → "); - assert_eq!(paras(&s)[0].1, None); - run(&mut s, "edit.undo", json!({})); - assert_eq!(s.doc.plain_text(StoryRef::Body), "Go --> "); - let s = typed("go --> here"); - assert_eq!(s.doc.plain_text(StoryRef::Body), "Go → here"); - let s = typed("--> "); - assert_eq!(s.doc.plain_text(StoryRef::Body), "→ "); - assert_eq!(paras(&s)[0].1, None); -} - -#[test] -fn double_hyphen_arrow_respects_autocorrect_preferences() { - for prefs in [json!({"enabled": false}), json!({"replaceText": false}), json!({"delete": "-->"})] { - let mut s = s(); - run(&mut s, "tools.autocorrect", prefs); - typ(&mut s, "go --> here"); - assert!(s.doc.plain_text(StoryRef::Body).ends_with(" --> here")); - } - let mut s = s(); - run(&mut s, "review.language", json!({"lang": "pl-PL", "default": true})); - typ(&mut s, "go --> here"); - assert_eq!(s.doc.plain_text(StoryRef::Body), "Go → here"); - let mut s = self::s(); - run(&mut s, "tools.autocorrect", json!({"add": {"from": "-->", "to": "custom"}})); - typ(&mut s, "go --> here"); - assert_eq!(s.doc.plain_text(StoryRef::Body), "Go custom here"); -} - -/// #835: the English replacement list applies only to English text. Polish "i" ("and") stays -/// lowercase; symbols still change; English and untagged text still get "I". -#[test] -fn autocorrect_replacement_list_follows_the_texts_language() { - // Polish as the document's default language. - let mut s = s(); - run(&mut s, "review.language", json!({"lang": "pl-PL", "default": true})); - typ(&mut s, "Ala i kot. kawa i herbata teh (c) "); - assert_eq!(s.doc.plain_text(StoryRef::Body), "Ala i kot. Kawa i herbata teh © "); - // Polish on the run only: the word's own language counts. - let mut s = self::s(); - typ(&mut s, "Ala"); - run(&mut s, "select.all", json!({})); - run(&mut s, "review.language", json!({"lang": "pl-PL"})); - run(&mut s, "caret.docEnd", json!({})); - typ(&mut s, " i kot "); - assert_eq!(s.doc.plain_text(StoryRef::Body), "Ala i kot "); - // English (any region) and untagged text (the default proofing language is English). - let mut s = self::s(); - run(&mut s, "review.language", json!({"lang": "en-GB", "default": true})); - typ(&mut s, "so i think teh end "); - assert_eq!(s.doc.plain_text(StoryRef::Body), "So I think the end "); - let s = typed("so i think teh end "); - assert_eq!(s.doc.plain_text(StoryRef::Body), "So I think the end "); - // A Polish interface: a new document is Polish by its default language, and text with no - // language at all (a .docx without `w:lang`) follows the editing language too. - let mut s = self::s(); - s.set_editing_language(Some("pl-PL")); - typ(&mut s, "Ala i kot "); - assert_eq!(s.doc.plain_text(StoryRef::Body), "Ala i kot ", "document default from the editing language"); - let mut s = self::s(); - s.set_editing_language(Some("pl-PL")); - Arc::make_mut(&mut s.doc.styles).default_chr.lang = None; - typ(&mut s, "Ala i kot "); - assert_eq!(s.doc.plain_text(StoryRef::Body), "Ala i kot ", "untagged text in the editing language"); - // `eng` is English too, as for proofing. - let mut s = self::s(); - run(&mut s, "review.language", json!({"lang": "eng", "default": true})); - typ(&mut s, "so i think "); - assert_eq!(s.doc.plain_text(StoryRef::Body), "So I think "); -} - -#[test] -fn autocorrect_options_change_what_typing_does() { - let mut s = s(); - // A user entry, a deleted built-in entry, an added exception; names of days. - run(&mut s, "tools.autocorrect", json!({"add": {"from": "wc", "to": "WordCraft"}})); - run(&mut s, "tools.autocorrect", json!({"delete": "teh"})); - run(&mut s, "tools.autocorrect", json!({"addException": "approx."})); - typ(&mut s, "wc ships on monday. teh end is approx. three weeks "); - assert_eq!(s.doc.plain_text(StoryRef::Body), "WordCraft ships on Monday. Teh end is approx. three weeks "); - // AutoFormat options off: straight quotes, no dashes, no list; sentences not capitalised. - let mut s = self::s(); - run(&mut s, "tools.autocorrect", json!({"smartQuotes": false, "dashes": false, "bullets": false, "capSentences": false, "capDays": false})); - typ(&mut s, "* \"hi\" a -- b sunday "); - assert_eq!(s.doc.plain_text(StoryRef::Body), "* \"hi\" a -- b sunday "); - assert_eq!(paras(&s)[0].1, None); -} - -#[test] -fn enter_finishes_a_word_for_autocorrect() { - let s = typed("hello\nteh end\n"); - assert_eq!(s.doc.plain_text(StoryRef::Body), "Hello\nThe end\n"); -} - -/// A word after a no-break space or an ideographic space is corrected as after a plain space: -/// replacements, sentence capitals, ordinals and links. -#[test] -fn autocorrect_finds_the_word_after_a_wide_space() { - let s = typed("a\u{a0}teh x\u{3000}teh "); - assert_eq!(s.doc.plain_text(StoryRef::Body), "a\u{a0}the x\u{3000}the "); - // A narrow no-break space and a figure space too. - let s = typed("a\u{202f}teh x\u{2007}teh "); - assert_eq!(s.doc.plain_text(StoryRef::Body), "a\u{202f}the x\u{2007}the "); - let s = typed("end.\u{a0}hello end.\u{3000}world "); - assert_eq!(s.doc.plain_text(StoryRef::Body), "End.\u{a0}Hello end.\u{3000}World "); - let s = typed("the\u{a0}1st and\u{3000}22nd "); - let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); - let sup = |t: &str| p.props_of_char(p.text.find(t).unwrap() + t.len() - 2).vert_align == Some(VertAlign::Superscript); - assert!(sup("1st") && sup("22nd")); - let s = typed("see\u{a0}https://example.org/log, and\u{3000}https://example.net/day, then "); - let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); - let link = |t: &str| p.props_of_char(p.text.find(t).unwrap()).link.clone(); - assert_eq!(link("https://example.org").as_deref(), Some("https://example.org/log")); - assert_eq!(link("https://example.net").as_deref(), Some("https://example.net/day")); -} - -#[test] -fn ordinals_become_superscript() { - let s = typed("the 1st and 22nd and 13th but not 1th "); - let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); - let sup = |t: &str| { - let i = p.text.find(t).unwrap() + t.len() - 2; - p.props_of_char(i).vert_align == Some(VertAlign::Superscript) - }; - assert!(sup("1st") && sup("22nd") && sup("13th")); - assert!(!sup("1th")); -} - -#[test] -fn bold_and_italic_marks_become_formatting() { - let mut s = s(); - run(&mut s, "tools.autocorrect", json!({"boldItalic": true, "capSentences": false})); - typ(&mut s, "say *very* nice, _quite_ good *two words*. ok\n*end*\nnext"); - assert_eq!(s.doc.plain_text(StoryRef::Body), "say very nice, quite good two words. ok\nend\nnext"); - let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); - let at = |t: &str| p.props_of_char(p.text.find(t).unwrap()); - assert_eq!((at("very").bold, at("very").italic), (Some(true), None)); - assert_eq!((at("quite").italic, at("quite").bold), (Some(true), None)); - // Complex-script text (Arabic, Hebrew) takes the formatting too. - assert_eq!((at("very").bold_cs, at("quite").italic_cs), (Some(true), Some(true))); - assert_eq!(at("two").bold, Some(true)); - assert_eq!(at("words").bold, Some(true)); - for plain in ["say", " nice", ", ", " good", ". ok"] { - assert_eq!((at(plain).bold, at(plain).italic), (None, None), "{plain:?}"); - } - // Enter finishes the marked word too, and the next paragraph is not bold. - let p = s.doc.para_at(&Pos::body(1, 0)).unwrap(); - assert_eq!((p.props_of_char(0).bold, p.mark.bold), (Some(true), None)); - let p = s.doc.para_at(&Pos::body(2, 0)).unwrap(); - assert_eq!((p.props_of_char(0).bold, p.mark.bold), (None, None)); - // With Track Changes on, the paragraph marks carry the same revisions as for unmarked text. - let marks = |keys: &str| { - let mut s = self::s(); - run(&mut s, "tools.autocorrect", json!({"boldItalic": true})); - run(&mut s, "review.trackChanges", json!({"value": true})); - typ(&mut s, keys); - s.doc.body.iter().filter_map(|b| b.as_para()).map(|p| (p.mark.ins.is_some(), p.mark.del.is_some())).collect::>() - }; - assert_eq!(marks("x *end*\n"), marks("x end\n")); - // After a no-break space too. - let mut s = self::s(); - run(&mut s, "tools.autocorrect", json!({"boldItalic": true})); - typ(&mut s, "a\u{a0}*word* "); - assert_eq!(s.doc.plain_text(StoryRef::Body), "a\u{a0}word "); - // A red word between plain marks: the next paragraph takes the closing mark's formatting. - let mut s = self::s(); - run(&mut s, "tools.autocorrect", json!({"boldItalic": true})); - typ(&mut s, "*"); - run(&mut s, "format.color", json!({"color": "FF0000"})); - typ(&mut s, "red"); - run(&mut s, "format.color", json!({"color": "auto"})); - typ(&mut s, "*\nnext"); - let red = s.doc.para_at(&Pos::body(0, 0)).unwrap().props_of_char(0).color; - let p = s.doc.para_at(&Pos::body(1, 0)).unwrap(); - assert!(red.is_some()); - assert_ne!(p.mark.color, red); - assert_ne!(p.props_of_char(0).color, red); - // A heading's next style starts afresh after Enter, as without the marks. - let mut s = self::s(); - run(&mut s, "tools.autocorrect", json!({"boldItalic": true})); - run(&mut s, "para.heading1", json!({})); - run(&mut s, "format.bold", json!({})); - typ(&mut s, "*title*\n"); - let p = s.doc.para_at(&Pos::body(1, 0)).unwrap(); - assert_eq!((p.props.style.as_deref(), p.mark.bold), (Some("Normal"), None)); - // Marks typed before Track Changes was turned on stay: removing them would not be tracked. - let mut s = self::s(); - run(&mut s, "tools.autocorrect", json!({"boldItalic": true})); - typ(&mut s, "x *end*"); - run(&mut s, "review.trackChanges", json!({"value": true})); - typ(&mut s, " "); - assert_eq!(s.doc.plain_text(StoryRef::Body), "X *end* "); - // Marks the same author typed with Track Changes on are converted. - let mut s = self::s(); - run(&mut s, "tools.autocorrect", json!({"boldItalic": true})); - run(&mut s, "review.trackChanges", json!({"value": true})); - typ(&mut s, "x *end* "); - assert_eq!(s.doc.plain_text(StoryRef::Body), "X end "); - // Marks another author inserted stay. - let mut s = self::s(); - run(&mut s, "tools.autocorrect", json!({"boldItalic": true})); - run(&mut s, "review.trackChanges", json!({"value": true})); - run(&mut s, "file.setAuthor", json!({"name": "Ana"})); - typ(&mut s, "x *end*"); - run(&mut s, "file.setAuthor", json!({"name": "Ben"})); - typ(&mut s, " "); - assert_eq!(s.doc.plain_text(StoryRef::Body), "X *end* "); - // Undo right after brings the marks back. - let mut s = self::s(); - run(&mut s, "tools.autocorrect", json!({"boldItalic": true})); - typ(&mut s, "a *b* "); - assert_eq!(s.doc.plain_text(StoryRef::Body), "A b "); - run(&mut s, "edit.undo", json!({})); - assert_eq!(s.doc.plain_text(StoryRef::Body), "A *b* "); - // After Enter, Undo brings the marks back and the Enter stays. - let mut s = self::s(); - run(&mut s, "tools.autocorrect", json!({"boldItalic": true})); - typ(&mut s, "a *b*\n"); - assert_eq!(s.doc.plain_text(StoryRef::Body), "A b\n"); - run(&mut s, "edit.undo", json!({})); - assert_eq!(s.doc.plain_text(StoryRef::Body), "A *b*\n"); -} - -#[test] -fn autocorrect_goes_on_after_bold_and_italic_marks() { - // After a space or punctuation, the word without its marks is replaced and capitalized. - let mut s = s(); - run(&mut s, "tools.autocorrect", json!({"boldItalic": true, "add": {"from": "wc", "to": "WordCraft"}})); - typ(&mut s, "*teh* end. _wc_."); - assert_eq!(s.doc.plain_text(StoryRef::Body), "The end. WordCraft."); - let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); - let at = |t: &str| p.props_of_char(p.text.find(t).unwrap()).clone(); - assert_eq!((at("The").bold, at("WordCraft").italic, at(" end").bold), (Some(true), Some(true), None)); - // One Undo brings the marks back as typed. - run(&mut s, "edit.undo", json!({})); - assert_eq!(s.doc.plain_text(StoryRef::Body), "The end. _wc_."); - // A marked web address becomes a link, and the text typed after it is neither bold nor italic. - let mut s = self::s(); - run(&mut s, "tools.autocorrect", json!({"boldItalic": true})); - typ(&mut s, "see *www.example.org* and _www.example.com_ next "); - let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); - let at = |t: &str| p.props_of_char(p.text.find(t).unwrap()).clone(); - assert_eq!((at("www.example.org").bold, at("www.example.org").link.is_some()), (Some(true), true)); - assert_eq!((at("www.example.com").italic, at("www.example.com").link.is_some()), (Some(true), true)); - for plain in [" and", " next"] { - assert_eq!((at(plain).bold, at(plain).italic, at(plain).link.is_some()), (None, None, false), "{plain:?}"); - } - // After Enter too; a longer replacement moves the Enter with it, and Undo keeps the Enter. - let mut s = self::s(); - run(&mut s, "tools.autocorrect", json!({"boldItalic": true, "add": {"from": "wc", "to": "WordCraft"}})); - typ(&mut s, "*teh*\nok. *wc*\nnext"); - assert_eq!(s.doc.plain_text(StoryRef::Body), "The\nOk. WordCraft\nnext"); - let p = s.doc.para_at(&Pos::body(1, 0)).unwrap(); - assert_eq!(p.props_of_char(p.text.find("WordCraft").unwrap()).bold, Some(true)); - let mut s = self::s(); - run(&mut s, "tools.autocorrect", json!({"boldItalic": true})); - typ(&mut s, "*teh*\n"); - run(&mut s, "edit.undo", json!({})); - assert_eq!(s.doc.plain_text(StoryRef::Body), "*teh*\n"); - // In a span of several words, the first word is replaced and capitalized as it is typed. - let mut s = self::s(); - run(&mut s, "tools.autocorrect", json!({"boldItalic": true})); - typ(&mut s, "*teh end* and _teh rest_ "); - assert_eq!(s.doc.plain_text(StoryRef::Body), "The end and the rest "); - let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); - let at = |t: &str| p.props_of_char(p.text.find(t).unwrap()).clone(); - assert_eq!((at("The end").bold, at("the rest").italic, at(" and").bold), (Some(true), Some(true), None)); -} - -#[test] -fn bold_and_italic_marks_stay_as_typed_unless_they_enclose_words() { - let mut s = s(); - run(&mut s, "tools.autocorrect", json!({"boldItalic": true, "capSentences": false})); - let keys = "2*3* file_name_ * x* *x * ** __ *?!* _-_ "; - typ(&mut s, keys); - assert_eq!(s.doc.plain_text(StoryRef::Body), keys); - // Marks stay while formatting is limited to the document's styles. - let mut s = self::s(); - run(&mut s, "tools.autocorrect", json!({"boldItalic": true})); - run(&mut s, "review.restrict", json!({"mode": "none", "formatting": true})); - typ(&mut s, "*word* "); - assert_eq!(s.doc.plain_text(StoryRef::Body), "*word* "); - // Marks around an object (here a check box content control) stay. - let mut s = self::s(); - run(&mut s, "tools.autocorrect", json!({"boldItalic": true})); - typ(&mut s, "x *"); - run(&mut s, "developer.checkBox", json!({})); - run(&mut s, "caret.docEnd", json!({})); - typ(&mut s, "* "); - let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); - assert_eq!(p.text.matches('*').count(), 2, "{:?}", p.text); - // Off by default. - let s = typed("*bold* _italic_ "); - assert_eq!(s.doc.plain_text(StoryRef::Body), "*bold* _italic_ "); -} - -#[test] -fn web_addresses_become_links() { - let mut s = typed("see https://example.org/log, then "); - let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); - let at = p.text.find("https").unwrap(); - assert_eq!(p.props_of_char(at).link.as_deref(), Some("https://example.org/log")); - assert_eq!(p.props_of_char(p.text.find(',').unwrap()).link, None, "trailing comma stays outside"); - typ(&mut s, "more"); - let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); - assert_eq!(p.props_of_char(p.text.find("more").unwrap()).link, None); -} - -#[test] -fn dashes() { - let s = typed("a -- b and c - d and e--f and café--g "); - assert_eq!(s.doc.plain_text(StoryRef::Body), "A – b and c – d and e—f and café—g "); -} - -/// #2036: a dash AutoFormat makes right after a symbol is text, not the symbol's font. -#[test] -fn a_dash_after_a_symbol_is_not_in_the_symbol_font() { - let mut s = s(); - typ(&mut s, "e"); - run(&mut s, "insert.symbol", json!({"char": "\u{F0FC}", "font": "Wingdings"})); - typ(&mut s, "--f "); - let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); - let dash = p.text.find('\u{2014}').expect("em dash"); - assert_eq!(p.props_of_char(dash).sym_font, None); -} - -/// #2036: the en dashes of "x -- y" and "x - y" after a symbol (and its symbol-font space) are -/// text, not the symbol's font. -#[test] -fn en_dashes_after_a_symbol_are_not_in_the_symbol_font() { - for keys in ["-- f ", "- f "] { - let mut s = s(); - typ(&mut s, "e"); - run(&mut s, "insert.symbol", json!({"char": "\u{F0FC} ", "font": "Wingdings"})); - typ(&mut s, keys); - let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); - let dash = p.text.find('\u{2013}').unwrap_or_else(|| panic!("en dash for {keys:?}: {:?}", p.text)); - assert_eq!(p.props_of_char(dash).sym_font, None, "{keys:?}"); - } -} - -/// #2036: an AutoCorrect replacement of a word that starts with a symbol-font character is text. -#[test] -fn an_autocorrect_replacement_of_a_symbol_word_is_not_in_the_symbol_font() { - let mut s = s(); - run(&mut s, "insert.symbol", json!({"char": "(", "font": "Symbol"})); - typ(&mut s, "c) "); - let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); - let c = p.text.find('\u{A9}').unwrap_or_else(|| panic!("(c) → ©: {:?}", p.text)); - assert_eq!(p.props_of_char(c).sym_font, None); -} - -/// #2036: a letter AutoCorrect capitalises after a symbol-font character is text, not the symbol's font. -#[test] -fn a_capitalised_word_after_a_symbol_is_not_in_the_symbol_font() { - let mut s = s(); - run(&mut s, "insert.symbol", json!({"char": "m", "font": "Wingdings"})); - typ(&mut s, "onday "); - let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); - let m = p.text.find('M').unwrap_or_else(|| panic!("monday → Monday: {:?}", p.text)); - assert_eq!(p.props_of_char(m).sym_font, None); -} - -/// Whether every character of the body paragraph `i` is marked deleted. -fn all_deleted(s: &Session, i: usize, text: &str) -> bool { - s.doc.para_at(&Pos::body(i, 0)).is_some_and(|p| p.text == text && p.run_ranges().all(|(_, c)| c.del.is_some())) -} - -#[test] -fn a_tracked_border_line_is_a_deletion_and_a_formatting_change() { - let bottom = |s: &Session, i| s.doc.para_at(&Pos::body(i, 0)).and_then(|p| p.props.borders).and_then(|b| b.bottom); - let tracked = |s: &Session, i| s.doc.para_at(&Pos::body(i, 0)).is_some_and(|p| p.props.fmt_change.is_some()); - // Characters that were there before Track Changes was turned on are marked deleted. - let mut s = typed("Above\n---"); - run(&mut s, "review.trackChanges", json!({"value": true})); - typ(&mut s, "\n"); - assert!(bottom(&s, 0).is_some() && tracked(&s, 0)); - assert!(all_deleted(&s, 1, "---"), "{:?}", paras(&s)); - // Enter again after the deleted characters is a plain Enter. - typ(&mut s, "\n"); - assert_eq!(texts(&s), ["above", "---", ""]); - assert_eq!(s.sel.focus, Pos::body(2, 0)); - run(&mut s, "edit.undo", json!({})); - run(&mut s, "review.rejectAll", json!({})); - assert_eq!(texts(&s), ["above", "---"]); - assert!(bottom(&s, 0).is_none()); - run(&mut s, "edit.undo", json!({})); - run(&mut s, "review.acceptAll", json!({})); - assert_eq!(texts(&s), ["above", ""]); - assert!(bottom(&s, 0).is_some()); - // At the top of the document the border goes on the characters' paragraph, and typing - // continues in a new one. - let mut s = typed("---"); - run(&mut s, "review.trackChanges", json!({"value": true})); - typ(&mut s, "\nText"); - assert!(bottom(&s, 0).is_some() && tracked(&s, 0)); - assert!(all_deleted(&s, 0, "---"), "{:?}", paras(&s)); - assert_eq!(texts(&s)[1], "text"); - run(&mut s, "review.rejectAll", json!({})); - assert_eq!(texts(&s), ["---"]); - assert!(bottom(&s, 0).is_none()); - // Typed with Track Changes on, the characters go and the border is still a change. - let mut s = typed(""); - run(&mut s, "review.trackChanges", json!({"value": true})); - typ(&mut s, "---\n"); - assert_eq!(texts(&s), ["", ""]); - assert!(bottom(&s, 0).is_some() && tracked(&s, 0)); - run(&mut s, "review.rejectAll", json!({})); - assert!(bottom(&s, 0).is_none()); -} - -#[test] -fn a_tracked_border_line_at_the_top_supersedes_another_authors_formatting() { - use wordcraft_doc::props::Align; - // Ana centers the characters' paragraph; Ben's Enter gives it the border. The formatting - // change is Ben's and holds Ana's centered paragraph without a border. - let mut s = typed("---"); - run(&mut s, "review.trackChanges", json!({"value": true})); - s.author = "Ana".into(); - run(&mut s, "para.align", json!({"value": "center"})); - s.author = "Ben".into(); - typ(&mut s, "\n"); - let p = s.doc.para_at(&Pos::body(0, 0)).cloned().unwrap_or_default(); - assert!(p.props.borders.and_then(|b| b.bottom).is_some()); - let ch = p.props.fmt_change.as_deref(); - let author = ch.and_then(|c| s.doc.revisions.get(c.rev as usize)).map(|r| r.author.as_str()); - assert_eq!(author, Some("Ben"), "{:?}", s.doc.revisions); - assert_eq!(ch.map(|c| (c.old.align, c.old.borders.and_then(|b| b.bottom).is_some())), Some((Some(Align::Center), false))); - run(&mut s, "review.rejectAll", json!({})); - let p = s.doc.para_at(&Pos::body(0, 0)).cloned().unwrap_or_default(); - assert_eq!((p.props.align, p.props.borders.and_then(|b| b.bottom).is_some()), (Some(Align::Center), false)); -} - -#[test] -fn a_tracked_table_line_keeps_its_characters_as_a_deletion() { - let mut s = typed("+--+------+"); - run(&mut s, "review.trackChanges", json!({"value": true})); - typ(&mut s, "\nA"); - let Some(wordcraft_doc::Block::Table(t)) = s.doc.body.first().map(|b| &**b) else { panic!("no table: {:?}", paras(&s)) }; - assert_eq!(t.rows[0].cells[0].blocks[0].as_para().map(|p| p.text.as_str()), Some("A"), "the caret is in the first cell"); - assert!(all_deleted(&s, 1, "+--+------+"), "{:?}", paras(&s)); - // Enter after the deleted characters makes no second table. - run(&mut s, "caret.set", json!({"pos": {"block": 1, "off": 11}})); - typ(&mut s, "\n"); - assert_eq!(s.doc.body.iter().filter(|b| b.as_para().is_none()).count(), 1, "{:?}", paras(&s)); - run(&mut s, "edit.undo", json!({})); - run(&mut s, "review.rejectAll", json!({})); - assert!(s.doc.body.get(1).and_then(|b| b.as_para()).is_some_and(|p| p.text == "+--+------+")); - // The table was inserted untracked, so Reject All keeps it before the characters. - assert_eq!(s.doc.body.len(), 2, "{:?}", paras(&s)); - assert!(matches!(s.doc.body.first().map(|b| &**b), Some(wordcraft_doc::Block::Table(_)))); -} - -#[test] -fn a_border_line_at_the_top_with_formatting_markup_off_is_not_a_formatting_change() { - // Track Changes on with formatting left out of the markup: the characters are still marked - // deleted, the border is not a formatting change, and Reject All keeps it. - let mut s = typed("---"); - run(&mut s, "review.trackChanges", json!({"value": true})); - s.prefs.markup.track_formatting = false; - typ(&mut s, "\n"); - let p = s.doc.para_at(&Pos::body(0, 0)).cloned().unwrap_or_default(); - assert!(p.props.borders.and_then(|b| b.bottom).is_some()); - assert!(p.props.fmt_change.is_none(), "{:?}", p.props.fmt_change); - assert!(all_deleted(&s, 0, "---"), "{:?}", paras(&s)); - run(&mut s, "review.rejectAll", json!({})); - let p = s.doc.para_at(&Pos::body(0, 0)).cloned().unwrap_or_default(); - assert_eq!(p.text, "---"); - assert!(p.props.borders.and_then(|b| b.bottom).is_some()); -} - -#[test] -fn a_border_line_at_the_top_keeps_the_authors_own_earlier_formatting_change() { - // Ana centers the characters' paragraph, then her Enter gives it the border: the paragraph - // keeps Ana's one formatting change, which holds the paragraph from before both. - let mut s = typed("---"); - run(&mut s, "review.trackChanges", json!({"value": true})); - s.author = "Ana".into(); - run(&mut s, "para.align", json!({"value": "center"})); - let rev = s.doc.para_at(&Pos::body(0, 0)).and_then(|p| p.props.fmt_change.as_deref()).map(|c| c.rev); - typ(&mut s, "\n"); - let p = s.doc.para_at(&Pos::body(0, 0)).cloned().unwrap_or_default(); - assert!(p.props.borders.and_then(|b| b.bottom).is_some()); - let ch = p.props.fmt_change.as_deref(); - assert_eq!(ch.map(|c| c.rev), rev); - assert_eq!(ch.map(|c| (c.old.align, c.old.borders.and_then(|b| b.bottom).is_some())), Some((None, false))); - run(&mut s, "review.rejectAll", json!({})); - let p = s.doc.para_at(&Pos::body(0, 0)).cloned().unwrap_or_default(); - assert_eq!((p.text.as_str(), p.props.align, p.props.borders.and_then(|b| b.bottom).is_some()), ("---", None, false)); -} - -#[test] -fn a_border_line_the_content_control_locks_refuse_to_delete_stays_as_backspace_leaves_it() { - // "---" inside an editable rich text control that sits in one locked against editing: - // Backspace can't delete its characters, so Enter is refused too, untracked, and the line - // stays as it is. - let mut s = Session::new(wordcraft_doc::Document::from_text("A\nB\n---\nC\nD")); - s.sel = crate::Selection { anchor: Pos::body(0, 0), focus: Pos::body(4, 1) }; - run(&mut s, "developer.richText", json!({"tag": "outer"})); - s.sel = crate::Selection { anchor: Pos::body(1, 0), focus: Pos::body(3, 1) }; - run(&mut s, "developer.richText", json!({"tag": "inner"})); - run(&mut s, "developer.properties", json!({"control": "outer", "lockEdit": true})); - let before = s.doc.clone(); - for key in ["text.backspace", "text.newParagraph"] { - s.sel = crate::Selection::caret(Pos::body(2, 3)); - assert!(s.run(key, &json!({})).is_err(), "{key}"); - assert_eq!(s.doc, before, "{key}"); - } - assert!(s.doc.para_at(&Pos::body(2, 0)).is_some_and(|p| p.text == "---" && p.props.borders.is_none())); -} - -#[test] -fn border_line_autoformat() { - let s = typed("Above\n---\nBelow"); - assert_eq!(s.doc.plain_text(StoryRef::Body), "Above\nBelow"); - let b = s.doc.para_at(&Pos::body(0, 0)).and_then(|p| p.props.borders).and_then(|b| b.bottom).unwrap(); - assert_eq!(b.style, BorderStyle::Single); - assert!(s.doc.para_at(&Pos::body(1, 0)).unwrap().props.borders.is_none()); - for (keys, style) in [("===", BorderStyle::Double), ("***", BorderStyle::Dotted), ("___", BorderStyle::Thick)] { - let s = typed(&format!("x\n{keys}\n")); - let b = s.doc.para_at(&Pos::body(0, 0)).and_then(|p| p.props.borders).and_then(|b| b.bottom); - assert_eq!(b.map(|b| b.style), Some(style), "{keys}"); - } - // At the top of the document the line goes under that paragraph and typing continues below. - let s = typed("---\nText"); - assert_eq!(s.doc.plain_text(StoryRef::Body), "\nText"); - assert!(s.doc.para_at(&Pos::body(0, 0)).and_then(|p| p.props.borders).is_some()); - assert!(s.doc.para_at(&Pos::body(1, 0)).unwrap().props.borders.is_none()); -} - -#[test] -fn undo_right_after_a_border_line_brings_the_characters_back() { - let mut s = typed("Above\n---\n"); - assert_eq!(s.doc.plain_text(StoryRef::Body), "Above\n"); - run(&mut s, "edit.undo", json!({})); - // The AutoFormat is undone on its own: the characters are back, the Enter stays. - assert_eq!(s.doc.plain_text(StoryRef::Body), "Above\n---\n"); - assert!(s.doc.body.iter().filter_map(|b| b.as_para()).all(|p| p.props.borders.is_none())); - assert_eq!(s.sel.focus, Pos::body(2, 0)); - run(&mut s, "edit.undo", json!({})); - assert_eq!(s.doc.plain_text(StoryRef::Body), "Above\n---"); -} - -/// The first paragraph's (left, first-line) indents as set on the paragraph itself. -fn indents(s: &Session) -> (Option, Option) { - let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); - (p.props.indent_left, p.props.indent_first) -} - -#[test] -fn tab_and_backspace_at_a_paragraph_start_set_its_indents() { - let mut s = s(); - run(&mut s, "tools.autocorrect", json!({"tabIndent": true})); - typ(&mut s, "Some text"); - run(&mut s, "caret.docStart", json!({})); - // The first Tab indents the first line, the next one the whole paragraph. - typ(&mut s, "\t"); - assert_eq!(indents(&s), (None, Some(36.0))); - typ(&mut s, "\t"); - assert_eq!(indents(&s), (Some(36.0), Some(36.0))); - assert_eq!(s.doc.plain_text(StoryRef::Body), "Some text"); - // Backspace takes the first-line indent back, then the left indent, and stops there. - typ(&mut s, "\u{8}"); - assert_eq!(indents(&s), (Some(36.0), Some(0.0))); - typ(&mut s, "\u{8}\u{8}"); - assert_eq!(indents(&s), (Some(0.0), Some(0.0))); - assert_eq!(s.doc.plain_text(StoryRef::Body), "Some text"); - // Each Tab is one undo step. - typ(&mut s, "\t"); - run(&mut s, "edit.undo", json!({})); - assert_eq!(indents(&s), (Some(0.0), Some(0.0))); - // A left indent in characters goes back one tab stop too, instead of the paragraphs joining: - // six characters of 11 or 12 points are 66 or 72 points, two tab stops. - let mut s = self::s(); - run(&mut s, "tools.autocorrect", json!({"tabIndent": true})); - typ(&mut s, "One\nTwo"); - let second = Pos::body(1, 0); - s.doc.para_mut(StoryRef::Body, &second.path).unwrap().props.indent_left_chars = Some(6.0); - s.sel = crate::Selection::caret(second.clone()); - typ(&mut s, "\u{8}"); - assert_eq!(s.doc.plain_text(StoryRef::Body), "One\nTwo"); - let p = s.doc.para_at(&second).unwrap(); - assert_eq!((p.props.indent_left, p.props.indent_left_chars), (Some(36.0), Some(0.0))); - // With formatting limited to the document's styles the option is off: Tab types a tab - // character at the start of the first line and of a later one, and Backspace at the start - // keeps the paragraph's left indent. - let mut s = self::s(); - run(&mut s, "tools.autocorrect", json!({"tabIndent": true})); - typ(&mut s, "One"); - run(&mut s, "text.lineBreak", json!({})); - typ(&mut s, "Two"); - run(&mut s, "review.restrict", json!({"mode": "none", "formatting": true})); - run(&mut s, "caret.home", json!({})); - typ(&mut s, "\t"); - run(&mut s, "caret.docStart", json!({})); - typ(&mut s, "\t"); - assert_eq!(s.doc.plain_text(StoryRef::Body), "\tOne\n\tTwo"); - assert_eq!(indents(&s), (None, None)); - s.doc.para_mut(StoryRef::Body, &Pos::body(0, 0).path).unwrap().props.indent_left = Some(36.0); - typ(&mut s, "\u{8}\u{8}"); - assert_eq!(s.doc.plain_text(StoryRef::Body), "One\n\tTwo"); - assert_eq!(indents(&s), (Some(36.0), None)); - // An empty paragraph takes a tab character. - let mut s = self::s(); - run(&mut s, "tools.autocorrect", json!({"tabIndent": true})); - typ(&mut s, "\tText"); - assert_eq!(s.doc.plain_text(StoryRef::Body), "\tText"); - assert_eq!(indents(&s), (None, None)); - // At the start of a list item numbered by its style, Tab types a tab character, as with the - // option off. - let mut s = style_inherited_list(wordcraft_doc::numbering::ListKind::Numbered, false); - run(&mut s, "tools.autocorrect", json!({"tabIndent": true})); - run(&mut s, "caret.docStart", json!({})); - typ(&mut s, "\t"); - assert_eq!(s.doc.plain_text(StoryRef::Body), "\tAlpha\nBeta"); - assert_eq!(indents(&s), (None, None)); - // With the option off (the default), Tab types a tab character. - let mut s = self::s(); - typ(&mut s, "Some text"); - run(&mut s, "caret.docStart", json!({})); - typ(&mut s, "\t"); - assert_eq!(s.doc.plain_text(StoryRef::Body), "\tSome text"); - assert_eq!(indents(&s), (None, None)); -} - -#[test] -fn tab_before_a_later_line_of_a_paragraph_indents_the_whole_paragraph() { - let mut s = s(); - run(&mut s, "tools.autocorrect", json!({"tabIndent": true})); - typ(&mut s, &"Words that run on past the end of one line. ".repeat(6)); - run(&mut s, "caret.docStart", json!({})); - run(&mut s, "caret.down", json!({})); - run(&mut s, "caret.home", json!({})); - assert!(s.sel.focus.off > 0, "the caret is on the second line"); - typ(&mut s, "\t"); - assert_eq!(indents(&s), (Some(36.0), None)); - assert!(!s.doc.plain_text(StoryRef::Body).contains('\t')); - // After a line break too; inside a line Tab still types a tab character. - let mut s = self::s(); - run(&mut s, "tools.autocorrect", json!({"tabIndent": true})); - typ(&mut s, "One"); - run(&mut s, "text.lineBreak", json!({})); - typ(&mut s, "Two"); - run(&mut s, "caret.home", json!({})); - typ(&mut s, "\t"); - assert_eq!(indents(&s), (Some(36.0), None)); - run(&mut s, "caret.right", json!({})); - typ(&mut s, "\t"); - assert_eq!(s.doc.plain_text(StoryRef::Body), "One\nT\two"); - // Before a later line of a list item numbered by its style, Tab takes it down a level, as - // for a list item numbered directly. - let mut s = style_inherited_list(wordcraft_doc::numbering::ListKind::Numbered, false); - run(&mut s, "tools.autocorrect", json!({"tabIndent": true})); - run(&mut s, "caret.docStart", json!({})); - run(&mut s, "caret.end", json!({})); - run(&mut s, "text.lineBreak", json!({})); - typ(&mut s, "More"); - run(&mut s, "caret.home", json!({})); - typ(&mut s, "\t"); - assert!(!s.doc.plain_text(StoryRef::Body).contains('\t')); - let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); - assert_eq!(p.props.numbering.map(|n| n.level), Some(1)); - assert_eq!(indents(&s), (None, None)); - // A list level past the last one, from a style or set directly, goes to the last level. - let mut s = style_inherited_list(wordcraft_doc::numbering::ListKind::Numbered, false); - let style = Arc::make_mut(&mut s.doc.styles).styles.iter_mut().find(|x| x.id == "ListBase").unwrap(); - style.para.numbering = style.para.numbering.map(|n| wordcraft_doc::props::NumRef { level: u8::MAX, ..n }); - run(&mut s, "tools.autocorrect", json!({"tabIndent": true})); - run(&mut s, "caret.docStart", json!({})); - run(&mut s, "caret.end", json!({})); - run(&mut s, "text.lineBreak", json!({})); - typ(&mut s, "More"); - run(&mut s, "caret.home", json!({})); - typ(&mut s, "\t"); - assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().props.numbering.map(|n| n.level), Some(8)); - let num = s.doc.para_at(&Pos::body(0, 0)).unwrap().props.numbering.unwrap().num; - s.doc.para_mut(StoryRef::Body, &Pos::body(1, 0).path).unwrap().props.numbering = Some(wordcraft_doc::props::NumRef { num, level: u8::MAX }); - s.sel = crate::Selection::caret(Pos::body(1, 0)); - typ(&mut s, "\t"); - assert_eq!(s.doc.para_at(&Pos::body(1, 0)).unwrap().props.numbering.map(|n| n.level), Some(8)); - s.doc.para_mut(StoryRef::Body, &Pos::body(0, 0).path).unwrap().props.numbering = Some(wordcraft_doc::props::NumRef { num, level: u8::MAX }); - run(&mut s, "caret.docStart", json!({})); - run(&mut s, "caret.down", json!({})); - run(&mut s, "caret.home", json!({})); - assert_eq!((s.sel.focus.path.clone(), s.sel.focus.off), (Pos::body(0, 0).path, 6), "the caret is before \"More\""); - typ(&mut s, "\t"); - assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().props.numbering.map(|n| n.level), Some(8)); - s.doc.para_mut(StoryRef::Body, &Pos::body(0, 0).path).unwrap().props.numbering = Some(wordcraft_doc::props::NumRef { num, level: u8::MAX }); - run(&mut s, "outline.demote", json!({})); - assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().props.numbering.map(|n| n.level), Some(8)); -} - -#[test] -fn with_tab_indents_on_tab_in_a_table_cell_moves_to_the_next_cell() { - let mut s = typed("+--+--+\nA"); - run(&mut s, "tools.autocorrect", json!({"tabIndent": true})); - run(&mut s, "caret.home", json!({})); - typ(&mut s, "\t"); - assert_eq!(s.sel.focus.path.cell().map(|(_, r, c)| (r, c)), Some((0, 1))); - let Some(wordcraft_doc::Block::Table(t)) = s.doc.body.first().map(|b| &**b) else { panic!("no table: {:?}", paras(&s)) }; - let a = t.rows[0].cells[0].blocks[0].as_para().unwrap(); - assert_eq!((a.text.as_str(), a.props.indent_left, a.props.indent_first), ("A", None, None)); -} - -#[test] -fn with_tab_indents_off_backspace_before_an_indented_paragraph_joins_it() { - // The paragraph's own left indent, in points or in characters, doesn't stop the join. - for chars in [false, true] { - let mut s = typed("One\nTwo"); - let second = Pos::body(1, 0); - let p = s.doc.para_mut(StoryRef::Body, &second.path).unwrap(); - if chars { - p.props.indent_left_chars = Some(6.0); - } else { - p.props.indent_left = Some(36.0); - } - s.sel = crate::Selection::caret(second); - typ(&mut s, "\u{8}"); - assert_eq!(s.doc.plain_text(StoryRef::Body), "OneTwo", "indent in characters: {chars}"); - } -} - -#[test] -fn plus_and_hyphen_line_becomes_a_table() { - let mut s = typed("+--+------+\nA"); - let Some(wordcraft_doc::Block::Table(t)) = s.doc.body.first().map(|b| &**b) else { panic!("no table: {:?}", paras(&s)) }; - assert_eq!(t.rows.len(), 1); - assert_eq!(t.grid.len(), 2); - assert!((t.grid[1] / t.grid[0] - 3.0).abs() < 0.01, "widths follow the hyphens: {:?}", t.grid); - assert_eq!(t.rows[0].cells[0].blocks[0].as_para().map(|p| p.text.as_str()), Some("A"), "the caret is in the first cell"); - assert!(s.doc.body.get(1).and_then(|b| b.as_para()).is_some_and(|p| p.is_empty()), "a paragraph follows the table"); - // Undo right after brings the typed characters back, the Enter stays. - run(&mut s, "edit.undo", json!({})); - run(&mut s, "edit.undo", json!({})); - assert_eq!(s.doc.plain_text(StoryRef::Body), "+--+------+\n"); - assert!(s.doc.body.iter().all(|b| b.as_para().is_some())); - // Not a table: text around it, or more columns than a table can hold. - let s = typed("x +--+--+\n"); - assert_eq!(s.doc.plain_text(StoryRef::Body), "X +--+--+\n"); - let s = typed(&format!("+{}\n", "-+".repeat(64))); - assert!(s.doc.body.iter().all(|b| b.as_para().is_some())); -} - -/// Body paragraphs as (text, paragraph style). -fn styled(s: &Session) -> Vec<(String, Option)> { - s.doc.body.iter().filter_map(|b| b.as_para()).map(|p| (p.text.clone(), p.props.style.clone())).collect() -} - -fn with_heading_styles(keys: &str) -> Session { - let mut s = s(); - run(&mut s, "tools.autocorrect", json!({"headingStyles": true})); - typ(&mut s, keys); - s -} - -#[test] -fn two_enters_after_a_short_line_make_it_a_heading() { - let h = |t: &str, st: Option<&str>| (t.to_string(), st.map(str::to_string)); - // No tab: Heading 1; one or two tabs: Heading 2 or 3, without the tabs. Typing goes on in - // the paragraph after the heading. - for (keys, style) in [("Introduction\n\nText", "Heading1"), ("\tOur methods\n\nText", "Heading2"), ("\t\tStep 2\n\nText", "Heading3")] { - let s = with_heading_styles(keys); - let text = keys.trim_start_matches('\t').split('\n').next().unwrap_or_default(); - assert_eq!(styled(&s), [h(text, Some(style)), h("Text", None)], "{keys:?}"); - } - // Five words is the most; one Enter only ends the line. - let s = with_heading_styles("One two three four five\n\n"); - assert_eq!(s.doc.body.first().and_then(|b| b.as_para()).and_then(|p| p.props.style.as_deref()), Some("Heading1")); - assert_eq!(styled(&with_heading_styles("Introduction\nText")), [h("Introduction", None), h("Text", None)]); -} - -#[test] -fn a_line_that_is_not_a_short_line_of_body_text_stays_as_it_is() { - // Not a heading: six words, a closing punctuation mark, three tabs, a list item, a style. - for keys in ["One two three four five six\n\n", "Dear Sir,\n\n", "Is it?\n\n", "\t\t\tDeep\n\n", "* Item\n\n"] { - let s = with_heading_styles(keys); - assert!( - s.doc.body.iter().filter_map(|b| b.as_para()).all(|p| p.props.style.as_deref().is_none_or(|st| !st.starts_with("Heading"))), - "{keys:?}: {:?}", - styled(&s) - ); - } - let mut q = self::s(); - run(&mut q, "tools.autocorrect", json!({"headingStyles": true})); - run(&mut q, "para.style", json!({"style": "Quote"})); - typ(&mut q, "Quoted\n\n"); - assert_eq!(q.doc.body.first().and_then(|b| b.as_para()).and_then(|p| p.props.style.as_deref()), Some("Quote")); - // A list item numbered by the Normal style is not body text either. - let mut q = self::s(); - run(&mut q, "tools.autocorrect", json!({"headingStyles": true})); - let num = Arc::make_mut(&mut q.doc.numbering).try_add_list(wordcraft_doc::ListKind::Bullet).unwrap(); - if let Some(normal) = Arc::make_mut(&mut q.doc.styles).get_mut("Normal") { - normal.para.numbering = Some(wordcraft_doc::props::NumRef { num, level: 0 }); - } - typ(&mut q, "Item\n\n"); - assert!(styled(&q).iter().all(|(_, st)| st.as_deref().is_none_or(|st| !st.starts_with("Heading"))), "{:?}", styled(&q)); - // A line with an outline level stays; the body-text level (9 and above) is no outline level. - for (level, style) in [(0, None), (8, None), (9, Some("Heading1")), (u8::MAX, Some("Heading1"))] { - let mut q = with_heading_styles("Title\n"); - q.doc.para_mut(StoryRef::Body, &Pos::body(0, 0).path).unwrap().props.outline_level = Some(level); - typ(&mut q, "\n"); - assert_eq!(styled(&q).first().and_then(|(_, st)| st.as_deref()), style, "outline level {level}"); - } -} - -#[test] -fn built_in_heading_styles_are_off_by_default() { - let s = typed("Introduction\n\n"); - let h = |t: &str| (t.to_string(), None); - assert_eq!(styled(&s), [h("Introduction"), h(""), h("")]); -} - -#[test] -fn undo_right_after_a_heading_autoformat_keeps_the_enter() { - let mut s = with_heading_styles("\tMethods\n\n"); - assert_eq!(styled(&s), [("Methods".to_string(), Some("Heading2".to_string())), (String::new(), None)]); - assert_eq!(s.sel.focus, Pos::body(1, 0)); - run(&mut s, "edit.undo", json!({})); - // The AutoFormat is undone on its own: the tab and Normal text are back, the Enter stays. - assert_eq!(s.doc.plain_text(StoryRef::Body), "\tMethods\n\n"); - assert!(styled(&s).iter().all(|(_, st)| st.is_none())); - assert_eq!(s.sel.focus, Pos::body(2, 0)); -} - -#[test] -fn a_tracked_heading_autoformat_can_be_rejected() { - // Enter in the empty paragraph turns a zero-tab line into Heading 1. - let mut s = with_heading_styles("Title\n"); - run(&mut s, "review.trackChanges", json!({"value": true})); - s.author = "Ana".into(); - typ(&mut s, "\n"); - let p = s.doc.para_at(&Pos::body(0, 0)).cloned().unwrap_or_default(); - assert_eq!((p.text.as_str(), p.props.style.as_deref()), ("Title", Some("Heading1"))); - let ch = p.props.fmt_change.as_deref(); - assert_eq!(ch.and_then(|c| s.doc.revisions.get(c.rev as usize)).map(|r| r.author.as_str()), Some("Ana")); - run(&mut s, "review.rejectAll", json!({})); - assert_eq!(styled(&s).first(), Some(&("Title".to_string(), None))); - - // With Track Changes on, the tab is marked deleted and the style change is a formatting - // revision, so Reject All brings the line back as it was. - let mut s = with_heading_styles("\tMethods\n"); - run(&mut s, "review.trackChanges", json!({"value": true})); - typ(&mut s, "\n"); - let p = s.doc.para_at(&Pos::body(0, 0)).cloned().unwrap_or_default(); - assert_eq!((p.text.as_str(), p.props.style.as_deref()), ("\tMethods", Some("Heading2"))); - assert!(p.run_ranges().any(|(r, c)| r.start == 0 && c.del.is_some()), "the tab is marked deleted"); - assert!(p.props.fmt_change.is_some(), "the style change is tracked"); - run(&mut s, "review.rejectAll", json!({})); - assert_eq!(styled(&s).first(), Some(&("\tMethods".to_string(), None))); - // A tab this author inserted is removed outright, and the style change is still tracked. - let mut s = with_heading_styles("Title\n"); - run(&mut s, "review.trackChanges", json!({"value": true})); - run(&mut s, "caret.set", json!({"pos": {"block": 0, "off": 0}})); - typ(&mut s, "\t"); - run(&mut s, "caret.set", json!({"pos": {"block": 1, "off": 0}})); - typ(&mut s, "\n"); - let p = s.doc.para_at(&Pos::body(0, 0)).cloned().unwrap_or_default(); - assert_eq!((p.text.as_str(), p.props.style.as_deref()), ("Title", Some("Heading2"))); - assert!(p.props.fmt_change.is_some(), "the style change is tracked"); - run(&mut s, "review.rejectAll", json!({})); - assert_eq!(styled(&s).first(), Some(&("Title".to_string(), None))); - // A line holding a tracked deletion is left alone. - let mut s = with_heading_styles("Old title\n"); - run(&mut s, "review.trackChanges", json!({"value": true})); - run(&mut s, "caret.set", json!({"pos": {"block": 0, "off": 3}})); - typ(&mut s, "\u{8}\u{8}\u{8}"); - run(&mut s, "caret.set", json!({"pos": {"block": 1, "off": 0}})); - typ(&mut s, "\n"); - assert!(styled(&s).iter().all(|(_, st)| st.is_none()), "{:?}", styled(&s)); -} - -#[test] -fn a_tracked_heading_autoformat_supersedes_another_authors_formatting() { - use wordcraft_doc::props::Align; - // Ana centers a zero-tab line; Ben's Enter makes it Heading 1. - let mut s = with_heading_styles("Title\n"); - run(&mut s, "review.trackChanges", json!({"value": true})); - s.author = "Ana".into(); - run(&mut s, "caret.set", json!({"pos": {"block": 0, "off": 0}})); - run(&mut s, "para.align", json!({"value": "center"})); - s.author = "Ben".into(); - run(&mut s, "caret.set", json!({"pos": {"block": 1, "off": 0}})); - typ(&mut s, "\n"); - let p = s.doc.para_at(&Pos::body(0, 0)).cloned().unwrap_or_default(); - assert_eq!((p.text.as_str(), p.props.style.as_deref()), ("Title", Some("Heading1"))); - let ch = p.props.fmt_change.as_deref(); - assert_eq!(ch.and_then(|c| s.doc.revisions.get(c.rev as usize)).map(|r| r.author.as_str()), Some("Ben")); - assert_eq!(ch.map(|c| (c.old.style.as_deref(), c.old.align)), Some((None, Some(Align::Center)))); - run(&mut s, "review.rejectAll", json!({})); - let p = s.doc.para_at(&Pos::body(0, 0)).cloned().unwrap_or_default(); - assert_eq!((p.text.as_str(), p.props.style.as_deref(), p.props.align), ("Title", None, Some(Align::Center))); - - // Ana centers the line; Ben types a tab before it and makes it Heading 2. Ben's tab is - // removed, and the heading's formatting change is Ben's, holding Ana's centered Normal line. - let mut s = with_heading_styles("Title\n"); - run(&mut s, "review.trackChanges", json!({"value": true})); - s.author = "Ana".into(); - run(&mut s, "caret.set", json!({"pos": {"block": 0, "off": 0}})); - run(&mut s, "para.align", json!({"value": "center"})); - s.author = "Ben".into(); - typ(&mut s, "\t"); - run(&mut s, "caret.set", json!({"pos": {"block": 1, "off": 0}})); - typ(&mut s, "\n"); - let p = s.doc.para_at(&Pos::body(0, 0)).cloned().unwrap_or_default(); - assert_eq!((p.text.as_str(), p.props.style.as_deref()), ("Title", Some("Heading2"))); - let ch = p.props.fmt_change.as_deref(); - let author = ch.and_then(|c| s.doc.revisions.get(c.rev as usize)).map(|r| r.author.as_str()); - assert_eq!(author, Some("Ben"), "{:?}", s.doc.revisions); - assert_eq!(ch.map(|c| (c.old.style.as_deref(), c.old.align)), Some((None, Some(Align::Center)))); - // Rejecting it brings back Ana's centered Normal line. - run(&mut s, "review.rejectAll", json!({})); - let p = s.doc.para_at(&Pos::body(0, 0)).cloned().unwrap_or_default(); - assert_eq!((p.text.as_str(), p.props.style.as_deref(), p.props.align), ("Title", None, Some(Align::Center))); - // The author's own earlier change keeps the formatting before it. - let mut s = with_heading_styles("Title\n"); - run(&mut s, "review.trackChanges", json!({"value": true})); - s.author = "Ana".into(); - run(&mut s, "caret.set", json!({"pos": {"block": 0, "off": 0}})); - run(&mut s, "para.align", json!({"value": "center"})); - typ(&mut s, "\t"); - run(&mut s, "caret.set", json!({"pos": {"block": 1, "off": 0}})); - typ(&mut s, "\n"); - let p = s.doc.para_at(&Pos::body(0, 0)).cloned().unwrap_or_default(); - let ch = p.props.fmt_change.as_deref(); - assert_eq!(ch.and_then(|c| s.doc.revisions.get(c.rev as usize)).map(|r| r.author.as_str()), Some("Ana")); - assert_eq!(ch.map(|c| c.old.style.as_deref()), Some(None)); - assert!(ch.is_some_and(|c| c.old.align != Some(Align::Center)), "{ch:?}"); -} - -#[test] -fn a_locked_heading_style_is_not_applied_as_you_type() { - let mut s = s(); - run(&mut s, "tools.autocorrect", json!({"headingStyles": true})); - run(&mut s, "review.restrict", json!({"mode": "none", "formatting": true, "lockedStyles": ["Heading1"]})); - typ(&mut s, "Locked\n\n\tOpen\n\n"); - let st = styled(&s); - assert_eq!(st.first(), Some(&("Locked".to_string(), None)), "{st:?}"); - assert!(st.contains(&("Open".to_string(), Some("Heading2".to_string()))), "{st:?}"); -} - -#[test] -fn enter_in_an_editable_region_after_a_protected_short_line_breaks_the_paragraph() { - // Read only, with an editable region from the end of "Title" to the start of "End.": Enter - // twice in the region breaks the paragraph and leaves the protected line Normal text, and a - // border line typed there goes under the paragraph above it, inside the region. - let mut s = Session::new(wordcraft_doc::Document::from_text("Title\nX\nEnd.")); - run(&mut s, "tools.autocorrect", json!({"headingStyles": true})); - s.sel = crate::Selection { anchor: Pos::body(0, 5), focus: Pos::body(2, 0) }; - run(&mut s, "review.restrict", json!({"exception": "add"})); - run(&mut s, "review.restrict", json!({"mode": "readOnly"})); - run(&mut s, "select.text", json!({"text": "X"})); - typ(&mut s, "\n\n"); - assert_eq!(s.doc.body.len(), 5, "{:?}", styled(&s)); - assert!(styled(&s).iter().all(|(_, st)| st.is_none()), "{:?}", styled(&s)); - typ(&mut s, "---\n"); - assert!(s.doc.para_at(&Pos::body(2, 0)).and_then(|p| p.props.borders).and_then(|b| b.bottom).is_some()); - assert_eq!(s.doc.plain_text(StoryRef::Body).replace(wordcraft_doc::para::OBJ, ""), "Title\n\n\n\nEnd."); -} - -#[test] -fn arrow_bullets() { - for (keys, bullet) in [("> ", "\u{27A2}"), ("-> ", "\u{2794}"), ("=> ", "\u{21E8}")] { - let s = typed(&format!("{keys}item\nnext")); - assert_eq!(paras(&s), vec![para("Item", Some(0)), para("next", Some(0))], "{keys}"); - assert_eq!(label_at(&s, 0), bullet, "{keys}"); - } - // An arrow list doesn't join a plain bullet list. - let s = typed("* a\n\n> b"); - assert_eq!(label_at(&s, 0), "\u{2022}"); - assert_eq!(label_at(&s, 1), "\u{27A2}"); -} - -#[test] -fn smileys_are_replaced_inside_words_too() { - let s = typed("so ok:) sad:( meh:| :) fine:-) "); - assert_eq!(s.doc.plain_text(StoryRef::Body), "So ok\u{263A} sad\u{2639} meh\u{1F610} \u{263A} fine\u{263A} "); -} - -#[test] -fn a_large_mixed_document_types_like_word() { - // The corpus typed into Word and WordCraft side by side; the paragraph structure must match. - let mut s = s(); - let prose = "The keeper's log runs to four hundred pages, and almost every one begins with the weather. "; - typ(&mut s, &format!("Notes\n{}\n", prose.repeat(20))); - typ(&mut s, "The supplies were:\n* Lamp oil, 1/2 barrel\nWicks of the 1st quality\n\tSpare chimneys\nSpare springs\n"); - run(&mut s, "text.backTab", json!({})); - typ(&mut s, "Salt beef\n\nThe schedule:\n1. Signal the boat\nLower the stores\n\tCheck the net\n"); - run(&mut s, "text.backTab", json!({})); - typ(&mut s, "Sign the manifest\n\n---\nIn March the storm broke -- and he slept.\n"); - typ(&mut s, "a. Inspect the glass\nReplace the seals\n\n"); - typ(&mut s, &format!("Afterword. teh keeper retired. {}\n* one more\nsecond\n\n\nFinal.", prose.repeat(10))); - let got: Vec<(String, Option)> = paras(&s).into_iter().map(|(t, l, _)| (t.chars().take(24).collect(), l)).collect(); - let want: Vec<(&str, Option)> = vec![ - ("Notes", None), - ("The keeper’s log runs to", None), - ("The supplies were:", None), - ("Lamp oil, ½ barrel", Some(0)), - ("Wicks of the 1st quality", Some(0)), - ("Spare chimneys", Some(1)), - ("Spare springs", Some(1)), - ("Salt beef", Some(0)), - ("The schedule:", None), - ("Signal the boat", Some(0)), - ("Lower the stores", Some(0)), - ("Check the net", Some(1)), - ("Sign the manifest", Some(0)), - ("In March the storm broke", None), - ("Inspect the glass", Some(0)), - ("Replace the seals", Some(0)), - ("Afterword. The keeper re", None), - ("One more", Some(0)), - ("Second", Some(0)), - ("", None), - ("Final.", None), - ]; - let want: Vec<(String, Option)> = want.into_iter().map(|(t, l)| (t.to_string(), l)).collect(); - assert_eq!(got, want); - // Every non-list paragraph is Normal (no list indent left behind). - for (t, l, lp) in paras(&s) { - assert!(l.is_some() || !lp, "{t:?} still a List Paragraph"); - } -} - -#[test] -fn enter_right_after_starting_a_list_cancels_it() { - let s = typed("* \nPlain"); - assert_eq!(paras(&s), vec![para("Plain", None)]); -} - -#[test] -fn shift_enter_stays_inside_the_list_item() { - let mut s = typed("* line one"); - run(&mut s, "text.lineBreak", json!({})); - typ(&mut s, "cont\ntwo"); - assert_eq!(paras(&s), vec![para("Line one\ncont", Some(0)), para("two", Some(0))]); -} - -/// Body paragraph texts, lower-cased (AutoCorrect may capitalise a sentence start). -fn texts(s: &Session) -> Vec { - paras(s).into_iter().map(|p| p.0.to_lowercase()).collect() -} - -#[test] -fn tracked_enter_and_backspace_track_the_paragraph_mark() { - // Issue #229: with Track Changes on, Enter inserts a tracked paragraph mark that Reject All - // takes out again; Backspace over your own Enter removes it outright. - let mut s = typed("one"); - run(&mut s, "review.trackChanges", json!({"value": true})); - typ(&mut s, "\ntwo"); - assert_eq!(texts(&s), ["one", "two"]); - typ(&mut s, "\u{8}\u{8}\u{8}\u{8}"); - assert_eq!(texts(&s), ["one"]); - typ(&mut s, "\n"); - assert_eq!(texts(&s), ["one", ""]); - run(&mut s, "review.rejectAll", json!({})); - assert_eq!(texts(&s), ["one"]); - // A break that was already there isn't removed: Backspace marks it deleted and moves before it. - let mut s = typed("one\ntwo"); - run(&mut s, "review.trackChanges", json!({"value": true})); - run(&mut s, "caret.set", json!({"pos": {"block": 1, "off": 0}})); - typ(&mut s, "\u{8}"); - assert_eq!(texts(&s), ["one", "two"]); - assert_eq!(s.sel.focus, Pos::body(0, 3)); - assert!(s.doc.para_at(&Pos::body(0, 0)).is_some_and(|p| p.mark.del.is_some())); -} - -/// Linked text in each body paragraph, and whether its paragraph mark carries a link. -fn links(s: &Session) -> Vec<(String, bool)> { - s.doc - .body - .iter() - .filter_map(|b| b.as_para()) - .map(|p| { - let linked = p.run_ranges().filter(|(_, c)| c.link.is_some()).filter_map(|(r, _)| p.text.get(r)).collect(); - (linked, p.mark.link.is_some()) - }) - .collect() -} - -#[test] -fn a_link_covers_only_its_own_text() { - // Issue #413: linking a whole paragraph also linked its mark, so Enter carried the link into - // the next paragraph and everything typed there (or hovered below it) became the link. - let mut s = typed("hi\nhello"); - run(&mut s, "select.text", json!({"text": "hello"})); - run(&mut s, "insert.link", json!({"url": "https://example.org/"})); - run(&mut s, "caret.set", json!({"pos": {"block": 1, "off": 5}})); - typ(&mut s, "\nnext"); - run(&mut s, "caret.set", json!({"pos": {"block": 1, "off": 0}})); - typ(&mut s, "\n"); - run(&mut s, "caret.set", json!({"pos": {"block": 1, "off": 0}})); - typ(&mut s, "above"); - assert_eq!(texts(&s), ["hi", "above", "hello", "next"]); - let unlinked = (String::new(), false); - assert_eq!(links(&s), [unlinked.clone(), unlinked.clone(), ("Hello".into(), false), unlinked]); -} - -#[test] -fn typing_at_either_end_of_a_link_is_not_linked() { - // As in Word: a link typed in with Ctrl+K, then text typed after it, at its end later on, - // at its start, or after Enter, stays outside the link; typing inside it extends it. - let mut s = typed("see "); - run(&mut s, "insert.link", json!({"url": "https://example.org/", "text": "site"})); - typ(&mut s, " now"); - run(&mut s, "caret.set", json!({"pos": {"block": 0, "off": 8}})); - typ(&mut s, "!"); - run(&mut s, "caret.set", json!({"pos": {"block": 0, "off": 4}})); - typ(&mut s, "a "); - run(&mut s, "caret.set", json!({"pos": {"block": 0, "off": 8}})); - typ(&mut s, "x"); - run(&mut s, "caret.set", json!({"pos": {"block": 0, "off": 11}})); - typ(&mut s, "\nmore"); - assert_eq!(texts(&s), ["see a sixte", "more! now"]); - assert_eq!(links(&s), [("sixte".into(), false), (String::new(), false)]); -} - -#[test] -fn a_link_on_a_selection_shows_the_text_to_display() { - // Ctrl+K on selected text with a new Text to display: the selection is retyped with it and - // linked, keeping its formatting; typing after it stays outside the link (as in Word). - let mut s = typed("go see kiln now"); - run(&mut s, "select.text", json!({"text": "kiln"})); - run(&mut s, "format.bold", json!({})); - run(&mut s, "insert.link", json!({"url": "https://example.org/", "text": "Oven"})); - typ(&mut s, "!"); - assert_eq!(texts(&s), ["go see oven! now"]); - assert_eq!(links(&s), [("Oven".into(), false)]); - let p = s.doc.body.iter().find_map(|b| b.as_para()).unwrap(); - assert!(p.run_ranges().any(|(r, c)| &p.text[r] == "Oven" && c.bold == Some(true)), "the new text keeps the selection's formatting"); - // The same text, or text that can't stand in one line, leaves the selection as it is. - run(&mut s, "select.text", json!({"text": "see"})); - run(&mut s, "insert.link", json!({"url": "https://example.org/", "text": "see"})); - run(&mut s, "select.text", json!({"text": "now"})); - run(&mut s, "insert.link", json!({"url": "https://example.org/", "text": "two\nlines"})); - assert_eq!(texts(&s), ["go see oven! now"]); -} - -#[test] -fn a_new_selection_drops_the_formatting_left_after_a_link() { - // A link leaves plain formatting for what is typed after it. A selection made next doesn't - // take it: retyping that selection keeps its own formatting. - let bold_run = |s: &Session, text: &str| { - let p = s.doc.body.iter().find_map(|b| b.as_para()).unwrap(); - p.run_ranges().any(|(r, c)| &p.text[r] == text && c.bold == Some(true)) - }; - let mut s = typed("kiln"); - run(&mut s, "select.text", json!({"text": "kiln"})); - run(&mut s, "format.bold", json!({})); - run(&mut s, "insert.link", json!({"url": "https://example.org/", "text": "Oven"})); - run(&mut s, "select.all", json!({})); - run(&mut s, "insert.link", json!({"url": "https://example.org/", "text": "Stove"})); - assert!(bold_run(&s, "Stove"), "select.all"); - run(&mut s, "select.word", json!({})); - run(&mut s, "insert.link", json!({"url": "https://example.org/", "text": "Range"})); - assert!(bold_run(&s, "Range"), "select.word"); - let anchor = serde_json::to_value(s.doc.start_of(s.sel.focus.story)).unwrap(); - let focus = serde_json::to_value(s.sel.focus.clone()).unwrap(); - run(&mut s, "select.range", json!({"anchor": anchor, "focus": focus})); - run(&mut s, "insert.link", json!({"url": "https://example.org/", "text": "Hob"})); - assert!(bold_run(&s, "Hob"), "select.range"); -} - -#[test] -fn a_link_over_two_paragraphs_keeps_their_text() { - // Word shows <> for such a selection: the text to display is ignored. - let mut s = typed("one\ntwo"); - run(&mut s, "select.all", json!({})); - run(&mut s, "insert.link", json!({"url": "https://example.org/", "text": "both"})); - assert_eq!(texts(&s), ["one", "two"]); - assert_eq!(links(&s), [("One".into(), false), ("two".into(), false)]); -} - -#[test] -fn a_retyped_link_text_is_tracked() { - let mut s = typed("go see kiln now"); - run(&mut s, "review.trackChanges", json!({"value": true})); - run(&mut s, "select.text", json!({"text": "kiln"})); - run(&mut s, "insert.link", json!({"url": "https://example.org/", "text": "Oven"})); - let p = s.doc.body.iter().find_map(|b| b.as_para()).unwrap(); - assert!(p.run_ranges().any(|(r, c)| &p.text[r] == "kiln" && c.del.is_some()), "the old text stays as a deletion"); - assert!(p.run_ranges().any(|(r, c)| &p.text[r] == "Oven" && c.ins.is_some() && c.link.is_some())); -} - -#[test] -fn a_link_keeps_a_selection_it_cannot_retype() { - use wordcraft_doc::props::CharProps; - use wordcraft_doc::{InlineObject, Path}; - let url = "https://example.org/"; - // A second retyped link keeps its own selection's formatting: the first one's left no - // formatting pending for it. - let mut s = typed("go see kiln and vat now"); - run(&mut s, "select.text", json!({"text": "kiln"})); - run(&mut s, "insert.link", json!({"url": url, "text": "Oven"})); - run(&mut s, "select.text", json!({"text": "vat"})); - run(&mut s, "format.bold", json!({})); - run(&mut s, "insert.link", json!({"url": url, "text": "Tub"})); - let p = s.doc.body.iter().find_map(|b| b.as_para()).unwrap(); - assert!(p.run_ranges().any(|(r, c)| &p.text[r] == "Tub" && c.bold == Some(true) && c.link.is_some()), "{:?}", p.text); - // A selection holding a manual line break keeps both lines. - let mut s = typed("one"); - run(&mut s, "text.lineBreak", json!({})); - typ(&mut s, "two"); - run(&mut s, "select.all", json!({})); - run(&mut s, "insert.link", json!({"url": url, "text": "both"})); - assert_eq!(s.doc.plain_text(StoryRef::Body), "one\ntwo"); - // A selection in a field's result is only linked; the field's next update owns that text. - let mut doc = wordcraft_doc::Document::from_text("See "); - let c = CharProps::default(); - let p = doc.para_mut(StoryRef::Body, &Path::top(0)).unwrap(); - let start = p.len(); - p.insert_object(start, InlineObject::FieldStart { instr: "ADDIN ZOTERO_ITEM CSL_CITATION {}".into(), locked: false, dirty: false }, &c).unwrap(); - let n = p.insert_text(p.len(), "(Smith 2020)", &c).unwrap(); - p.insert_object(start + 3 + n, InlineObject::FieldEnd, &c).unwrap(); - let mut s = Session::new(doc); - run(&mut s, "select.text", json!({"text": "Smith"})); - run(&mut s, "insert.link", json!({"url": url, "text": "Jones"})); - assert_eq!(s.doc.plain_text(StoryRef::Body), "See (Smith 2020)"); - assert_eq!(s.doc.field_ranges(StoryRef::Body).len(), 1); -} - -#[test] -fn typing_then_selecting_then_typing_are_two_undo_steps() { - // #419: a selection that moves the insertion point ends the typing group, like a caret move. - let mut s = typed("alpha"); - run(&mut s, "select.text", json!({"text": "ph"})); - run(&mut s, "text.insert", json!({"text": "X"})); - assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().text, "alXa"); - run(&mut s, "edit.undo", json!({})); - assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().text, "alpha"); - assert_eq!(s.selected_text(), "ph"); - assert!(s.can_undo()); - run(&mut s, "edit.undo", json!({})); - assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().text, ""); - // Uninterrupted typing (a read-only command in between included) is still one step. - let mut s = typed("alpha"); - run(&mut s, "document.inspect", json!({})); - typ(&mut s, "X"); - run(&mut s, "edit.undo", json!({})); - assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().text, ""); -} - -#[test] -fn backspace_at_the_start_of_the_document_is_not_an_edit() { - // #23: a key that changes nothing adds no undo step, marks no unsaved changes and - // leaves the redo stack alone. - let mut s = typed("alpha"); - run(&mut s, "caret.set", json!({"pos": {"block": 0, "off": 0}})); - s.dirty = false; - let depth = s.undo_depth(); - typ(&mut s, "\u{8}"); - assert_eq!(s.undo_depth(), depth, "no undo step"); - assert!(!s.dirty, "no unsaved changes"); - assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().text, "alpha"); -} - -#[test] -fn undoing_bold_at_the_caret_turns_bold_typing_off_again() { - // #424: Bold at a caret in an empty paragraph, Undo, type: the text isn't bold. - let mut s = s(); - run(&mut s, "format.bold", json!({"value": true})); - run(&mut s, "edit.undo", json!({})); - assert_ne!(s.typing_props().bold, Some(true)); - typ(&mut s, "abc"); - let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); - assert_eq!(p.text, "abc"); - assert_ne!(p.props_of_char(0).bold, Some(true)); - // Redo brings bold typing back. - let mut s = Session::new(wordcraft_doc::Document::new()); - run(&mut s, "format.bold", json!({"value": true})); - run(&mut s, "edit.undo", json!({})); - run(&mut s, "edit.redo", json!({})); - typ(&mut s, "abc"); - assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().props_of_char(0).bold, Some(true)); -} - -/// Myanmar (#604): typed in memory order (consonant, medial, then the prevowel ေ that is drawn -/// before it), the caret steps over whole syllables and Backspace removes one sign at a time. -#[test] -fn myanmar_typing_steps_by_syllable_and_backspaces_by_sign() { - // ကြေး ("money"): က + ြ + ေ + း, then မြန်မာ. - let word = "\u{1000}\u{103C}\u{1031}\u{1038}"; - let mut s = typed(word); - assert_eq!(paras(&s), vec![para(word, None)]); - // Backspace takes the visarga, then the vowel sign: the consonant and medial stay. - typ(&mut s, "\u{8}"); - assert_eq!(paras(&s)[0].0, "\u{1000}\u{103C}\u{1031}"); - typ(&mut s, "\u{8}\u{1031}\u{1038}"); - assert_eq!(paras(&s)[0].0, word, "retyped"); - // Left from the end skips the whole syllable (the visarga and aa signs aren't grapheme - // extenders, so a grapheme step would stop inside it). - let mut s = typed("\u{1019}\u{102C}\u{1005}\u{102C}"); // မာစာ - run(&mut s, "caret.left", json!({})); - assert_eq!(s.sel.focus.off, 6); - run(&mut s, "caret.left", json!({})); - assert_eq!(s.sel.focus.off, 0); - run(&mut s, "caret.right", json!({})); - assert_eq!(s.sel.focus.off, 6); - // Delete removes the whole syllable after the caret. - run(&mut s, "text.delete", json!({})); - assert_eq!(paras(&s)[0].0, "\u{1019}\u{102C}"); -} - -/// Devanagari and Khmer: Backspace removes the last character (a matra), not the conjunct. -#[test] -fn indic_and_khmer_backspace_removes_one_character() { - let mut s = typed("\u{0915}\u{094D}\u{0937}\u{093F}\u{8}"); // क्षि ⌫ - assert_eq!(paras(&s)[0].0, "\u{0915}\u{094D}\u{0937}"); - typ(&mut s, "\u{8}"); - assert_eq!(paras(&s)[0].0, "\u{0915}\u{094D}"); - let s = typed("\u{1781}\u{17D2}\u{1798}\u{17C2}\u{8}"); // ខ្មែ ⌫ - assert_eq!(paras(&s)[0].0, "\u{1781}\u{17D2}\u{1798}"); - // Latin keeps grapheme Backspace: e + combining acute goes as one. - let s = typed("ae\u{301}\u{8}"); - assert_eq!(paras(&s)[0].0, "a"); -} - -/// Arabic vowel and Quranic marks are individually editable without retyping their base (#4757). -#[test] -fn arabic_backspace_removes_one_mark_and_keeps_the_base() { - for mark in [ - '\u{0610}', - '\u{064B}', - '\u{064C}', - '\u{064D}', - '\u{064E}', - '\u{064F}', - '\u{0650}', - '\u{0651}', - '\u{0652}', - '\u{065F}', - '\u{0670}', - '\u{06D6}', - '\u{06DF}', - '\u{06E7}', - '\u{06EA}', - '\u{0897}', - '\u{089F}', - '\u{08CA}', - '\u{08E1}', - '\u{08E3}', - '\u{08FF}', - '\u{10EFA}', - '\u{10EFF}', - ] { - let original = format!("Λ ب{mark}"); - let mut s = typed(&original); - typ(&mut s, "\u{8}"); - assert_eq!(paras(&s)[0].0, "Λ ب", "mark U+{:04X}", u32::from(mark)); - assert_eq!(s.sel.focus.off, "Λ ب".len()); - run(&mut s, "edit.undo", json!({})); - assert_eq!(paras(&s)[0].0, original); - run(&mut s, "edit.redo", json!({})); - typ(&mut s, "\u{0650}"); - assert_eq!(paras(&s)[0].0, "Λ ب\u{0650}"); - } -} - -#[test] -fn arabic_backspace_uses_stored_mark_order_but_delete_keeps_the_cluster() { - for marks in ["\u{0651}\u{064E}", "\u{064E}\u{0651}"] { - let mut s = typed(&format!("ب{marks}")); - typ(&mut s, "\u{8}"); - assert_eq!(paras(&s)[0].0, format!("ب{}", marks.chars().next().unwrap())); - typ(&mut s, "\u{8}"); - assert_eq!(paras(&s)[0].0, "ب"); - typ(&mut s, "\u{8}"); - assert!(paras(&s)[0].0.is_empty()); - let original = format!("ب{marks}ت"); - let mut s = typed(&original); - // Arabic arrows move visually. They still visit only whole-cluster boundaries. - let boundaries = [0, original.len() - "ت".len(), original.len()]; - let mut visited = vec![s.sel.focus.off]; - for command in ["caret.right", "caret.right", "caret.left", "caret.left"] { - run(&mut s, command, json!({})); - assert!(boundaries.contains(&s.sel.focus.off)); - visited.push(s.sel.focus.off); - } - assert!(boundaries.iter().all(|off| visited.contains(off))); - s.sel = crate::Selection::caret(Pos::body(0, 0)); - run(&mut s, "text.delete", json!({})); - assert_eq!(paras(&s)[0].0, "ت"); - } - for cluster in ["e\u{0301}", "👩‍💻", "👍🏽", "🇵🇱", "ב\u{05B7}"] { - let s = typed(&format!("a{cluster}\u{8}")); - assert_eq!(paras(&s)[0].0, "a", "unchanged grapheme {cluster}"); - } -} - -/// Press a shortcut the way the keyboard does: run the command bound to it. -fn press(s: &mut Session, combo: &str) -> Result { - let id = s.registry.by_shortcut(combo).map(|c| c.id).unwrap_or_else(|| panic!("nothing bound to {combo}")); - s.run(id, &json!({})) -} - -/// Word's outline keys: Alt+Shift+arrows, ⌃⇧+arrows on macOS. -fn outline_key(arrow: &str) -> String { - if cfg!(target_os = "macos") { format!("Ctrl+Shift+{arrow}") } else { format!("Alt+Shift+{arrow}") } -} - -#[test] -fn alt_shift_down_then_up_moves_a_paragraph_and_back_and_undoes() { - let mut s = typed("One\nTwo\nThree"); - run(&mut s, "caret.docStart", json!({})); - press(&mut s, &outline_key("Down")).unwrap(); - assert_eq!(texts(&s), ["two", "one", "three"]); - assert_eq!(s.sel.focus, Pos::body(1, 0), "the caret moves with the paragraph"); - press(&mut s, &outline_key("Up")).unwrap(); - assert_eq!(texts(&s), ["one", "two", "three"]); - assert!(press(&mut s, &outline_key("Up")).is_err(), "nothing above the first paragraph"); - run(&mut s, "edit.undo", json!({})); - assert_eq!(texts(&s), ["two", "one", "three"], "each move is one undo step"); - run(&mut s, "edit.undo", json!({})); - assert_eq!(texts(&s), ["one", "two", "three"]); - // A selected paragraph (with its mark) moves alone; list items keep their list. - let mut s = typed("1. First\nSecond\n\nAfter"); - run(&mut s, "select.paragraph", json!({})); - press(&mut s, &outline_key("Up")).unwrap(); - assert_eq!(paras(&s), vec![para("First", Some(0)), para("After", None), para("Second", Some(0))]); - assert!(press(&mut s, &outline_key("Down")).is_ok()); - assert!(press(&mut s, &outline_key("Down")).is_err(), "nothing below the last paragraph"); -} - -fn style_of(s: &Session, i: usize) -> Option { - s.doc.body.get(i).and_then(|b| b.as_para()).and_then(|p| p.props.style.clone()) -} - -#[test] -fn alt_shift_left_and_right_promote_and_demote_headings_and_list_items() { - let mut s = typed("Intro\nBody"); - run(&mut s, "caret.docStart", json!({})); - run(&mut s, "para.heading2", json!({})); - run(&mut s, "caret.docEnd", json!({})); - // Promoted body text takes the level of the heading before it; then one level at a time. - press(&mut s, &outline_key("Left")).unwrap(); - assert_eq!(style_of(&s, 1).as_deref(), Some("Heading2")); - press(&mut s, &outline_key("Right")).unwrap(); - assert_eq!(style_of(&s, 1).as_deref(), Some("Heading3")); - press(&mut s, &outline_key("Left")).unwrap(); - press(&mut s, &outline_key("Left")).unwrap(); - assert_eq!(style_of(&s, 1).as_deref(), Some("Heading1")); - assert!(press(&mut s, &outline_key("Left")).is_err(), "Heading 1 is the top level"); - for _ in 0..8 { - press(&mut s, &outline_key("Right")).unwrap(); - } - assert_eq!(style_of(&s, 1).as_deref(), Some("Heading9")); - assert!(press(&mut s, &outline_key("Right")).is_err(), "Heading 9 is the lowest heading"); - // In a list they change the item's level, like Tab and Shift+Tab. - let mut s = typed("* One\nTwo"); - press(&mut s, &outline_key("Right")).unwrap(); - assert_eq!(paras(&s), vec![para("One", Some(0)), para("Two", Some(1))]); - press(&mut s, &outline_key("Left")).unwrap(); - assert_eq!(paras(&s), vec![para("One", Some(0)), para("Two", Some(0))]); -} - -#[test] -fn down_and_up_from_a_line_start_beside_a_table_go_into_it() { - let mut s = typed("Intro line\n"); - run(&mut s, "insert.table", json!({"rows": 2, "cols": 2})); - let (table, _, _) = s.sel.focus.path.cell().unwrap(); - typ(&mut s, "a"); - let cell = |r: u32, c: u32| { - let mut p = table.0.clone(); - p.extend([r, c, 0]); - wordcraft_doc::Path(p) - }; - // From the start of the line above, left of the cells' text: the first cell, then the one - // below, not the paragraph after the table. - run(&mut s, "caret.docStart", json!({})); - run(&mut s, "caret.down", json!({})); - assert_eq!(s.sel.focus.path, cell(0, 0)); - run(&mut s, "caret.down", json!({})); - assert_eq!(s.sel.focus.path, cell(1, 0), "then the cell below"); - // Up from the start of the paragraph after it: the last row's first cell. - run(&mut s, "caret.docEnd", json!({})); - run(&mut s, "caret.up", json!({})); - assert_eq!(s.sel.focus.path, cell(1, 0)); - // Shift+Down from the line above takes the first row, not the whole table. - run(&mut s, "caret.docStart", json!({})); - run(&mut s, "caret.down", json!({"extend": true})); - assert_eq!(s.sel.focus.path, cell(0, 1)); -} - -#[test] -fn alt_x_toggles_between_a_character_and_its_code() { - if !cfg!(target_os = "macos") { - assert_eq!(s().registry.by_shortcut("Alt+X").map(|c| c.id), Some("text.toggleUnicode")); - } - let mut s = s(); - run(&mut s, "text.insert", json!({"text": "x 00e9"})); - run(&mut s, "text.toggleUnicode", json!({})); - assert_eq!(texts(&s), ["x é"]); - run(&mut s, "text.toggleUnicode", json!({})); - assert_eq!(texts(&s), ["x 00e9"]); - run(&mut s, "edit.undo", json!({})); - assert_eq!(texts(&s), ["x é"], "one undo step"); - run(&mut s, "text.insert", json!({"text": " U+1F600"})); - run(&mut s, "text.toggleUnicode", json!({})); - assert_eq!(texts(&s), ["x é 😀"]); - // A selected code converts too. - run(&mut s, "text.insert", json!({"text": " 2014"})); - run(&mut s, "select.text", json!({"text": "2014"})); - run(&mut s, "text.toggleUnicode", json!({})); - assert_eq!(texts(&s), ["x é 😀 —"]); -} - -#[test] -fn alt_numpad_codes_map_like_word_on_windows() { - use crate::cmd::text::{alt_code_char, oem_code_page}; - let c = |code| alt_code_char(code, 1252, 437); - // Leading 0: Windows-1252. - assert_eq!(c("0233"), Some('é')); - assert_eq!(c("0128"), Some('€')); - assert_eq!(c("0150"), Some('–')); - assert_eq!(c("065"), Some('A')); - assert_eq!(c("0032"), Some(' ')); - assert_eq!(c("0255"), Some('ÿ')); - // No leading 0: the OEM code page (437 here, 850 in Western Europe). - assert_eq!(c("233"), Some('Θ')); - // The issue said 233 → é in 850; the CP850 mapping file has Ú at 0xE9 (é is 130 in both). - assert_eq!(alt_code_char("233", 1252, 850), Some('Ú')); - assert_eq!(alt_code_char("130", 1252, 850), Some('é')); - assert_eq!(c("130"), Some('é')); - assert_eq!(alt_code_char("155", 1252, 850), Some('ø')); - assert_eq!(c("155"), Some('¢')); - assert_eq!(c("65"), Some('A')); - assert_eq!(c("1"), Some('☺')); - assert_eq!(c("7"), Some('•')); - assert_eq!(c("127"), Some('⌂')); - assert_eq!(c("176"), Some('░')); - // Above 255: the Unicode character with that decimal value. - assert_eq!(c("8364"), Some('€')); - assert_eq!(c("09733"), Some('★')); - assert_eq!(c("128512"), Some('😀')); - // Nothing for junk, 0, control characters, surrogates or values past Unicode. - for bad in ["", "0", "000", "0009", "0013", "0127", "0129", "x1", "-5", "55296", "1114112", "99999999999999999999", "4294967361"] { - assert_eq!(c(bad), None, "{bad:?}"); - } - assert_eq!(oem_code_page(None), 437); - assert_eq!(oem_code_page(Some("en-US")), 437); - assert_eq!(oem_code_page(Some("en_GB.UTF-8")), 850); - assert_eq!(oem_code_page(Some("fr-FR")), 850); - assert_eq!(oem_code_page(Some("de")), 850); - assert_eq!(oem_code_page(Some("ru-RU")), 437, "only 437/850 without the shell's code pages"); -} - -#[test] -fn alt_numpad_codes_follow_the_system_code_pages() { - use crate::cmd::text::{alt_code_char, windows_code_pages}; - // Polish (1250/852): Alt+0185 and Alt+165 are both ą, not ¹ / Ñ. - assert_eq!(alt_code_char("0185", 1250, 852), Some('ą')); - assert_eq!(alt_code_char("165", 1250, 852), Some('ą')); - assert_eq!(alt_code_char("0185", 1252, 437), Some('¹')); - // Russian (1251/866), Greek (1253/737), Turkish (1254/857). - assert_eq!(alt_code_char("0207", 1251, 866), Some('П')); - assert_eq!(alt_code_char("143", 1251, 866), Some('П')); - assert_eq!(alt_code_char("0193", 1253, 737), Some('Α')); - assert_eq!(alt_code_char("128", 1253, 737), Some('Α')); - assert_eq!(alt_code_char("0253", 1254, 857), Some('ı')); - assert_eq!(alt_code_char("141", 1254, 857), Some('ı')); - // Above 255 the code page doesn't matter. - assert_eq!(alt_code_char("8364", 1251, 866), Some('€')); - // A byte the code page leaves unassigned types nothing. - assert_eq!(alt_code_char("213", 1254, 857), None); - assert_eq!(alt_code_char("0219", 874, 437), None); - assert_eq!(windows_code_pages(None), (1252, 437)); - assert_eq!(windows_code_pages(Some("en-US")), (1252, 437)); - assert_eq!(windows_code_pages(Some("de-DE")), (1252, 850)); - assert_eq!(windows_code_pages(Some("pl-PL")), (1250, 852)); - assert_eq!(windows_code_pages(Some("sr-Latn-RS")), (1250, 852)); - assert_eq!(windows_code_pages(Some("ru_RU.UTF-8")), (1251, 866)); - assert_eq!(windows_code_pages(Some("el-GR")), (1253, 737)); - assert_eq!(windows_code_pages(Some("tr-TR")), (1254, 857)); - assert_eq!(windows_code_pages(Some("ja-JP")), (1252, 437)); -} - -#[test] -fn alt_numpad_code_types_one_character() { - // `texts` lowercases; the capitals matter here. - let raw = |s: &Session| paras(s).into_iter().map(|p| p.0).collect::>(); - let mut s = s(); - run(&mut s, "text.insert", json!({"text": "caf"})); - run(&mut s, "text.typeCode", json!({"code": "0233"})); - run(&mut s, "text.insert", json!({"text": " "})); - run(&mut s, "text.typeCode", json!({"code": "233", "oem": 437})); - run(&mut s, "text.insert", json!({"text": " "})); - run(&mut s, "text.typeCode", json!({"code": "155", "oem": 850})); - assert_eq!(raw(&s), ["Café Θ ø"]); - run(&mut s, "edit.undo", json!({})); - assert_eq!(raw(&s), ["Café Θ "], "one undo step per code"); - assert!(s.run("text.typeCode", &json!({"code": "0"})).is_err()); - assert!(s.run("text.typeCode", &json!({"code": "65", "oem": 855})).is_err()); - assert!(s.run("text.typeCode", &json!({"code": "065", "ansi": 932})).is_err()); - assert!(s.run("text.typeCode", &json!({"code": "065", "ansi": 70000})).is_err()); - assert_eq!(raw(&s), ["Café Θ "]); - // The system's code pages: Polish Alt+0185 and Alt+165 both type ą. - run(&mut s, "text.typeCode", json!({"code": "0185", "ansi": 1250, "oem": 852})); - run(&mut s, "text.typeCode", json!({"code": "165", "ansi": 1250, "oem": 852})); - run(&mut s, "text.typeCode", json!({"code": "143", "oem": 866})); - assert_eq!(raw(&s), ["Café Θ ąąП"]); -} - -#[test] -fn shift_f5_goes_back_through_the_last_edits() { - let mut s = typed("One\nTwo\nThree"); - run(&mut s, "caret.docStart", json!({})); - let mut back = || { - press(&mut s, "Shift+F5").unwrap(); - s.sel.focus.clone() - }; - assert_eq!(back(), Pos::body(2, 5), "the last edit first"); - assert_eq!(back(), Pos::body(1, 3)); - assert_eq!(back(), Pos::body(0, 3)); - assert_eq!(back(), Pos::body(2, 5), "then round again"); - assert!(Session::new(wordcraft_doc::Document::new()).run("edit.goBack", &json!({})).is_err(), "nothing edited yet"); -} - -#[test] -fn shift_right_at_the_end_of_a_cell_selects_the_cell_first() { - let mut s = s(); - run(&mut s, "insert.table", json!({"rows": 2, "cols": 2})); - let (table, _, _) = s.sel.focus.path.cell().unwrap(); - typ(&mut s, "ab"); - run(&mut s, "caret.left", json!({})); - let shift_right = |s: &mut Session| { - run(s, "caret.right", json!({"extend": true})); - s.cell_range().map(|r| (r.rows, r.cols)) - }; - assert_eq!(shift_right(&mut s), None, "the letter first"); - assert_eq!((s.sel.anchor.off, s.sel.focus.off), (1, 2)); - assert_eq!(shift_right(&mut s), Some(((0, 0), (0, 0))), "then the cell alone"); - assert_eq!(shift_right(&mut s), Some(((0, 0), (0, 1))), "then the next cell with it"); - // In an empty cell the first press takes it. - let mut empty = table.0.clone(); - empty.extend([1, 0, 0]); - let pos = Pos { story: StoryRef::Body, path: wordcraft_doc::Path(empty), off: 0 }; - run(&mut s, "caret.set", json!({"pos": pos})); - assert_eq!(shift_right(&mut s), Some(((1, 1), (0, 0)))); -} - -#[test] -fn two_initial_capitals_are_corrected() { - let s = typed("the WOrd and THe end, not IDs or CDs. TV is fine "); - assert_eq!(s.doc.plain_text(StoryRef::Body), "The Word and The end, not IDs or CDs. TV is fine "); - // "TEh" loses its second capital, then the replacement list fixes the typo. - assert_eq!(typed("TEh ").doc.plain_text(StoryRef::Body), "The "); - // A user exception. - let mut s = typed(""); - run(&mut s, "tools.autocorrect", json!({"addInitialCapsException": "WCraft"})); - typ(&mut s, "WCraft "); - assert_eq!(s.doc.plain_text(StoryRef::Body), "WCraft "); -} - -#[test] -fn accidental_caps_lock_is_corrected() { - let s = typed("a wORD and hELLO there. iOS stays "); - assert_eq!(s.doc.plain_text(StoryRef::Body), "A Word and Hello there. iOS stays "); -} - -#[test] -fn capitalisation_fixes_undo_and_turn_off() { - let mut s = typed("WOrd"); - typ(&mut s, " "); - assert_eq!(s.doc.plain_text(StoryRef::Body), "Word "); - run(&mut s, "edit.undo", json!({})); - assert_eq!(s.doc.plain_text(StoryRef::Body), "WOrd "); - let mut s = typed("x wORD"); - typ(&mut s, " "); - run(&mut s, "edit.undo", json!({})); - assert_eq!(s.doc.plain_text(StoryRef::Body), "X wORD "); - let mut s = typed(""); - run(&mut s, "tools.autocorrect", json!({"twoInitialCaps": false, "capsLock": false})); - typ(&mut s, "x WOrd wORD "); - assert_eq!(s.doc.plain_text(StoryRef::Body), "X WOrd wORD "); -} - -#[test] -fn first_letter_of_a_table_cell_is_capitalised() { - let cell_text = |s: &Session, c: usize| { - s.doc.body.iter().find_map(|b| b.as_table()).and_then(|t| t.rows.first()?.cells.get(c)?.blocks.first()?.as_para().map(|p| p.text.clone())) - }; - let mut s = typed(""); - run(&mut s, "insert.table", json!({"rows": 1, "cols": 2})); - typ(&mut s, "apples and pears\tplums "); - assert_eq!(cell_text(&s, 0).as_deref(), Some("Apples and pears")); - assert_eq!(cell_text(&s, 1).as_deref(), Some("Plums ")); - run(&mut s, "edit.undo", json!({})); - assert_eq!(cell_text(&s, 1).as_deref(), Some("plums ")); - let mut s = typed(""); - run(&mut s, "tools.autocorrect", json!({"capCells": false})); - run(&mut s, "insert.table", json!({"rows": 1, "cols": 1})); - typ(&mut s, "plums "); - assert_eq!(cell_text(&s, 0).as_deref(), Some("plums ")); -} - -/// File › Options › Advanced, "Typing replaces selected text" (#485): on (Word's default) the -/// typed text replaces the selection; off, it goes in front of the selection and keeps it. -#[test] -fn typing_over_a_selection_follows_the_replace_selection_option() { - let mut s = typed("One two"); - run(&mut s, "select.text", json!({"text": "two"})); - typ(&mut s, "X"); - assert_eq!(s.doc.plain_text(StoryRef::Body), "One X"); - let mut s = typed("One two"); - run(&mut s, "file.options", json!({"typingReplacesSelection": false})); - run(&mut s, "select.text", json!({"text": "two"})); - typ(&mut s, "XY"); - assert_eq!(s.doc.plain_text(StoryRef::Body), "One XYtwo"); -} - -/// Overtype (#485): typed characters replace the ones after the caret, never past the end of the -/// paragraph; the Insert key toggles it only with "Use the Insert key to control overtype" on -/// (the UI checks that option; `text.overtype` is the switch). -#[test] -fn overtype_replaces_the_characters_after_the_caret() { - let mut s = typed("Abcdef\nXy"); - run(&mut s, "caret.set", json!({"pos": Pos::body(0, 1)})); - run(&mut s, "text.overtype", json!({"value": true})); - typ(&mut s, "XY"); - assert_eq!(s.doc.plain_text(StoryRef::Body), "AXYdef\nXy"); - run(&mut s, "caret.set", json!({"pos": Pos::body(0, 5)})); - typ(&mut s, "123"); - assert_eq!(s.doc.plain_text(StoryRef::Body), "AXYde123\nXy", "the paragraph mark isn't typed over"); - run(&mut s, "text.overtype", json!({})); - run(&mut s, "caret.set", json!({"pos": Pos::body(0, 0)})); - typ(&mut s, "Z"); - assert_eq!(s.doc.plain_text(StoryRef::Body), "ZAXYde123\nXy", "insert mode again"); -} - -#[test] -fn reject_all_takes_out_the_paragraph_breaks_a_tracked_paste_inserted() { - // Issue #417: Paste's paragraph marks weren't tracked, so Reject All left the split. - for text in ["X\nY", "\n"] { - let mut s = s(); - run(&mut s, "text.insert", json!({"text": "AB", "raw": true})); - run(&mut s, "caret.set", json!({"pos": {"block": 0, "off": 1}})); - run(&mut s, "review.trackChanges", json!({"value": true})); - run(&mut s, "edit.paste", json!({"text": text})); - let (head, tail) = text.split_once('\n').unwrap_or_default(); - assert_eq!(texts(&s), [format!("a{}", head.to_lowercase()), format!("{}b", tail.to_lowercase())], "{text:?}"); - let pasted = texts(&s); - run(&mut s, "review.rejectAll", json!({})); - assert_eq!(texts(&s), ["ab"], "{text:?}"); - assert!(s.doc.revisions.is_empty(), "{text:?}"); - run(&mut s, "edit.undo", json!({})); - run(&mut s, "review.acceptAll", json!({})); - assert_eq!(texts(&s), pasted, "{text:?}: accepting keeps the break"); - } -} - -#[test] -fn plain_text_paste_keeps_an_empty_paragraph_in_a_list() { - // Issue #422: the pasted paragraph separators went through Enter, which ends the list on an - // empty item, so `A\n\nB` lost its empty paragraph. - // Paste of plain text from another program goes the same way. - for (id, v) in [ - ("edit.pasteText", json!({"text": "A\n\nB"})), - ("edit.pasteSpecial", json!({"as": "text", "text": "A\n\nB"})), - ("edit.paste", json!({"text": "A\n\nB"})), - ] { - let mut s = s(); - run(&mut s, "para.bullets", json!({})); - run(&mut s, id, v); - assert_eq!(paras(&s), vec![para("A", Some(0)), para("", Some(0)), para("B", Some(0))], "{id}"); - } - // Enter twice still ends the list. - let mut s = s(); - run(&mut s, "para.bullets", json!({})); - typ(&mut s, "A\n\nB"); - assert_eq!(paras(&s), vec![para("A", Some(0)), para("B", None)]); -} - -/// Each body paragraph's text and paragraph formatting. -fn para_formats(s: &Session) -> Vec<(String, wordcraft_doc::props::ParaProps)> { - s.doc.body.iter().filter_map(|b| b.as_para()).map(|p| (p.text.clone(), p.props.clone())).collect() -} - -/// Plain text from another program pastes as Keep Text Only does, as in Word: every pasted line -/// becomes a paragraph formatted like the paragraph it lands in. Paste gave the lines after the -/// first default formatting, and the text after the caret went with the last line, so a -/// right-to-left paragraph came out left-to-right from its second line on. -#[test] -fn plain_text_paste_gives_every_line_the_formatting_of_its_paragraph() { - let mut s = s(); - run(&mut s, "text.insert", json!({"text": "بداية نهاية", "raw": true})); - run(&mut s, "para.rtl", json!({})); - run(&mut s, "para.indents", json!({"left": 18, "right": 36})); - run(&mut s, "para.spacing", json!({"after": 12})); - let before = para_formats(&s); - let [(_, props)] = &before[..] else { panic!("{before:?}") }; - assert_eq!(props.bidi, Some(true)); - let off = "بداية ".len(); - for id in ["edit.paste", "edit.pasteText"] { - run(&mut s, "caret.set", json!({"pos": {"block": 0, "off": off}})); - run(&mut s, id, json!({"text": "سطر أول\r\nسطر ثان\r\nسطر ثالث"})); - let want: Vec<(String, _)> = ["بداية سطر أول", "سطر ثان", "سطر ثالثنهاية"].iter().map(|t| (t.to_string(), props.clone())).collect(); - assert_eq!(para_formats(&s), want, "{id}"); - // One undo step takes the whole paste out again. - run(&mut s, "edit.undo", json!({})); - assert_eq!(para_formats(&s), before, "{id}"); - } -} - -/// Empty lines in pasted plain text come through as empty paragraphs, formatted like the rest, -/// and so does a line break at the end of the text. The text takes the formatting before the -/// caret. -#[test] -fn plain_text_paste_keeps_empty_lines_as_paragraphs() { - let mut s = s(); - run(&mut s, "text.insert", json!({"text": "Lead ", "raw": true})); - run(&mut s, "select.all", json!({})); - run(&mut s, "format.bold", json!({})); - run(&mut s, "caret.docEnd", json!({})); - run(&mut s, "para.alignCenter", json!({})); - run(&mut s, "para.spacing", json!({"before": 6, "after": 18})); - let props = para_formats(&s)[0].1.clone(); - run(&mut s, "edit.paste", json!({"text": "Alpha one.\n\nAlpha two.\n\n\nAlpha three.\n"})); - let got = para_formats(&s); - let texts: Vec<&str> = got.iter().map(|(t, _)| t.as_str()).collect(); - assert_eq!(texts, ["Lead Alpha one.", "", "Alpha two.", "", "", "Alpha three.", ""]); - assert!(got.iter().all(|(_, p)| *p == props), "{got:?}"); - assert_eq!(s.sel.focus, Pos::body(6, 0), "the caret ends after the pasted text"); - let bold = s.doc.body.iter().filter_map(|b| b.as_para()).all(|p| p.runs.iter().all(|r| r.props.bold == Some(true))); - assert!(bold, "the pasted text takes the bold before the caret"); -} - -/// #713: Arabic or Hebrew typed into an empty paragraph makes it right-to-left; a paragraph that -/// already has letters, or whose style sets a direction, keeps its own. -#[test] -fn typing_rtl_letters_into_an_empty_paragraph_makes_it_rtl() { - let bidi = |s: &Session, i: usize| s.doc.body.iter().filter_map(|b| b.as_para()).nth(i).and_then(|p| p.props.bidi); - // Empty paragraph: the first Arabic letter flips it, the rest keep it. - let s = typed("سلام"); - assert_eq!(bidi(&s, 0), Some(true)); - // Digits first, then Arabic: still no letters before, so it flips. - let s = typed("1. سلام"); - assert_eq!(bidi(&s, 0), Some(true)); - // Latin first: Arabic typed later doesn't flip the paragraph. - let s = typed("Hello سلام"); - assert_eq!(bidi(&s, 0), None); - // A new paragraph after an RTL one starts its own detection. - let s = typed("سلام\nHello"); - assert_eq!(bidi(&s, 1), Some(true), "Enter carries the paragraph's direction on, as in Word"); - // A style that sets left-to-right wins. - let mut doc = Session::new(wordcraft_doc::Document::new()); - if let Some(st) = Arc::make_mut(&mut doc.doc.styles).get_mut("Normal") { - st.para.bidi = Some(false); - } - typ(&mut doc, "سلام"); - assert_eq!(bidi(&doc, 0), None); - // An opened paragraph without w:bidi lays out left-to-right whatever its text: the caret - // after the Arabic word sits near the left margin, not the right one. - let mut doc = typed("سلام"); - let path = doc.sel.focus.path.clone(); - if let Ok(p) = doc.doc.para_mut(StoryRef::Body, &path) { - p.props.bidi = None; - p.touch(); - } - let x = doc.layout().caret(&doc.sel.focus).map(|c| c.x).unwrap_or(f32::MAX); - assert!(x < 300.0, "left-to-right paragraph: caret at {x}"); -} - -/// Select All then Delete or Backspace empties the document, tables included (Word, #1198): -/// whether the text starts with a table or with a paragraph. -#[test] -fn select_all_then_delete_removes_tables() { - let tables = |s: &Session| s.doc.body.iter().filter(|b| b.as_table().is_some()).count(); - // With the caret in a table, Select All grows the selection a step at a time (cell, row, - // table, document; #1478): press it until the whole document is selected. - let select_whole_document = |s: &mut Session| { - for _ in 0..6 { - let before = s.sel.clone(); - run(s, "select.all", json!({})); - if s.sel == before { - break; - } - } - }; - for key in ["text.delete", "text.backspace"] { - let mut s = typed(""); - run(&mut s, "insert.table", json!({"rows": 2, "cols": 2})); - typ(&mut s, "cell"); - assert_eq!(tables(&s), 1); - select_whole_document(&mut s); - run(&mut s, key, json!({})); - assert_eq!(tables(&s), 0, "{key}: a table at the start of the document goes"); - assert_eq!(s.doc.plain_text(StoryRef::Body), "", "{key}"); - - let mut s = typed("before\n"); - run(&mut s, "insert.table", json!({"rows": 2, "cols": 2})); - typ(&mut s, "cell"); - assert_eq!(tables(&s), 1); - select_whole_document(&mut s); - run(&mut s, key, json!({})); - assert_eq!(tables(&s), 0, "{key}: a table after text goes"); - assert_eq!(s.doc.plain_text(StoryRef::Body), "", "{key}"); - } -} - -/// #898: a click just after a citation at the end of a paragraph puts the caret before the -/// citation's hidden field end; what is typed there goes after the citation, as in Word, instead -/// of into its result (Zotero then reports the citation as edited by hand). -#[test] -fn typing_after_a_citation_goes_outside_its_field() { - use wordcraft_doc::props::CharProps; - use wordcraft_doc::{InlineObject, Path}; - let mut doc = wordcraft_doc::Document::from_text("Cats purr "); - let c = CharProps::default(); - let p = doc.para_mut(StoryRef::Body, &Path::top(0)).unwrap(); - let start = p.len(); - p.insert_object(start, InlineObject::FieldStart { instr: "ADDIN ZOTERO_ITEM CSL_CITATION {}".into(), locked: false, dirty: false }, &c).unwrap(); - let n = p.insert_text(p.len(), "(Smith 2020)", &c).unwrap(); - p.insert_object(start + 3 + n, InlineObject::FieldEnd, &c).unwrap(); - let mut s = Session::new(doc); - let end = Pos::body(0, s.doc.para(StoryRef::Body, &Path::top(0)).unwrap().len()); - let at = s.layout().caret(&end).unwrap(); - let hit = s.layout().hit(at.page, at.x + 20.0, at.top + at.height / 2.0, StoryRef::Body).unwrap(); - s.sel = crate::Selection::caret(hit); - typ(&mut s, ". Dogs bark."); - assert_eq!(s.doc.plain_text(StoryRef::Body), "Cats purr (Smith 2020). Dogs bark."); - let f = s.doc.field_ranges(StoryRef::Body); - assert_eq!(f.len(), 1); - let inside = Pos { off: f[0].start.off + wordcraft_doc::para::OBJ.len_utf8(), ..f[0].start.clone() }; - assert_eq!(s.doc.copy_range(&inside, &f[0].end).plain_text(), "(Smith 2020)"); -} - -/// Tab in a table typed in right-to-left text (#2634): like Word, a table inserted in a -/// right-to-left paragraph is a right-to-left table, so its first cell stands at the right and -/// Tab moves to the cell on the left (the next one in reading order); Shift+Tab comes back. A -/// table inserted in a left-to-right paragraph is unchanged: Tab moves right. -#[test] -fn tab_in_a_table_inserted_in_right_to_left_text_moves_left() { - // The caret's cell column and the x of that cell on the page. - fn caret_cell_x(s: &mut Session) -> (usize, f32) { - let (_, r, c) = s.sel.focus.path.cell().expect("caret in a cell"); - let x = s - .layout() - .pages - .iter() - .flat_map(|p| p.items.iter()) - .find_map(|it| match it { - wordcraft_layout::Placed::Cell { rect, row, cell, covered: false, .. } if *row == r && *cell == c => Some(rect.x), - _ => None, - }) - .expect("cell laid out"); - (c, x) - } - for (keys, rtl) in [("مرحبا\n", true), ("Hello\n", false)] { - let mut s = s(); - typ(&mut s, keys); - run(&mut s, "insert.table", json!({"rows": 1, "cols": 3})); - let t = s.doc.body.iter().find_map(|b| b.as_table()).expect("table"); - assert_eq!(t.props.bidi_visual, rtl, "{keys:?}: the table takes the paragraph's direction"); - let (c0, x0) = caret_cell_x(&mut s); - typ(&mut s, "\t"); - let (c1, x1) = caret_cell_x(&mut s); - assert_eq!((c0, c1), (0, 1), "{keys:?}: Tab goes to the next cell in reading order"); - assert_eq!(x1 < x0, rtl, "{keys:?}: Tab moves {} ({x0} -> {x1})", if rtl { "left" } else { "right" }); - run(&mut s, "text.backTab", json!({})); - assert_eq!(caret_cell_x(&mut s), (0, x0), "{keys:?}: Shift+Tab comes back"); - } -} - -#[test] -fn ctrl_tab_inserts_a_tab_inside_a_table_cell() { - let mut s = s(); - run(&mut s, "insert.table", json!({"rows": 1, "cols": 2})); - typ(&mut s, "A"); - run(&mut s, "text.insertTab", json!({})); - typ(&mut s, "B"); - let (_, r, c) = s.sel.focus.path.cell().expect("caret in a cell"); - assert_eq!((r, c), (0, 0), "Ctrl+Tab stays in the cell"); - assert_eq!(s.doc.para_at(&s.sel.focus).map(|p| p.text.as_str()), Some("A\tB")); - let key = if cfg!(target_os = "macos") { "Alt+Tab" } else { "Mod+Tab" }; - assert_eq!(s.keymap.resolve(&s.registry, key).map(|c| c.id), Some("text.insertTab")); -} - -/// A .docx of two paragraphs in two sections, as Word writes it: the first section's properties -/// in the first paragraph's mark (portrait), the last section's in the body (landscape, starting -/// as `kind`). -fn two_section_docx(kind: &str) -> Vec { - const W: &str = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"; - let files = [ - ( - "[Content_Types].xml", - r#""#.to_string(), - ), - ( - "_rels/.rels", - r#""#.to_string(), - ), - ( - "word/document.xml", - format!( - r#"One.Two."# - ), - ), - ]; - let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); - for (name, data) in files { - zip.start_file(name, zip::write::SimpleFileOptions::default()).unwrap(); - std::io::Write::write_all(&mut zip, data.as_bytes()).unwrap(); - } - zip.finish().unwrap().into_inner() -} - -/// #2127: Backspace just after a section break, or Delete just before it, removes the break, as -/// in Word: the two paragraphs join, the text before the break takes the following section's -/// page setup, and Undo brings the break back. Imported breaks and ones made here alike. -#[test] -fn backspace_and_delete_remove_a_section_break() { - let made_here = || { - let mut s = typed("One.Two."); - s.sel = crate::Selection::caret(Pos::body(0, 4)); - run(&mut s, "layout.break", json!({"kind": "nextPage"})); - s - }; - for source in ["nextPage", "continuous", "made here"] { - for (key, caret) in [("text.backspace", Pos::body(1, 0)), ("text.delete", Pos::body(0, 4))] { - let mut s = match source { - "made here" => made_here(), - kind => Session::new(crate::io::open_bytes("s.docx", &two_section_docx(kind)).unwrap()), - }; - assert_eq!(s.doc.sections().len(), 2, "{source}"); - assert_eq!(s.doc.plain_text(StoryRef::Body), "One.\nTwo.", "{source}"); - let after = s.doc.sections().last().map(|(_, sp)| (**sp).clone()).unwrap(); - s.sel = crate::Selection::caret(caret); - run(&mut s, key, json!({})); - assert_eq!(s.doc.plain_text(StoryRef::Body), "One.Two.", "{source} {key}"); - let sections = s.doc.sections(); - assert_eq!(sections.len(), 1, "{source} {key}: the break is gone"); - assert_eq!(*sections[0].1, after, "{source} {key}: the following section's setup"); - run(&mut s, "edit.undo", json!({})); - assert_eq!(s.doc.plain_text(StoryRef::Body), "One.\nTwo.", "{source} {key}: undo"); - assert_eq!(s.doc.sections().len(), 2, "{source} {key}: undo brings the break back"); - } - } -} - -/// A .docx laid out like the one attached to #2127: a continuous section break, with the -/// document's only header, on the mark of an empty paragraph after "One.", and the last -/// section (nextPage, no header of its own, so linked to the previous one) in the body. -/// `then` adds a paragraph after the break; without it the break is on the last paragraph and -/// the last section has none. -fn break_on_the_last_paragraph_docx(then: Option<&str>) -> Vec { - const W: &str = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"; - const R: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; - let then = then.map(|t| format!("{t}")).unwrap_or_default(); - let files = [ - ( - "[Content_Types].xml", - r#""#.to_string(), - ), - ( - "_rels/.rels", - r#""#.to_string(), - ), - ( - "word/_rels/document.xml.rels", - r#""#.to_string(), - ), - ("word/header1.xml", format!(r#"Lab report"#)), - ( - "word/document.xml", - format!( - r#"One.{then}"# - ), - ), - ]; - let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); - for (name, data) in files { - zip.start_file(name, zip::write::SimpleFileOptions::default()).unwrap(); - std::io::Write::write_all(&mut zip, data.as_bytes()).unwrap(); - } - zip.finish().unwrap().into_inner() -} - -/// #2127: Delete at the end of the last paragraph removes a section break on its mark too, -/// though there's no paragraph after it to join. The text takes the last section's setup, and -/// the header the last section showed by linking to the removed one stays. Removing the break -/// by joining two paragraphs keeps that header as well. Undo brings the break back. -#[test] -fn delete_removes_a_section_break_on_the_last_paragraph() { - for then in [None, Some("Two.")] { - let mut s = Session::new(crate::io::open_bytes("s.docx", &break_on_the_last_paragraph_docx(then)).unwrap()); - let sections = s.doc.sections(); - assert_eq!(sections.len(), 2, "{then:?}"); - let header = sections[0].1.headers.default; - assert!(header.is_some() && sections[1].1.headers.default.is_none(), "{then:?}: the header is on the break's section"); - let mut after = sections[1].1.clone(); - after.headers.default = header; - s.sel = crate::Selection::caret(Pos::body(1, 0)); - run(&mut s, "text.delete", json!({})); - let text = if then.is_some() { "One.\nTwo." } else { "One.\n" }; - assert_eq!(s.doc.plain_text(StoryRef::Body), text, "{then:?}"); - let sections = s.doc.sections(); - assert_eq!(sections.len(), 1, "{then:?}: the break is gone"); - assert_eq!(*sections[0].1, after, "{then:?}: the last section's setup, with the header"); - run(&mut s, "edit.undo", json!({})); - let sections = s.doc.sections(); - assert_eq!(sections.len(), 2, "{then:?}: undo brings the break back"); - assert_eq!((sections[0].1.headers.default, sections[1].1.headers.default), (header, None), "{then:?}: undo"); - } -} - -fn first_letter_list(s: &Session) -> Vec { - s.prefs.autocorrect.exceptions.clone() -} - -/// Undoing an automatic sentence capital adds the word before it to the First Letter exceptions -/// (Word: "Automatically add words to list", #1857); typing the line again isn't capitalised. -#[test] -fn undoing_a_sentence_capital_adds_a_first_letter_exception() { - let mut s = typed("hello there. world "); - assert_eq!(s.doc.plain_text(StoryRef::Body), "Hello there. World "); - run(&mut s, "edit.undo", json!({})); - assert_eq!(s.doc.plain_text(StoryRef::Body), "Hello there. world "); - assert_eq!(first_letter_list(&s), ["there."]); - run(&mut s, "text.newParagraph", json!({})); - typ(&mut s, "x there. world "); - assert_eq!(s.doc.plain_text(StoryRef::Body), "Hello there. world \nX there. world "); -} - -#[test] -fn backspacing_a_sentence_capital_and_retyping_adds_a_first_letter_exception() { - let mut s = typed("hello there. world "); - typ(&mut s, "\u{8}\u{8}\u{8}\u{8}\u{8}\u{8}world "); - assert_eq!(s.doc.plain_text(StoryRef::Body), "Hello there. world "); - assert_eq!(first_letter_list(&s), ["there."]); -} - -#[test] -fn automatic_exceptions_can_be_turned_off() { - let mut s = typed(""); - run(&mut s, "tools.autocorrect", json!({"autoAddFirstLetter": false})); - typ(&mut s, "hello there. world "); - run(&mut s, "edit.undo", json!({})); - assert!(first_letter_list(&s).is_empty()); - let mut s = typed(""); - run(&mut s, "tools.autocorrect", json!({"autoAddFirstLetter": false})); - typ(&mut s, "hello there. world \u{8}\u{8}\u{8}\u{8}\u{8}\u{8}world "); - assert_eq!(s.doc.plain_text(StoryRef::Body), "Hello there. World "); - assert!(first_letter_list(&s).is_empty()); -} - -#[test] -fn undoing_a_replacement_adds_no_exception() { - let mut s = typed("so teh "); - assert_eq!(s.doc.plain_text(StoryRef::Body), "So the "); - run(&mut s, "edit.undo", json!({})); - assert_eq!(s.doc.plain_text(StoryRef::Body), "So teh "); - assert!(first_letter_list(&s).is_empty()); - assert!(s.prefs.autocorrect.entries.is_empty() && s.prefs.autocorrect.removed.is_empty()); - typ(&mut s, "and teh "); - assert_eq!(s.doc.plain_text(StoryRef::Body), "So teh and the "); -} - -/// Checked in Word for Mac: the same rule as First Letter. -#[test] -fn undoing_an_initial_caps_fix_adds_an_initial_caps_exception() { - let mut s = typed("so TWo "); - run(&mut s, "edit.undo", json!({})); - assert_eq!(s.prefs.autocorrect.initial_caps_exceptions, ["TWo"]); - typ(&mut s, "and TWo "); - assert_eq!(s.doc.plain_text(StoryRef::Body), "So TWo and TWo "); -} - -#[test] -fn undoing_other_edits_after_a_capital_adds_no_exception() { - // Typing more, then one Undo, and a second Undo that reverses the capital's step later. - let mut s = typed("hello there. world more text"); - run(&mut s, "edit.undo", json!({})); - assert!(first_letter_list(&s).is_empty()); - run(&mut s, "edit.undo", json!({})); - assert!(first_letter_list(&s).is_empty(), "only the Undo that reverses the capital itself counts"); -} - -#[test] -fn a_full_undo_history_does_not_match_later_undos() { - let mut s = typed(""); - for _ in 0..300 { - typ(&mut s, "x\n"); - } - typ(&mut s, "hello there. world more text\nand more"); - run(&mut s, "edit.undo", json!({})); - assert!(first_letter_list(&s).is_empty()); -} - -#[test] -fn a_new_document_forgets_the_capital() { - let mut s = typed("hello there. world "); - run(&mut s, "file.new", json!({})); - typ(&mut s, "abc\ndef\nghi\njkl"); - for _ in 0..8 { - let _ = s.run("edit.undo", &json!({})); - } - assert!(first_letter_list(&s).is_empty()); -} - -#[test] -fn an_overlong_word_is_not_added_as_an_exception() { - let mut s = typed(&format!("hi {}. world ", "a".repeat(5000))); - run(&mut s, "edit.undo", json!({})); - assert!(first_letter_list(&s).is_empty()); -} - -/// Each body paragraph's laid-out number (`None`: none) and its style. -fn numbers(s: &mut Session) -> Vec<(Option, String)> { - let l = s.layout(); - let labels: Vec> = l - .pages - .iter() - .flat_map(|p| &p.items) - .filter_map(|i| { - if let wordcraft_layout::Placed::Lines { para, l0: 0, .. } = i { Some(para.label.as_ref().map(|lb| lb.text.clone())) } else { None } - }) - .collect(); - let styles = s.doc.body.iter().filter_map(|b| b.as_para()).map(|p| p.props.style.clone().unwrap_or_else(|| "Normal".into())); - labels.into_iter().zip(styles).collect() -} - -fn num(label: Option<&str>, style: &str) -> (Option, String) { - (label.map(str::to_string), style.to_string()) -} - -/// #1793 (Word for Mac, 2026-10-15): after the "1 / 1.1 / 1.1.1" heading list, a new Heading 2 -/// typed after "Three" is numbered 2.1 on its own; the body text after it stays unnumbered. -#[test] -fn a_new_heading_after_a_heading_linked_list_is_numbered() { - let mut s = s(); - typ(&mut s, "One"); - run(&mut s, "para.heading1", json!({})); - typ(&mut s, "\nTwo"); - run(&mut s, "para.heading2", json!({})); - typ(&mut s, "\nThree"); - run(&mut s, "para.heading1", json!({})); - // The caret in "Three" (a Heading 1, the list's first level). - run(&mut s, "list.multilevelPreset", json!({"preset": "headings"})); - run(&mut s, "caret.docEnd", json!({})); - typ(&mut s, "\n"); - run(&mut s, "para.heading2", json!({})); - typ(&mut s, "Four\nBody"); - assert_eq!( - numbers(&mut s), - [num(Some("1"), "Heading1"), num(Some("1.1"), "Heading2"), num(Some("2"), "Heading1"), num(Some("2.1"), "Heading2"), num(None, "Normal")] - ); -} - -/// #1793: plain Numbering on a Heading 1 numbers it "1." and keeps Heading 1 (Word: "1. One"). -#[test] -fn numbering_a_heading_keeps_the_heading_style() { - let mut s = s(); - typ(&mut s, "One"); - run(&mut s, "para.heading1", json!({})); - run(&mut s, "para.numbering", json!({})); - assert_eq!(numbers(&mut s), [num(Some("1."), "Heading1")]); -} - -/// #1793: applying a heading style that carries list numbering numbers the paragraph, even one -/// whose numbering was switched off before. -#[test] -fn applying_a_numbered_heading_style_numbers_the_paragraph() { - let mut s = s(); - typ(&mut s, "One"); - run(&mut s, "para.heading1", json!({})); - run(&mut s, "list.multilevelPreset", json!({"preset": "headings"})); - typ(&mut s, "\nTwo"); - run(&mut s, "para.numbering", json!({})); - run(&mut s, "para.numbering", json!({"off": true})); - assert_eq!(numbers(&mut s)[1].0, None); - run(&mut s, "para.heading2", json!({})); - assert_eq!(numbers(&mut s), [num(Some("1"), "Heading1"), num(Some("1.1"), "Heading2")]); -} - -/// #1793: "1. " typed at the start of a heading numbers it and keeps the heading style; only -/// body text becomes List Paragraph. -#[test] -fn typing_1_dot_in_a_heading_keeps_the_heading_style() { - let mut s = s(); - run(&mut s, "para.heading1", json!({})); - typ(&mut s, "1. One\n"); - run(&mut s, "para.normal", json!({})); - typ(&mut s, "1. Body"); - assert_eq!(numbers(&mut s), [num(Some("1."), "Heading1"), num(Some("1."), "ListParagraph")]); - for (style, keys) in [("Heading2", "a) Part"), ("Subtitle", "1. Item")] { - let mut s = self::s(); - run(&mut s, "para.style", json!({"style": style})); - typ(&mut s, keys); - let got = numbers(&mut s); - assert_eq!(got.len(), 1); - assert!(got[0].0.is_some(), "{style}: a list"); - assert_eq!(got[0].1, style); - } -} - -// Backspace and Delete on selected tables, rows and columns (Word for Mac, #1844). - -/// "before", a 3×3 table with cells `a1`…`c3` (column letter, row number), "after"; the caret -/// in the cell at row `r`, column `c`. -fn table_between(r: u32, c: u32) -> Session { - let mut s = typed("before\n"); - run(&mut s, "insert.table", json!({"rows": 3, "cols": 3})); - typ(&mut s, "a1\tb1\tc1\ta2\tb2\tc2\ta3\tb3\tc3"); - run(&mut s, "caret.docEnd", json!({})); - typ(&mut s, "after"); - let shape: Vec<&str> = s.doc.body.iter().map(|b| if b.as_table().is_some() { "table" } else { "para" }).collect(); - assert_eq!(shape, ["para", "table", "para"], "setup"); - run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [1, r, c, 0], "off": 0}})); - s -} - -fn tables(s: &Session) -> usize { - s.doc.body.iter().filter(|b| b.as_table().is_some()).count() -} - -/// The first top-level table's cells, row by row (tracked deletions included). -fn table_cells(s: &Session) -> Vec> { - s.doc - .body - .iter() - .find_map(|b| b.as_table()) - .map(|t| { - t.rows - .iter() - .map(|r| r.cells.iter().map(|c| c.blocks.iter().filter_map(|b| b.as_para()).map(|p| p.plain_text()).collect()).collect()) - .collect() - }) - .unwrap_or_default() -} - -#[test] -fn backspace_with_the_whole_table_selected_removes_the_table() { - let mut s = table_between(1, 1); - run(&mut s, "table.selectTable", json!({})); - run(&mut s, "text.backspace", json!({})); - assert_eq!(tables(&s), 0, "the table stays: {:?}", table_cells(&s)); - assert_eq!(texts(&s), ["before", "after"]); - // The caret is in the paragraph that followed the table. - assert_eq!(s.sel.focus, Pos { story: StoryRef::Body, path: wordcraft_doc::Path::top(1), off: 0 }); - assert!(s.sel.is_collapsed()); - // One undo brings it back. - run(&mut s, "edit.undo", json!({})); - assert_eq!(tables(&s), 1); - assert_eq!(table_cells(&s), [["a1", "b1", "c1"], ["a2", "b2", "c2"], ["a3", "b3", "c3"]]); -} - -#[test] -fn delete_with_the_whole_table_selected_clears_the_cells_and_keeps_the_table() { - let mut s = table_between(0, 0); - run(&mut s, "table.selectTable", json!({})); - run(&mut s, "text.delete", json!({})); - assert_eq!(tables(&s), 1); - assert_eq!(table_cells(&s), [["", "", ""], ["", "", ""], ["", "", ""]]); - assert_eq!(texts(&s), ["before", "after"]); -} - -#[test] -fn delete_or_backspace_with_the_table_and_the_paragraph_after_selected_removes_the_table() { - for key in ["text.delete", "text.backspace"] { - // From the first cell to the end of the document: Word's rule widens it to whole rows. - let mut s = table_between(0, 0); - run(&mut s, "caret.docEnd", json!({"extend": true})); - run(&mut s, key, json!({})); - assert_eq!(tables(&s), 0, "{key}: {:?}", table_cells(&s)); - assert_eq!(texts(&s), ["before", ""], "{key}"); - } -} - -#[test] -fn delete_or_backspace_with_the_paragraph_before_and_the_table_selected_removes_the_table() { - for key in ["text.delete", "text.backspace"] { - // From the start of "before" down into the last row: the rows are taken whole. - let mut s = table_between(0, 0); - run(&mut s, "caret.docStart", json!({})); - for _ in 0..3 { - run(&mut s, "caret.down", json!({"extend": true})); - } - assert_eq!(s.sel.focus.path.cell().map(|(_, r, _)| r), Some(2), "{key}: the selection reaches the last row"); - run(&mut s, key, json!({})); - assert_eq!(tables(&s), 0, "{key}: {:?}", table_cells(&s)); - assert_eq!(texts(&s), ["after"], "{key}"); - assert_eq!(s.sel.focus, Pos { story: StoryRef::Body, path: wordcraft_doc::Path::top(0), off: 0 }, "{key}"); - } -} - -#[test] -fn backspace_deletes_selected_rows_and_delete_clears_them() { - let mut s = table_between(1, 0); - run(&mut s, "table.selectRow", json!({})); - run(&mut s, "text.backspace", json!({})); - assert_eq!(table_cells(&s), [["a1", "b1", "c1"], ["a3", "b3", "c3"]]); - - let mut s = table_between(1, 0); - run(&mut s, "table.selectRow", json!({})); - run(&mut s, "text.delete", json!({})); - assert_eq!(table_cells(&s), [["a1", "b1", "c1"], ["", "", ""], ["a3", "b3", "c3"]]); -} - -#[test] -fn backspace_deletes_selected_columns_and_delete_clears_them() { - let mut s = table_between(0, 1); - run(&mut s, "table.selectColumn", json!({})); - run(&mut s, "text.backspace", json!({})); - assert_eq!(table_cells(&s), [["a1", "c1"], ["a2", "c2"], ["a3", "c3"]]); - - let mut s = table_between(0, 1); - run(&mut s, "table.selectColumn", json!({})); - run(&mut s, "text.delete", json!({})); - assert_eq!(table_cells(&s), [["a1", "", "c1"], ["a2", "", "c2"], ["a3", "", "c3"]]); -} - -/// The first top-level table's cells, row by row, one entry per block: a paragraph's text, or -/// `` for a nested table. -fn cell_blocks(s: &Session) -> Vec>> { - let block = |b: &std::sync::Arc| b.as_para().map_or_else(|| "
".to_string(), |p| p.plain_text()); - s.doc - .body - .iter() - .find_map(|b| b.as_table()) - .map(|t| t.rows.iter().map(|r| r.cells.iter().map(|c| c.blocks.iter().map(block).collect()).collect()).collect()) - .unwrap_or_default() -} - -#[test] -fn delete_on_whole_cells_leaves_one_empty_paragraph_in_each() { - // a1 starts with a nested table and holds three paragraphs (centred, centred, right-aligned), - // b2 holds a nested table after its text (#2802). - let setup = || { - let mut s = table_between(0, 0); - run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [1, 0, 0, 0], "off": 2}})); - run(&mut s, "para.alignCenter", json!({})); - typ(&mut s, "\nX\nY"); - run(&mut s, "para.alignRight", json!({})); - run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [1, 1, 1, 0], "off": 2}})); - run(&mut s, "text.newParagraph", json!({})); - run(&mut s, "insert.table", json!({"rows": 1, "cols": 2})); - typ(&mut s, "n"); - let b2 = |s: &Session| s.doc.body.get(1).and_then(|b| b.as_table()).map(|t| t.rows[1].cells[1].blocks.clone()).unwrap_or_default(); - let nested = b2(&s).iter().find(|b| b.as_table().is_some()).map(|b| (**b).clone()).expect("setup: a table nested in b2"); - s.doc.insert_block(StoryRef::Body, &wordcraft_doc::Path(vec![1, 0, 0, 0]), nested).unwrap(); - s - }; - let all: Vec<(usize, usize)> = (0..3).flat_map(|r| (0..3).map(move |c| (r, c))).collect(); - let cases: [(&str, (usize, usize), &str, Vec<(usize, usize)>); 5] = [ - ("table.selectRow", (0, 0), "text.delete", vec![(0, 0), (0, 1), (0, 2)]), - ("table.selectColumn", (0, 0), "text.delete", vec![(0, 0), (1, 0), (2, 0)]), - ("table.selectRow", (1, 1), "text.delete", vec![(1, 0), (1, 1), (1, 2)]), - ("table.selectTable", (0, 0), "text.delete", all), - // One cell of several: Backspace clears it too. - ("table.selectCell", (0, 0), "text.backspace", vec![(0, 0)]), - ]; - for (select, (r, c), key, cleared) in cases { - let mut s = setup(); - let before = cell_blocks(&s); - assert_eq!(before[0][0], ["
", "a1", "X", "Y"], "setup"); - assert!(before[1][1].len() > 2 && before[1][1].contains(&"
".to_string()), "setup: {:?}", before[1][1]); - // a1's first paragraph is after the nested table. - let first = usize::from((r, c) == (0, 0)); - run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [1, r, c, first], "off": 0}})); - run(&mut s, select, json!({})); - run(&mut s, key, json!({})); - let mut want = before.clone(); - for &(r, c) in &cleared { - want[r][c] = vec![String::new()]; - } - assert_eq!(cell_blocks(&s), want, "{select} {key}"); - // The paragraph left keeps the cell's last paragraph's formatting (its mark ends the cell). - if cleared.contains(&(0, 0)) { - let p = s.doc.para(StoryRef::Body, &wordcraft_doc::Path(vec![1, 0, 0, 0])).map(|p| p.props.align); - assert_eq!(p, Some(Some(wordcraft_doc::props::Align::Right)), "{select} {key}"); - } - assert!(s.sel.is_collapsed(), "{select} {key}"); - assert_eq!(s.sel.focus.path.cell().map(|(_, r, c)| (r, c)), cleared.first().copied(), "{select} {key}"); - // One undo brings everything back. - run(&mut s, "edit.undo", json!({})); - assert_eq!(cell_blocks(&s), before, "{select} {key}: undo"); - } -} - -#[test] -fn backspace_on_a_selected_nested_table_removes_only_it() { - let mut s = table_between(1, 1); - run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [1, 1, 1, 0], "off": 2}})); - run(&mut s, "text.newParagraph", json!({})); - run(&mut s, "insert.table", json!({"rows": 2, "cols": 2})); - typ(&mut s, "x"); - let cell_blocks = |s: &Session| { - s.doc - .body - .iter() - .find_map(|b| b.as_table()) - .and_then(|t| t.rows.get(1)?.cells.get(1)) - .map(|c| c.blocks.iter().filter(|b| b.as_table().is_some()).count()) - }; - assert_eq!(cell_blocks(&s), Some(1), "setup: a table nested in b2"); - run(&mut s, "table.selectTable", json!({})); - run(&mut s, "text.backspace", json!({})); - assert_eq!(cell_blocks(&s), Some(0), "the nested table stays"); - assert_eq!(tables(&s), 1, "the outer table goes too"); - // The caret is in the cell's paragraph after where the nested table was. - assert_eq!(s.sel.focus.path.cell().map(|(t, r, c)| (t.0, r, c)), Some((vec![1], 1, 1))); - assert!(s.doc.para_at(&s.sel.focus).is_some()); -} - -#[test] -fn backspace_on_a_selected_table_that_is_the_only_block_leaves_an_empty_paragraph() { - let mut s = s(); - run(&mut s, "insert.table", json!({"rows": 2, "cols": 2})); - typ(&mut s, "x"); - s.doc.body.retain(|b| b.as_table().is_some()); - assert_eq!(s.doc.body.len(), 1, "setup: the table is the only block"); - run(&mut s, "table.selectTable", json!({})); - run(&mut s, "text.backspace", json!({})); - assert_eq!(tables(&s), 0); - assert_eq!(texts(&s), [""]); - assert!(s.doc.para_at(&s.sel.focus).is_some(), "the caret is in a paragraph: {:?}", s.sel.focus); -} - -#[test] -fn tracked_backspace_on_a_selected_table_marks_its_text_deleted_like_delete_table() { - let mut s = table_between(0, 0); - run(&mut s, "review.trackChanges", json!({"value": true})); - run(&mut s, "table.selectTable", json!({})); - run(&mut s, "text.backspace", json!({})); - // Like Table Layout › Delete Table with tracking on: the structure stays, the text is marked deleted. - assert_eq!(tables(&s), 1); - assert_eq!(table_cells(&s), [["a1", "b1", "c1"], ["a2", "b2", "c2"], ["a3", "b3", "c3"]]); - assert_eq!(s.doc.final_text(StoryRef::Body).replace(['\t', '\n'], ""), "Beforeafter"); - run(&mut s, "review.rejectAll", json!({})); - assert_eq!(s.doc.final_text(StoryRef::Body).replace(['\t', '\n'], ""), "Beforea1b1c1a2b2c2a3b3c3after"); - assert!(s.doc.revisions.is_empty()); -} - -/// One selected cell isn't a whole row or column, even in a one-row or one-column table: -/// Backspace clears it (Word's cell selection has no end-of-row mark). -#[test] -fn backspace_on_one_selected_cell_of_a_one_row_or_one_column_table_clears_it() { - for (rows, cols, at) in [(1, 3, [0, 1]), (3, 1, [1, 0])] { - let mut s = typed("before\n"); - run(&mut s, "insert.table", json!({"rows": rows, "cols": cols})); - typ(&mut s, "x\ty\tz"); - run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [1, at[0], at[1], 0], "off": 0}})); - run(&mut s, "table.selectCell", json!({})); - run(&mut s, "text.backspace", json!({})); - let want: Vec> = - if rows == 1 { vec![vec!["x".into(), "".into(), "z".into()]] } else { vec![vec!["x".into()], vec!["".into()], vec!["z".into()]] }; - assert_eq!(table_cells(&s), want, "{rows}×{cols}"); - } -} - -/// #1670: smart quotes in Hebrew follow the characters around the quote in logical order, as -/// Word does (Word for Mac 16.113.4): an opening “ after a space or at the start, a closing ” -/// after a letter, and ” inside an acronym (צה"ל), not gershayim; ' after a letter is ’. -#[test] -fn smart_quotes_in_hebrew_use_logical_order() { - let bidi = |s: &Session| s.doc.body.iter().filter_map(|b| b.as_para()).next().and_then(|p| p.props.bidi); - let s = typed("הוא אמר \"שלום\" לי"); - assert_eq!(bidi(&s), Some(true), "a right-to-left paragraph"); - assert_eq!(s.doc.plain_text(StoryRef::Body), "הוא אמר “שלום” לי"); - let s = typed("\"שלום\""); - assert_eq!(s.doc.plain_text(StoryRef::Body), "“שלום”"); - let s = typed("צה\"ל "); - assert_eq!(s.doc.plain_text(StoryRef::Body), "צה”ל "); - let s = typed("ג'ירפה 'כן'"); - assert_eq!(s.doc.plain_text(StoryRef::Body), "ג’ירפה ‘כן’"); -} - -/// #1923: Turkish and Azerbaijani case mapping (i↔İ, ı↔I) in AutoCorrect, checked against Word -/// with Turkish proofing; other languages keep the Unicode default. -#[test] -fn autocorrect_capitalisation_follows_turkic_case_mapping() { - for tag in ["tr-TR", "az-AZ"] { - let mut s = s(); - run(&mut s, "review.language", json!({"lang": tag, "default": true})); - typ(&mut s, "istanbul güzel. "); - assert_eq!(s.doc.plain_text(StoryRef::Body), "İstanbul güzel. ", "{tag}: i capitalises to İ"); - let mut s = self::s(); - run(&mut s, "review.language", json!({"lang": tag, "default": true})); - typ(&mut s, "İNgilizce "); - assert_eq!(s.doc.plain_text(StoryRef::Body), "İngilizce ", "{tag}: İ kept, N lowercased, no combining dot"); - let mut s = self::s(); - run(&mut s, "review.language", json!({"lang": tag, "default": true})); - typ(&mut s, "ılık ırmak. "); - assert_eq!(s.doc.plain_text(StoryRef::Body), "Ilık ırmak. ", "{tag}: ı capitalises to I"); - let mut s = self::s(); - run(&mut s, "review.language", json!({"lang": tag, "default": true})); - typ(&mut s, "iSTANBUL "); - assert_eq!(s.doc.plain_text(StoryRef::Body), "İstanbul ", "{tag}: cAPS LOCK fix"); - let mut s = self::s(); - run(&mut s, "review.language", json!({"lang": tag, "default": true})); - typ(&mut s, "kızım. TIklamak "); - assert_eq!(s.doc.plain_text(StoryRef::Body), "Kızım. Tıklamak ", "{tag}: TWo INitial CApitals lowercases I to ı"); - } - // Other languages are unchanged; a stray İ still lowercases to a single "i". - let mut s = s(); - run(&mut s, "review.language", json!({"lang": "en-US", "default": true})); - typ(&mut s, "istanbul güzel. "); - assert_eq!(s.doc.plain_text(StoryRef::Body), "Istanbul güzel. "); - let mut s = self::s(); - run(&mut s, "review.language", json!({"lang": "en-US", "default": true})); - typ(&mut s, "İNgilizce "); - assert_eq!(s.doc.plain_text(StoryRef::Body), "İngilizce ", "no combining dot in any language"); -} - -/// #3479: Format > Change Case follows the same Turkic case mapping as AutoCorrect, using -/// each run's own language; other languages keep the Unicode default. -#[test] -fn change_case_follows_turkic_case_mapping() { - for tag in ["tr-TR", "az-AZ"] { - for (mode, input, want) in [ - ("upper", "istanbul", "İSTANBUL"), - ("lower", "IŞIK", "ışık"), - ("title", "istanbul güzel", "İstanbul Güzel"), - ("sentence", "istanbul. izmir", "İstanbul. İzmir"), - ("toggle", "iI", "İı"), - ] { - let mut s = s(); - run(&mut s, "review.language", json!({"lang": tag, "default": true})); - run(&mut s, "text.insert", json!({"text": input, "raw": true})); - run(&mut s, "select.all", json!({})); - run(&mut s, "format.changeCase", json!({"mode": mode})); - assert_eq!(s.doc.plain_text(StoryRef::Body), want, "{tag} {mode}"); - } - } - // Other languages keep the Unicode default. - let mut s = self::s(); - run(&mut s, "text.insert", json!({"text": "istanbul", "raw": true})); - run(&mut s, "select.all", json!({})); - run(&mut s, "format.changeCase", json!({"mode": "title"})); - assert_eq!(s.doc.plain_text(StoryRef::Body), "Istanbul", "en-US"); - // Mixed runs: each run's own language decides. - let mut s = self::s(); - run(&mut s, "text.insert", json!({"text": "IŞIK IŞIK", "raw": true})); - run(&mut s, "caret.set", json!({"pos": Pos::body(0, 0)})); - run(&mut s, "caret.set", json!({"pos": Pos::body(0, 5), "extend": true})); - run(&mut s, "review.language", json!({"lang": "tr-TR"})); - run(&mut s, "caret.set", json!({"pos": Pos::body(0, 6)})); - run(&mut s, "caret.set", json!({"pos": Pos::body(0, 11), "extend": true})); - run(&mut s, "review.language", json!({"lang": "en-US"})); - run(&mut s, "select.all", json!({})); - run(&mut s, "format.changeCase", json!({"mode": "lower"})); - assert_eq!(s.doc.plain_text(StoryRef::Body), "ışık işik", "mixed runs"); -} - -// Spelling as you type (#1552): the word being typed gets no red squiggle until the caret leaves -// it; F7 and the Spelling pane still report it. See docs/typing-parity.md. Paragraphs start with -// a correct word so AutoCorrect's sentence capital doesn't change the misspelled one. - -/// The words body paragraph `i` has a spelling squiggle under, in order. -fn squiggles(s: &mut Session, i: usize) -> Vec { - let path = wordcraft_doc::Path::top(i); - let text = s.doc.para(StoryRef::Body, &path).map(|p| p.text.clone()).unwrap_or_default(); - let l = s.layout(); - l.pages - .iter() - .flat_map(|pg| pg.items.iter()) - .filter_map(|it| match it { - wordcraft_layout::Placed::Lines { para, path: p, l0: 0, .. } if *p == path => { - Some(para.issues.iter().filter(|i| !i.2).filter_map(|i| text.get(i.0..i.1).map(str::to_string)).collect::>()) - } - _ => None, - }) - .flatten() - .collect() -} - -/// The misspelled words `review.issues` (the Spelling pane) reports, in order. -fn reported(s: &mut Session) -> Vec { - let issues = s.run("review.issues", &json!({})).unwrap(); - issues.as_array().unwrap().iter().filter(|i| i["kind"] == "spelling").map(|i| i["text"].as_str().unwrap().to_string()).collect() -} - -#[test] -fn the_word_being_typed_is_not_marked_until_it_ends() { - let mut s = typed("The helo"); - assert_eq!(squiggles(&mut s, 0), Vec::::new(), "no squiggle while the word is typed"); - // F7 and the Spelling pane still see it. - assert_eq!(reported(&mut s), ["helo"]); - let next = s.run("review.spelling", &json!({})).unwrap(); - assert_eq!(next["text"], "helo"); - // A space ends the word. - let mut s = typed("The helo "); - assert_eq!(squiggles(&mut s, 0), ["helo"]); - typ(&mut s, "wrld"); - assert_eq!(squiggles(&mut s, 0), ["helo"], "the next word is being typed"); - assert_eq!(reported(&mut s), ["helo", "wrld"]); - // So does punctuation. - typ(&mut s, "."); - assert_eq!(squiggles(&mut s, 0), ["helo", "wrld"]); - // Enter and Tab too. - let mut s = typed("The helo\n"); - assert_eq!(squiggles(&mut s, 0), ["helo"]); - let mut s = typed("The helo\t"); - assert_eq!(squiggles(&mut s, 0), ["helo"]); -} - -#[test] -fn moving_the_caret_out_of_the_typed_word_marks_it() { - for key in ["caret.left", "caret.home", "caret.wordLeft", "caret.docStart"] { - let mut s = typed("The helo"); - run(&mut s, key, json!({})); - assert_eq!(squiggles(&mut s, 0), ["helo"], "{key}"); - // Coming back to the end of the word doesn't hide the squiggle again. - run(&mut s, "caret.end", json!({})); - assert_eq!(s.sel.focus, Pos::body(0, 8)); - assert_eq!(squiggles(&mut s, 0), ["helo"], "{key} then End"); - } - // A selection ends it too. - let mut s = typed("The helo"); - run(&mut s, "select.all", json!({})); - assert_eq!(squiggles(&mut s, 0), ["helo"]); -} - -#[test] -fn a_click_elsewhere_marks_the_typed_word_and_a_click_back_keeps_the_mark() { - let mut s = typed("Fine\nThe helo"); - assert_eq!(squiggles(&mut s, 1), Vec::::new()); - // The canvas sets the caret directly on a click. - s.sel = crate::Selection::caret(Pos::body(0, 2)); - assert_eq!(squiggles(&mut s, 1), ["helo"]); - s.sel = crate::Selection::caret(Pos::body(1, 8)); - assert_eq!(squiggles(&mut s, 1), ["helo"], "an existing misspelled word keeps its squiggle"); - // Typing into it makes it the word being typed again. - typ(&mut s, "o"); - assert_eq!(s.doc.plain_text(StoryRef::Body), "Fine\nThe heloo"); - assert_eq!(squiggles(&mut s, 1), Vec::::new()); - typ(&mut s, " "); - assert_eq!(squiggles(&mut s, 1), ["heloo"]); -} - -#[test] -fn backspace_into_a_finished_word_edits_it_again() { - let mut s = typed("The helo "); - assert_eq!(squiggles(&mut s, 0), ["helo"]); - typ(&mut s, "\u{8}"); - assert_eq!(squiggles(&mut s, 0), Vec::::new()); - typ(&mut s, "\u{8}\u{8}llo "); - assert_eq!(s.doc.plain_text(StoryRef::Body), "The hello "); - assert_eq!(squiggles(&mut s, 0), Vec::::new()); -} - -#[test] -fn autocorrect_and_inserted_text_are_marked_as_usual() { - // AutoCorrect fixes the word as the space ends it: nothing to mark. - let mut s = typed("I typed teh "); - assert_eq!(s.doc.plain_text(StoryRef::Body), "I typed the "); - assert_eq!(squiggles(&mut s, 0), Vec::::new()); - // Text inserted whole (a paste, an agent): only the word the caret ends in is being typed. - let mut s = typed(""); - run(&mut s, "text.insert", json!({"text": "The helo wrld"})); - assert_eq!(squiggles(&mut s, 0), ["helo"]); - // Undo moves the caret: whatever is left is marked, as the Spelling pane reports it. - let mut s = typed("The helo"); - run(&mut s, "caret.left", json!({})); - typ(&mut s, "x"); - assert_eq!(s.doc.plain_text(StoryRef::Body), "The helxo"); - assert_eq!(squiggles(&mut s, 0), Vec::::new()); - run(&mut s, "edit.undo", json!({})); - assert_eq!(s.doc.plain_text(StoryRef::Body), "The helo"); - assert_eq!(squiggles(&mut s, 0), ["helo"]); - assert_eq!(reported(&mut s), ["helo"]); -} - -#[test] -fn apostrophes_and_hyphens_stay_part_of_the_word_being_typed() { - // Smart quotes turn the apostrophe into ’, which the spelling check keeps in the word. - let mut s = typed("It doesn'"); - assert_eq!(s.doc.plain_text(StoryRef::Body), "It doesn’"); - assert_eq!(reported(&mut s), ["doesn"], "the stem alone is a misspelling"); - assert_eq!(squiggles(&mut s, 0), Vec::::new()); - let mut s = typed("It doesn't "); - assert_eq!(squiggles(&mut s, 0), Vec::::new()); - // A hyphen joins the parts of a compound being typed. - let mut s = typed("The nonn-"); - assert_eq!(reported(&mut s), ["nonn"]); - assert_eq!(squiggles(&mut s, 0), Vec::::new()); - typ(&mut s, "exist"); - assert_eq!(squiggles(&mut s, 0), Vec::::new()); - typ(&mut s, " "); - assert!(!squiggles(&mut s, 0).is_empty()); - assert_eq!(squiggles(&mut s, 0), reported(&mut s)); - // A hyphen after a space starts no word. - let mut s = typed("The helo -"); - assert_eq!(squiggles(&mut s, 0), ["helo"]); -} - -#[test] -fn text_commands_that_edit_nothing_leave_an_existing_word_marked() { - let mut s = typed("Fine\nThe helo"); - s.sel = crate::Selection::caret(Pos::body(0, 2)); - assert_eq!(squiggles(&mut s, 1), ["helo"]); - s.sel = crate::Selection::caret(Pos::body(1, 8)); - // Insert switches overtype; Delete at the end of the document and Shift+Tab change no text. - run(&mut s, "text.overtype", json!({})); - assert_eq!(squiggles(&mut s, 1), ["helo"], "Insert"); - run(&mut s, "text.overtype", json!({})); - let _ = s.run("text.delete", &json!({})); - assert_eq!(squiggles(&mut s, 1), ["helo"], "Delete with nothing after the caret"); - let _ = s.run("text.backTab", &json!({})); - assert_eq!(squiggles(&mut s, 1), ["helo"], "Shift+Tab"); - assert_eq!(s.doc.plain_text(StoryRef::Body), "Fine\nThe helo"); -} - -#[test] -fn typing_over_backward_selection_keeps_its_font() { - // As in Word, text typed over a selection takes the first selected character's formatting, - // whichever way the selection was made (#2879). - for (anchor, focus) in [(6, 11), (11, 6)] { - let mut s = s(); - typ(&mut s, "Hello world"); - run(&mut s, "select.range", json!({"anchor": {"block": 0, "off": 6}, "focus": {"block": 0, "off": 11}})); - run(&mut s, "format.font", json!({"name": "Courier New"})); - run(&mut s, "select.range", json!({"anchor": {"block": 0, "off": anchor}, "focus": {"block": 0, "off": focus}})); - typ(&mut s, "X"); - let p = s.doc.body.first().and_then(|b| b.as_para()).unwrap(); - assert_eq!(p.text, "Hello X", "anchor={anchor} focus={focus}"); - assert_eq!(p.props_of_char(6).font.as_deref(), Some("Courier New"), "anchor={anchor} focus={focus}"); - assert_eq!(p.props_of_char(0).font, None, "anchor={anchor} focus={focus}"); - } -} - -#[test] -fn right_arrow_moves_through_a_ligature_one_letter_at_a_time() { - // A font that draws "ffi" as one glyph (bundled, so the test doesn't depend on the installed - // fonts); each Right press moves the drawn caret (#1974). - let mut s = s(); - run(&mut s, "format.font", json!({"name": "Source Serif 4"})); - typ(&mut s, "ffi"); - let drawn_as_one_glyph = s - .layout() - .pages - .iter() - .flat_map(|p| p.items.iter()) - .any(|it| matches!(it, wordcraft_layout::Placed::Lines { para, .. } if para.glyphs.len() < para.clusters.len())); - assert!(drawn_as_one_glyph, "Source Serif 4 should draw ffi as a ligature"); - run(&mut s, "caret.home", json!({})); - let mut xs = Vec::new(); - for _ in 0..4 { - let l = s.layout(); - xs.push((s.sel.focus.off, l.caret(&s.sel.focus).unwrap().x)); - run(&mut s, "caret.right", json!({})); - } - let offs: Vec = xs.iter().map(|p| p.0).collect(); - assert_eq!(offs, [0, 1, 2, 3]); - assert!(xs.windows(2).all(|w| w[0].1 < w[1].1), "caret x after each Right press: {xs:?}"); -} - -/// Two original paragraphs whose list comes from a style, optionally through a basedOn chain. -fn style_inherited_list(kind: wordcraft_doc::numbering::ListKind, chain: bool) -> Session { - use wordcraft_doc::props::NumRef; - use wordcraft_doc::styles::Style; - let mut doc = wordcraft_doc::Document::from_text("Alpha\nBeta"); - let num = Arc::make_mut(&mut doc.numbering).try_add_list(kind).unwrap(); - Arc::make_mut(&mut doc.styles).styles.push(Style { - id: "ListBase".into(), - name: "Original List".into(), - para: wordcraft_doc::ParaProps { numbering: Some(NumRef { num, level: 0 }), ..Default::default() }, - ..Default::default() - }); - if chain { - Arc::make_mut(&mut doc.styles).styles.push(Style { - id: "ListChild".into(), - name: "Original Derived".into(), - based_on: Some("ListBase".into()), - ..Default::default() - }); - } - for i in 0..2 { - doc.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(i)).unwrap().props.style = Some(if chain { "ListChild" } else { "ListBase" }.into()); - } - Session::new(doc) -} - -#[test] -fn toggle_style_inherited_list_off_preserves_other_paragraphs_and_round_trips() { - use wordcraft_doc::numbering::ListKind; - for (kind, command) in [(ListKind::Bullet, "para.bullets"), (ListKind::Numbered, "para.numbering")] { - for chain in [false, true] { - let mut s = style_inherited_list(kind, chain); - let before = s.doc.clone(); - s.sel = crate::Selection::caret(Pos::body(0, 2)); - run(&mut s, command, json!({})); - let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); - assert_eq!(p.props.numbering.unwrap().num, 0, "{command}, chain={chain}"); - assert_eq!(p.props.style, before.para_at(&Pos::body(0, 0)).unwrap().props.style); - assert_eq!(s.doc.para_at(&Pos::body(1, 0)), before.para_at(&Pos::body(1, 0))); - assert_eq!(s.doc.styles, before.styles); - assert_eq!(s.doc.numbering, before.numbering); - let after = s.doc.clone(); - run(&mut s, "edit.undo", json!({})); - assert_eq!(s.doc, before); - run(&mut s, "edit.redo", json!({})); - assert_eq!(s.doc, after); - let bytes = crate::io::save_bytes("list.docx", &s.doc).unwrap(); - let saved = crate::io::open_bytes("list.docx", &bytes).unwrap(); - let a = saved.para_at(&Pos::body(0, 0)).unwrap(); - let b = saved.para_at(&Pos::body(1, 0)).unwrap(); - assert_eq!(a.props.numbering.unwrap().num, 0); - assert!(saved.styles.resolve_para(&a.props).numbering.is_none()); - assert!(saved.styles.resolve_para(&b.props).numbering.is_some_and(|n| n.num != 0)); - assert_eq!(a.text, "Alpha"); - assert_eq!(b.text, "Beta"); - } - } -} - -#[test] -fn explicit_list_off_override_is_not_toggled_off_again() { - use wordcraft_doc::numbering::ListKind; - use wordcraft_doc::props::NumRef; - for (kind, command) in [(ListKind::Bullet, "para.bullets"), (ListKind::Numbered, "para.numbering")] { - let mut s = style_inherited_list(kind, true); - s.doc.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().props.numbering = Some(NumRef { num: 0, level: 0 }); - s.sel = crate::Selection::caret(Pos::body(0, 2)); - run(&mut s, command, json!({})); - assert!(s.doc.para_at(&Pos::body(0, 0)).unwrap().props.numbering.is_some_and(|n| n.num != 0)); - // A mixed selection must turn the list on, rather than remove the inherited item. - s.doc.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().props.numbering = Some(NumRef { num: 0, level: 0 }); - s.sel = crate::Selection { anchor: Pos::body(0, 0), focus: Pos::body(1, 4) }; - run(&mut s, command, json!({})); - for i in 0..2 { - let p = s.doc.para_at(&Pos::body(i, 0)).unwrap(); - assert!(s.doc.styles.resolve_para(&p.props).numbering.is_some_and(|n| n.num != 0)); - } - } -} - -#[test] -fn list_toggle_uses_effective_level_and_override() { - use wordcraft_doc::numbering::{ListKind, levels_for}; - use wordcraft_doc::props::NumRef; - for (kind, command, wrong) in [(ListKind::Bullet, "para.bullets", "para.numbering"), (ListKind::Numbered, "para.numbering", "para.bullets")] { - for override_level in [false, true] { - for explicit_kind in [false, true] { - let mut s = style_inherited_list(kind, true); - let opposite = if kind == ListKind::Bullet { ListKind::Numbered } else { ListKind::Bullet }; - let num = Arc::make_mut(&mut s.doc.styles).styles.iter_mut().find(|x| x.id == "ListBase").unwrap().para.numbering.as_mut().unwrap(); - num.level = 1; - let num_id = num.num; - Arc::make_mut(&mut s.doc.numbering).abstracts[0].levels[0] = levels_for(opposite)[0].clone(); - if override_level { - Arc::make_mut(&mut s.doc.numbering).abstracts[0].levels[1] = levels_for(opposite)[1].clone(); - Arc::make_mut(&mut s.doc.numbering).nums[0].level_overrides.push((1, levels_for(kind)[1].clone())); - } - let before = s.doc.clone(); - let params = if explicit_kind { json!({"kind": if kind == ListKind::Bullet { "bullet" } else { "numbered" }}) } else { json!({}) }; - run(&mut s, command, params); - assert_eq!( - s.doc.para_at(&Pos::body(0, 0)).unwrap().props.numbering, - Some(NumRef { num: 0, level: 0 }), - "{command}, override={override_level}, explicit={explicit_kind}" - ); - assert_eq!(s.doc.styles, before.styles); - assert_eq!(s.doc.numbering, before.numbering); - assert_eq!(s.doc.para_at(&Pos::body(1, 0)), before.para_at(&Pos::body(1, 0))); - let mut other = Session::new(before); - run(&mut other, wrong, json!({})); - assert!(other.doc.para_at(&Pos::body(0, 0)).unwrap().props.numbering.is_some_and(|n| n.num != 0)); - assert!(other.doc.numbering.level(num_id, 1).is_some()); - } - } - } -} - -#[test] -fn list_paragraph_style_survives_inherited_toggle_off() { - use wordcraft_doc::numbering::ListKind; - use wordcraft_doc::props::{Align, ParaProps}; - use wordcraft_doc::styles::Style; - for kind in [ListKind::Bullet, ListKind::Numbered] { - let mut s = style_inherited_list(kind, false); - *Arc::make_mut(&mut s.doc.styles).styles.iter_mut().find(|x| x.id == "ListParagraph").unwrap() = Style { - id: "ListParagraph".into(), - name: "Original formatted list".into(), - based_on: Some("ListBase".into()), - para: ParaProps { align: Some(Align::Right), space_after: Some(17.0), ..Default::default() }, - ..Default::default() - }; - s.doc.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().props.style = Some("ListParagraph".into()); - let before = s.doc.clone(); - run(&mut s, if kind == ListKind::Bullet { "para.bullets" } else { "para.numbering" }, json!({})); - let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); - assert_eq!(p.props.style.as_deref(), Some("ListParagraph")); - let effective = s.doc.styles.resolve_para(&p.props); - assert!(effective.numbering.is_none()); - assert_eq!(effective.align, Align::Right); - assert_eq!(effective.space_after, 17.0); - assert_eq!(s.doc.styles, before.styles); - assert_eq!(s.doc.numbering, before.numbering); - assert_eq!(s.doc.para_at(&Pos::body(1, 0)), before.para_at(&Pos::body(1, 0))); - let after = s.doc.clone(); - run(&mut s, "edit.undo", json!({})); - assert_eq!(s.doc, before); - run(&mut s, "edit.redo", json!({})); - assert_eq!(s.doc, after); - let bytes = crate::io::save_bytes("list.docx", &s.doc).unwrap(); - let saved = crate::io::open_bytes("list.docx", &bytes).unwrap(); - let p = saved.para_at(&Pos::body(0, 0)).unwrap(); - assert_eq!(p.props.style.as_deref(), Some("ListParagraph")); - let effective = saved.styles.resolve_para(&p.props); - assert!(effective.numbering.is_none()); - assert_eq!(effective.align, Align::Right); - assert_eq!(effective.space_after, 17.0); - } -} - -#[test] -fn direct_list_toggle_still_clears_the_automatic_list_paragraph_style() { - for command in ["para.bullets", "para.numbering"] { - let mut s = Session::new(wordcraft_doc::Document::from_text("Direct list")); - run(&mut s, command, json!({})); - assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().props.style.as_deref(), Some("ListParagraph")); - run(&mut s, command, json!({})); - let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); - assert!(p.props.style.is_none()); - assert!(s.doc.styles.resolve_para(&p.props).numbering.is_none()); - } -} - -#[test] -fn a_new_list_uses_the_requested_kind_after_a_mixed_format_list() { - use wordcraft_doc::numbering::{ListKind, levels_for}; - use wordcraft_doc::props::{NumRef, ParaProps}; - for (kind, command) in [(ListKind::Bullet, "para.bullets"), (ListKind::Numbered, "para.numbering")] { - let mut s = style_inherited_list(kind, false); - let opposite = if kind == ListKind::Bullet { ListKind::Numbered } else { ListKind::Bullet }; - Arc::make_mut(&mut s.doc.numbering).abstracts[0].levels[0] = levels_for(opposite)[0].clone(); - let num = s.doc.numbering.nums[0].id; - s.doc.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().props.numbering = Some(NumRef { num, level: 1 }); - s.doc.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(1)).unwrap().props = ParaProps::default(); - let before = s.doc.para_at(&Pos::body(0, 0)).unwrap().clone(); - s.sel = crate::Selection::caret(Pos::body(1, 0)); - run(&mut s, command, json!({})); - let n = s.doc.para_at(&Pos::body(1, 0)).unwrap().props.numbering.unwrap(); - assert_eq!(n.level, 0); - let level = s.doc.numbering.level(n.num, n.level).unwrap(); - assert_eq!(level.format == wordcraft_doc::section::NumFormat::Bullet, kind == ListKind::Bullet); - assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap(), &before); - } -} - -/// #4085: the registered shortcut toggles the last selected highlight, while explicit -/// colour commands remain idempotent setters for the palette, CLI and automation. -#[test] -#[cfg(not(target_os = "macos"))] -fn highlight_shortcut_toggles_the_last_colour() { - let mut s = typed("Alpha Beta"); - run(&mut s, "select.all", json!({})); - let colour = |s: &mut Session| s.run("format.state", &json!({})).unwrap()["highlight"].clone(); - press(&mut s, "Mod+Alt+H").unwrap(); - assert_eq!(colour(&mut s), json!("FFFF00")); - press(&mut s, "Mod+Alt+H").unwrap(); - assert_eq!(colour(&mut s), serde_json::Value::Null); - run(&mut s, "edit.undo", json!({})); - assert_eq!(colour(&mut s), json!("FFFF00")); - run(&mut s, "edit.redo", json!({})); - assert_eq!(colour(&mut s), serde_json::Value::Null); - run(&mut s, "format.highlight", json!({"color":"blue"})); - run(&mut s, "format.highlight", json!({"color":"blue"})); - assert_eq!(colour(&mut s), json!("0000FF")); - press(&mut s, "Mod+Alt+H").unwrap(); - assert_eq!(colour(&mut s), serde_json::Value::Null); - press(&mut s, "Mod+Alt+H").unwrap(); - assert_eq!(colour(&mut s), json!("0000FF")); - run(&mut s, "format.highlight", json!({"color":"none"})); - press(&mut s, "Mod+Alt+H").unwrap(); - assert_eq!(colour(&mut s), serde_json::Value::Null); - assert_eq!(s.doc.body[0].as_para().unwrap().text, "Alpha Beta"); -} - -/// #4085: mixed selections take one colour; removing an inherited colour writes an -/// explicit No Color, and a collapsed caret toggles pending formatting for future typing. -#[test] -fn highlight_toggle_handles_mixed_inherited_and_pending_formatting() { - let mut s = typed("Alpha Beta "); - run(&mut s, "select.text", json!({"text":"Alpha"})); - run(&mut s, "format.highlight", json!({"color":"blue"})); - run(&mut s, "select.all", json!({})); - run(&mut s, "format.highlight", json!({})); - assert_eq!(s.run("format.state", &json!({})).unwrap()["highlight"], json!("0000FF")); - run(&mut s, "format.highlight", json!({})); - assert_eq!(s.run("format.state", &json!({})).unwrap()["highlight"], serde_json::Value::Null); - Arc::make_mut(&mut s.doc.styles).default_chr.highlight = Some(wordcraft_doc::props::Highlight::Blue); - run(&mut s, "format.highlight", json!({"color":"blue"})); - run(&mut s, "format.highlight", json!({})); - assert!(s.doc.body[0].as_para().unwrap().runs.iter().all(|r| r.props.highlight == Some(wordcraft_doc::props::Highlight::None))); - run(&mut s, "caret.docEnd", json!({})); - run(&mut s, "format.highlight", json!({})); - assert_eq!(s.pending.as_ref().unwrap().highlight, Some(wordcraft_doc::props::Highlight::Blue)); - run(&mut s, "format.highlight", json!({})); - assert_eq!(s.pending.as_ref().unwrap().highlight, Some(wordcraft_doc::props::Highlight::None)); - run(&mut s, "text.insert", json!({"text":"X"})); - run(&mut s, "select.text", json!({"text":"X"})); - assert_eq!(s.run("format.state", &json!({})).unwrap()["highlight"], serde_json::Value::Null); - let before = serde_json::to_value(&s.doc).unwrap(); - assert!(s.run("format.highlight", &json!({"color":123})).is_err()); - assert_eq!(serde_json::to_value(&s.doc).unwrap(), before); -} - -/// #4086: a caret inside a word highlights what is typed next, not the word; Bold still -/// formats the word. -#[test] -fn highlight_at_a_caret_leaves_the_word_alone() { - let mut s = typed("Alpha Beta"); - run(&mut s, "caret.docStart", json!({})); - run(&mut s, "caret.right", json!({})); - run(&mut s, "format.highlight", json!({"color":"yellow"})); - assert!(s.doc.body[0].as_para().unwrap().runs.iter().all(|r| r.props.highlight.is_none())); - assert_eq!(s.pending.as_ref().unwrap().highlight, Some(wordcraft_doc::props::Highlight::Yellow)); - run(&mut s, "text.insert", json!({"text":"X"})); - run(&mut s, "select.text", json!({"text":"X"})); - assert_eq!(s.run("format.state", &json!({})).unwrap()["highlight"], json!("FFFF00")); - run(&mut s, "select.text", json!({"text":"Beta"})); - assert_eq!(s.run("format.state", &json!({})).unwrap()["highlight"], serde_json::Value::Null); - // The toggle reads the pending colour, not the word's: a second press turns it off. - run(&mut s, "caret.docStart", json!({})); - run(&mut s, "caret.right", json!({})); - run(&mut s, "format.highlight", json!({})); - assert_eq!(s.pending.as_ref().unwrap().highlight, Some(wordcraft_doc::props::Highlight::Yellow)); - run(&mut s, "format.highlight", json!({})); - assert_eq!(s.pending.as_ref().unwrap().highlight, Some(wordcraft_doc::props::Highlight::None)); - // Bold after Highlight formats the word and keeps both for what is typed next. - run(&mut s, "format.highlight", json!({})); - run(&mut s, "format.bold", json!({})); - run(&mut s, "text.insert", json!({"text":"Y"})); - run(&mut s, "select.text", json!({"text":"Y"})); - let state = s.run("format.state", &json!({})).unwrap(); - assert_eq!((state["highlight"].clone(), state["bold"].clone()), (json!("FFFF00"), json!(true))); -} - -/// A differently coloured final paragraph must remain visible to the shortcut beyond -/// the formatting-state query limit, including a style-inherited tail and Undo/Redo. -#[cfg(not(target_os = "macos"))] -#[test] -fn highlight_toggle_checks_past_the_resolved_run_limit() { - use wordcraft_doc::para::Run; - use wordcraft_doc::props::{CharProps, Highlight}; - for count in [10_000, 10_001, 10_002] { - for inherited in [false, true] { - let mut doc = wordcraft_doc::Document::from_text(&format!("{}\nZ", "x".repeat(count))); - doc.para_mut(StoryRef::Body, &Pos::body(0, 0).path).unwrap().runs = (0..count) - .map(|i| Run { len: 1, props: CharProps { highlight: Some(Highlight::Blue), bold: Some(i % 2 == 0), ..Default::default() } }) - .collect(); - if inherited { - Arc::make_mut(&mut doc.styles).default_chr.highlight = Some(Highlight::Yellow); - } else { - doc.para_mut(StoryRef::Body, &Pos::body(1, 0).path).unwrap().runs[0].props.highlight = Some(Highlight::Yellow); - } - doc.para_mut(StoryRef::Body, &Pos::body(0, 0).path).unwrap().mark.highlight = Some(Highlight::Blue); - let original = doc.clone(); - let mut s = Session::new(doc); - s.last_highlight = Highlight::Blue; - run(&mut s, "select.all", json!({})); - press(&mut s, "Mod+Alt+H").unwrap(); - assert!( - s.doc.body.iter().filter_map(|b| b.as_para()).flat_map(|p| &p.runs).all(|r| r.props.highlight == Some(Highlight::Blue)), - "count={count}, inherited={inherited}" - ); - press(&mut s, "Mod+Alt+H").unwrap(); - assert!(s.doc.body.iter().filter_map(|b| b.as_para()).flat_map(|p| &p.runs).all(|r| r.props.highlight == Some(Highlight::None))); - run(&mut s, "edit.undo", json!({})); - assert!(s.doc.body[1].as_para().unwrap().runs.iter().all(|r| r.props.highlight == Some(Highlight::Blue))); - run(&mut s, "edit.undo", json!({})); - assert_eq!(serde_json::to_value(&s.doc).unwrap(), serde_json::to_value(&original).unwrap()); - run(&mut s, "edit.redo", json!({})); - assert!(s.doc.body[1].as_para().unwrap().runs.iter().all(|r| r.props.highlight == Some(Highlight::Blue))); - } - } -} - -/// A command preview uses the real session's remembered colour without changing its -/// document, colour choice or undo history when the preview toggles highlighting off. -#[test] -fn highlight_preview_preserves_the_session_colour_and_document() { - use wordcraft_doc::props::Highlight; - let mut s = typed("Alpha"); - run(&mut s, "select.all", json!({})); - run(&mut s, "format.highlight", json!({"color":"blue"})); - let original = serde_json::to_value(&s.doc).unwrap(); - let depth = s.undo_depth(); - let (preview, _, _) = s.preview_layout("format.highlight", &json!({})).unwrap(); - assert!(preview.body[0].as_para().unwrap().runs.iter().all(|r| r.props.highlight == Some(Highlight::None))); - assert_eq!(serde_json::to_value(&s.doc).unwrap(), original); - assert_eq!(s.last_highlight, Highlight::Blue); - assert_eq!(s.undo_depth(), depth); -} - -/// Whole-cell column selections include paragraph marks, including empty cells and -/// inherited mark colours; adjacent unselected cells and undo/redo keep their formatting. -#[test] -fn highlight_toggle_checks_whole_cell_marks() { - use wordcraft_doc::props::Highlight; - use wordcraft_doc::{CharProps, Paragraph}; - for inherited in [false, true] { - for empty in [false, true] { - let mut s = s(); - run(&mut s, "insert.table", json!({"rows":2,"cols":2})); - let (table, _, _) = s.sel.focus.path.cell().unwrap(); - let pos = |row, col| { - let mut path = table.clone(); - path.0.extend([row, col, 0]); - Pos::new(StoryRef::Body, path, 0) - }; - if inherited { - Arc::make_mut(&mut s.doc.styles).default_chr.highlight = Some(Highlight::Yellow); - } - for row in 0..2 { - for col in 0..2 { - let p = s.doc.para_mut(StoryRef::Body, &pos(row, col).path).unwrap(); - let colour = if col == 0 { Highlight::Blue } else { Highlight::Red }; - *p = Paragraph::with_text( - if empty && row == 1 && col == 0 { "" } else { "AB" }, - CharProps { highlight: Some(colour), ..Default::default() }, - ); - p.mark.highlight = Some(colour); - if row == 1 && col == 0 { - p.mark.highlight = if inherited { None } else { Some(Highlight::Yellow) }; - } - } - } - s.last_highlight = Highlight::Blue; - run(&mut s, "caret.set", json!({"pos":pos(0,0)})); - run(&mut s, "table.selectColumn", json!({})); - assert!(!s.cell_range().unwrap().stream); - let original = serde_json::to_value(&s.doc).unwrap(); - run(&mut s, "format.highlight", json!({})); - for row in 0..2 { - let p = s.doc.para_at(&pos(row, 0)).unwrap(); - assert_eq!(p.mark.highlight, Some(Highlight::Blue), "inherited={inherited}, empty={empty}"); - assert!(p.runs.iter().all(|r| r.props.highlight == Some(Highlight::Blue))); - } - run(&mut s, "format.highlight", json!({})); - for row in 0..2 { - let p = s.doc.para_at(&pos(row, 0)).unwrap(); - assert_eq!(p.mark.highlight, Some(Highlight::None)); - assert!(p.runs.iter().all(|r| r.props.highlight == Some(Highlight::None))); - let other = s.doc.para_at(&pos(row, 1)).unwrap(); - assert_eq!(other.mark.highlight, Some(Highlight::Red)); - assert!(other.runs.iter().all(|r| r.props.highlight == Some(Highlight::Red))); - } - run(&mut s, "edit.undo", json!({})); - run(&mut s, "edit.undo", json!({})); - assert_eq!(serde_json::to_value(&s.doc).unwrap(), original); - run(&mut s, "edit.redo", json!({})); - assert_eq!(s.doc.para_at(&pos(1, 0)).unwrap().mark.highlight, Some(Highlight::Blue)); - } - } -} - -/// Fully selected marks participate even when the selection has no text runs or an -/// empty paragraph between blue runs; repeated toggles and undo preserve these targets. -#[test] -fn highlight_toggle_checks_ordinary_paragraph_marks() { - use wordcraft_doc::props::Highlight; - for text in ["AB", "AB\n\nAB", "\n"] { - let mut doc = wordcraft_doc::Document::from_text(text); - Arc::make_mut(&mut doc.styles).default_chr.highlight = Some(Highlight::Blue); - let paths = doc.para_paths(StoryRef::Body); - for path in &paths { - let p = doc.para_mut(StoryRef::Body, path).unwrap(); - p.mark.highlight = Some(Highlight::Blue); - for r in &mut p.runs { - r.props.highlight = Some(Highlight::Blue); - } - } - let mismatch = if text == "AB\n\nAB" { 1 } else { 0 }; - doc.para_mut(StoryRef::Body, &paths[mismatch]).unwrap().mark.highlight = Some(Highlight::Yellow); - let original = serde_json::to_value(&doc).unwrap(); - let mut s = Session::new(doc); - s.last_highlight = Highlight::Blue; - run(&mut s, "select.all", json!({})); - run(&mut s, "format.highlight", json!({})); - for path in &paths { - let p = s.doc.para(StoryRef::Body, path).unwrap(); - assert_eq!(p.mark.highlight, Some(Highlight::Blue), "text={text:?}"); - assert!(p.runs.iter().all(|r| r.props.highlight == Some(Highlight::Blue))); - } - run(&mut s, "format.highlight", json!({})); - assert!(paths.iter().all(|p| s.doc.para(StoryRef::Body, p).unwrap().mark.highlight == Some(Highlight::None))); - run(&mut s, "edit.undo", json!({})); - run(&mut s, "edit.undo", json!({})); - assert_eq!(serde_json::to_value(&s.doc).unwrap(), original); - } -} - -/// A partial text column excludes paragraph marks and text beyond its selected offsets, -/// so a different mark colour must not prevent clearing the selected blue characters. -#[test] -fn highlight_partial_column_ignores_unselected_marks() { - use wordcraft_doc::props::Highlight; - let mut doc = wordcraft_doc::Document::from_text("AB\nAB"); - for path in doc.para_paths(StoryRef::Body) { - let p = doc.para_mut(StoryRef::Body, &path).unwrap(); - p.mark.highlight = Some(Highlight::Yellow); - for r in &mut p.runs { - r.props.highlight = Some(Highlight::Blue); - } - } - let mut s = Session::new(doc); - s.last_highlight = Highlight::Blue; - run(&mut s, "select.column", json!({"anchor":Pos::body(0,0),"focus":Pos::body(1,1)})); - run(&mut s, "format.highlight", json!({})); - for row in 0..2 { - let p = s.doc.para_at(&Pos::body(row, 0)).unwrap(); - assert_eq!(p.props_of_char(0).highlight, Some(Highlight::None)); - assert_eq!(p.props_of_char(1).highlight, Some(Highlight::Blue)); - assert_eq!(p.mark.highlight, Some(Highlight::Yellow)); - } -} - -#[test] -fn tab_with_a_whole_paragraph_selected_indents_it() { - let mut s = typed("Hello world"); - run(&mut s, "select.paragraph", json!({})); - run(&mut s, "text.tab", json!({})); - assert_eq!(paras(&s), vec![para("Hello world", None)]); - assert_eq!(indents(&s).0, Some(36.0)); -} - -#[test] -fn tab_with_a_partial_selection_still_inserts_a_tab() { - let mut s = typed("Hello world"); - run(&mut s, "select.text", json!({"text": "ello"})); - run(&mut s, "text.tab", json!({})); - assert_eq!(paras(&s), vec![para("H\t world", None)]); -} - -/// Tab with a whole paragraph selected indents it as one Undo step: the selection stays while -/// the indent is applied, and Undo restores both the indentation and that same selection. A -/// partial selection still types a tab character (the test above), and Tab in a table cell still -/// navigates (the table tests above). -#[test] -fn tab_with_a_whole_paragraph_selected_undoes_indent_and_selection_together() { - let mut s = typed("Hello world"); - run(&mut s, "select.paragraph", json!({})); - let selected = s.sel.clone(); - let depth = s.undo_depth(); - run(&mut s, "text.tab", json!({})); - assert_eq!(s.doc.plain_text(StoryRef::Body), "Hello world", "Tab indents, it types no tab"); - assert_eq!(s.sel, selected, "the whole paragraph stays selected"); - assert_eq!(indents(&s), (Some(36.0), None)); - assert_eq!(s.undo_depth(), depth + 1, "one paragraph, one Undo step"); - run(&mut s, "edit.undo", json!({})); - assert_eq!(indents(&s), (None, None), "Undo takes the indent back"); - assert_eq!(s.sel, selected, "and restores the selection the indent was applied to"); - assert_eq!(paras(&s), vec![para("Hello world", None)]); -} - -/// #2848: "Add a period with double-space" (an AutoFormat As You Type switch, off by default): -/// a second Space after a word, a number or a closing bracket or quote turns the first into a -/// period. Undo puts the two spaces back; other punctuation, a lone space and a run of three -/// spaces are left as typed. -#[test] -fn double_space_adds_a_period_when_asked() { - let with_option = |keys: &str| { - let mut s = s(); - run(&mut s, "tools.autocorrect", json!({"doubleSpacePeriod": true})); - typ(&mut s, keys); - s - }; - let text = |s: &Session| s.doc.plain_text(StoryRef::Body); - // Off unless asked for. - assert_eq!(text(&typed("Hello world")), "Hello world"); - assert!(!s().prefs.autocorrect.double_space_period); - // On: the second space makes the period, and the next sentence starts with a capital. - assert_eq!(text(&with_option("Hello world")), "Hello. world"); - assert_eq!(text(&with_option("Hello world ")), "Hello. World "); - assert_eq!(text(&with_option("See page 5 Then")), "See page 5. Then"); - assert_eq!(text(&with_option("(it is done) Next")), "(it is done). Next"); - assert_eq!(text(&with_option("He said \"stop\" Next")), "He said “stop”. Next"); - // Undo brings the two spaces back, in one step. - let mut t = with_option("Hello "); - assert_eq!(text(&t), "Hello. "); - run(&mut t, "edit.undo", json!({})); - assert_eq!(text(&t), "Hello "); - // Left alone: other punctuation before the spaces, a lone space, spaces at the start of a - // paragraph or after a tab, and a third space. - for keys in ["Hello. x", "Hello, x", "Hello? x", "Hello; x", "Hello: x", "Hello x", " x", "\n x", "a\t x"] { - let want = typed(keys); - assert_eq!(text(&with_option(keys)), text(&want), "{keys:?}"); - } - assert_eq!(text(&with_option("Hello x")), "Hello. x"); - // A selection typed over, and the master switch. - let mut t = with_option("Hello world"); - run(&mut t, "select.all", json!({})); - run(&mut t, "text.insert", json!({"text": " "})); - assert_eq!(text(&t), " "); - let mut t = s(); - run(&mut t, "tools.autocorrect", json!({"doubleSpacePeriod": true, "enabled": false})); - typ(&mut t, "Hello world"); - assert_eq!(text(&t), "Hello world"); -} - -#[test] -fn backspace_after_a_floating_shape_anchor_joins_paragraphs_and_keeps_the_shape() { - let mut s = typed("Line one\nLine two"); - run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [1], "off": 0}})); - run(&mut s, "insert.textBox", json!({"text": "Box"})); - let (a, f) = (Pos::body(1, 0), Pos::body(1, 3)); - run(&mut s, "select.range", json!({"anchor": a, "focus": f})); - run(&mut s, "arrange.wrap", json!({"wrap": "inFrontOfText"})); - let objects = |s: &Session| s.doc.para_at(&Pos::body(1, 0)).map(|p| p.object_offsets().len()); - assert_eq!(objects(&s), Some(1)); - run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [1], "off": 3}})); - run(&mut s, "text.backspace", json!({})); - assert_eq!(s.doc.body.iter().filter(|b| b.as_para().is_some()).count(), 1); - assert_eq!(objects(&s), None); - assert_eq!( - s.doc.para_at(&Pos::body(0, 0)).map(|p| (p.text.replace('\u{FFFC}', ""), p.object_offsets().len())), - Some(("Line oneLine two".to_string(), 1)) - ); -} - -#[test] -fn backspace_after_marker_and_floating_anchors_still_joins_paragraphs() { - let mut s = typed("Line one\nLine two"); - run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [1], "off": 0}})); - run(&mut s, "insert.textBox", json!({"text": "Box"})); - let (a, f) = (Pos::body(1, 0), Pos::body(1, 3)); - run(&mut s, "select.range", json!({"anchor": a, "focus": f})); - run(&mut s, "arrange.wrap", json!({"wrap": "inFrontOfText"})); - s.doc.insert_object(&Pos::body(1, 0), wordcraft_doc::InlineObject::BookmarkStart { name: "anchor".into() }, &Default::default()).unwrap(); - s.doc.insert_object(&Pos::body(1, 0), wordcraft_doc::InlineObject::CommentStart { id: 1 }, &Default::default()).unwrap(); - run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [1], "off": 9}})); - run(&mut s, "text.backspace", json!({})); - assert_eq!(s.doc.body.iter().filter(|b| b.as_para().is_some()).count(), 1); - assert_eq!(s.doc.para_at(&Pos::body(0, 0)).map(|p| p.text.replace('\u{FFFC}', "")), Some("Line oneLine two".to_string())); -} - -#[test] -fn backspace_cannot_join_into_locked_content_control() { - let mut s = typed("Above\nInside"); - run(&mut s, "select.range", json!({"anchor": {"story": "body", "path": [1], "off": 0}, "focus": {"story": "body", "path": [1], "off": 6}})); - run(&mut s, "developer.richText", json!({"tag": "locked"})); - run(&mut s, "developer.properties", json!({"control": "locked", "lockEdit": true})); - run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [1], "off": 0}})); - let before = s.doc.clone(); - assert!(s.run("text.backspace", &json!({})).is_err()); - assert_eq!(s.doc, before); -} - -// #3693 is an additional editing shortcut, not a claim of Word's default key behaviour. -#[test] -fn delete_previous_sentence_respects_caret_boundaries_and_unicode() { - for (text, before, expected) in [ - ("First sentence. Second sentence.", "First sentence. Second sentence.", "First sentence. "), - ("First sentence. Second sentence.", "First sentence. Sec", "First sentence. ond sentence."), - ("First sentence. Second sentence.", "First sentence. ", "Second sentence."), - ("Grüße! Überall café.", "Grüße! Überall café.", "Grüße! "), - ("No punctuation here", "No punctuation", " here"), - ("One. Two.", "", "One. Two."), - ("", "", ""), - ] { - let mut s = s(); - run(&mut s, "text.insert", json!({"text": text, "raw": true})); - let mut pos = s.sel.focus.clone(); - pos.off = before.len(); - s.sel = crate::Selection::caret(pos); - run(&mut s, "text.deleteSentenceBack", json!({})); - assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().text, expected, "{text:?} at {before:?}"); - if expected != text { - run(&mut s, "edit.undo", json!({})); - assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().text, text); - run(&mut s, "edit.redo", json!({})); - assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().text, expected); - } - } -} - -#[test] -fn delete_previous_sentence_keeps_paragraph_boundaries_and_deletes_selections() { - let mut s = s(); - run(&mut s, "text.insert", json!({"text": "First.\nSecond sentence.", "raw": true, "paragraphs": true})); - let end = s.sel.focus.clone(); - s.sel = crate::Selection::caret(Pos { off: 0, ..end.clone() }); - let count = s.doc.body.len(); - run(&mut s, "text.deleteSentenceBack", json!({})); - assert_eq!(s.doc.body.len(), count); - assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().text, "Second sentence."); - s.sel = crate::Selection { anchor: Pos { off: 0, ..end.clone() }, focus: Pos { off: 6, ..end } }; - run(&mut s, "text.deleteSentenceBack", json!({})); - assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().text, " sentence."); - run(&mut s, "edit.undo", json!({})); - assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().text, "Second sentence."); -} - -#[test] -fn delete_previous_sentence_tracks_changes_and_respects_read_only() { - for accept in [true, false] { - let mut s = s(); - run(&mut s, "text.insert", json!({"text": "One. Two.", "raw": true})); - run(&mut s, "review.trackChanges", json!({"value": true})); - run(&mut s, "text.deleteSentenceBack", json!({})); - let changes = s.run("review.changes", &json!({})).unwrap(); - assert!(changes.as_array().unwrap().iter().any(|c| c["kind"] == "delete")); - run(&mut s, if accept { "review.acceptAll" } else { "review.rejectAll" }, json!({})); - assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().text, if accept { "One. " } else { "One. Two." }); - } - let mut s = s(); - run(&mut s, "text.insert", json!({"text": "Keep this.", "raw": true})); - run(&mut s, "review.restrict", json!({"mode": "readOnly"})); - assert!(s.run("text.deleteSentenceBack", &json!({})).is_err()); - assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().text, "Keep this."); -} - -#[test] -fn delete_previous_sentence_does_not_cross_a_table_cell_boundary() { - let mut s = s(); - run(&mut s, "insert.table", json!({"rows": 1, "cols": 2})); - run(&mut s, "text.insert", json!({"text": "First cell.", "raw": true})); - run(&mut s, "text.tab", json!({})); - run(&mut s, "text.insert", json!({"text": "Second cell.", "raw": true})); - let end = s.sel.focus.clone(); - s.sel = crate::Selection::caret(Pos { off: 0, ..end.clone() }); - let before = s.doc.plain_text(StoryRef::Body); - run(&mut s, "text.deleteSentenceBack", json!({})); - assert_eq!(s.doc.plain_text(StoryRef::Body), before); - s.sel = crate::Selection::caret(end); - run(&mut s, "text.deleteSentenceBack", json!({})); - assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().text, ""); - assert!(s.doc.plain_text(StoryRef::Body).contains("First cell.")); -} - -// English spelling from SCOWL 2026 (CP2): each English reads its own SCOWL dictionary, with -// `en-variants.txt` deciding the other dialect's spellings. Typed text, as a person types it. - -/// The spelling words typed text gets in `lang` (the whole document's language), with the first -/// suggestion of each. -fn typed_spelling_in(lang: &str, keys: &str) -> Vec<(String, String)> { - let mut s = s(); - run(&mut s, "review.language", json!({"lang": lang, "default": true})); - typ(&mut s, keys); - let issues = s.run("review.issues", &json!({})).unwrap(); - issues - .as_array() - .unwrap() - .iter() - .filter(|i| i["kind"] == "spelling") - .map(|i| (i["text"].as_str().unwrap().to_string(), i["suggestions"][0].as_str().unwrap_or("").to_string())) - .collect() -} - -/// #40: the British spellings the issue lists are right in en-GB, and the American ones are -/// flagged with the British form first. -#[test] -fn issue_40_british_spellings_are_right_in_en_gb() { - if super::no_language_data("issue_40_british_spellings_are_right_in_en_gb") { - return; - } - let none: Vec<(String, String)> = Vec::new(); - let text = "The judgement on the defence licence put the programme in the catalogue. "; - assert_eq!(typed_spelling_in("en-GB", text), none); - assert_eq!(typed_spelling_in("en-GB", "Its colour and centre, organised and organized, travelled. "), none); - let flagged = typed_spelling_in("en-GB", "The color of the center. "); - assert_eq!(flagged, [("color".to_string(), "colour".to_string()), ("center".to_string(), "centre".to_string())]); -} - -/// #520: common modern words are right (SCOWL plus our supplement), and real misspellings of -/// them are still caught. -#[test] -fn issue_520_common_words_are_right() { - if super::no_language_data("issue_520_common_words_are_right") { - return; - } - let none: Vec<(String, String)> = Vec::new(); - assert_eq!(typed_spelling_in("en-US", "My desktop email has downloads, a smartphone and a website. "), none); - // (`recieved` would be AutoCorrected as it is typed.) - let flagged = typed_spelling_in("en-US", "My desktpo emial. "); - assert_eq!(flagged.iter().map(|f| f.0.as_str()).collect::>(), ["desktpo", "emial"]); - assert_eq!(flagged[0].1, "desktop"); -} - -/// #1281 and #1045: Canadian and Australian English (and the other British-spelling Englishes) -/// proof by their own rules; ordinals are words in every English. -#[test] -fn issues_1281_1045_english_dialects_proof_typed_text() { - if super::no_language_data("issues_1281_1045_english_dialects_proof_typed_text") { - return; - } - let none: Vec<(String, String)> = Vec::new(); - // Canada: British -our and -re, American -ize. - assert_eq!(typed_spelling_in("en-CA", "We organize the colour in the centre. "), none); - assert_eq!( - typed_spelling_in("en-CA", "We organise the color. "), - [("organise".to_string(), "organize".to_string()), ("color".to_string(), "colour".to_string())] - ); - // Australia, New Zealand, India: British spelling. - for lang in ["en-AU", "en-NZ", "en-IN"] { - assert_eq!(typed_spelling_in(lang, "We analyse the colour of the theatre. "), none, "{lang}"); - assert_eq!(typed_spelling_in(lang, "We analyze it. "), [("analyze".to_string(), "analyse".to_string())], "{lang}"); - } - // US English flags British spellings with the American form first. - assert_eq!(typed_spelling_in("en-US", "The colour. "), [("colour".to_string(), "color".to_string())]); - // SCOWL's ordinals (COMPOUNDRULE) are words, even where numbers are checked. - let mut s = s(); - run(&mut s, "file.options", json!({"ignoreNumbers": false})); - typ(&mut s, "The 21st and 112th times, not the 21th. "); - let issues = s.run("review.issues", &json!({})).unwrap(); - 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, ["21th"]); -} +//! Typing behaviour checked side by side against Word: keystrokes go through the same commands +//! as the keyboard (one `text.insert` per character, Enter, Tab, Backspace), and the expected +//! results are what Word produces for the same keys. + +use serde_json::json; +use std::sync::Arc; +use wordcraft_doc::props::{BorderStyle, VertAlign}; +use wordcraft_doc::{Pos, StoryRef}; + +use crate::Session; + +/// Leaf #152: deleting the empty heading above body text must not promote that text to a heading. +/// Deleting the empty heading mark keeps the next paragraph as body text. +#[test] +fn deleting_a_preceding_empty_heading_keeps_the_body_paragraph_style() { + let mut s = Session::new(wordcraft_doc::Document::from_text("\nBody paragraph.")); + run(&mut s, "para.style", json!({"style": "Heading1"})); + run(&mut s, "text.delete", json!({})); + let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); + assert_eq!(p.text, "Body paragraph."); + assert_eq!(p.props.style, None); + assert_eq!(wordcraft_layout::outline::levels(&s.doc), [wordcraft_layout::outline::BODY]); +} + +/// Create an empty session for command-driven typing fixtures. +fn s() -> Session { + Session::new(wordcraft_doc::Document::new()) +} + +/// Execute a registry command and report its id on a test failure. +fn run(s: &mut Session, id: &str, v: serde_json::Value) { + s.run(id, &v).unwrap_or_else(|e| panic!("{id}: {e}")); +} + +/// Type like a person: `\n` is Enter, `\t` is Tab, `\u{8}` is Backspace, everything else one +/// character at a time. +fn typ(s: &mut Session, keys: &str) { + for c in keys.chars() { + match c { + '\n' => run(s, "text.newParagraph", json!({})), + '\t' => run(s, "text.tab", json!({})), + '\u{8}' => run(s, "text.backspace", json!({})), + c => run(s, "text.insert", json!({"text": c.to_string()})), + } + } +} + +/// Body paragraphs as (text, list level if in a list, in "List Paragraph"). +fn paras(s: &Session) -> Vec<(String, Option, bool)> { + s.doc + .body + .iter() + .filter_map(|b| b.as_para()) + .map(|p| (p.text.clone(), p.props.numbering.filter(|n| n.num != 0).map(|n| n.level), p.props.style.as_deref() == Some("ListParagraph"))) + .collect() +} + +/// Expected paragraph text and list membership for typing assertions. +fn para(text: &str, level: Option) -> (String, Option, bool) { + (text.to_string(), level, level.is_some()) +} + +/// Create a session and enter its fixture through the actual typing commands. +fn typed(keys: &str) -> Session { + let mut s = s(); + typ(&mut s, keys); + s +} + +#[test] +fn sample_text_formulas_expand_on_enter() { + for name in ["rand", "lorem"] { + for (args, paragraphs, sentences) in [ + ("", 5, 3), + ("2", 2, 3), + (" 2 ", 2, 3), + ("2,4", 2, 4), + ("2, 4", 2, 4), + (" 2 , 4 ", 2, 4), + ("1,1", 1, 1), + ("200,1", 200, 1), + ("1,200", 1, 200), + ] { + let mut s = typed(&format!("={name}({args})")); + let depth = s.undo_depth(); + typ(&mut s, "\n"); + let generated: Vec<_> = paras(&s).into_iter().map(|p| p.0).collect(); + assert_eq!(generated.len(), paragraphs + 1, "{name}({args})"); + for text in generated.iter().take(paragraphs) { + assert_eq!(text.matches('.').count(), sentences); + assert!(text.starts_with(if name == "lorem" { "Lorem ipsum" } else { "A blank page" }), "{name}({args}): {text:?}"); + } + assert_eq!(generated.last().unwrap(), ""); + assert_eq!(s.sel.focus, Pos::body(paragraphs, 0)); + assert_eq!(s.undo_depth(), depth + 1); + let expanded = s.doc.clone(); + run(&mut s, "edit.undo", json!({})); + assert_eq!(texts(&s), [format!("={name}({args})")]); + run(&mut s, "edit.redo", json!({})); + assert_eq!(s.doc, expanded); + } + } +} + +#[test] +fn sample_text_invalid_parameters_use_defaults() { + for name in ["rand", "lorem"] { + for args in ["0", "-1", "201", "abc", "1.5", "99999999999999999999999999999", "2,0", "0,4", "2,201", "2,no", "2,", ",4", "1,2,3"] { + let s = typed(&format!("={name}({args})\n")); + let texts = texts(&s); + assert_eq!(texts.len(), 6, "{name}({args})"); + assert!(texts.iter().take(5).all(|text| text.matches('.').count() == 3)); + } + } +} + +#[test] +fn sample_text_requires_an_exact_paragraph_and_caret_at_end() { + for formula in [" =rand()", "=rand() ", "prefix =rand()", "=lorem() suffix", "=other()", "=rand(", "=rand(())"] { + assert_eq!(texts(&typed(&format!("{formula}\n"))), [formula, ""]); + } + let mut s = typed("=rand()"); + run(&mut s, "caret.home", json!({})); + typ(&mut s, "\n"); + assert_eq!(texts(&s), ["", "=rand()"]); + let mut s = typed("=rand()suffix"); + s.sel = crate::Selection { anchor: Pos::body(0, 7), focus: Pos::body(0, 13) }; + typ(&mut s, "\n"); + assert_eq!(texts(&s), ["=rand()", ""]); +} + +#[test] +fn sample_text_preserves_typing_and_paragraph_formatting() { + let mut s = s(); + run(&mut s, "format.bold", json!({})); + run(&mut s, "para.alignCenter", json!({})); + typ(&mut s, "=rand(2,1)\n"); + for p in s.doc.body.iter().filter_map(|b| b.as_para()).take(2) { + assert_eq!(p.props.align, Some(wordcraft_doc::props::Align::Center)); + assert_eq!(p.props_at(0).bold, Some(true)); + } +} + +#[test] +fn sample_text_replacement_tracks_changes() { + let mut s = typed("=lorem(2,1)"); + run(&mut s, "review.trackChanges", json!({})); + typ(&mut s, "\n"); + assert!(s.doc.body.iter().filter_map(|b| b.as_para()).flat_map(|p| &p.runs).any(|r| r.props.ins.is_some())); + run(&mut s, "review.rejectAll", json!({})); + assert_eq!(texts(&s), ["=lorem(2,1)"]); + run(&mut s, "edit.undo", json!({})); + run(&mut s, "review.acceptAll", json!({})); + assert_eq!(texts(&s).len(), 3); + assert!(!s.doc.plain_text(StoryRef::Body).contains("=lorem")); +} + +#[test] +fn enter_twice_after_a_list_item_ends_the_list() { + let s = typed("* item\n\ntext"); + assert_eq!(paras(&s), vec![para("Item", Some(0)), para("text", None)]); +} + +#[test] +fn more_enters_after_a_list_make_blank_paragraphs() { + // The "stuck Enter" bug: after the list ended, further Enters did nothing. + let s = typed("* item\n\n\ntext"); + assert_eq!(paras(&s), vec![para("Item", Some(0)), para("", None), para("text", None)]); + let s = typed("1. item\n\n\n\nafter"); + assert_eq!(paras(&s), vec![para("Item", Some(0)), para("", None), para("", None), para("after", None)]); +} + +#[test] +fn the_paragraph_after_a_list_is_normal_text() { + let mut s = typed("* Apple\nBanana\n\nAfter list"); + let p = s.doc.para_at(&s.sel.focus).unwrap(); + assert_eq!(p.props.style, None, "back to Normal, not an indented List Paragraph"); + assert_eq!(p.props.numbering, None); + // And the next paragraph too. + typ(&mut s, "\nMore"); + assert_eq!(paras(&s).last(), Some(¶("More", None))); +} + +#[test] +fn enter_on_an_empty_nested_item_moves_it_up_a_level_first() { + let s = typed("* a\n\tb\n\ntext"); + assert_eq!(paras(&s), vec![para("A", Some(0)), para("B", Some(1)), para("text", Some(0))]); + let s = typed("* a\n\tb\n\n\ntext"); + assert_eq!(paras(&s), vec![para("A", Some(0)), para("B", Some(1)), para("text", None)]); + let s = typed("* a\n\tb\n\n\n\ntext"); + assert_eq!(paras(&s), vec![para("A", Some(0)), para("B", Some(1)), para("", None), para("text", None)]); + let s = typed("* a\n\tb\n\tc\n\n\n\ntext"); + assert_eq!(paras(&s), vec![para("A", Some(0)), para("B", Some(1)), para("C", Some(2)), para("text", None)]); + let s = typed("1. a\n\tb\n\n\ntext"); + assert_eq!(paras(&s), vec![para("A", Some(0)), para("B", Some(1)), para("text", None)]); +} + +/// #2143: in a Word file whose list comes from the paragraph styles (List Number, List Number 2 +/// with `w:numPr` in styles.xml, none on the paragraphs), Enter on an empty nested item moves it +/// up a level and then ends the list, as with direct numbering. +#[test] +fn enter_on_an_empty_item_numbered_by_its_style_moves_it_up_a_level() { + use std::io::Write; + let w = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main""#; + let style = |id: &str, name: &str, ilvl: u8| { + format!( + r#""# + ) + }; + let styles = format!( + r#"{}{}"#, + style("ListNumber", "List Number", 0), + style("ListNumber2", "List Number 2", 1) + ); + let numbering = format!( + r#""# + ); + let p = |st: &str, text: &str| format!(r#"{text}"#); + let body: String = + [p("ListNumber", "Lorem"), p("ListNumber", "Ipsum"), p("ListNumber2", "Latin"), p("ListNumber2", "Nonsense"), p("ListNumber2", "")].concat(); + let document = format!(r#"{body}"#); + let rel = |id: &str, kind: &str, target: &str| { + format!(r#""#) + }; + let rels_ns = r#"xmlns="http://schemas.openxmlformats.org/package/2006/relationships""#; + let main = format!("{}", rel("rId1", "officeDocument", "word/document.xml")); + let doc_rels = + format!("{}{}", rel("rId1", "styles", "styles.xml"), rel("rId2", "numbering", "numbering.xml")); + let ct = r#""#; + let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + let o = zip::write::SimpleFileOptions::default(); + for (name, data) in [ + ("[Content_Types].xml", ct), + ("_rels/.rels", main.as_str()), + ("word/_rels/document.xml.rels", doc_rels.as_str()), + ("word/document.xml", document.as_str()), + ("word/styles.xml", styles.as_str()), + ("word/numbering.xml", numbering.as_str()), + ] { + z.start_file(name, o).unwrap(); + z.write_all(data.as_bytes()).unwrap(); + } + let doc = crate::io::open_bytes("t.docx", &z.finish().unwrap().into_inner()).unwrap(); + let mut s = Session::new(doc); + // The list level each paragraph shows, wherever its numbering comes from. + let levels = |s: &Session| -> Vec> { + s.doc + .body + .iter() + .filter_map(|b| b.as_para()) + .map(|p| s.doc.styles.resolve_para(&p.props).numbering.filter(|n| n.num != 0).map(|n| n.level)) + .collect() + }; + assert_eq!(levels(&s), [Some(0), Some(0), Some(1), Some(1), Some(1)]); + s.sel = crate::Selection::caret(Pos::body(4, 0)); + typ(&mut s, "\n"); + assert_eq!(levels(&s), [Some(0), Some(0), Some(1), Some(1), Some(0)]); + assert_eq!(s.sel.focus, Pos::body(4, 0)); + typ(&mut s, "\n"); + assert_eq!(levels(&s), [Some(0), Some(0), Some(1), Some(1), None]); + run(&mut s, "edit.undo", json!({})); + run(&mut s, "edit.undo", json!({})); + assert_eq!(levels(&s), [Some(0), Some(0), Some(1), Some(1), Some(1)]); +} + +#[test] +fn shift_tab_and_tab_change_list_levels() { + let mut s = typed("1. One\nTwo\n\tSub a\nSub b\n"); + run(&mut s, "text.backTab", json!({})); + typ(&mut s, "Three\n\nDone"); + assert_eq!( + paras(&s), + vec![para("One", Some(0)), para("Two", Some(0)), para("Sub a", Some(1)), para("Sub b", Some(1)), para("Three", Some(0)), para("Done", None)] + ); +} + +#[test] +fn backspace_at_the_start_of_an_item_removes_the_number_then_the_indent() { + let mut s = typed("* One\nTwo"); + run(&mut s, "caret.home", json!({})); + let text_x = |s: &mut Session| s.layout().caret(&Pos::body(1, 0)).map(|c| c.x); + let before = text_x(&mut s); + typ(&mut s, "\u{8}"); + // Number gone, still indented with the list: the text stays where it was. + assert_eq!(paras(&s)[1], ("Two".to_string(), None, true)); + assert_eq!(text_x(&mut s), before); + typ(&mut s, "\u{8}|"); + // Second Backspace: Normal paragraph, not joined to the one above. + assert_eq!(paras(&s), vec![para("One", Some(0)), para("|Two", None)]); +} + +#[test] +fn backspace_on_an_empty_item_keeps_the_paragraph() { + let s = typed("* One\n\u{8}Z"); + assert_eq!(paras(&s), vec![para("One", Some(0)), ("Z".to_string(), None, true)]); +} + +#[test] +fn enter_in_the_middle_or_at_the_start_of_an_item() { + let mut s = typed("* Hello world"); + for _ in 0..5 { + run(&mut s, "caret.left", json!({})); + } + typ(&mut s, "\nX"); + assert_eq!(paras(&s), vec![para("Hello ", Some(0)), para("Xworld", Some(0))]); + let mut s = typed("* first\nsecond"); + run(&mut s, "caret.home", json!({})); + typ(&mut s, "\nY"); + assert_eq!(paras(&s), vec![para("First", Some(0)), para("", Some(0)), para("Ysecond", Some(0))]); +} + +fn label_at(s: &Session, i: usize) -> String { + let n = s.doc.para_at(&Pos::body(i, 0)).and_then(|p| p.props.numbering).unwrap(); + s.doc.numbering.level(n.num, n.level).map(|l| l.text.clone()).unwrap_or_default() +} + +/// #3352 follow-up (CodeRabbit): a heading numbered only through its style's linked Multilevel +/// List (#1793) — `props.numbering` itself unset — isn't papered over by a fresh bullet when "- " +/// is typed at its start; the trigger stays as plain text and the heading's own number is kept. +#[test] +fn list_autoformat_does_not_override_a_style_linked_heading_number() { + let mut s = s(); + typ(&mut s, "One"); + run(&mut s, "para.heading1", json!({})); + run(&mut s, "list.multilevelPreset", json!({"preset": "headings"})); + typ(&mut s, "\nTwo"); + run(&mut s, "para.heading2", json!({})); + assert_eq!(numbers(&mut s), [num(Some("1"), "Heading1"), num(Some("1.1"), "Heading2")]); + assert_eq!(s.doc.para_at(&Pos::body(1, 0)).unwrap().props.numbering, None, "numbered through the style, not directly"); + run(&mut s, "caret.home", json!({})); + typ(&mut s, "- "); + assert_eq!(numbers(&mut s), [num(Some("1"), "Heading1"), num(Some("1.1"), "Heading2")], "the heading number survives"); + assert_eq!(paras(&s)[1].0, "- Two", "the trigger stays as typed text, not stripped into a bullet"); +} + +/// A heading numbered only through its style's linked Multilevel List keeps "---" and "+--+--+" +/// as text on Enter, as a paragraph numbered directly does: no border, no table, and at the top +/// of the document the heading keeps its number. +#[test] +fn border_and_table_lines_stay_text_in_a_style_numbered_heading() { + for (above, keys) in [("", "---"), ("Above\n", "---"), ("", "+--+--+"), ("Above\n", "+--+--+")] { + let mut s = s(); + typ(&mut s, &format!("{above}{keys}")); + run(&mut s, "para.heading1", json!({})); + run(&mut s, "list.multilevelPreset", json!({"preset": "headings"})); + let at = Pos::body(s.doc.body.len() - 1, 0); + assert_eq!(s.doc.para_at(&at).unwrap().props.numbering, None, "numbered through the style, not directly"); + typ(&mut s, "\nText"); + assert_eq!(s.doc.plain_text(StoryRef::Body), format!("{above}{keys}\nText"), "{above:?}{keys}"); + assert!(s.doc.body.iter().all(|b| b.as_para().is_some_and(|p| p.props.borders.is_none())), "{above:?}{keys}"); + let mut want = vec![num(Some("1"), "Heading1"), num(None, "Normal")]; + if !above.is_empty() { + want.insert(0, num(None, "Normal")); + } + assert_eq!(numbers(&mut s), want, "{above:?}{keys}"); + } +} + +#[test] +fn list_autoformat_triggers() { + let s = typed("- Item\nNext"); + assert_eq!(label_at(&s, 0), "-"); + assert_eq!(paras(&s), vec![para("Item", Some(0)), para("Next", Some(0))]); + let s = typed("1) a\nb"); + assert_eq!(label_at(&s, 0), "%1)"); + let s = typed("a. alpha\nbeta"); + assert_eq!(paras(&s), vec![para("Alpha", Some(0)), para("beta", Some(0))]); + assert_eq!(label_at(&s, 0), "%1."); + let n = s.doc.para_at(&Pos::body(0, 0)).and_then(|p| p.props.numbering).unwrap(); + assert_eq!(s.doc.numbering.level(n.num, 0).map(|l| l.format), Some(wordcraft_doc::section::NumFormat::LowerLetter)); +} + +/// #3352: typing the trigger at the start of an already-written paragraph (not only a fresh, +/// empty one) still turns it into a list item, keeping the rest of the paragraph's text — as in +/// Word, confirmed in Word for Windows 2026-10-21. +#[test] +fn list_autoformat_triggers_in_front_of_existing_text() { + let mut s = s(); + typ(&mut s, "On peut faire mieux"); + run(&mut s, "caret.home", json!({})); + typ(&mut s, "- "); + assert_eq!(paras(&s), vec![para("On peut faire mieux", Some(0))]); + assert_eq!(label_at(&s, 0), "-"); + // Undo right after takes only the AutoFormat back, as for a fresh paragraph. + run(&mut s, "edit.undo", json!({})); + assert_eq!(paras(&s), vec![("- On peut faire mieux".to_string(), None, false)]); +} + +#[test] +fn typing_1_dot_starts_numbering_again() { + let s = typed("1. a\nb\n\nmid\n1. c\nd"); + let num = |i: usize| s.doc.para_at(&Pos::body(i, 0)).and_then(|p| p.props.numbering).map(|n| n.num); + assert_eq!(num(0), num(1)); + assert_eq!(num(3), num(4)); + assert_ne!(num(0), num(3), "a new list restarts at 1"); +} + +#[test] +fn undo_right_after_autoformat_undoes_only_the_autoformat() { + let mut s = typed("* "); + assert!(paras(&s)[0].1.is_some()); + run(&mut s, "edit.undo", json!({})); + typ(&mut s, "x"); + assert_eq!(paras(&s), vec![("* x".to_string(), None, false)]); + // Same for AutoCorrect. + let mut s = typed("teh "); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc.plain_text(StoryRef::Body), "teh "); +} + +#[test] +fn autocorrect_as_you_type() { + let s = typed("teh cat said hello. world (c) \"quoted\" don't -- yes wait... i think so. e.g. this. "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "The cat said hello. World © “quoted” don’t – yes wait… I think so. E.g. this. "); + // Abbreviations and initials don't end sentences. + let s = typed("see e.g. the map. Ask J. smith or Dr. jones "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "See e.g. the map. Ask J. smith or Dr. jones "); + // Fractions; "iPhone" and file names stay as typed. + let s = typed("1/2 cup. iPhone. see readme.txt "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "½ cup. iPhone. See readme.txt "); +} + +#[test] +fn smart_quotes_nested_and_opening_delimiters() { + let s = typed("\"'nested'\" '\"inner\"' (\"'parens'\")"); + assert_eq!(s.doc.plain_text(StoryRef::Body), "“‘nested’” ‘“inner”’ (“‘parens’”)"); + // Empty quotes preserve open then close + let s = typed("\"\" ''"); + assert_eq!(s.doc.plain_text(StoryRef::Body), "“” ‘’"); + // After opening guillemets and low quotes + let s = typed("«'french'» „'german'“"); + assert_eq!(s.doc.plain_text(StoryRef::Body), "«‘french’» „‘german’“"); +} + +/// #2223: the double-hyphen arrow is a replacement, not the existing arrow bullet. +#[test] +fn double_hyphen_arrow_autocorrect_preserves_typing_scope_and_undo() { + let mut s = typed("go --> "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "Go → "); + assert_eq!(paras(&s)[0].1, None); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc.plain_text(StoryRef::Body), "Go --> "); + let s = typed("go --> here"); + assert_eq!(s.doc.plain_text(StoryRef::Body), "Go → here"); + let s = typed("--> "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "→ "); + assert_eq!(paras(&s)[0].1, None); +} + +#[test] +fn double_hyphen_arrow_respects_autocorrect_preferences() { + for prefs in [json!({"enabled": false}), json!({"replaceText": false}), json!({"delete": "-->"})] { + let mut s = s(); + run(&mut s, "tools.autocorrect", prefs); + typ(&mut s, "go --> here"); + assert!(s.doc.plain_text(StoryRef::Body).ends_with(" --> here")); + } + let mut s = s(); + run(&mut s, "review.language", json!({"lang": "pl-PL", "default": true})); + typ(&mut s, "go --> here"); + assert_eq!(s.doc.plain_text(StoryRef::Body), "Go → here"); + let mut s = self::s(); + run(&mut s, "tools.autocorrect", json!({"add": {"from": "-->", "to": "custom"}})); + typ(&mut s, "go --> here"); + assert_eq!(s.doc.plain_text(StoryRef::Body), "Go custom here"); +} + +/// #835: the English replacement list applies only to English text. Polish "i" ("and") stays +/// lowercase; symbols still change; English and untagged text still get "I". +#[test] +fn autocorrect_replacement_list_follows_the_texts_language() { + // Polish as the document's default language. + let mut s = s(); + run(&mut s, "review.language", json!({"lang": "pl-PL", "default": true})); + typ(&mut s, "Ala i kot. kawa i herbata teh (c) "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "Ala i kot. Kawa i herbata teh © "); + // Polish on the run only: the word's own language counts. + let mut s = self::s(); + typ(&mut s, "Ala"); + run(&mut s, "select.all", json!({})); + run(&mut s, "review.language", json!({"lang": "pl-PL"})); + run(&mut s, "caret.docEnd", json!({})); + typ(&mut s, " i kot "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "Ala i kot "); + // English (any region) and untagged text (the default proofing language is English). + let mut s = self::s(); + run(&mut s, "review.language", json!({"lang": "en-GB", "default": true})); + typ(&mut s, "so i think teh end "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "So I think the end "); + let s = typed("so i think teh end "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "So I think the end "); + // A Polish interface: a new document is Polish by its default language, and text with no + // language at all (a .docx without `w:lang`) follows the editing language too. + let mut s = self::s(); + s.set_editing_language(Some("pl-PL")); + typ(&mut s, "Ala i kot "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "Ala i kot ", "document default from the editing language"); + let mut s = self::s(); + s.set_editing_language(Some("pl-PL")); + Arc::make_mut(&mut s.doc.styles).default_chr.lang = None; + typ(&mut s, "Ala i kot "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "Ala i kot ", "untagged text in the editing language"); + // `eng` is English too, as for proofing. + let mut s = self::s(); + run(&mut s, "review.language", json!({"lang": "eng", "default": true})); + typ(&mut s, "so i think "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "So I think "); +} + +#[test] +fn autocorrect_options_change_what_typing_does() { + let mut s = s(); + // A user entry, a deleted built-in entry, an added exception; names of days. + run(&mut s, "tools.autocorrect", json!({"add": {"from": "wc", "to": "WordCraft"}})); + run(&mut s, "tools.autocorrect", json!({"delete": "teh"})); + run(&mut s, "tools.autocorrect", json!({"addException": "approx."})); + typ(&mut s, "wc ships on monday. teh end is approx. three weeks "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "WordCraft ships on Monday. Teh end is approx. three weeks "); + // AutoFormat options off: straight quotes, no dashes, no list; sentences not capitalised. + let mut s = self::s(); + run(&mut s, "tools.autocorrect", json!({"smartQuotes": false, "dashes": false, "bullets": false, "capSentences": false, "capDays": false})); + typ(&mut s, "* \"hi\" a -- b sunday "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "* \"hi\" a -- b sunday "); + assert_eq!(paras(&s)[0].1, None); +} + +#[test] +fn enter_finishes_a_word_for_autocorrect() { + let s = typed("hello\nteh end\n"); + assert_eq!(s.doc.plain_text(StoryRef::Body), "Hello\nThe end\n"); +} + +/// A word after a no-break space or an ideographic space is corrected as after a plain space: +/// replacements, sentence capitals, ordinals and links. +#[test] +fn autocorrect_finds_the_word_after_a_wide_space() { + let s = typed("a\u{a0}teh x\u{3000}teh "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "a\u{a0}the x\u{3000}the "); + // A narrow no-break space and a figure space too. + let s = typed("a\u{202f}teh x\u{2007}teh "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "a\u{202f}the x\u{2007}the "); + let s = typed("end.\u{a0}hello end.\u{3000}world "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "End.\u{a0}Hello end.\u{3000}World "); + let s = typed("the\u{a0}1st and\u{3000}22nd "); + let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); + let sup = |t: &str| p.props_of_char(p.text.find(t).unwrap() + t.len() - 2).vert_align == Some(VertAlign::Superscript); + assert!(sup("1st") && sup("22nd")); + let s = typed("see\u{a0}https://example.org/log, and\u{3000}https://example.net/day, then "); + let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); + let link = |t: &str| p.props_of_char(p.text.find(t).unwrap()).link.clone(); + assert_eq!(link("https://example.org").as_deref(), Some("https://example.org/log")); + assert_eq!(link("https://example.net").as_deref(), Some("https://example.net/day")); +} + +#[test] +fn ordinals_become_superscript() { + let s = typed("the 1st and 22nd and 13th but not 1th "); + let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); + let sup = |t: &str| { + let i = p.text.find(t).unwrap() + t.len() - 2; + p.props_of_char(i).vert_align == Some(VertAlign::Superscript) + }; + assert!(sup("1st") && sup("22nd") && sup("13th")); + assert!(!sup("1th")); +} + +#[test] +fn bold_and_italic_marks_become_formatting() { + let mut s = s(); + run(&mut s, "tools.autocorrect", json!({"boldItalic": true, "capSentences": false})); + typ(&mut s, "say *very* nice, _quite_ good *two words*. ok\n*end*\nnext"); + assert_eq!(s.doc.plain_text(StoryRef::Body), "say very nice, quite good two words. ok\nend\nnext"); + let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); + let at = |t: &str| p.props_of_char(p.text.find(t).unwrap()); + assert_eq!((at("very").bold, at("very").italic), (Some(true), None)); + assert_eq!((at("quite").italic, at("quite").bold), (Some(true), None)); + // Complex-script text (Arabic, Hebrew) takes the formatting too. + assert_eq!((at("very").bold_cs, at("quite").italic_cs), (Some(true), Some(true))); + assert_eq!(at("two").bold, Some(true)); + assert_eq!(at("words").bold, Some(true)); + for plain in ["say", " nice", ", ", " good", ". ok"] { + assert_eq!((at(plain).bold, at(plain).italic), (None, None), "{plain:?}"); + } + // Enter finishes the marked word too, and the next paragraph is not bold. + let p = s.doc.para_at(&Pos::body(1, 0)).unwrap(); + assert_eq!((p.props_of_char(0).bold, p.mark.bold), (Some(true), None)); + let p = s.doc.para_at(&Pos::body(2, 0)).unwrap(); + assert_eq!((p.props_of_char(0).bold, p.mark.bold), (None, None)); + // With Track Changes on, the paragraph marks carry the same revisions as for unmarked text. + let marks = |keys: &str| { + let mut s = self::s(); + run(&mut s, "tools.autocorrect", json!({"boldItalic": true})); + run(&mut s, "review.trackChanges", json!({"value": true})); + typ(&mut s, keys); + s.doc.body.iter().filter_map(|b| b.as_para()).map(|p| (p.mark.ins.is_some(), p.mark.del.is_some())).collect::>() + }; + assert_eq!(marks("x *end*\n"), marks("x end\n")); + // After a no-break space too. + let mut s = self::s(); + run(&mut s, "tools.autocorrect", json!({"boldItalic": true})); + typ(&mut s, "a\u{a0}*word* "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "a\u{a0}word "); + // A red word between plain marks: the next paragraph takes the closing mark's formatting. + let mut s = self::s(); + run(&mut s, "tools.autocorrect", json!({"boldItalic": true})); + typ(&mut s, "*"); + run(&mut s, "format.color", json!({"color": "FF0000"})); + typ(&mut s, "red"); + run(&mut s, "format.color", json!({"color": "auto"})); + typ(&mut s, "*\nnext"); + let red = s.doc.para_at(&Pos::body(0, 0)).unwrap().props_of_char(0).color; + let p = s.doc.para_at(&Pos::body(1, 0)).unwrap(); + assert!(red.is_some()); + assert_ne!(p.mark.color, red); + assert_ne!(p.props_of_char(0).color, red); + // A heading's next style starts afresh after Enter, as without the marks. + let mut s = self::s(); + run(&mut s, "tools.autocorrect", json!({"boldItalic": true})); + run(&mut s, "para.heading1", json!({})); + run(&mut s, "format.bold", json!({})); + typ(&mut s, "*title*\n"); + let p = s.doc.para_at(&Pos::body(1, 0)).unwrap(); + assert_eq!((p.props.style.as_deref(), p.mark.bold), (Some("Normal"), None)); + // Marks typed before Track Changes was turned on stay: removing them would not be tracked. + let mut s = self::s(); + run(&mut s, "tools.autocorrect", json!({"boldItalic": true})); + typ(&mut s, "x *end*"); + run(&mut s, "review.trackChanges", json!({"value": true})); + typ(&mut s, " "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "X *end* "); + // Marks the same author typed with Track Changes on are converted. + let mut s = self::s(); + run(&mut s, "tools.autocorrect", json!({"boldItalic": true})); + run(&mut s, "review.trackChanges", json!({"value": true})); + typ(&mut s, "x *end* "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "X end "); + // Marks another author inserted stay. + let mut s = self::s(); + run(&mut s, "tools.autocorrect", json!({"boldItalic": true})); + run(&mut s, "review.trackChanges", json!({"value": true})); + run(&mut s, "file.setAuthor", json!({"name": "Ana"})); + typ(&mut s, "x *end*"); + run(&mut s, "file.setAuthor", json!({"name": "Ben"})); + typ(&mut s, " "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "X *end* "); + // Undo right after brings the marks back. + let mut s = self::s(); + run(&mut s, "tools.autocorrect", json!({"boldItalic": true})); + typ(&mut s, "a *b* "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "A b "); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc.plain_text(StoryRef::Body), "A *b* "); + // After Enter, Undo brings the marks back and the Enter stays. + let mut s = self::s(); + run(&mut s, "tools.autocorrect", json!({"boldItalic": true})); + typ(&mut s, "a *b*\n"); + assert_eq!(s.doc.plain_text(StoryRef::Body), "A b\n"); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc.plain_text(StoryRef::Body), "A *b*\n"); +} + +#[test] +fn autocorrect_goes_on_after_bold_and_italic_marks() { + // After a space or punctuation, the word without its marks is replaced and capitalized. + let mut s = s(); + run(&mut s, "tools.autocorrect", json!({"boldItalic": true, "add": {"from": "wc", "to": "WordCraft"}})); + typ(&mut s, "*teh* end. _wc_."); + assert_eq!(s.doc.plain_text(StoryRef::Body), "The end. WordCraft."); + let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); + let at = |t: &str| p.props_of_char(p.text.find(t).unwrap()).clone(); + assert_eq!((at("The").bold, at("WordCraft").italic, at(" end").bold), (Some(true), Some(true), None)); + // One Undo brings the marks back as typed. + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc.plain_text(StoryRef::Body), "The end. _wc_."); + // A marked web address becomes a link, and the text typed after it is neither bold nor italic. + let mut s = self::s(); + run(&mut s, "tools.autocorrect", json!({"boldItalic": true})); + typ(&mut s, "see *www.example.org* and _www.example.com_ next "); + let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); + let at = |t: &str| p.props_of_char(p.text.find(t).unwrap()).clone(); + assert_eq!((at("www.example.org").bold, at("www.example.org").link.is_some()), (Some(true), true)); + assert_eq!((at("www.example.com").italic, at("www.example.com").link.is_some()), (Some(true), true)); + for plain in [" and", " next"] { + assert_eq!((at(plain).bold, at(plain).italic, at(plain).link.is_some()), (None, None, false), "{plain:?}"); + } + // After Enter too; a longer replacement moves the Enter with it, and Undo keeps the Enter. + let mut s = self::s(); + run(&mut s, "tools.autocorrect", json!({"boldItalic": true, "add": {"from": "wc", "to": "WordCraft"}})); + typ(&mut s, "*teh*\nok. *wc*\nnext"); + assert_eq!(s.doc.plain_text(StoryRef::Body), "The\nOk. WordCraft\nnext"); + let p = s.doc.para_at(&Pos::body(1, 0)).unwrap(); + assert_eq!(p.props_of_char(p.text.find("WordCraft").unwrap()).bold, Some(true)); + let mut s = self::s(); + run(&mut s, "tools.autocorrect", json!({"boldItalic": true})); + typ(&mut s, "*teh*\n"); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc.plain_text(StoryRef::Body), "*teh*\n"); + // In a span of several words, the first word is replaced and capitalized as it is typed. + let mut s = self::s(); + run(&mut s, "tools.autocorrect", json!({"boldItalic": true})); + typ(&mut s, "*teh end* and _teh rest_ "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "The end and the rest "); + let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); + let at = |t: &str| p.props_of_char(p.text.find(t).unwrap()).clone(); + assert_eq!((at("The end").bold, at("the rest").italic, at(" and").bold), (Some(true), Some(true), None)); +} + +#[test] +fn bold_and_italic_marks_stay_as_typed_unless_they_enclose_words() { + let mut s = s(); + run(&mut s, "tools.autocorrect", json!({"boldItalic": true, "capSentences": false})); + let keys = "2*3* file_name_ * x* *x * ** __ *?!* _-_ "; + typ(&mut s, keys); + assert_eq!(s.doc.plain_text(StoryRef::Body), keys); + // Marks stay while formatting is limited to the document's styles. + let mut s = self::s(); + run(&mut s, "tools.autocorrect", json!({"boldItalic": true})); + run(&mut s, "review.restrict", json!({"mode": "none", "formatting": true})); + typ(&mut s, "*word* "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "*word* "); + // Marks around an object (here a check box content control) stay. + let mut s = self::s(); + run(&mut s, "tools.autocorrect", json!({"boldItalic": true})); + typ(&mut s, "x *"); + run(&mut s, "developer.checkBox", json!({})); + run(&mut s, "caret.docEnd", json!({})); + typ(&mut s, "* "); + let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); + assert_eq!(p.text.matches('*').count(), 2, "{:?}", p.text); + // Off by default. + let s = typed("*bold* _italic_ "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "*bold* _italic_ "); +} + +#[test] +fn web_addresses_become_links() { + let mut s = typed("see https://example.org/log, then "); + let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); + let at = p.text.find("https").unwrap(); + assert_eq!(p.props_of_char(at).link.as_deref(), Some("https://example.org/log")); + assert_eq!(p.props_of_char(p.text.find(',').unwrap()).link, None, "trailing comma stays outside"); + typ(&mut s, "more"); + let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); + assert_eq!(p.props_of_char(p.text.find("more").unwrap()).link, None); +} + +#[test] +fn dashes() { + let s = typed("a -- b and c - d and e--f and café--g "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "A – b and c – d and e—f and café—g "); +} + +/// #2036: a dash AutoFormat makes right after a symbol is text, not the symbol's font. +#[test] +fn a_dash_after_a_symbol_is_not_in_the_symbol_font() { + let mut s = s(); + typ(&mut s, "e"); + run(&mut s, "insert.symbol", json!({"char": "\u{F0FC}", "font": "Wingdings"})); + typ(&mut s, "--f "); + let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); + let dash = p.text.find('\u{2014}').expect("em dash"); + assert_eq!(p.props_of_char(dash).sym_font, None); +} + +/// #2036: the en dashes of "x -- y" and "x - y" after a symbol (and its symbol-font space) are +/// text, not the symbol's font. +#[test] +fn en_dashes_after_a_symbol_are_not_in_the_symbol_font() { + for keys in ["-- f ", "- f "] { + let mut s = s(); + typ(&mut s, "e"); + run(&mut s, "insert.symbol", json!({"char": "\u{F0FC} ", "font": "Wingdings"})); + typ(&mut s, keys); + let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); + let dash = p.text.find('\u{2013}').unwrap_or_else(|| panic!("en dash for {keys:?}: {:?}", p.text)); + assert_eq!(p.props_of_char(dash).sym_font, None, "{keys:?}"); + } +} + +/// #2036: an AutoCorrect replacement of a word that starts with a symbol-font character is text. +#[test] +fn an_autocorrect_replacement_of_a_symbol_word_is_not_in_the_symbol_font() { + let mut s = s(); + run(&mut s, "insert.symbol", json!({"char": "(", "font": "Symbol"})); + typ(&mut s, "c) "); + let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); + let c = p.text.find('\u{A9}').unwrap_or_else(|| panic!("(c) → ©: {:?}", p.text)); + assert_eq!(p.props_of_char(c).sym_font, None); +} + +/// #2036: a letter AutoCorrect capitalises after a symbol-font character is text, not the symbol's font. +#[test] +fn a_capitalised_word_after_a_symbol_is_not_in_the_symbol_font() { + let mut s = s(); + run(&mut s, "insert.symbol", json!({"char": "m", "font": "Wingdings"})); + typ(&mut s, "onday "); + let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); + let m = p.text.find('M').unwrap_or_else(|| panic!("monday → Monday: {:?}", p.text)); + assert_eq!(p.props_of_char(m).sym_font, None); +} + +/// Whether every character of the body paragraph `i` is marked deleted. +fn all_deleted(s: &Session, i: usize, text: &str) -> bool { + s.doc.para_at(&Pos::body(i, 0)).is_some_and(|p| p.text == text && p.run_ranges().all(|(_, c)| c.del.is_some())) +} + +#[test] +fn a_tracked_border_line_is_a_deletion_and_a_formatting_change() { + let bottom = |s: &Session, i| s.doc.para_at(&Pos::body(i, 0)).and_then(|p| p.props.borders).and_then(|b| b.bottom); + let tracked = |s: &Session, i| s.doc.para_at(&Pos::body(i, 0)).is_some_and(|p| p.props.fmt_change.is_some()); + // Characters that were there before Track Changes was turned on are marked deleted. + let mut s = typed("Above\n---"); + run(&mut s, "review.trackChanges", json!({"value": true})); + typ(&mut s, "\n"); + assert!(bottom(&s, 0).is_some() && tracked(&s, 0)); + assert!(all_deleted(&s, 1, "---"), "{:?}", paras(&s)); + // Enter again after the deleted characters is a plain Enter. + typ(&mut s, "\n"); + assert_eq!(texts(&s), ["above", "---", ""]); + assert_eq!(s.sel.focus, Pos::body(2, 0)); + run(&mut s, "edit.undo", json!({})); + run(&mut s, "review.rejectAll", json!({})); + assert_eq!(texts(&s), ["above", "---"]); + assert!(bottom(&s, 0).is_none()); + run(&mut s, "edit.undo", json!({})); + run(&mut s, "review.acceptAll", json!({})); + assert_eq!(texts(&s), ["above", ""]); + assert!(bottom(&s, 0).is_some()); + // At the top of the document the border goes on the characters' paragraph, and typing + // continues in a new one. + let mut s = typed("---"); + run(&mut s, "review.trackChanges", json!({"value": true})); + typ(&mut s, "\nText"); + assert!(bottom(&s, 0).is_some() && tracked(&s, 0)); + assert!(all_deleted(&s, 0, "---"), "{:?}", paras(&s)); + assert_eq!(texts(&s)[1], "text"); + run(&mut s, "review.rejectAll", json!({})); + assert_eq!(texts(&s), ["---"]); + assert!(bottom(&s, 0).is_none()); + // Typed with Track Changes on, the characters go and the border is still a change. + let mut s = typed(""); + run(&mut s, "review.trackChanges", json!({"value": true})); + typ(&mut s, "---\n"); + assert_eq!(texts(&s), ["", ""]); + assert!(bottom(&s, 0).is_some() && tracked(&s, 0)); + run(&mut s, "review.rejectAll", json!({})); + assert!(bottom(&s, 0).is_none()); +} + +#[test] +fn a_tracked_border_line_at_the_top_supersedes_another_authors_formatting() { + use wordcraft_doc::props::Align; + // Ana centers the characters' paragraph; Ben's Enter gives it the border. The formatting + // change is Ben's and holds Ana's centered paragraph without a border. + let mut s = typed("---"); + run(&mut s, "review.trackChanges", json!({"value": true})); + s.author = "Ana".into(); + run(&mut s, "para.align", json!({"value": "center"})); + s.author = "Ben".into(); + typ(&mut s, "\n"); + let p = s.doc.para_at(&Pos::body(0, 0)).cloned().unwrap_or_default(); + assert!(p.props.borders.and_then(|b| b.bottom).is_some()); + let ch = p.props.fmt_change.as_deref(); + let author = ch.and_then(|c| s.doc.revisions.get(c.rev as usize)).map(|r| r.author.as_str()); + assert_eq!(author, Some("Ben"), "{:?}", s.doc.revisions); + assert_eq!(ch.map(|c| (c.old.align, c.old.borders.and_then(|b| b.bottom).is_some())), Some((Some(Align::Center), false))); + run(&mut s, "review.rejectAll", json!({})); + let p = s.doc.para_at(&Pos::body(0, 0)).cloned().unwrap_or_default(); + assert_eq!((p.props.align, p.props.borders.and_then(|b| b.bottom).is_some()), (Some(Align::Center), false)); +} + +#[test] +fn a_tracked_table_line_keeps_its_characters_as_a_deletion() { + let mut s = typed("+--+------+"); + run(&mut s, "review.trackChanges", json!({"value": true})); + typ(&mut s, "\nA"); + let Some(wordcraft_doc::Block::Table(t)) = s.doc.body.first().map(|b| &**b) else { panic!("no table: {:?}", paras(&s)) }; + assert_eq!(t.rows[0].cells[0].blocks[0].as_para().map(|p| p.text.as_str()), Some("A"), "the caret is in the first cell"); + assert!(all_deleted(&s, 1, "+--+------+"), "{:?}", paras(&s)); + // Enter after the deleted characters makes no second table. + run(&mut s, "caret.set", json!({"pos": {"block": 1, "off": 11}})); + typ(&mut s, "\n"); + assert_eq!(s.doc.body.iter().filter(|b| b.as_para().is_none()).count(), 1, "{:?}", paras(&s)); + run(&mut s, "edit.undo", json!({})); + run(&mut s, "review.rejectAll", json!({})); + assert!(s.doc.body.get(1).and_then(|b| b.as_para()).is_some_and(|p| p.text == "+--+------+")); + // The table was inserted untracked, so Reject All keeps it before the characters. + assert_eq!(s.doc.body.len(), 2, "{:?}", paras(&s)); + assert!(matches!(s.doc.body.first().map(|b| &**b), Some(wordcraft_doc::Block::Table(_)))); +} + +#[test] +fn a_border_line_at_the_top_with_formatting_markup_off_is_not_a_formatting_change() { + // Track Changes on with formatting left out of the markup: the characters are still marked + // deleted, the border is not a formatting change, and Reject All keeps it. + let mut s = typed("---"); + run(&mut s, "review.trackChanges", json!({"value": true})); + s.prefs.markup.track_formatting = false; + typ(&mut s, "\n"); + let p = s.doc.para_at(&Pos::body(0, 0)).cloned().unwrap_or_default(); + assert!(p.props.borders.and_then(|b| b.bottom).is_some()); + assert!(p.props.fmt_change.is_none(), "{:?}", p.props.fmt_change); + assert!(all_deleted(&s, 0, "---"), "{:?}", paras(&s)); + run(&mut s, "review.rejectAll", json!({})); + let p = s.doc.para_at(&Pos::body(0, 0)).cloned().unwrap_or_default(); + assert_eq!(p.text, "---"); + assert!(p.props.borders.and_then(|b| b.bottom).is_some()); +} + +#[test] +fn a_border_line_at_the_top_keeps_the_authors_own_earlier_formatting_change() { + // Ana centers the characters' paragraph, then her Enter gives it the border: the paragraph + // keeps Ana's one formatting change, which holds the paragraph from before both. + let mut s = typed("---"); + run(&mut s, "review.trackChanges", json!({"value": true})); + s.author = "Ana".into(); + run(&mut s, "para.align", json!({"value": "center"})); + let rev = s.doc.para_at(&Pos::body(0, 0)).and_then(|p| p.props.fmt_change.as_deref()).map(|c| c.rev); + typ(&mut s, "\n"); + let p = s.doc.para_at(&Pos::body(0, 0)).cloned().unwrap_or_default(); + assert!(p.props.borders.and_then(|b| b.bottom).is_some()); + let ch = p.props.fmt_change.as_deref(); + assert_eq!(ch.map(|c| c.rev), rev); + assert_eq!(ch.map(|c| (c.old.align, c.old.borders.and_then(|b| b.bottom).is_some())), Some((None, false))); + run(&mut s, "review.rejectAll", json!({})); + let p = s.doc.para_at(&Pos::body(0, 0)).cloned().unwrap_or_default(); + assert_eq!((p.text.as_str(), p.props.align, p.props.borders.and_then(|b| b.bottom).is_some()), ("---", None, false)); +} + +#[test] +fn a_border_line_the_content_control_locks_refuse_to_delete_stays_as_backspace_leaves_it() { + // "---" inside an editable rich text control that sits in one locked against editing: + // Backspace can't delete its characters, so Enter is refused too, untracked, and the line + // stays as it is. + let mut s = Session::new(wordcraft_doc::Document::from_text("A\nB\n---\nC\nD")); + s.sel = crate::Selection { anchor: Pos::body(0, 0), focus: Pos::body(4, 1) }; + run(&mut s, "developer.richText", json!({"tag": "outer"})); + s.sel = crate::Selection { anchor: Pos::body(1, 0), focus: Pos::body(3, 1) }; + run(&mut s, "developer.richText", json!({"tag": "inner"})); + run(&mut s, "developer.properties", json!({"control": "outer", "lockEdit": true})); + let before = s.doc.clone(); + for key in ["text.backspace", "text.newParagraph"] { + s.sel = crate::Selection::caret(Pos::body(2, 3)); + assert!(s.run(key, &json!({})).is_err(), "{key}"); + assert_eq!(s.doc, before, "{key}"); + } + assert!(s.doc.para_at(&Pos::body(2, 0)).is_some_and(|p| p.text == "---" && p.props.borders.is_none())); +} + +#[test] +fn border_line_autoformat() { + let s = typed("Above\n---\nBelow"); + assert_eq!(s.doc.plain_text(StoryRef::Body), "Above\nBelow"); + let b = s.doc.para_at(&Pos::body(0, 0)).and_then(|p| p.props.borders).and_then(|b| b.bottom).unwrap(); + assert_eq!(b.style, BorderStyle::Single); + assert!(s.doc.para_at(&Pos::body(1, 0)).unwrap().props.borders.is_none()); + for (keys, style) in [("===", BorderStyle::Double), ("***", BorderStyle::Dotted), ("___", BorderStyle::Thick)] { + let s = typed(&format!("x\n{keys}\n")); + let b = s.doc.para_at(&Pos::body(0, 0)).and_then(|p| p.props.borders).and_then(|b| b.bottom); + assert_eq!(b.map(|b| b.style), Some(style), "{keys}"); + } + // At the top of the document the line goes under that paragraph and typing continues below. + let s = typed("---\nText"); + assert_eq!(s.doc.plain_text(StoryRef::Body), "\nText"); + assert!(s.doc.para_at(&Pos::body(0, 0)).and_then(|p| p.props.borders).is_some()); + assert!(s.doc.para_at(&Pos::body(1, 0)).unwrap().props.borders.is_none()); +} + +#[test] +fn undo_right_after_a_border_line_brings_the_characters_back() { + let mut s = typed("Above\n---\n"); + assert_eq!(s.doc.plain_text(StoryRef::Body), "Above\n"); + run(&mut s, "edit.undo", json!({})); + // The AutoFormat is undone on its own: the characters are back, the Enter stays. + assert_eq!(s.doc.plain_text(StoryRef::Body), "Above\n---\n"); + assert!(s.doc.body.iter().filter_map(|b| b.as_para()).all(|p| p.props.borders.is_none())); + assert_eq!(s.sel.focus, Pos::body(2, 0)); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc.plain_text(StoryRef::Body), "Above\n---"); +} + +/// The first paragraph's (left, first-line) indents as set on the paragraph itself. +fn indents(s: &Session) -> (Option, Option) { + let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); + (p.props.indent_left, p.props.indent_first) +} + +#[test] +fn tab_and_backspace_at_a_paragraph_start_set_its_indents() { + let mut s = s(); + run(&mut s, "tools.autocorrect", json!({"tabIndent": true})); + typ(&mut s, "Some text"); + run(&mut s, "caret.docStart", json!({})); + // The first Tab indents the first line, the next one the whole paragraph. + typ(&mut s, "\t"); + assert_eq!(indents(&s), (None, Some(36.0))); + typ(&mut s, "\t"); + assert_eq!(indents(&s), (Some(36.0), Some(36.0))); + assert_eq!(s.doc.plain_text(StoryRef::Body), "Some text"); + // Backspace takes the first-line indent back, then the left indent, and stops there. + typ(&mut s, "\u{8}"); + assert_eq!(indents(&s), (Some(36.0), Some(0.0))); + typ(&mut s, "\u{8}\u{8}"); + assert_eq!(indents(&s), (Some(0.0), Some(0.0))); + assert_eq!(s.doc.plain_text(StoryRef::Body), "Some text"); + // Each Tab is one undo step. + typ(&mut s, "\t"); + run(&mut s, "edit.undo", json!({})); + assert_eq!(indents(&s), (Some(0.0), Some(0.0))); + // A left indent in characters goes back one tab stop too, instead of the paragraphs joining: + // six characters of 11 or 12 points are 66 or 72 points, two tab stops. + let mut s = self::s(); + run(&mut s, "tools.autocorrect", json!({"tabIndent": true})); + typ(&mut s, "One\nTwo"); + let second = Pos::body(1, 0); + s.doc.para_mut(StoryRef::Body, &second.path).unwrap().props.indent_left_chars = Some(6.0); + s.sel = crate::Selection::caret(second.clone()); + typ(&mut s, "\u{8}"); + assert_eq!(s.doc.plain_text(StoryRef::Body), "One\nTwo"); + let p = s.doc.para_at(&second).unwrap(); + assert_eq!((p.props.indent_left, p.props.indent_left_chars), (Some(36.0), Some(0.0))); + // With formatting limited to the document's styles the option is off: Tab types a tab + // character at the start of the first line and of a later one, and Backspace at the start + // keeps the paragraph's left indent. + let mut s = self::s(); + run(&mut s, "tools.autocorrect", json!({"tabIndent": true})); + typ(&mut s, "One"); + run(&mut s, "text.lineBreak", json!({})); + typ(&mut s, "Two"); + run(&mut s, "review.restrict", json!({"mode": "none", "formatting": true})); + run(&mut s, "caret.home", json!({})); + typ(&mut s, "\t"); + run(&mut s, "caret.docStart", json!({})); + typ(&mut s, "\t"); + assert_eq!(s.doc.plain_text(StoryRef::Body), "\tOne\n\tTwo"); + assert_eq!(indents(&s), (None, None)); + s.doc.para_mut(StoryRef::Body, &Pos::body(0, 0).path).unwrap().props.indent_left = Some(36.0); + typ(&mut s, "\u{8}\u{8}"); + assert_eq!(s.doc.plain_text(StoryRef::Body), "One\n\tTwo"); + assert_eq!(indents(&s), (Some(36.0), None)); + // An empty paragraph takes a tab character. + let mut s = self::s(); + run(&mut s, "tools.autocorrect", json!({"tabIndent": true})); + typ(&mut s, "\tText"); + assert_eq!(s.doc.plain_text(StoryRef::Body), "\tText"); + assert_eq!(indents(&s), (None, None)); + // At the start of a list item numbered by its style, Tab types a tab character, as with the + // option off. + let mut s = style_inherited_list(wordcraft_doc::numbering::ListKind::Numbered, false); + run(&mut s, "tools.autocorrect", json!({"tabIndent": true})); + run(&mut s, "caret.docStart", json!({})); + typ(&mut s, "\t"); + assert_eq!(s.doc.plain_text(StoryRef::Body), "\tAlpha\nBeta"); + assert_eq!(indents(&s), (None, None)); + // With the option off (the default), Tab types a tab character. + let mut s = self::s(); + typ(&mut s, "Some text"); + run(&mut s, "caret.docStart", json!({})); + typ(&mut s, "\t"); + assert_eq!(s.doc.plain_text(StoryRef::Body), "\tSome text"); + assert_eq!(indents(&s), (None, None)); +} + +#[test] +fn tab_before_a_later_line_of_a_paragraph_indents_the_whole_paragraph() { + let mut s = s(); + run(&mut s, "tools.autocorrect", json!({"tabIndent": true})); + typ(&mut s, &"Words that run on past the end of one line. ".repeat(6)); + run(&mut s, "caret.docStart", json!({})); + run(&mut s, "caret.down", json!({})); + run(&mut s, "caret.home", json!({})); + assert!(s.sel.focus.off > 0, "the caret is on the second line"); + typ(&mut s, "\t"); + assert_eq!(indents(&s), (Some(36.0), None)); + assert!(!s.doc.plain_text(StoryRef::Body).contains('\t')); + // After a line break too; inside a line Tab still types a tab character. + let mut s = self::s(); + run(&mut s, "tools.autocorrect", json!({"tabIndent": true})); + typ(&mut s, "One"); + run(&mut s, "text.lineBreak", json!({})); + typ(&mut s, "Two"); + run(&mut s, "caret.home", json!({})); + typ(&mut s, "\t"); + assert_eq!(indents(&s), (Some(36.0), None)); + run(&mut s, "caret.right", json!({})); + typ(&mut s, "\t"); + assert_eq!(s.doc.plain_text(StoryRef::Body), "One\nT\two"); + // Before a later line of a list item numbered by its style, Tab takes it down a level, as + // for a list item numbered directly. + let mut s = style_inherited_list(wordcraft_doc::numbering::ListKind::Numbered, false); + run(&mut s, "tools.autocorrect", json!({"tabIndent": true})); + run(&mut s, "caret.docStart", json!({})); + run(&mut s, "caret.end", json!({})); + run(&mut s, "text.lineBreak", json!({})); + typ(&mut s, "More"); + run(&mut s, "caret.home", json!({})); + typ(&mut s, "\t"); + assert!(!s.doc.plain_text(StoryRef::Body).contains('\t')); + let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); + assert_eq!(p.props.numbering.map(|n| n.level), Some(1)); + assert_eq!(indents(&s), (None, None)); + // A list level past the last one, from a style or set directly, goes to the last level. + let mut s = style_inherited_list(wordcraft_doc::numbering::ListKind::Numbered, false); + let style = Arc::make_mut(&mut s.doc.styles).styles.iter_mut().find(|x| x.id == "ListBase").unwrap(); + style.para.numbering = style.para.numbering.map(|n| wordcraft_doc::props::NumRef { level: u8::MAX, ..n }); + run(&mut s, "tools.autocorrect", json!({"tabIndent": true})); + run(&mut s, "caret.docStart", json!({})); + run(&mut s, "caret.end", json!({})); + run(&mut s, "text.lineBreak", json!({})); + typ(&mut s, "More"); + run(&mut s, "caret.home", json!({})); + typ(&mut s, "\t"); + assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().props.numbering.map(|n| n.level), Some(8)); + let num = s.doc.para_at(&Pos::body(0, 0)).unwrap().props.numbering.unwrap().num; + s.doc.para_mut(StoryRef::Body, &Pos::body(1, 0).path).unwrap().props.numbering = Some(wordcraft_doc::props::NumRef { num, level: u8::MAX }); + s.sel = crate::Selection::caret(Pos::body(1, 0)); + typ(&mut s, "\t"); + assert_eq!(s.doc.para_at(&Pos::body(1, 0)).unwrap().props.numbering.map(|n| n.level), Some(8)); + s.doc.para_mut(StoryRef::Body, &Pos::body(0, 0).path).unwrap().props.numbering = Some(wordcraft_doc::props::NumRef { num, level: u8::MAX }); + run(&mut s, "caret.docStart", json!({})); + run(&mut s, "caret.down", json!({})); + run(&mut s, "caret.home", json!({})); + assert_eq!((s.sel.focus.path.clone(), s.sel.focus.off), (Pos::body(0, 0).path, 6), "the caret is before \"More\""); + typ(&mut s, "\t"); + assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().props.numbering.map(|n| n.level), Some(8)); + s.doc.para_mut(StoryRef::Body, &Pos::body(0, 0).path).unwrap().props.numbering = Some(wordcraft_doc::props::NumRef { num, level: u8::MAX }); + run(&mut s, "outline.demote", json!({})); + assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().props.numbering.map(|n| n.level), Some(8)); +} + +#[test] +fn with_tab_indents_on_tab_in_a_table_cell_moves_to_the_next_cell() { + let mut s = typed("+--+--+\nA"); + run(&mut s, "tools.autocorrect", json!({"tabIndent": true})); + run(&mut s, "caret.home", json!({})); + typ(&mut s, "\t"); + assert_eq!(s.sel.focus.path.cell().map(|(_, r, c)| (r, c)), Some((0, 1))); + let Some(wordcraft_doc::Block::Table(t)) = s.doc.body.first().map(|b| &**b) else { panic!("no table: {:?}", paras(&s)) }; + let a = t.rows[0].cells[0].blocks[0].as_para().unwrap(); + assert_eq!((a.text.as_str(), a.props.indent_left, a.props.indent_first), ("A", None, None)); +} + +#[test] +fn with_tab_indents_off_backspace_before_an_indented_paragraph_joins_it() { + // The paragraph's own left indent, in points or in characters, doesn't stop the join. + for chars in [false, true] { + let mut s = typed("One\nTwo"); + let second = Pos::body(1, 0); + let p = s.doc.para_mut(StoryRef::Body, &second.path).unwrap(); + if chars { + p.props.indent_left_chars = Some(6.0); + } else { + p.props.indent_left = Some(36.0); + } + s.sel = crate::Selection::caret(second); + typ(&mut s, "\u{8}"); + assert_eq!(s.doc.plain_text(StoryRef::Body), "OneTwo", "indent in characters: {chars}"); + } +} + +#[test] +fn plus_and_hyphen_line_becomes_a_table() { + let mut s = typed("+--+------+\nA"); + let Some(wordcraft_doc::Block::Table(t)) = s.doc.body.first().map(|b| &**b) else { panic!("no table: {:?}", paras(&s)) }; + assert_eq!(t.rows.len(), 1); + assert_eq!(t.grid.len(), 2); + assert!((t.grid[1] / t.grid[0] - 3.0).abs() < 0.01, "widths follow the hyphens: {:?}", t.grid); + assert_eq!(t.rows[0].cells[0].blocks[0].as_para().map(|p| p.text.as_str()), Some("A"), "the caret is in the first cell"); + assert!(s.doc.body.get(1).and_then(|b| b.as_para()).is_some_and(|p| p.is_empty()), "a paragraph follows the table"); + // Undo right after brings the typed characters back, the Enter stays. + run(&mut s, "edit.undo", json!({})); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc.plain_text(StoryRef::Body), "+--+------+\n"); + assert!(s.doc.body.iter().all(|b| b.as_para().is_some())); + // Not a table: text around it, or more columns than a table can hold. + let s = typed("x +--+--+\n"); + assert_eq!(s.doc.plain_text(StoryRef::Body), "X +--+--+\n"); + let s = typed(&format!("+{}\n", "-+".repeat(64))); + assert!(s.doc.body.iter().all(|b| b.as_para().is_some())); +} + +/// Body paragraphs as (text, paragraph style). +fn styled(s: &Session) -> Vec<(String, Option)> { + s.doc.body.iter().filter_map(|b| b.as_para()).map(|p| (p.text.clone(), p.props.style.clone())).collect() +} + +fn with_heading_styles(keys: &str) -> Session { + let mut s = s(); + run(&mut s, "tools.autocorrect", json!({"headingStyles": true})); + typ(&mut s, keys); + s +} + +#[test] +fn two_enters_after_a_short_line_make_it_a_heading() { + let h = |t: &str, st: Option<&str>| (t.to_string(), st.map(str::to_string)); + // No tab: Heading 1; one or two tabs: Heading 2 or 3, without the tabs. Typing goes on in + // the paragraph after the heading. + for (keys, style) in [("Introduction\n\nText", "Heading1"), ("\tOur methods\n\nText", "Heading2"), ("\t\tStep 2\n\nText", "Heading3")] { + let s = with_heading_styles(keys); + let text = keys.trim_start_matches('\t').split('\n').next().unwrap_or_default(); + assert_eq!(styled(&s), [h(text, Some(style)), h("Text", None)], "{keys:?}"); + } + // Five words is the most; one Enter only ends the line. + let s = with_heading_styles("One two three four five\n\n"); + assert_eq!(s.doc.body.first().and_then(|b| b.as_para()).and_then(|p| p.props.style.as_deref()), Some("Heading1")); + assert_eq!(styled(&with_heading_styles("Introduction\nText")), [h("Introduction", None), h("Text", None)]); +} + +#[test] +fn a_line_that_is_not_a_short_line_of_body_text_stays_as_it_is() { + // Not a heading: six words, a closing punctuation mark, three tabs, a list item, a style. + for keys in ["One two three four five six\n\n", "Dear Sir,\n\n", "Is it?\n\n", "\t\t\tDeep\n\n", "* Item\n\n"] { + let s = with_heading_styles(keys); + assert!( + s.doc.body.iter().filter_map(|b| b.as_para()).all(|p| p.props.style.as_deref().is_none_or(|st| !st.starts_with("Heading"))), + "{keys:?}: {:?}", + styled(&s) + ); + } + let mut q = self::s(); + run(&mut q, "tools.autocorrect", json!({"headingStyles": true})); + run(&mut q, "para.style", json!({"style": "Quote"})); + typ(&mut q, "Quoted\n\n"); + assert_eq!(q.doc.body.first().and_then(|b| b.as_para()).and_then(|p| p.props.style.as_deref()), Some("Quote")); + // A list item numbered by the Normal style is not body text either. + let mut q = self::s(); + run(&mut q, "tools.autocorrect", json!({"headingStyles": true})); + let num = Arc::make_mut(&mut q.doc.numbering).try_add_list(wordcraft_doc::ListKind::Bullet).unwrap(); + if let Some(normal) = Arc::make_mut(&mut q.doc.styles).get_mut("Normal") { + normal.para.numbering = Some(wordcraft_doc::props::NumRef { num, level: 0 }); + } + typ(&mut q, "Item\n\n"); + assert!(styled(&q).iter().all(|(_, st)| st.as_deref().is_none_or(|st| !st.starts_with("Heading"))), "{:?}", styled(&q)); + // A line with an outline level stays; the body-text level (9 and above) is no outline level. + for (level, style) in [(0, None), (8, None), (9, Some("Heading1")), (u8::MAX, Some("Heading1"))] { + let mut q = with_heading_styles("Title\n"); + q.doc.para_mut(StoryRef::Body, &Pos::body(0, 0).path).unwrap().props.outline_level = Some(level); + typ(&mut q, "\n"); + assert_eq!(styled(&q).first().and_then(|(_, st)| st.as_deref()), style, "outline level {level}"); + } +} + +#[test] +fn built_in_heading_styles_are_off_by_default() { + let s = typed("Introduction\n\n"); + let h = |t: &str| (t.to_string(), None); + assert_eq!(styled(&s), [h("Introduction"), h(""), h("")]); +} + +#[test] +fn undo_right_after_a_heading_autoformat_keeps_the_enter() { + let mut s = with_heading_styles("\tMethods\n\n"); + assert_eq!(styled(&s), [("Methods".to_string(), Some("Heading2".to_string())), (String::new(), None)]); + assert_eq!(s.sel.focus, Pos::body(1, 0)); + run(&mut s, "edit.undo", json!({})); + // The AutoFormat is undone on its own: the tab and Normal text are back, the Enter stays. + assert_eq!(s.doc.plain_text(StoryRef::Body), "\tMethods\n\n"); + assert!(styled(&s).iter().all(|(_, st)| st.is_none())); + assert_eq!(s.sel.focus, Pos::body(2, 0)); +} + +#[test] +fn a_tracked_heading_autoformat_can_be_rejected() { + // Enter in the empty paragraph turns a zero-tab line into Heading 1. + let mut s = with_heading_styles("Title\n"); + run(&mut s, "review.trackChanges", json!({"value": true})); + s.author = "Ana".into(); + typ(&mut s, "\n"); + let p = s.doc.para_at(&Pos::body(0, 0)).cloned().unwrap_or_default(); + assert_eq!((p.text.as_str(), p.props.style.as_deref()), ("Title", Some("Heading1"))); + let ch = p.props.fmt_change.as_deref(); + assert_eq!(ch.and_then(|c| s.doc.revisions.get(c.rev as usize)).map(|r| r.author.as_str()), Some("Ana")); + run(&mut s, "review.rejectAll", json!({})); + assert_eq!(styled(&s).first(), Some(&("Title".to_string(), None))); + + // With Track Changes on, the tab is marked deleted and the style change is a formatting + // revision, so Reject All brings the line back as it was. + let mut s = with_heading_styles("\tMethods\n"); + run(&mut s, "review.trackChanges", json!({"value": true})); + typ(&mut s, "\n"); + let p = s.doc.para_at(&Pos::body(0, 0)).cloned().unwrap_or_default(); + assert_eq!((p.text.as_str(), p.props.style.as_deref()), ("\tMethods", Some("Heading2"))); + assert!(p.run_ranges().any(|(r, c)| r.start == 0 && c.del.is_some()), "the tab is marked deleted"); + assert!(p.props.fmt_change.is_some(), "the style change is tracked"); + run(&mut s, "review.rejectAll", json!({})); + assert_eq!(styled(&s).first(), Some(&("\tMethods".to_string(), None))); + // A tab this author inserted is removed outright, and the style change is still tracked. + let mut s = with_heading_styles("Title\n"); + run(&mut s, "review.trackChanges", json!({"value": true})); + run(&mut s, "caret.set", json!({"pos": {"block": 0, "off": 0}})); + typ(&mut s, "\t"); + run(&mut s, "caret.set", json!({"pos": {"block": 1, "off": 0}})); + typ(&mut s, "\n"); + let p = s.doc.para_at(&Pos::body(0, 0)).cloned().unwrap_or_default(); + assert_eq!((p.text.as_str(), p.props.style.as_deref()), ("Title", Some("Heading2"))); + assert!(p.props.fmt_change.is_some(), "the style change is tracked"); + run(&mut s, "review.rejectAll", json!({})); + assert_eq!(styled(&s).first(), Some(&("Title".to_string(), None))); + // A line holding a tracked deletion is left alone. + let mut s = with_heading_styles("Old title\n"); + run(&mut s, "review.trackChanges", json!({"value": true})); + run(&mut s, "caret.set", json!({"pos": {"block": 0, "off": 3}})); + typ(&mut s, "\u{8}\u{8}\u{8}"); + run(&mut s, "caret.set", json!({"pos": {"block": 1, "off": 0}})); + typ(&mut s, "\n"); + assert!(styled(&s).iter().all(|(_, st)| st.is_none()), "{:?}", styled(&s)); +} + +#[test] +fn a_tracked_heading_autoformat_supersedes_another_authors_formatting() { + use wordcraft_doc::props::Align; + // Ana centers a zero-tab line; Ben's Enter makes it Heading 1. + let mut s = with_heading_styles("Title\n"); + run(&mut s, "review.trackChanges", json!({"value": true})); + s.author = "Ana".into(); + run(&mut s, "caret.set", json!({"pos": {"block": 0, "off": 0}})); + run(&mut s, "para.align", json!({"value": "center"})); + s.author = "Ben".into(); + run(&mut s, "caret.set", json!({"pos": {"block": 1, "off": 0}})); + typ(&mut s, "\n"); + let p = s.doc.para_at(&Pos::body(0, 0)).cloned().unwrap_or_default(); + assert_eq!((p.text.as_str(), p.props.style.as_deref()), ("Title", Some("Heading1"))); + let ch = p.props.fmt_change.as_deref(); + assert_eq!(ch.and_then(|c| s.doc.revisions.get(c.rev as usize)).map(|r| r.author.as_str()), Some("Ben")); + assert_eq!(ch.map(|c| (c.old.style.as_deref(), c.old.align)), Some((None, Some(Align::Center)))); + run(&mut s, "review.rejectAll", json!({})); + let p = s.doc.para_at(&Pos::body(0, 0)).cloned().unwrap_or_default(); + assert_eq!((p.text.as_str(), p.props.style.as_deref(), p.props.align), ("Title", None, Some(Align::Center))); + + // Ana centers the line; Ben types a tab before it and makes it Heading 2. Ben's tab is + // removed, and the heading's formatting change is Ben's, holding Ana's centered Normal line. + let mut s = with_heading_styles("Title\n"); + run(&mut s, "review.trackChanges", json!({"value": true})); + s.author = "Ana".into(); + run(&mut s, "caret.set", json!({"pos": {"block": 0, "off": 0}})); + run(&mut s, "para.align", json!({"value": "center"})); + s.author = "Ben".into(); + typ(&mut s, "\t"); + run(&mut s, "caret.set", json!({"pos": {"block": 1, "off": 0}})); + typ(&mut s, "\n"); + let p = s.doc.para_at(&Pos::body(0, 0)).cloned().unwrap_or_default(); + assert_eq!((p.text.as_str(), p.props.style.as_deref()), ("Title", Some("Heading2"))); + let ch = p.props.fmt_change.as_deref(); + let author = ch.and_then(|c| s.doc.revisions.get(c.rev as usize)).map(|r| r.author.as_str()); + assert_eq!(author, Some("Ben"), "{:?}", s.doc.revisions); + assert_eq!(ch.map(|c| (c.old.style.as_deref(), c.old.align)), Some((None, Some(Align::Center)))); + // Rejecting it brings back Ana's centered Normal line. + run(&mut s, "review.rejectAll", json!({})); + let p = s.doc.para_at(&Pos::body(0, 0)).cloned().unwrap_or_default(); + assert_eq!((p.text.as_str(), p.props.style.as_deref(), p.props.align), ("Title", None, Some(Align::Center))); + // The author's own earlier change keeps the formatting before it. + let mut s = with_heading_styles("Title\n"); + run(&mut s, "review.trackChanges", json!({"value": true})); + s.author = "Ana".into(); + run(&mut s, "caret.set", json!({"pos": {"block": 0, "off": 0}})); + run(&mut s, "para.align", json!({"value": "center"})); + typ(&mut s, "\t"); + run(&mut s, "caret.set", json!({"pos": {"block": 1, "off": 0}})); + typ(&mut s, "\n"); + let p = s.doc.para_at(&Pos::body(0, 0)).cloned().unwrap_or_default(); + let ch = p.props.fmt_change.as_deref(); + assert_eq!(ch.and_then(|c| s.doc.revisions.get(c.rev as usize)).map(|r| r.author.as_str()), Some("Ana")); + assert_eq!(ch.map(|c| c.old.style.as_deref()), Some(None)); + assert!(ch.is_some_and(|c| c.old.align != Some(Align::Center)), "{ch:?}"); +} + +#[test] +fn a_locked_heading_style_is_not_applied_as_you_type() { + let mut s = s(); + run(&mut s, "tools.autocorrect", json!({"headingStyles": true})); + run(&mut s, "review.restrict", json!({"mode": "none", "formatting": true, "lockedStyles": ["Heading1"]})); + typ(&mut s, "Locked\n\n\tOpen\n\n"); + let st = styled(&s); + assert_eq!(st.first(), Some(&("Locked".to_string(), None)), "{st:?}"); + assert!(st.contains(&("Open".to_string(), Some("Heading2".to_string()))), "{st:?}"); +} + +#[test] +fn enter_in_an_editable_region_after_a_protected_short_line_breaks_the_paragraph() { + // Read only, with an editable region from the end of "Title" to the start of "End.": Enter + // twice in the region breaks the paragraph and leaves the protected line Normal text, and a + // border line typed there goes under the paragraph above it, inside the region. + let mut s = Session::new(wordcraft_doc::Document::from_text("Title\nX\nEnd.")); + run(&mut s, "tools.autocorrect", json!({"headingStyles": true})); + s.sel = crate::Selection { anchor: Pos::body(0, 5), focus: Pos::body(2, 0) }; + run(&mut s, "review.restrict", json!({"exception": "add"})); + run(&mut s, "review.restrict", json!({"mode": "readOnly"})); + run(&mut s, "select.text", json!({"text": "X"})); + typ(&mut s, "\n\n"); + assert_eq!(s.doc.body.len(), 5, "{:?}", styled(&s)); + assert!(styled(&s).iter().all(|(_, st)| st.is_none()), "{:?}", styled(&s)); + typ(&mut s, "---\n"); + assert!(s.doc.para_at(&Pos::body(2, 0)).and_then(|p| p.props.borders).and_then(|b| b.bottom).is_some()); + assert_eq!(s.doc.plain_text(StoryRef::Body).replace(wordcraft_doc::para::OBJ, ""), "Title\n\n\n\nEnd."); +} + +#[test] +fn arrow_bullets() { + for (keys, bullet) in [("> ", "\u{27A2}"), ("-> ", "\u{2794}"), ("=> ", "\u{21E8}")] { + let s = typed(&format!("{keys}item\nnext")); + assert_eq!(paras(&s), vec![para("Item", Some(0)), para("next", Some(0))], "{keys}"); + assert_eq!(label_at(&s, 0), bullet, "{keys}"); + } + // An arrow list doesn't join a plain bullet list. + let s = typed("* a\n\n> b"); + assert_eq!(label_at(&s, 0), "\u{2022}"); + assert_eq!(label_at(&s, 1), "\u{27A2}"); +} + +#[test] +fn smileys_are_replaced_inside_words_too() { + let s = typed("so ok:) sad:( meh:| :) fine:-) "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "So ok\u{263A} sad\u{2639} meh\u{1F610} \u{263A} fine\u{263A} "); +} + +#[test] +fn a_large_mixed_document_types_like_word() { + // The corpus typed into Word and WordCraft side by side; the paragraph structure must match. + let mut s = s(); + let prose = "The keeper's log runs to four hundred pages, and almost every one begins with the weather. "; + typ(&mut s, &format!("Notes\n{}\n", prose.repeat(20))); + typ(&mut s, "The supplies were:\n* Lamp oil, 1/2 barrel\nWicks of the 1st quality\n\tSpare chimneys\nSpare springs\n"); + run(&mut s, "text.backTab", json!({})); + typ(&mut s, "Salt beef\n\nThe schedule:\n1. Signal the boat\nLower the stores\n\tCheck the net\n"); + run(&mut s, "text.backTab", json!({})); + typ(&mut s, "Sign the manifest\n\n---\nIn March the storm broke -- and he slept.\n"); + typ(&mut s, "a. Inspect the glass\nReplace the seals\n\n"); + typ(&mut s, &format!("Afterword. teh keeper retired. {}\n* one more\nsecond\n\n\nFinal.", prose.repeat(10))); + let got: Vec<(String, Option)> = paras(&s).into_iter().map(|(t, l, _)| (t.chars().take(24).collect(), l)).collect(); + let want: Vec<(&str, Option)> = vec![ + ("Notes", None), + ("The keeper’s log runs to", None), + ("The supplies were:", None), + ("Lamp oil, ½ barrel", Some(0)), + ("Wicks of the 1st quality", Some(0)), + ("Spare chimneys", Some(1)), + ("Spare springs", Some(1)), + ("Salt beef", Some(0)), + ("The schedule:", None), + ("Signal the boat", Some(0)), + ("Lower the stores", Some(0)), + ("Check the net", Some(1)), + ("Sign the manifest", Some(0)), + ("In March the storm broke", None), + ("Inspect the glass", Some(0)), + ("Replace the seals", Some(0)), + ("Afterword. The keeper re", None), + ("One more", Some(0)), + ("Second", Some(0)), + ("", None), + ("Final.", None), + ]; + let want: Vec<(String, Option)> = want.into_iter().map(|(t, l)| (t.to_string(), l)).collect(); + assert_eq!(got, want); + // Every non-list paragraph is Normal (no list indent left behind). + for (t, l, lp) in paras(&s) { + assert!(l.is_some() || !lp, "{t:?} still a List Paragraph"); + } +} + +#[test] +fn enter_right_after_starting_a_list_cancels_it() { + let s = typed("* \nPlain"); + assert_eq!(paras(&s), vec![para("Plain", None)]); +} + +#[test] +fn shift_enter_stays_inside_the_list_item() { + let mut s = typed("* line one"); + run(&mut s, "text.lineBreak", json!({})); + typ(&mut s, "cont\ntwo"); + assert_eq!(paras(&s), vec![para("Line one\ncont", Some(0)), para("two", Some(0))]); +} + +/// Body paragraph texts, lower-cased (AutoCorrect may capitalise a sentence start). +fn texts(s: &Session) -> Vec { + paras(s).into_iter().map(|p| p.0.to_lowercase()).collect() +} + +#[test] +fn tracked_enter_and_backspace_track_the_paragraph_mark() { + // Issue #229: with Track Changes on, Enter inserts a tracked paragraph mark that Reject All + // takes out again; Backspace over your own Enter removes it outright. + let mut s = typed("one"); + run(&mut s, "review.trackChanges", json!({"value": true})); + typ(&mut s, "\ntwo"); + assert_eq!(texts(&s), ["one", "two"]); + typ(&mut s, "\u{8}\u{8}\u{8}\u{8}"); + assert_eq!(texts(&s), ["one"]); + typ(&mut s, "\n"); + assert_eq!(texts(&s), ["one", ""]); + run(&mut s, "review.rejectAll", json!({})); + assert_eq!(texts(&s), ["one"]); + // A break that was already there isn't removed: Backspace marks it deleted and moves before it. + let mut s = typed("one\ntwo"); + run(&mut s, "review.trackChanges", json!({"value": true})); + run(&mut s, "caret.set", json!({"pos": {"block": 1, "off": 0}})); + typ(&mut s, "\u{8}"); + assert_eq!(texts(&s), ["one", "two"]); + assert_eq!(s.sel.focus, Pos::body(0, 3)); + assert!(s.doc.para_at(&Pos::body(0, 0)).is_some_and(|p| p.mark.del.is_some())); +} + +/// Linked text in each body paragraph, and whether its paragraph mark carries a link. +fn links(s: &Session) -> Vec<(String, bool)> { + s.doc + .body + .iter() + .filter_map(|b| b.as_para()) + .map(|p| { + let linked = p.run_ranges().filter(|(_, c)| c.link.is_some()).filter_map(|(r, _)| p.text.get(r)).collect(); + (linked, p.mark.link.is_some()) + }) + .collect() +} + +#[test] +fn a_link_covers_only_its_own_text() { + // Issue #413: linking a whole paragraph also linked its mark, so Enter carried the link into + // the next paragraph and everything typed there (or hovered below it) became the link. + let mut s = typed("hi\nhello"); + run(&mut s, "select.text", json!({"text": "hello"})); + run(&mut s, "insert.link", json!({"url": "https://example.org/"})); + run(&mut s, "caret.set", json!({"pos": {"block": 1, "off": 5}})); + typ(&mut s, "\nnext"); + run(&mut s, "caret.set", json!({"pos": {"block": 1, "off": 0}})); + typ(&mut s, "\n"); + run(&mut s, "caret.set", json!({"pos": {"block": 1, "off": 0}})); + typ(&mut s, "above"); + assert_eq!(texts(&s), ["hi", "above", "hello", "next"]); + let unlinked = (String::new(), false); + assert_eq!(links(&s), [unlinked.clone(), unlinked.clone(), ("Hello".into(), false), unlinked]); +} + +#[test] +fn typing_at_either_end_of_a_link_is_not_linked() { + // As in Word: a link typed in with Ctrl+K, then text typed after it, at its end later on, + // at its start, or after Enter, stays outside the link; typing inside it extends it. + let mut s = typed("see "); + run(&mut s, "insert.link", json!({"url": "https://example.org/", "text": "site"})); + typ(&mut s, " now"); + run(&mut s, "caret.set", json!({"pos": {"block": 0, "off": 8}})); + typ(&mut s, "!"); + run(&mut s, "caret.set", json!({"pos": {"block": 0, "off": 4}})); + typ(&mut s, "a "); + run(&mut s, "caret.set", json!({"pos": {"block": 0, "off": 8}})); + typ(&mut s, "x"); + run(&mut s, "caret.set", json!({"pos": {"block": 0, "off": 11}})); + typ(&mut s, "\nmore"); + assert_eq!(texts(&s), ["see a sixte", "more! now"]); + assert_eq!(links(&s), [("sixte".into(), false), (String::new(), false)]); +} + +#[test] +fn a_link_on_a_selection_shows_the_text_to_display() { + // Ctrl+K on selected text with a new Text to display: the selection is retyped with it and + // linked, keeping its formatting; typing after it stays outside the link (as in Word). + let mut s = typed("go see kiln now"); + run(&mut s, "select.text", json!({"text": "kiln"})); + run(&mut s, "format.bold", json!({})); + run(&mut s, "insert.link", json!({"url": "https://example.org/", "text": "Oven"})); + typ(&mut s, "!"); + assert_eq!(texts(&s), ["go see oven! now"]); + assert_eq!(links(&s), [("Oven".into(), false)]); + let p = s.doc.body.iter().find_map(|b| b.as_para()).unwrap(); + assert!(p.run_ranges().any(|(r, c)| &p.text[r] == "Oven" && c.bold == Some(true)), "the new text keeps the selection's formatting"); + // The same text, or text that can't stand in one line, leaves the selection as it is. + run(&mut s, "select.text", json!({"text": "see"})); + run(&mut s, "insert.link", json!({"url": "https://example.org/", "text": "see"})); + run(&mut s, "select.text", json!({"text": "now"})); + run(&mut s, "insert.link", json!({"url": "https://example.org/", "text": "two\nlines"})); + assert_eq!(texts(&s), ["go see oven! now"]); +} + +#[test] +fn a_new_selection_drops_the_formatting_left_after_a_link() { + // A link leaves plain formatting for what is typed after it. A selection made next doesn't + // take it: retyping that selection keeps its own formatting. + let bold_run = |s: &Session, text: &str| { + let p = s.doc.body.iter().find_map(|b| b.as_para()).unwrap(); + p.run_ranges().any(|(r, c)| &p.text[r] == text && c.bold == Some(true)) + }; + let mut s = typed("kiln"); + run(&mut s, "select.text", json!({"text": "kiln"})); + run(&mut s, "format.bold", json!({})); + run(&mut s, "insert.link", json!({"url": "https://example.org/", "text": "Oven"})); + run(&mut s, "select.all", json!({})); + run(&mut s, "insert.link", json!({"url": "https://example.org/", "text": "Stove"})); + assert!(bold_run(&s, "Stove"), "select.all"); + run(&mut s, "select.word", json!({})); + run(&mut s, "insert.link", json!({"url": "https://example.org/", "text": "Range"})); + assert!(bold_run(&s, "Range"), "select.word"); + let anchor = serde_json::to_value(s.doc.start_of(s.sel.focus.story)).unwrap(); + let focus = serde_json::to_value(s.sel.focus.clone()).unwrap(); + run(&mut s, "select.range", json!({"anchor": anchor, "focus": focus})); + run(&mut s, "insert.link", json!({"url": "https://example.org/", "text": "Hob"})); + assert!(bold_run(&s, "Hob"), "select.range"); +} + +#[test] +fn a_link_over_two_paragraphs_keeps_their_text() { + // Word shows <> for such a selection: the text to display is ignored. + let mut s = typed("one\ntwo"); + run(&mut s, "select.all", json!({})); + run(&mut s, "insert.link", json!({"url": "https://example.org/", "text": "both"})); + assert_eq!(texts(&s), ["one", "two"]); + assert_eq!(links(&s), [("One".into(), false), ("two".into(), false)]); +} + +#[test] +fn a_retyped_link_text_is_tracked() { + let mut s = typed("go see kiln now"); + run(&mut s, "review.trackChanges", json!({"value": true})); + run(&mut s, "select.text", json!({"text": "kiln"})); + run(&mut s, "insert.link", json!({"url": "https://example.org/", "text": "Oven"})); + let p = s.doc.body.iter().find_map(|b| b.as_para()).unwrap(); + assert!(p.run_ranges().any(|(r, c)| &p.text[r] == "kiln" && c.del.is_some()), "the old text stays as a deletion"); + assert!(p.run_ranges().any(|(r, c)| &p.text[r] == "Oven" && c.ins.is_some() && c.link.is_some())); +} + +#[test] +fn a_link_keeps_a_selection_it_cannot_retype() { + use wordcraft_doc::props::CharProps; + use wordcraft_doc::{InlineObject, Path}; + let url = "https://example.org/"; + // A second retyped link keeps its own selection's formatting: the first one's left no + // formatting pending for it. + let mut s = typed("go see kiln and vat now"); + run(&mut s, "select.text", json!({"text": "kiln"})); + run(&mut s, "insert.link", json!({"url": url, "text": "Oven"})); + run(&mut s, "select.text", json!({"text": "vat"})); + run(&mut s, "format.bold", json!({})); + run(&mut s, "insert.link", json!({"url": url, "text": "Tub"})); + let p = s.doc.body.iter().find_map(|b| b.as_para()).unwrap(); + assert!(p.run_ranges().any(|(r, c)| &p.text[r] == "Tub" && c.bold == Some(true) && c.link.is_some()), "{:?}", p.text); + // A selection holding a manual line break keeps both lines. + let mut s = typed("one"); + run(&mut s, "text.lineBreak", json!({})); + typ(&mut s, "two"); + run(&mut s, "select.all", json!({})); + run(&mut s, "insert.link", json!({"url": url, "text": "both"})); + assert_eq!(s.doc.plain_text(StoryRef::Body), "one\ntwo"); + // A selection in a field's result is only linked; the field's next update owns that text. + let mut doc = wordcraft_doc::Document::from_text("See "); + let c = CharProps::default(); + let p = doc.para_mut(StoryRef::Body, &Path::top(0)).unwrap(); + let start = p.len(); + p.insert_object(start, InlineObject::FieldStart { instr: "ADDIN ZOTERO_ITEM CSL_CITATION {}".into(), locked: false, dirty: false }, &c).unwrap(); + let n = p.insert_text(p.len(), "(Smith 2020)", &c).unwrap(); + p.insert_object(start + 3 + n, InlineObject::FieldEnd, &c).unwrap(); + let mut s = Session::new(doc); + run(&mut s, "select.text", json!({"text": "Smith"})); + run(&mut s, "insert.link", json!({"url": url, "text": "Jones"})); + assert_eq!(s.doc.plain_text(StoryRef::Body), "See (Smith 2020)"); + assert_eq!(s.doc.field_ranges(StoryRef::Body).len(), 1); +} + +#[test] +fn typing_then_selecting_then_typing_are_two_undo_steps() { + // #419: a selection that moves the insertion point ends the typing group, like a caret move. + let mut s = typed("alpha"); + run(&mut s, "select.text", json!({"text": "ph"})); + run(&mut s, "text.insert", json!({"text": "X"})); + assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().text, "alXa"); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().text, "alpha"); + assert_eq!(s.selected_text(), "ph"); + assert!(s.can_undo()); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().text, ""); + // Uninterrupted typing (a read-only command in between included) is still one step. + let mut s = typed("alpha"); + run(&mut s, "document.inspect", json!({})); + typ(&mut s, "X"); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().text, ""); +} + +#[test] +fn backspace_at_the_start_of_the_document_is_not_an_edit() { + // #23: a key that changes nothing adds no undo step, marks no unsaved changes and + // leaves the redo stack alone. + let mut s = typed("alpha"); + run(&mut s, "caret.set", json!({"pos": {"block": 0, "off": 0}})); + s.dirty = false; + let depth = s.undo_depth(); + typ(&mut s, "\u{8}"); + assert_eq!(s.undo_depth(), depth, "no undo step"); + assert!(!s.dirty, "no unsaved changes"); + assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().text, "alpha"); +} + +#[test] +fn undoing_bold_at_the_caret_turns_bold_typing_off_again() { + // #424: Bold at a caret in an empty paragraph, Undo, type: the text isn't bold. + let mut s = s(); + run(&mut s, "format.bold", json!({"value": true})); + run(&mut s, "edit.undo", json!({})); + assert_ne!(s.typing_props().bold, Some(true)); + typ(&mut s, "abc"); + let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); + assert_eq!(p.text, "abc"); + assert_ne!(p.props_of_char(0).bold, Some(true)); + // Redo brings bold typing back. + let mut s = Session::new(wordcraft_doc::Document::new()); + run(&mut s, "format.bold", json!({"value": true})); + run(&mut s, "edit.undo", json!({})); + run(&mut s, "edit.redo", json!({})); + typ(&mut s, "abc"); + assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().props_of_char(0).bold, Some(true)); +} + +/// Myanmar (#604): typed in memory order (consonant, medial, then the prevowel ေ that is drawn +/// before it), the caret steps over whole syllables and Backspace removes one sign at a time. +#[test] +fn myanmar_typing_steps_by_syllable_and_backspaces_by_sign() { + // ကြေး ("money"): က + ြ + ေ + း, then မြန်မာ. + let word = "\u{1000}\u{103C}\u{1031}\u{1038}"; + let mut s = typed(word); + assert_eq!(paras(&s), vec![para(word, None)]); + // Backspace takes the visarga, then the vowel sign: the consonant and medial stay. + typ(&mut s, "\u{8}"); + assert_eq!(paras(&s)[0].0, "\u{1000}\u{103C}\u{1031}"); + typ(&mut s, "\u{8}\u{1031}\u{1038}"); + assert_eq!(paras(&s)[0].0, word, "retyped"); + // Left from the end skips the whole syllable (the visarga and aa signs aren't grapheme + // extenders, so a grapheme step would stop inside it). + let mut s = typed("\u{1019}\u{102C}\u{1005}\u{102C}"); // မာစာ + run(&mut s, "caret.left", json!({})); + assert_eq!(s.sel.focus.off, 6); + run(&mut s, "caret.left", json!({})); + assert_eq!(s.sel.focus.off, 0); + run(&mut s, "caret.right", json!({})); + assert_eq!(s.sel.focus.off, 6); + // Delete removes the whole syllable after the caret. + run(&mut s, "text.delete", json!({})); + assert_eq!(paras(&s)[0].0, "\u{1019}\u{102C}"); +} + +/// Devanagari and Khmer: Backspace removes the last character (a matra), not the conjunct. +#[test] +fn indic_and_khmer_backspace_removes_one_character() { + let mut s = typed("\u{0915}\u{094D}\u{0937}\u{093F}\u{8}"); // क्षि ⌫ + assert_eq!(paras(&s)[0].0, "\u{0915}\u{094D}\u{0937}"); + typ(&mut s, "\u{8}"); + assert_eq!(paras(&s)[0].0, "\u{0915}\u{094D}"); + let s = typed("\u{1781}\u{17D2}\u{1798}\u{17C2}\u{8}"); // ខ្មែ ⌫ + assert_eq!(paras(&s)[0].0, "\u{1781}\u{17D2}\u{1798}"); + // Latin keeps grapheme Backspace: e + combining acute goes as one. + let s = typed("ae\u{301}\u{8}"); + assert_eq!(paras(&s)[0].0, "a"); +} + +/// Arabic vowel and Quranic marks are individually editable without retyping their base (#4757). +#[test] +fn arabic_backspace_removes_one_mark_and_keeps_the_base() { + for mark in [ + '\u{0610}', + '\u{064B}', + '\u{064C}', + '\u{064D}', + '\u{064E}', + '\u{064F}', + '\u{0650}', + '\u{0651}', + '\u{0652}', + '\u{065F}', + '\u{0670}', + '\u{06D6}', + '\u{06DF}', + '\u{06E7}', + '\u{06EA}', + '\u{0897}', + '\u{089F}', + '\u{08CA}', + '\u{08E1}', + '\u{08E3}', + '\u{08FF}', + '\u{10EFA}', + '\u{10EFF}', + ] { + let original = format!("Λ ب{mark}"); + let mut s = typed(&original); + typ(&mut s, "\u{8}"); + assert_eq!(paras(&s)[0].0, "Λ ب", "mark U+{:04X}", u32::from(mark)); + assert_eq!(s.sel.focus.off, "Λ ب".len()); + run(&mut s, "edit.undo", json!({})); + assert_eq!(paras(&s)[0].0, original); + run(&mut s, "edit.redo", json!({})); + typ(&mut s, "\u{0650}"); + assert_eq!(paras(&s)[0].0, "Λ ب\u{0650}"); + } +} + +#[test] +fn arabic_backspace_uses_stored_mark_order_but_delete_keeps_the_cluster() { + for marks in ["\u{0651}\u{064E}", "\u{064E}\u{0651}"] { + let mut s = typed(&format!("ب{marks}")); + typ(&mut s, "\u{8}"); + assert_eq!(paras(&s)[0].0, format!("ب{}", marks.chars().next().unwrap())); + typ(&mut s, "\u{8}"); + assert_eq!(paras(&s)[0].0, "ب"); + typ(&mut s, "\u{8}"); + assert!(paras(&s)[0].0.is_empty()); + let original = format!("ب{marks}ت"); + let mut s = typed(&original); + // Arabic arrows move visually. They still visit only whole-cluster boundaries. + let boundaries = [0, original.len() - "ت".len(), original.len()]; + let mut visited = vec![s.sel.focus.off]; + for command in ["caret.right", "caret.right", "caret.left", "caret.left"] { + run(&mut s, command, json!({})); + assert!(boundaries.contains(&s.sel.focus.off)); + visited.push(s.sel.focus.off); + } + assert!(boundaries.iter().all(|off| visited.contains(off))); + s.sel = crate::Selection::caret(Pos::body(0, 0)); + run(&mut s, "text.delete", json!({})); + assert_eq!(paras(&s)[0].0, "ت"); + } + for cluster in ["e\u{0301}", "👩‍💻", "👍🏽", "🇵🇱", "ב\u{05B7}"] { + let s = typed(&format!("a{cluster}\u{8}")); + assert_eq!(paras(&s)[0].0, "a", "unchanged grapheme {cluster}"); + } +} + +/// Press a shortcut the way the keyboard does: run the command bound to it. +fn press(s: &mut Session, combo: &str) -> Result { + let id = s.registry.by_shortcut(combo).map(|c| c.id).unwrap_or_else(|| panic!("nothing bound to {combo}")); + s.run(id, &json!({})) +} + +/// Word's outline keys: Alt+Shift+arrows, ⌃⇧+arrows on macOS. +fn outline_key(arrow: &str) -> String { + if cfg!(target_os = "macos") { format!("Ctrl+Shift+{arrow}") } else { format!("Alt+Shift+{arrow}") } +} + +#[test] +fn alt_shift_down_then_up_moves_a_paragraph_and_back_and_undoes() { + let mut s = typed("One\nTwo\nThree"); + run(&mut s, "caret.docStart", json!({})); + press(&mut s, &outline_key("Down")).unwrap(); + assert_eq!(texts(&s), ["two", "one", "three"]); + assert_eq!(s.sel.focus, Pos::body(1, 0), "the caret moves with the paragraph"); + press(&mut s, &outline_key("Up")).unwrap(); + assert_eq!(texts(&s), ["one", "two", "three"]); + assert!(press(&mut s, &outline_key("Up")).is_err(), "nothing above the first paragraph"); + run(&mut s, "edit.undo", json!({})); + assert_eq!(texts(&s), ["two", "one", "three"], "each move is one undo step"); + run(&mut s, "edit.undo", json!({})); + assert_eq!(texts(&s), ["one", "two", "three"]); + // A selected paragraph (with its mark) moves alone; list items keep their list. + let mut s = typed("1. First\nSecond\n\nAfter"); + run(&mut s, "select.paragraph", json!({})); + press(&mut s, &outline_key("Up")).unwrap(); + assert_eq!(paras(&s), vec![para("First", Some(0)), para("After", None), para("Second", Some(0))]); + assert!(press(&mut s, &outline_key("Down")).is_ok()); + assert!(press(&mut s, &outline_key("Down")).is_err(), "nothing below the last paragraph"); +} + +fn style_of(s: &Session, i: usize) -> Option { + s.doc.body.get(i).and_then(|b| b.as_para()).and_then(|p| p.props.style.clone()) +} + +#[test] +fn alt_shift_left_and_right_promote_and_demote_headings_and_list_items() { + let mut s = typed("Intro\nBody"); + run(&mut s, "caret.docStart", json!({})); + run(&mut s, "para.heading2", json!({})); + run(&mut s, "caret.docEnd", json!({})); + // Promoted body text takes the level of the heading before it; then one level at a time. + press(&mut s, &outline_key("Left")).unwrap(); + assert_eq!(style_of(&s, 1).as_deref(), Some("Heading2")); + press(&mut s, &outline_key("Right")).unwrap(); + assert_eq!(style_of(&s, 1).as_deref(), Some("Heading3")); + press(&mut s, &outline_key("Left")).unwrap(); + press(&mut s, &outline_key("Left")).unwrap(); + assert_eq!(style_of(&s, 1).as_deref(), Some("Heading1")); + assert!(press(&mut s, &outline_key("Left")).is_err(), "Heading 1 is the top level"); + for _ in 0..8 { + press(&mut s, &outline_key("Right")).unwrap(); + } + assert_eq!(style_of(&s, 1).as_deref(), Some("Heading9")); + assert!(press(&mut s, &outline_key("Right")).is_err(), "Heading 9 is the lowest heading"); + // In a list they change the item's level, like Tab and Shift+Tab. + let mut s = typed("* One\nTwo"); + press(&mut s, &outline_key("Right")).unwrap(); + assert_eq!(paras(&s), vec![para("One", Some(0)), para("Two", Some(1))]); + press(&mut s, &outline_key("Left")).unwrap(); + assert_eq!(paras(&s), vec![para("One", Some(0)), para("Two", Some(0))]); +} + +#[test] +fn down_and_up_from_a_line_start_beside_a_table_go_into_it() { + let mut s = typed("Intro line\n"); + run(&mut s, "insert.table", json!({"rows": 2, "cols": 2})); + let (table, _, _) = s.sel.focus.path.cell().unwrap(); + typ(&mut s, "a"); + let cell = |r: u32, c: u32| { + let mut p = table.0.clone(); + p.extend([r, c, 0]); + wordcraft_doc::Path(p) + }; + // From the start of the line above, left of the cells' text: the first cell, then the one + // below, not the paragraph after the table. + run(&mut s, "caret.docStart", json!({})); + run(&mut s, "caret.down", json!({})); + assert_eq!(s.sel.focus.path, cell(0, 0)); + run(&mut s, "caret.down", json!({})); + assert_eq!(s.sel.focus.path, cell(1, 0), "then the cell below"); + // Up from the start of the paragraph after it: the last row's first cell. + run(&mut s, "caret.docEnd", json!({})); + run(&mut s, "caret.up", json!({})); + assert_eq!(s.sel.focus.path, cell(1, 0)); + // Shift+Down from the line above takes the first row, not the whole table. + run(&mut s, "caret.docStart", json!({})); + run(&mut s, "caret.down", json!({"extend": true})); + assert_eq!(s.sel.focus.path, cell(0, 1)); +} + +#[test] +fn alt_x_toggles_between_a_character_and_its_code() { + if !cfg!(target_os = "macos") { + assert_eq!(s().registry.by_shortcut("Alt+X").map(|c| c.id), Some("text.toggleUnicode")); + } + let mut s = s(); + run(&mut s, "text.insert", json!({"text": "x 00e9"})); + run(&mut s, "text.toggleUnicode", json!({})); + assert_eq!(texts(&s), ["x é"]); + run(&mut s, "text.toggleUnicode", json!({})); + assert_eq!(texts(&s), ["x 00e9"]); + run(&mut s, "edit.undo", json!({})); + assert_eq!(texts(&s), ["x é"], "one undo step"); + run(&mut s, "text.insert", json!({"text": " U+1F600"})); + run(&mut s, "text.toggleUnicode", json!({})); + assert_eq!(texts(&s), ["x é 😀"]); + // A selected code converts too. + run(&mut s, "text.insert", json!({"text": " 2014"})); + run(&mut s, "select.text", json!({"text": "2014"})); + run(&mut s, "text.toggleUnicode", json!({})); + assert_eq!(texts(&s), ["x é 😀 —"]); +} + +#[test] +fn alt_numpad_codes_map_like_word_on_windows() { + use crate::cmd::text::{alt_code_char, oem_code_page}; + let c = |code| alt_code_char(code, 1252, 437); + // Leading 0: Windows-1252. + assert_eq!(c("0233"), Some('é')); + assert_eq!(c("0128"), Some('€')); + assert_eq!(c("0150"), Some('–')); + assert_eq!(c("065"), Some('A')); + assert_eq!(c("0032"), Some(' ')); + assert_eq!(c("0255"), Some('ÿ')); + // No leading 0: the OEM code page (437 here, 850 in Western Europe). + assert_eq!(c("233"), Some('Θ')); + // The issue said 233 → é in 850; the CP850 mapping file has Ú at 0xE9 (é is 130 in both). + assert_eq!(alt_code_char("233", 1252, 850), Some('Ú')); + assert_eq!(alt_code_char("130", 1252, 850), Some('é')); + assert_eq!(c("130"), Some('é')); + assert_eq!(alt_code_char("155", 1252, 850), Some('ø')); + assert_eq!(c("155"), Some('¢')); + assert_eq!(c("65"), Some('A')); + assert_eq!(c("1"), Some('☺')); + assert_eq!(c("7"), Some('•')); + assert_eq!(c("127"), Some('⌂')); + assert_eq!(c("176"), Some('░')); + // Above 255: the Unicode character with that decimal value. + assert_eq!(c("8364"), Some('€')); + assert_eq!(c("09733"), Some('★')); + assert_eq!(c("128512"), Some('😀')); + // Nothing for junk, 0, control characters, surrogates or values past Unicode. + for bad in ["", "0", "000", "0009", "0013", "0127", "0129", "x1", "-5", "55296", "1114112", "99999999999999999999", "4294967361"] { + assert_eq!(c(bad), None, "{bad:?}"); + } + assert_eq!(oem_code_page(None), 437); + assert_eq!(oem_code_page(Some("en-US")), 437); + assert_eq!(oem_code_page(Some("en_GB.UTF-8")), 850); + assert_eq!(oem_code_page(Some("fr-FR")), 850); + assert_eq!(oem_code_page(Some("de")), 850); + assert_eq!(oem_code_page(Some("ru-RU")), 437, "only 437/850 without the shell's code pages"); +} + +#[test] +fn alt_numpad_codes_follow_the_system_code_pages() { + use crate::cmd::text::{alt_code_char, windows_code_pages}; + // Polish (1250/852): Alt+0185 and Alt+165 are both ą, not ¹ / Ñ. + assert_eq!(alt_code_char("0185", 1250, 852), Some('ą')); + assert_eq!(alt_code_char("165", 1250, 852), Some('ą')); + assert_eq!(alt_code_char("0185", 1252, 437), Some('¹')); + // Russian (1251/866), Greek (1253/737), Turkish (1254/857). + assert_eq!(alt_code_char("0207", 1251, 866), Some('П')); + assert_eq!(alt_code_char("143", 1251, 866), Some('П')); + assert_eq!(alt_code_char("0193", 1253, 737), Some('Α')); + assert_eq!(alt_code_char("128", 1253, 737), Some('Α')); + assert_eq!(alt_code_char("0253", 1254, 857), Some('ı')); + assert_eq!(alt_code_char("141", 1254, 857), Some('ı')); + // Above 255 the code page doesn't matter. + assert_eq!(alt_code_char("8364", 1251, 866), Some('€')); + // A byte the code page leaves unassigned types nothing. + assert_eq!(alt_code_char("213", 1254, 857), None); + assert_eq!(alt_code_char("0219", 874, 437), None); + assert_eq!(windows_code_pages(None), (1252, 437)); + assert_eq!(windows_code_pages(Some("en-US")), (1252, 437)); + assert_eq!(windows_code_pages(Some("de-DE")), (1252, 850)); + assert_eq!(windows_code_pages(Some("pl-PL")), (1250, 852)); + assert_eq!(windows_code_pages(Some("sr-Latn-RS")), (1250, 852)); + assert_eq!(windows_code_pages(Some("ru_RU.UTF-8")), (1251, 866)); + assert_eq!(windows_code_pages(Some("el-GR")), (1253, 737)); + assert_eq!(windows_code_pages(Some("tr-TR")), (1254, 857)); + assert_eq!(windows_code_pages(Some("ja-JP")), (1252, 437)); +} + +#[test] +fn alt_numpad_code_types_one_character() { + // `texts` lowercases; the capitals matter here. + let raw = |s: &Session| paras(s).into_iter().map(|p| p.0).collect::>(); + let mut s = s(); + run(&mut s, "text.insert", json!({"text": "caf"})); + run(&mut s, "text.typeCode", json!({"code": "0233"})); + run(&mut s, "text.insert", json!({"text": " "})); + run(&mut s, "text.typeCode", json!({"code": "233", "oem": 437})); + run(&mut s, "text.insert", json!({"text": " "})); + run(&mut s, "text.typeCode", json!({"code": "155", "oem": 850})); + assert_eq!(raw(&s), ["Café Θ ø"]); + run(&mut s, "edit.undo", json!({})); + assert_eq!(raw(&s), ["Café Θ "], "one undo step per code"); + assert!(s.run("text.typeCode", &json!({"code": "0"})).is_err()); + assert!(s.run("text.typeCode", &json!({"code": "65", "oem": 855})).is_err()); + assert!(s.run("text.typeCode", &json!({"code": "065", "ansi": 932})).is_err()); + assert!(s.run("text.typeCode", &json!({"code": "065", "ansi": 70000})).is_err()); + assert_eq!(raw(&s), ["Café Θ "]); + // The system's code pages: Polish Alt+0185 and Alt+165 both type ą. + run(&mut s, "text.typeCode", json!({"code": "0185", "ansi": 1250, "oem": 852})); + run(&mut s, "text.typeCode", json!({"code": "165", "ansi": 1250, "oem": 852})); + run(&mut s, "text.typeCode", json!({"code": "143", "oem": 866})); + assert_eq!(raw(&s), ["Café Θ ąąП"]); +} + +#[test] +fn shift_f5_goes_back_through_the_last_edits() { + let mut s = typed("One\nTwo\nThree"); + run(&mut s, "caret.docStart", json!({})); + let mut back = || { + press(&mut s, "Shift+F5").unwrap(); + s.sel.focus.clone() + }; + assert_eq!(back(), Pos::body(2, 5), "the last edit first"); + assert_eq!(back(), Pos::body(1, 3)); + assert_eq!(back(), Pos::body(0, 3)); + assert_eq!(back(), Pos::body(2, 5), "then round again"); + assert!(Session::new(wordcraft_doc::Document::new()).run("edit.goBack", &json!({})).is_err(), "nothing edited yet"); +} + +#[test] +fn shift_right_at_the_end_of_a_cell_selects_the_cell_first() { + let mut s = s(); + run(&mut s, "insert.table", json!({"rows": 2, "cols": 2})); + let (table, _, _) = s.sel.focus.path.cell().unwrap(); + typ(&mut s, "ab"); + run(&mut s, "caret.left", json!({})); + let shift_right = |s: &mut Session| { + run(s, "caret.right", json!({"extend": true})); + s.cell_range().map(|r| (r.rows, r.cols)) + }; + assert_eq!(shift_right(&mut s), None, "the letter first"); + assert_eq!((s.sel.anchor.off, s.sel.focus.off), (1, 2)); + assert_eq!(shift_right(&mut s), Some(((0, 0), (0, 0))), "then the cell alone"); + assert_eq!(shift_right(&mut s), Some(((0, 0), (0, 1))), "then the next cell with it"); + // In an empty cell the first press takes it. + let mut empty = table.0.clone(); + empty.extend([1, 0, 0]); + let pos = Pos { story: StoryRef::Body, path: wordcraft_doc::Path(empty), off: 0 }; + run(&mut s, "caret.set", json!({"pos": pos})); + assert_eq!(shift_right(&mut s), Some(((1, 1), (0, 0)))); +} + +#[test] +fn two_initial_capitals_are_corrected() { + let s = typed("the WOrd and THe end, not IDs or CDs. TV is fine "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "The Word and The end, not IDs or CDs. TV is fine "); + // "TEh" loses its second capital, then the replacement list fixes the typo. + assert_eq!(typed("TEh ").doc.plain_text(StoryRef::Body), "The "); + // A user exception. + let mut s = typed(""); + run(&mut s, "tools.autocorrect", json!({"addInitialCapsException": "WCraft"})); + typ(&mut s, "WCraft "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "WCraft "); +} + +#[test] +fn accidental_caps_lock_is_corrected() { + let s = typed("a wORD and hELLO there. iOS stays "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "A Word and Hello there. iOS stays "); +} + +#[test] +fn capitalisation_fixes_undo_and_turn_off() { + let mut s = typed("WOrd"); + typ(&mut s, " "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "Word "); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc.plain_text(StoryRef::Body), "WOrd "); + let mut s = typed("x wORD"); + typ(&mut s, " "); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc.plain_text(StoryRef::Body), "X wORD "); + let mut s = typed(""); + run(&mut s, "tools.autocorrect", json!({"twoInitialCaps": false, "capsLock": false})); + typ(&mut s, "x WOrd wORD "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "X WOrd wORD "); +} + +#[test] +fn first_letter_of_a_table_cell_is_capitalised() { + let cell_text = |s: &Session, c: usize| { + s.doc.body.iter().find_map(|b| b.as_table()).and_then(|t| t.rows.first()?.cells.get(c)?.blocks.first()?.as_para().map(|p| p.text.clone())) + }; + let mut s = typed(""); + run(&mut s, "insert.table", json!({"rows": 1, "cols": 2})); + typ(&mut s, "apples and pears\tplums "); + assert_eq!(cell_text(&s, 0).as_deref(), Some("Apples and pears")); + assert_eq!(cell_text(&s, 1).as_deref(), Some("Plums ")); + run(&mut s, "edit.undo", json!({})); + assert_eq!(cell_text(&s, 1).as_deref(), Some("plums ")); + let mut s = typed(""); + run(&mut s, "tools.autocorrect", json!({"capCells": false})); + run(&mut s, "insert.table", json!({"rows": 1, "cols": 1})); + typ(&mut s, "plums "); + assert_eq!(cell_text(&s, 0).as_deref(), Some("plums ")); +} + +/// File › Options › Advanced, "Typing replaces selected text" (#485): on (Word's default) the +/// typed text replaces the selection; off, it goes in front of the selection and keeps it. +#[test] +fn typing_over_a_selection_follows_the_replace_selection_option() { + let mut s = typed("One two"); + run(&mut s, "select.text", json!({"text": "two"})); + typ(&mut s, "X"); + assert_eq!(s.doc.plain_text(StoryRef::Body), "One X"); + let mut s = typed("One two"); + run(&mut s, "file.options", json!({"typingReplacesSelection": false})); + run(&mut s, "select.text", json!({"text": "two"})); + typ(&mut s, "XY"); + assert_eq!(s.doc.plain_text(StoryRef::Body), "One XYtwo"); +} + +/// Overtype (#485): typed characters replace the ones after the caret, never past the end of the +/// paragraph; the Insert key toggles it only with "Use the Insert key to control overtype" on +/// (the UI checks that option; `text.overtype` is the switch). +#[test] +fn overtype_replaces_the_characters_after_the_caret() { + let mut s = typed("Abcdef\nXy"); + run(&mut s, "caret.set", json!({"pos": Pos::body(0, 1)})); + run(&mut s, "text.overtype", json!({"value": true})); + typ(&mut s, "XY"); + assert_eq!(s.doc.plain_text(StoryRef::Body), "AXYdef\nXy"); + run(&mut s, "caret.set", json!({"pos": Pos::body(0, 5)})); + typ(&mut s, "123"); + assert_eq!(s.doc.plain_text(StoryRef::Body), "AXYde123\nXy", "the paragraph mark isn't typed over"); + run(&mut s, "text.overtype", json!({})); + run(&mut s, "caret.set", json!({"pos": Pos::body(0, 0)})); + typ(&mut s, "Z"); + assert_eq!(s.doc.plain_text(StoryRef::Body), "ZAXYde123\nXy", "insert mode again"); +} + +#[test] +fn reject_all_takes_out_the_paragraph_breaks_a_tracked_paste_inserted() { + // Issue #417: Paste's paragraph marks weren't tracked, so Reject All left the split. + for text in ["X\nY", "\n"] { + let mut s = s(); + run(&mut s, "text.insert", json!({"text": "AB", "raw": true})); + run(&mut s, "caret.set", json!({"pos": {"block": 0, "off": 1}})); + run(&mut s, "review.trackChanges", json!({"value": true})); + run(&mut s, "edit.paste", json!({"text": text})); + let (head, tail) = text.split_once('\n').unwrap_or_default(); + assert_eq!(texts(&s), [format!("a{}", head.to_lowercase()), format!("{}b", tail.to_lowercase())], "{text:?}"); + let pasted = texts(&s); + run(&mut s, "review.rejectAll", json!({})); + assert_eq!(texts(&s), ["ab"], "{text:?}"); + assert!(s.doc.revisions.is_empty(), "{text:?}"); + run(&mut s, "edit.undo", json!({})); + run(&mut s, "review.acceptAll", json!({})); + assert_eq!(texts(&s), pasted, "{text:?}: accepting keeps the break"); + } +} + +#[test] +fn plain_text_paste_keeps_an_empty_paragraph_in_a_list() { + // Issue #422: the pasted paragraph separators went through Enter, which ends the list on an + // empty item, so `A\n\nB` lost its empty paragraph. + // Paste of plain text from another program goes the same way. + for (id, v) in [ + ("edit.pasteText", json!({"text": "A\n\nB"})), + ("edit.pasteSpecial", json!({"as": "text", "text": "A\n\nB"})), + ("edit.paste", json!({"text": "A\n\nB"})), + ] { + let mut s = s(); + run(&mut s, "para.bullets", json!({})); + run(&mut s, id, v); + assert_eq!(paras(&s), vec![para("A", Some(0)), para("", Some(0)), para("B", Some(0))], "{id}"); + } + // Enter twice still ends the list. + let mut s = s(); + run(&mut s, "para.bullets", json!({})); + typ(&mut s, "A\n\nB"); + assert_eq!(paras(&s), vec![para("A", Some(0)), para("B", None)]); +} + +/// Each body paragraph's text and paragraph formatting. +fn para_formats(s: &Session) -> Vec<(String, wordcraft_doc::props::ParaProps)> { + s.doc.body.iter().filter_map(|b| b.as_para()).map(|p| (p.text.clone(), p.props.clone())).collect() +} + +/// Plain text from another program pastes as Keep Text Only does, as in Word: every pasted line +/// becomes a paragraph formatted like the paragraph it lands in. Paste gave the lines after the +/// first default formatting, and the text after the caret went with the last line, so a +/// right-to-left paragraph came out left-to-right from its second line on. +#[test] +fn plain_text_paste_gives_every_line_the_formatting_of_its_paragraph() { + let mut s = s(); + run(&mut s, "text.insert", json!({"text": "بداية نهاية", "raw": true})); + run(&mut s, "para.rtl", json!({})); + run(&mut s, "para.indents", json!({"left": 18, "right": 36})); + run(&mut s, "para.spacing", json!({"after": 12})); + let before = para_formats(&s); + let [(_, props)] = &before[..] else { panic!("{before:?}") }; + assert_eq!(props.bidi, Some(true)); + let off = "بداية ".len(); + for id in ["edit.paste", "edit.pasteText"] { + run(&mut s, "caret.set", json!({"pos": {"block": 0, "off": off}})); + run(&mut s, id, json!({"text": "سطر أول\r\nسطر ثان\r\nسطر ثالث"})); + let want: Vec<(String, _)> = ["بداية سطر أول", "سطر ثان", "سطر ثالثنهاية"].iter().map(|t| (t.to_string(), props.clone())).collect(); + assert_eq!(para_formats(&s), want, "{id}"); + // One undo step takes the whole paste out again. + run(&mut s, "edit.undo", json!({})); + assert_eq!(para_formats(&s), before, "{id}"); + } +} + +/// Empty lines in pasted plain text come through as empty paragraphs, formatted like the rest, +/// and so does a line break at the end of the text. The text takes the formatting before the +/// caret. +#[test] +fn plain_text_paste_keeps_empty_lines_as_paragraphs() { + let mut s = s(); + run(&mut s, "text.insert", json!({"text": "Lead ", "raw": true})); + run(&mut s, "select.all", json!({})); + run(&mut s, "format.bold", json!({})); + run(&mut s, "caret.docEnd", json!({})); + run(&mut s, "para.alignCenter", json!({})); + run(&mut s, "para.spacing", json!({"before": 6, "after": 18})); + let props = para_formats(&s)[0].1.clone(); + run(&mut s, "edit.paste", json!({"text": "Alpha one.\n\nAlpha two.\n\n\nAlpha three.\n"})); + let got = para_formats(&s); + let texts: Vec<&str> = got.iter().map(|(t, _)| t.as_str()).collect(); + assert_eq!(texts, ["Lead Alpha one.", "", "Alpha two.", "", "", "Alpha three.", ""]); + assert!(got.iter().all(|(_, p)| *p == props), "{got:?}"); + assert_eq!(s.sel.focus, Pos::body(6, 0), "the caret ends after the pasted text"); + let bold = s.doc.body.iter().filter_map(|b| b.as_para()).all(|p| p.runs.iter().all(|r| r.props.bold == Some(true))); + assert!(bold, "the pasted text takes the bold before the caret"); +} + +/// #713: Arabic or Hebrew typed into an empty paragraph makes it right-to-left; a paragraph that +/// already has letters, or whose style sets a direction, keeps its own. +#[test] +fn typing_rtl_letters_into_an_empty_paragraph_makes_it_rtl() { + let bidi = |s: &Session, i: usize| s.doc.body.iter().filter_map(|b| b.as_para()).nth(i).and_then(|p| p.props.bidi); + // Empty paragraph: the first Arabic letter flips it, the rest keep it. + let s = typed("سلام"); + assert_eq!(bidi(&s, 0), Some(true)); + // Digits first, then Arabic: still no letters before, so it flips. + let s = typed("1. سلام"); + assert_eq!(bidi(&s, 0), Some(true)); + // Latin first: Arabic typed later doesn't flip the paragraph. + let s = typed("Hello سلام"); + assert_eq!(bidi(&s, 0), None); + // A new paragraph after an RTL one starts its own detection. + let s = typed("سلام\nHello"); + assert_eq!(bidi(&s, 1), Some(true), "Enter carries the paragraph's direction on, as in Word"); + // A style that sets left-to-right wins. + let mut doc = Session::new(wordcraft_doc::Document::new()); + if let Some(st) = Arc::make_mut(&mut doc.doc.styles).get_mut("Normal") { + st.para.bidi = Some(false); + } + typ(&mut doc, "سلام"); + assert_eq!(bidi(&doc, 0), None); + // An opened paragraph without w:bidi lays out left-to-right whatever its text: the caret + // after the Arabic word sits near the left margin, not the right one. + let mut doc = typed("سلام"); + let path = doc.sel.focus.path.clone(); + if let Ok(p) = doc.doc.para_mut(StoryRef::Body, &path) { + p.props.bidi = None; + p.touch(); + } + let x = doc.layout().caret(&doc.sel.focus).map(|c| c.x).unwrap_or(f32::MAX); + assert!(x < 300.0, "left-to-right paragraph: caret at {x}"); +} + +/// Select All then Delete or Backspace empties the document, tables included (Word, #1198): +/// whether the text starts with a table or with a paragraph. +#[test] +fn select_all_then_delete_removes_tables() { + let tables = |s: &Session| s.doc.body.iter().filter(|b| b.as_table().is_some()).count(); + // With the caret in a table, Select All grows the selection a step at a time (cell, row, + // table, document; #1478): press it until the whole document is selected. + let select_whole_document = |s: &mut Session| { + for _ in 0..6 { + let before = s.sel.clone(); + run(s, "select.all", json!({})); + if s.sel == before { + break; + } + } + }; + for key in ["text.delete", "text.backspace"] { + let mut s = typed(""); + run(&mut s, "insert.table", json!({"rows": 2, "cols": 2})); + typ(&mut s, "cell"); + assert_eq!(tables(&s), 1); + select_whole_document(&mut s); + run(&mut s, key, json!({})); + assert_eq!(tables(&s), 0, "{key}: a table at the start of the document goes"); + assert_eq!(s.doc.plain_text(StoryRef::Body), "", "{key}"); + + let mut s = typed("before\n"); + run(&mut s, "insert.table", json!({"rows": 2, "cols": 2})); + typ(&mut s, "cell"); + assert_eq!(tables(&s), 1); + select_whole_document(&mut s); + run(&mut s, key, json!({})); + assert_eq!(tables(&s), 0, "{key}: a table after text goes"); + assert_eq!(s.doc.plain_text(StoryRef::Body), "", "{key}"); + } +} + +/// #898: a click just after a citation at the end of a paragraph puts the caret before the +/// citation's hidden field end; what is typed there goes after the citation, as in Word, instead +/// of into its result (Zotero then reports the citation as edited by hand). +#[test] +fn typing_after_a_citation_goes_outside_its_field() { + use wordcraft_doc::props::CharProps; + use wordcraft_doc::{InlineObject, Path}; + let mut doc = wordcraft_doc::Document::from_text("Cats purr "); + let c = CharProps::default(); + let p = doc.para_mut(StoryRef::Body, &Path::top(0)).unwrap(); + let start = p.len(); + p.insert_object(start, InlineObject::FieldStart { instr: "ADDIN ZOTERO_ITEM CSL_CITATION {}".into(), locked: false, dirty: false }, &c).unwrap(); + let n = p.insert_text(p.len(), "(Smith 2020)", &c).unwrap(); + p.insert_object(start + 3 + n, InlineObject::FieldEnd, &c).unwrap(); + let mut s = Session::new(doc); + let end = Pos::body(0, s.doc.para(StoryRef::Body, &Path::top(0)).unwrap().len()); + let at = s.layout().caret(&end).unwrap(); + let hit = s.layout().hit(at.page, at.x + 20.0, at.top + at.height / 2.0, StoryRef::Body).unwrap(); + s.sel = crate::Selection::caret(hit); + typ(&mut s, ". Dogs bark."); + assert_eq!(s.doc.plain_text(StoryRef::Body), "Cats purr (Smith 2020). Dogs bark."); + let f = s.doc.field_ranges(StoryRef::Body); + assert_eq!(f.len(), 1); + let inside = Pos { off: f[0].start.off + wordcraft_doc::para::OBJ.len_utf8(), ..f[0].start.clone() }; + assert_eq!(s.doc.copy_range(&inside, &f[0].end).plain_text(), "(Smith 2020)"); +} + +/// Tab in a table typed in right-to-left text (#2634): like Word, a table inserted in a +/// right-to-left paragraph is a right-to-left table, so its first cell stands at the right and +/// Tab moves to the cell on the left (the next one in reading order); Shift+Tab comes back. A +/// table inserted in a left-to-right paragraph is unchanged: Tab moves right. +#[test] +fn tab_in_a_table_inserted_in_right_to_left_text_moves_left() { + // The caret's cell column and the x of that cell on the page. + fn caret_cell_x(s: &mut Session) -> (usize, f32) { + let (_, r, c) = s.sel.focus.path.cell().expect("caret in a cell"); + let x = s + .layout() + .pages + .iter() + .flat_map(|p| p.items.iter()) + .find_map(|it| match it { + wordcraft_layout::Placed::Cell { rect, row, cell, covered: false, .. } if *row == r && *cell == c => Some(rect.x), + _ => None, + }) + .expect("cell laid out"); + (c, x) + } + for (keys, rtl) in [("مرحبا\n", true), ("Hello\n", false)] { + let mut s = s(); + typ(&mut s, keys); + run(&mut s, "insert.table", json!({"rows": 1, "cols": 3})); + let t = s.doc.body.iter().find_map(|b| b.as_table()).expect("table"); + assert_eq!(t.props.bidi_visual, rtl, "{keys:?}: the table takes the paragraph's direction"); + let (c0, x0) = caret_cell_x(&mut s); + typ(&mut s, "\t"); + let (c1, x1) = caret_cell_x(&mut s); + assert_eq!((c0, c1), (0, 1), "{keys:?}: Tab goes to the next cell in reading order"); + assert_eq!(x1 < x0, rtl, "{keys:?}: Tab moves {} ({x0} -> {x1})", if rtl { "left" } else { "right" }); + run(&mut s, "text.backTab", json!({})); + assert_eq!(caret_cell_x(&mut s), (0, x0), "{keys:?}: Shift+Tab comes back"); + } +} + +#[test] +fn ctrl_tab_inserts_a_tab_inside_a_table_cell() { + let mut s = s(); + run(&mut s, "insert.table", json!({"rows": 1, "cols": 2})); + typ(&mut s, "A"); + run(&mut s, "text.insertTab", json!({})); + typ(&mut s, "B"); + let (_, r, c) = s.sel.focus.path.cell().expect("caret in a cell"); + assert_eq!((r, c), (0, 0), "Ctrl+Tab stays in the cell"); + assert_eq!(s.doc.para_at(&s.sel.focus).map(|p| p.text.as_str()), Some("A\tB")); + let key = if cfg!(target_os = "macos") { "Alt+Tab" } else { "Mod+Tab" }; + assert_eq!(s.keymap.resolve(&s.registry, key).map(|c| c.id), Some("text.insertTab")); +} + +/// A .docx of two paragraphs in two sections, as Word writes it: the first section's properties +/// in the first paragraph's mark (portrait), the last section's in the body (landscape, starting +/// as `kind`). +fn two_section_docx(kind: &str) -> Vec { + const W: &str = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"; + let files = [ + ( + "[Content_Types].xml", + r#""#.to_string(), + ), + ( + "_rels/.rels", + r#""#.to_string(), + ), + ( + "word/document.xml", + format!( + r#"One.Two."# + ), + ), + ]; + let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + for (name, data) in files { + zip.start_file(name, zip::write::SimpleFileOptions::default()).unwrap(); + std::io::Write::write_all(&mut zip, data.as_bytes()).unwrap(); + } + zip.finish().unwrap().into_inner() +} + +/// #2127: Backspace just after a section break, or Delete just before it, removes the break, as +/// in Word: the two paragraphs join, the text before the break takes the following section's +/// page setup, and Undo brings the break back. Imported breaks and ones made here alike. +#[test] +fn backspace_and_delete_remove_a_section_break() { + let made_here = || { + let mut s = typed("One.Two."); + s.sel = crate::Selection::caret(Pos::body(0, 4)); + run(&mut s, "layout.break", json!({"kind": "nextPage"})); + s + }; + for source in ["nextPage", "continuous", "made here"] { + for (key, caret) in [("text.backspace", Pos::body(1, 0)), ("text.delete", Pos::body(0, 4))] { + let mut s = match source { + "made here" => made_here(), + kind => Session::new(crate::io::open_bytes("s.docx", &two_section_docx(kind)).unwrap()), + }; + assert_eq!(s.doc.sections().len(), 2, "{source}"); + assert_eq!(s.doc.plain_text(StoryRef::Body), "One.\nTwo.", "{source}"); + let after = s.doc.sections().last().map(|(_, sp)| (**sp).clone()).unwrap(); + s.sel = crate::Selection::caret(caret); + run(&mut s, key, json!({})); + assert_eq!(s.doc.plain_text(StoryRef::Body), "One.Two.", "{source} {key}"); + let sections = s.doc.sections(); + assert_eq!(sections.len(), 1, "{source} {key}: the break is gone"); + assert_eq!(*sections[0].1, after, "{source} {key}: the following section's setup"); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc.plain_text(StoryRef::Body), "One.\nTwo.", "{source} {key}: undo"); + assert_eq!(s.doc.sections().len(), 2, "{source} {key}: undo brings the break back"); + } + } +} + +/// A .docx laid out like the one attached to #2127: a continuous section break, with the +/// document's only header, on the mark of an empty paragraph after "One.", and the last +/// section (nextPage, no header of its own, so linked to the previous one) in the body. +/// `then` adds a paragraph after the break; without it the break is on the last paragraph and +/// the last section has none. +fn break_on_the_last_paragraph_docx(then: Option<&str>) -> Vec { + const W: &str = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"; + const R: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; + let then = then.map(|t| format!("{t}")).unwrap_or_default(); + let files = [ + ( + "[Content_Types].xml", + r#""#.to_string(), + ), + ( + "_rels/.rels", + r#""#.to_string(), + ), + ( + "word/_rels/document.xml.rels", + r#""#.to_string(), + ), + ("word/header1.xml", format!(r#"Lab report"#)), + ( + "word/document.xml", + format!( + r#"One.{then}"# + ), + ), + ]; + let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + for (name, data) in files { + zip.start_file(name, zip::write::SimpleFileOptions::default()).unwrap(); + std::io::Write::write_all(&mut zip, data.as_bytes()).unwrap(); + } + zip.finish().unwrap().into_inner() +} + +/// #2127: Delete at the end of the last paragraph removes a section break on its mark too, +/// though there's no paragraph after it to join. The text takes the last section's setup, and +/// the header the last section showed by linking to the removed one stays. Removing the break +/// by joining two paragraphs keeps that header as well. Undo brings the break back. +#[test] +fn delete_removes_a_section_break_on_the_last_paragraph() { + for then in [None, Some("Two.")] { + let mut s = Session::new(crate::io::open_bytes("s.docx", &break_on_the_last_paragraph_docx(then)).unwrap()); + let sections = s.doc.sections(); + assert_eq!(sections.len(), 2, "{then:?}"); + let header = sections[0].1.headers.default; + assert!(header.is_some() && sections[1].1.headers.default.is_none(), "{then:?}: the header is on the break's section"); + let mut after = sections[1].1.clone(); + after.headers.default = header; + s.sel = crate::Selection::caret(Pos::body(1, 0)); + run(&mut s, "text.delete", json!({})); + let text = if then.is_some() { "One.\nTwo." } else { "One.\n" }; + assert_eq!(s.doc.plain_text(StoryRef::Body), text, "{then:?}"); + let sections = s.doc.sections(); + assert_eq!(sections.len(), 1, "{then:?}: the break is gone"); + assert_eq!(*sections[0].1, after, "{then:?}: the last section's setup, with the header"); + run(&mut s, "edit.undo", json!({})); + let sections = s.doc.sections(); + assert_eq!(sections.len(), 2, "{then:?}: undo brings the break back"); + assert_eq!((sections[0].1.headers.default, sections[1].1.headers.default), (header, None), "{then:?}: undo"); + } +} + +fn first_letter_list(s: &Session) -> Vec { + s.prefs.autocorrect.exceptions.clone() +} + +/// Undoing an automatic sentence capital adds the word before it to the First Letter exceptions +/// (Word: "Automatically add words to list", #1857); typing the line again isn't capitalised. +#[test] +fn undoing_a_sentence_capital_adds_a_first_letter_exception() { + let mut s = typed("hello there. world "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "Hello there. World "); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc.plain_text(StoryRef::Body), "Hello there. world "); + assert_eq!(first_letter_list(&s), ["there."]); + run(&mut s, "text.newParagraph", json!({})); + typ(&mut s, "x there. world "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "Hello there. world \nX there. world "); +} + +#[test] +fn backspacing_a_sentence_capital_and_retyping_adds_a_first_letter_exception() { + let mut s = typed("hello there. world "); + typ(&mut s, "\u{8}\u{8}\u{8}\u{8}\u{8}\u{8}world "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "Hello there. world "); + assert_eq!(first_letter_list(&s), ["there."]); +} + +#[test] +fn automatic_exceptions_can_be_turned_off() { + let mut s = typed(""); + run(&mut s, "tools.autocorrect", json!({"autoAddFirstLetter": false})); + typ(&mut s, "hello there. world "); + run(&mut s, "edit.undo", json!({})); + assert!(first_letter_list(&s).is_empty()); + let mut s = typed(""); + run(&mut s, "tools.autocorrect", json!({"autoAddFirstLetter": false})); + typ(&mut s, "hello there. world \u{8}\u{8}\u{8}\u{8}\u{8}\u{8}world "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "Hello there. World "); + assert!(first_letter_list(&s).is_empty()); +} + +#[test] +fn undoing_a_replacement_adds_no_exception() { + let mut s = typed("so teh "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "So the "); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc.plain_text(StoryRef::Body), "So teh "); + assert!(first_letter_list(&s).is_empty()); + assert!(s.prefs.autocorrect.entries.is_empty() && s.prefs.autocorrect.removed.is_empty()); + typ(&mut s, "and teh "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "So teh and the "); +} + +/// Checked in Word for Mac: the same rule as First Letter. +#[test] +fn undoing_an_initial_caps_fix_adds_an_initial_caps_exception() { + let mut s = typed("so TWo "); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.prefs.autocorrect.initial_caps_exceptions, ["TWo"]); + typ(&mut s, "and TWo "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "So TWo and TWo "); +} + +#[test] +fn undoing_other_edits_after_a_capital_adds_no_exception() { + // Typing more, then one Undo, and a second Undo that reverses the capital's step later. + let mut s = typed("hello there. world more text"); + run(&mut s, "edit.undo", json!({})); + assert!(first_letter_list(&s).is_empty()); + run(&mut s, "edit.undo", json!({})); + assert!(first_letter_list(&s).is_empty(), "only the Undo that reverses the capital itself counts"); +} + +#[test] +fn a_full_undo_history_does_not_match_later_undos() { + let mut s = typed(""); + for _ in 0..300 { + typ(&mut s, "x\n"); + } + typ(&mut s, "hello there. world more text\nand more"); + run(&mut s, "edit.undo", json!({})); + assert!(first_letter_list(&s).is_empty()); +} + +#[test] +fn a_new_document_forgets_the_capital() { + let mut s = typed("hello there. world "); + run(&mut s, "file.new", json!({})); + typ(&mut s, "abc\ndef\nghi\njkl"); + for _ in 0..8 { + let _ = s.run("edit.undo", &json!({})); + } + assert!(first_letter_list(&s).is_empty()); +} + +#[test] +fn an_overlong_word_is_not_added_as_an_exception() { + let mut s = typed(&format!("hi {}. world ", "a".repeat(5000))); + run(&mut s, "edit.undo", json!({})); + assert!(first_letter_list(&s).is_empty()); +} + +/// Each body paragraph's laid-out number (`None`: none) and its style. +fn numbers(s: &mut Session) -> Vec<(Option, String)> { + let l = s.layout(); + let labels: Vec> = l + .pages + .iter() + .flat_map(|p| &p.items) + .filter_map(|i| { + if let wordcraft_layout::Placed::Lines { para, l0: 0, .. } = i { Some(para.label.as_ref().map(|lb| lb.text.clone())) } else { None } + }) + .collect(); + let styles = s.doc.body.iter().filter_map(|b| b.as_para()).map(|p| p.props.style.clone().unwrap_or_else(|| "Normal".into())); + labels.into_iter().zip(styles).collect() +} + +fn num(label: Option<&str>, style: &str) -> (Option, String) { + (label.map(str::to_string), style.to_string()) +} + +/// #1793 (Word for Mac, 2026-10-15): after the "1 / 1.1 / 1.1.1" heading list, a new Heading 2 +/// typed after "Three" is numbered 2.1 on its own; the body text after it stays unnumbered. +#[test] +fn a_new_heading_after_a_heading_linked_list_is_numbered() { + let mut s = s(); + typ(&mut s, "One"); + run(&mut s, "para.heading1", json!({})); + typ(&mut s, "\nTwo"); + run(&mut s, "para.heading2", json!({})); + typ(&mut s, "\nThree"); + run(&mut s, "para.heading1", json!({})); + // The caret in "Three" (a Heading 1, the list's first level). + run(&mut s, "list.multilevelPreset", json!({"preset": "headings"})); + run(&mut s, "caret.docEnd", json!({})); + typ(&mut s, "\n"); + run(&mut s, "para.heading2", json!({})); + typ(&mut s, "Four\nBody"); + assert_eq!( + numbers(&mut s), + [num(Some("1"), "Heading1"), num(Some("1.1"), "Heading2"), num(Some("2"), "Heading1"), num(Some("2.1"), "Heading2"), num(None, "Normal")] + ); +} + +/// #1793: plain Numbering on a Heading 1 numbers it "1." and keeps Heading 1 (Word: "1. One"). +#[test] +fn numbering_a_heading_keeps_the_heading_style() { + let mut s = s(); + typ(&mut s, "One"); + run(&mut s, "para.heading1", json!({})); + run(&mut s, "para.numbering", json!({})); + assert_eq!(numbers(&mut s), [num(Some("1."), "Heading1")]); +} + +/// #1793: applying a heading style that carries list numbering numbers the paragraph, even one +/// whose numbering was switched off before. +#[test] +fn applying_a_numbered_heading_style_numbers_the_paragraph() { + let mut s = s(); + typ(&mut s, "One"); + run(&mut s, "para.heading1", json!({})); + run(&mut s, "list.multilevelPreset", json!({"preset": "headings"})); + typ(&mut s, "\nTwo"); + run(&mut s, "para.numbering", json!({})); + run(&mut s, "para.numbering", json!({"off": true})); + assert_eq!(numbers(&mut s)[1].0, None); + run(&mut s, "para.heading2", json!({})); + assert_eq!(numbers(&mut s), [num(Some("1"), "Heading1"), num(Some("1.1"), "Heading2")]); +} + +/// #1793: "1. " typed at the start of a heading numbers it and keeps the heading style; only +/// body text becomes List Paragraph. +#[test] +fn typing_1_dot_in_a_heading_keeps_the_heading_style() { + let mut s = s(); + run(&mut s, "para.heading1", json!({})); + typ(&mut s, "1. One\n"); + run(&mut s, "para.normal", json!({})); + typ(&mut s, "1. Body"); + assert_eq!(numbers(&mut s), [num(Some("1."), "Heading1"), num(Some("1."), "ListParagraph")]); + for (style, keys) in [("Heading2", "a) Part"), ("Subtitle", "1. Item")] { + let mut s = self::s(); + run(&mut s, "para.style", json!({"style": style})); + typ(&mut s, keys); + let got = numbers(&mut s); + assert_eq!(got.len(), 1); + assert!(got[0].0.is_some(), "{style}: a list"); + assert_eq!(got[0].1, style); + } +} + +// Backspace and Delete on selected tables, rows and columns (Word for Mac, #1844). + +/// "before", a 3×3 table with cells `a1`…`c3` (column letter, row number), "after"; the caret +/// in the cell at row `r`, column `c`. +fn table_between(r: u32, c: u32) -> Session { + let mut s = typed("before\n"); + run(&mut s, "insert.table", json!({"rows": 3, "cols": 3})); + typ(&mut s, "a1\tb1\tc1\ta2\tb2\tc2\ta3\tb3\tc3"); + run(&mut s, "caret.docEnd", json!({})); + typ(&mut s, "after"); + let shape: Vec<&str> = s.doc.body.iter().map(|b| if b.as_table().is_some() { "table" } else { "para" }).collect(); + assert_eq!(shape, ["para", "table", "para"], "setup"); + run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [1, r, c, 0], "off": 0}})); + s +} + +fn tables(s: &Session) -> usize { + s.doc.body.iter().filter(|b| b.as_table().is_some()).count() +} + +/// The first top-level table's cells, row by row (tracked deletions included). +fn table_cells(s: &Session) -> Vec> { + s.doc + .body + .iter() + .find_map(|b| b.as_table()) + .map(|t| { + t.rows + .iter() + .map(|r| r.cells.iter().map(|c| c.blocks.iter().filter_map(|b| b.as_para()).map(|p| p.plain_text()).collect()).collect()) + .collect() + }) + .unwrap_or_default() +} + +#[test] +fn backspace_with_the_whole_table_selected_removes_the_table() { + let mut s = table_between(1, 1); + run(&mut s, "table.selectTable", json!({})); + run(&mut s, "text.backspace", json!({})); + assert_eq!(tables(&s), 0, "the table stays: {:?}", table_cells(&s)); + assert_eq!(texts(&s), ["before", "after"]); + // The caret is in the paragraph that followed the table. + assert_eq!(s.sel.focus, Pos { story: StoryRef::Body, path: wordcraft_doc::Path::top(1), off: 0 }); + assert!(s.sel.is_collapsed()); + // One undo brings it back. + run(&mut s, "edit.undo", json!({})); + assert_eq!(tables(&s), 1); + assert_eq!(table_cells(&s), [["a1", "b1", "c1"], ["a2", "b2", "c2"], ["a3", "b3", "c3"]]); +} + +#[test] +fn delete_with_the_whole_table_selected_clears_the_cells_and_keeps_the_table() { + let mut s = table_between(0, 0); + run(&mut s, "table.selectTable", json!({})); + run(&mut s, "text.delete", json!({})); + assert_eq!(tables(&s), 1); + assert_eq!(table_cells(&s), [["", "", ""], ["", "", ""], ["", "", ""]]); + assert_eq!(texts(&s), ["before", "after"]); +} + +#[test] +fn delete_or_backspace_with_the_table_and_the_paragraph_after_selected_removes_the_table() { + for key in ["text.delete", "text.backspace"] { + // From the first cell to the end of the document: Word's rule widens it to whole rows. + let mut s = table_between(0, 0); + run(&mut s, "caret.docEnd", json!({"extend": true})); + run(&mut s, key, json!({})); + assert_eq!(tables(&s), 0, "{key}: {:?}", table_cells(&s)); + assert_eq!(texts(&s), ["before", ""], "{key}"); + } +} + +#[test] +fn delete_or_backspace_with_the_paragraph_before_and_the_table_selected_removes_the_table() { + for key in ["text.delete", "text.backspace"] { + // From the start of "before" down into the last row: the rows are taken whole. + let mut s = table_between(0, 0); + run(&mut s, "caret.docStart", json!({})); + for _ in 0..3 { + run(&mut s, "caret.down", json!({"extend": true})); + } + assert_eq!(s.sel.focus.path.cell().map(|(_, r, _)| r), Some(2), "{key}: the selection reaches the last row"); + run(&mut s, key, json!({})); + assert_eq!(tables(&s), 0, "{key}: {:?}", table_cells(&s)); + assert_eq!(texts(&s), ["after"], "{key}"); + assert_eq!(s.sel.focus, Pos { story: StoryRef::Body, path: wordcraft_doc::Path::top(0), off: 0 }, "{key}"); + } +} + +#[test] +fn backspace_deletes_selected_rows_and_delete_clears_them() { + let mut s = table_between(1, 0); + run(&mut s, "table.selectRow", json!({})); + run(&mut s, "text.backspace", json!({})); + assert_eq!(table_cells(&s), [["a1", "b1", "c1"], ["a3", "b3", "c3"]]); + + let mut s = table_between(1, 0); + run(&mut s, "table.selectRow", json!({})); + run(&mut s, "text.delete", json!({})); + assert_eq!(table_cells(&s), [["a1", "b1", "c1"], ["", "", ""], ["a3", "b3", "c3"]]); +} + +#[test] +fn backspace_deletes_selected_columns_and_delete_clears_them() { + let mut s = table_between(0, 1); + run(&mut s, "table.selectColumn", json!({})); + run(&mut s, "text.backspace", json!({})); + assert_eq!(table_cells(&s), [["a1", "c1"], ["a2", "c2"], ["a3", "c3"]]); + + let mut s = table_between(0, 1); + run(&mut s, "table.selectColumn", json!({})); + run(&mut s, "text.delete", json!({})); + assert_eq!(table_cells(&s), [["a1", "", "c1"], ["a2", "", "c2"], ["a3", "", "c3"]]); +} + +/// The first top-level table's cells, row by row, one entry per block: a paragraph's text, or +/// `
` for a nested table. +fn cell_blocks(s: &Session) -> Vec>> { + let block = |b: &std::sync::Arc| b.as_para().map_or_else(|| "
".to_string(), |p| p.plain_text()); + s.doc + .body + .iter() + .find_map(|b| b.as_table()) + .map(|t| t.rows.iter().map(|r| r.cells.iter().map(|c| c.blocks.iter().map(block).collect()).collect()).collect()) + .unwrap_or_default() +} + +#[test] +fn delete_on_whole_cells_leaves_one_empty_paragraph_in_each() { + // a1 starts with a nested table and holds three paragraphs (centred, centred, right-aligned), + // b2 holds a nested table after its text (#2802). + let setup = || { + let mut s = table_between(0, 0); + run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [1, 0, 0, 0], "off": 2}})); + run(&mut s, "para.alignCenter", json!({})); + typ(&mut s, "\nX\nY"); + run(&mut s, "para.alignRight", json!({})); + run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [1, 1, 1, 0], "off": 2}})); + run(&mut s, "text.newParagraph", json!({})); + run(&mut s, "insert.table", json!({"rows": 1, "cols": 2})); + typ(&mut s, "n"); + let b2 = |s: &Session| s.doc.body.get(1).and_then(|b| b.as_table()).map(|t| t.rows[1].cells[1].blocks.clone()).unwrap_or_default(); + let nested = b2(&s).iter().find(|b| b.as_table().is_some()).map(|b| (**b).clone()).expect("setup: a table nested in b2"); + s.doc.insert_block(StoryRef::Body, &wordcraft_doc::Path(vec![1, 0, 0, 0]), nested).unwrap(); + s + }; + let all: Vec<(usize, usize)> = (0..3).flat_map(|r| (0..3).map(move |c| (r, c))).collect(); + let cases: [(&str, (usize, usize), &str, Vec<(usize, usize)>); 5] = [ + ("table.selectRow", (0, 0), "text.delete", vec![(0, 0), (0, 1), (0, 2)]), + ("table.selectColumn", (0, 0), "text.delete", vec![(0, 0), (1, 0), (2, 0)]), + ("table.selectRow", (1, 1), "text.delete", vec![(1, 0), (1, 1), (1, 2)]), + ("table.selectTable", (0, 0), "text.delete", all), + // One cell of several: Backspace clears it too. + ("table.selectCell", (0, 0), "text.backspace", vec![(0, 0)]), + ]; + for (select, (r, c), key, cleared) in cases { + let mut s = setup(); + let before = cell_blocks(&s); + assert_eq!(before[0][0], ["
", "a1", "X", "Y"], "setup"); + assert!(before[1][1].len() > 2 && before[1][1].contains(&"
".to_string()), "setup: {:?}", before[1][1]); + // a1's first paragraph is after the nested table. + let first = usize::from((r, c) == (0, 0)); + run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [1, r, c, first], "off": 0}})); + run(&mut s, select, json!({})); + run(&mut s, key, json!({})); + let mut want = before.clone(); + for &(r, c) in &cleared { + want[r][c] = vec![String::new()]; + } + assert_eq!(cell_blocks(&s), want, "{select} {key}"); + // The paragraph left keeps the cell's last paragraph's formatting (its mark ends the cell). + if cleared.contains(&(0, 0)) { + let p = s.doc.para(StoryRef::Body, &wordcraft_doc::Path(vec![1, 0, 0, 0])).map(|p| p.props.align); + assert_eq!(p, Some(Some(wordcraft_doc::props::Align::Right)), "{select} {key}"); + } + assert!(s.sel.is_collapsed(), "{select} {key}"); + assert_eq!(s.sel.focus.path.cell().map(|(_, r, c)| (r, c)), cleared.first().copied(), "{select} {key}"); + // One undo brings everything back. + run(&mut s, "edit.undo", json!({})); + assert_eq!(cell_blocks(&s), before, "{select} {key}: undo"); + } +} + +#[test] +fn backspace_on_a_selected_nested_table_removes_only_it() { + let mut s = table_between(1, 1); + run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [1, 1, 1, 0], "off": 2}})); + run(&mut s, "text.newParagraph", json!({})); + run(&mut s, "insert.table", json!({"rows": 2, "cols": 2})); + typ(&mut s, "x"); + let cell_blocks = |s: &Session| { + s.doc + .body + .iter() + .find_map(|b| b.as_table()) + .and_then(|t| t.rows.get(1)?.cells.get(1)) + .map(|c| c.blocks.iter().filter(|b| b.as_table().is_some()).count()) + }; + assert_eq!(cell_blocks(&s), Some(1), "setup: a table nested in b2"); + run(&mut s, "table.selectTable", json!({})); + run(&mut s, "text.backspace", json!({})); + assert_eq!(cell_blocks(&s), Some(0), "the nested table stays"); + assert_eq!(tables(&s), 1, "the outer table goes too"); + // The caret is in the cell's paragraph after where the nested table was. + assert_eq!(s.sel.focus.path.cell().map(|(t, r, c)| (t.0, r, c)), Some((vec![1], 1, 1))); + assert!(s.doc.para_at(&s.sel.focus).is_some()); +} + +#[test] +fn backspace_on_a_selected_table_that_is_the_only_block_leaves_an_empty_paragraph() { + let mut s = s(); + run(&mut s, "insert.table", json!({"rows": 2, "cols": 2})); + typ(&mut s, "x"); + s.doc.body.retain(|b| b.as_table().is_some()); + assert_eq!(s.doc.body.len(), 1, "setup: the table is the only block"); + run(&mut s, "table.selectTable", json!({})); + run(&mut s, "text.backspace", json!({})); + assert_eq!(tables(&s), 0); + assert_eq!(texts(&s), [""]); + assert!(s.doc.para_at(&s.sel.focus).is_some(), "the caret is in a paragraph: {:?}", s.sel.focus); +} + +#[test] +fn tracked_backspace_on_a_selected_table_marks_its_text_deleted_like_delete_table() { + let mut s = table_between(0, 0); + run(&mut s, "review.trackChanges", json!({"value": true})); + run(&mut s, "table.selectTable", json!({})); + run(&mut s, "text.backspace", json!({})); + // Like Table Layout › Delete Table with tracking on: the structure stays, the text is marked deleted. + assert_eq!(tables(&s), 1); + assert_eq!(table_cells(&s), [["a1", "b1", "c1"], ["a2", "b2", "c2"], ["a3", "b3", "c3"]]); + assert_eq!(s.doc.final_text(StoryRef::Body).replace(['\t', '\n'], ""), "Beforeafter"); + run(&mut s, "review.rejectAll", json!({})); + assert_eq!(s.doc.final_text(StoryRef::Body).replace(['\t', '\n'], ""), "Beforea1b1c1a2b2c2a3b3c3after"); + assert!(s.doc.revisions.is_empty()); +} + +/// One selected cell isn't a whole row or column, even in a one-row or one-column table: +/// Backspace clears it (Word's cell selection has no end-of-row mark). +#[test] +fn backspace_on_one_selected_cell_of_a_one_row_or_one_column_table_clears_it() { + for (rows, cols, at) in [(1, 3, [0, 1]), (3, 1, [1, 0])] { + let mut s = typed("before\n"); + run(&mut s, "insert.table", json!({"rows": rows, "cols": cols})); + typ(&mut s, "x\ty\tz"); + run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [1, at[0], at[1], 0], "off": 0}})); + run(&mut s, "table.selectCell", json!({})); + run(&mut s, "text.backspace", json!({})); + let want: Vec> = + if rows == 1 { vec![vec!["x".into(), "".into(), "z".into()]] } else { vec![vec!["x".into()], vec!["".into()], vec!["z".into()]] }; + assert_eq!(table_cells(&s), want, "{rows}×{cols}"); + } +} + +/// #1670: smart quotes in Hebrew follow the characters around the quote in logical order, as +/// Word does (Word for Mac 16.113.4): an opening “ after a space or at the start, a closing ” +/// after a letter, and ” inside an acronym (צה"ל), not gershayim; ' after a letter is ’. +#[test] +fn smart_quotes_in_hebrew_use_logical_order() { + let bidi = |s: &Session| s.doc.body.iter().filter_map(|b| b.as_para()).next().and_then(|p| p.props.bidi); + let s = typed("הוא אמר \"שלום\" לי"); + assert_eq!(bidi(&s), Some(true), "a right-to-left paragraph"); + assert_eq!(s.doc.plain_text(StoryRef::Body), "הוא אמר “שלום” לי"); + let s = typed("\"שלום\""); + assert_eq!(s.doc.plain_text(StoryRef::Body), "“שלום”"); + let s = typed("צה\"ל "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "צה”ל "); + let s = typed("ג'ירפה 'כן'"); + assert_eq!(s.doc.plain_text(StoryRef::Body), "ג’ירפה ‘כן’"); +} + +/// #1923: Turkish and Azerbaijani case mapping (i↔İ, ı↔I) in AutoCorrect, checked against Word +/// with Turkish proofing; other languages keep the Unicode default. +#[test] +fn autocorrect_capitalisation_follows_turkic_case_mapping() { + for tag in ["tr-TR", "az-AZ"] { + let mut s = s(); + run(&mut s, "review.language", json!({"lang": tag, "default": true})); + typ(&mut s, "istanbul güzel. "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "İstanbul güzel. ", "{tag}: i capitalises to İ"); + let mut s = self::s(); + run(&mut s, "review.language", json!({"lang": tag, "default": true})); + typ(&mut s, "İNgilizce "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "İngilizce ", "{tag}: İ kept, N lowercased, no combining dot"); + let mut s = self::s(); + run(&mut s, "review.language", json!({"lang": tag, "default": true})); + typ(&mut s, "ılık ırmak. "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "Ilık ırmak. ", "{tag}: ı capitalises to I"); + let mut s = self::s(); + run(&mut s, "review.language", json!({"lang": tag, "default": true})); + typ(&mut s, "iSTANBUL "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "İstanbul ", "{tag}: cAPS LOCK fix"); + let mut s = self::s(); + run(&mut s, "review.language", json!({"lang": tag, "default": true})); + typ(&mut s, "kızım. TIklamak "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "Kızım. Tıklamak ", "{tag}: TWo INitial CApitals lowercases I to ı"); + } + // Other languages are unchanged; a stray İ still lowercases to a single "i". + let mut s = s(); + run(&mut s, "review.language", json!({"lang": "en-US", "default": true})); + typ(&mut s, "istanbul güzel. "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "Istanbul güzel. "); + let mut s = self::s(); + run(&mut s, "review.language", json!({"lang": "en-US", "default": true})); + typ(&mut s, "İNgilizce "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "İngilizce ", "no combining dot in any language"); +} + +/// #3479: Format > Change Case follows the same Turkic case mapping as AutoCorrect, using +/// each run's own language; other languages keep the Unicode default. +#[test] +fn change_case_follows_turkic_case_mapping() { + for tag in ["tr-TR", "az-AZ"] { + for (mode, input, want) in [ + ("upper", "istanbul", "İSTANBUL"), + ("lower", "IŞIK", "ışık"), + ("title", "istanbul güzel", "İstanbul Güzel"), + ("sentence", "istanbul. izmir", "İstanbul. İzmir"), + ("toggle", "iI", "İı"), + ] { + let mut s = s(); + run(&mut s, "review.language", json!({"lang": tag, "default": true})); + run(&mut s, "text.insert", json!({"text": input, "raw": true})); + run(&mut s, "select.all", json!({})); + run(&mut s, "format.changeCase", json!({"mode": mode})); + assert_eq!(s.doc.plain_text(StoryRef::Body), want, "{tag} {mode}"); + } + } + // Other languages keep the Unicode default. + let mut s = self::s(); + run(&mut s, "text.insert", json!({"text": "istanbul", "raw": true})); + run(&mut s, "select.all", json!({})); + run(&mut s, "format.changeCase", json!({"mode": "title"})); + assert_eq!(s.doc.plain_text(StoryRef::Body), "Istanbul", "en-US"); + // Mixed runs: each run's own language decides. + let mut s = self::s(); + run(&mut s, "text.insert", json!({"text": "IŞIK IŞIK", "raw": true})); + run(&mut s, "caret.set", json!({"pos": Pos::body(0, 0)})); + run(&mut s, "caret.set", json!({"pos": Pos::body(0, 5), "extend": true})); + run(&mut s, "review.language", json!({"lang": "tr-TR"})); + run(&mut s, "caret.set", json!({"pos": Pos::body(0, 6)})); + run(&mut s, "caret.set", json!({"pos": Pos::body(0, 11), "extend": true})); + run(&mut s, "review.language", json!({"lang": "en-US"})); + run(&mut s, "select.all", json!({})); + run(&mut s, "format.changeCase", json!({"mode": "lower"})); + assert_eq!(s.doc.plain_text(StoryRef::Body), "ışık işik", "mixed runs"); +} + +// Spelling as you type (#1552): the word being typed gets no red squiggle until the caret leaves +// it; F7 and the Spelling pane still report it. See docs/typing-parity.md. Paragraphs start with +// a correct word so AutoCorrect's sentence capital doesn't change the misspelled one. + +/// The words body paragraph `i` has a spelling squiggle under, in order. +fn squiggles(s: &mut Session, i: usize) -> Vec { + let path = wordcraft_doc::Path::top(i); + let text = s.doc.para(StoryRef::Body, &path).map(|p| p.text.clone()).unwrap_or_default(); + let l = s.layout(); + l.pages + .iter() + .flat_map(|pg| pg.items.iter()) + .filter_map(|it| match it { + wordcraft_layout::Placed::Lines { para, path: p, l0: 0, .. } if *p == path => { + Some(para.issues.iter().filter(|i| !i.2).filter_map(|i| text.get(i.0..i.1).map(str::to_string)).collect::>()) + } + _ => None, + }) + .flatten() + .collect() +} + +/// The misspelled words `review.issues` (the Spelling pane) reports, in order. +fn reported(s: &mut Session) -> Vec { + let issues = s.run("review.issues", &json!({})).unwrap(); + issues.as_array().unwrap().iter().filter(|i| i["kind"] == "spelling").map(|i| i["text"].as_str().unwrap().to_string()).collect() +} + +#[test] +fn the_word_being_typed_is_not_marked_until_it_ends() { + let mut s = typed("The helo"); + assert_eq!(squiggles(&mut s, 0), Vec::::new(), "no squiggle while the word is typed"); + // F7 and the Spelling pane still see it. + assert_eq!(reported(&mut s), ["helo"]); + let next = s.run("review.spelling", &json!({})).unwrap(); + assert_eq!(next["text"], "helo"); + // A space ends the word. + let mut s = typed("The helo "); + assert_eq!(squiggles(&mut s, 0), ["helo"]); + typ(&mut s, "wrld"); + assert_eq!(squiggles(&mut s, 0), ["helo"], "the next word is being typed"); + assert_eq!(reported(&mut s), ["helo", "wrld"]); + // So does punctuation. + typ(&mut s, "."); + assert_eq!(squiggles(&mut s, 0), ["helo", "wrld"]); + // Enter and Tab too. + let mut s = typed("The helo\n"); + assert_eq!(squiggles(&mut s, 0), ["helo"]); + let mut s = typed("The helo\t"); + assert_eq!(squiggles(&mut s, 0), ["helo"]); +} + +#[test] +fn moving_the_caret_out_of_the_typed_word_marks_it() { + for key in ["caret.left", "caret.home", "caret.wordLeft", "caret.docStart"] { + let mut s = typed("The helo"); + run(&mut s, key, json!({})); + assert_eq!(squiggles(&mut s, 0), ["helo"], "{key}"); + // Coming back to the end of the word doesn't hide the squiggle again. + run(&mut s, "caret.end", json!({})); + assert_eq!(s.sel.focus, Pos::body(0, 8)); + assert_eq!(squiggles(&mut s, 0), ["helo"], "{key} then End"); + } + // A selection ends it too. + let mut s = typed("The helo"); + run(&mut s, "select.all", json!({})); + assert_eq!(squiggles(&mut s, 0), ["helo"]); +} + +#[test] +fn a_click_elsewhere_marks_the_typed_word_and_a_click_back_keeps_the_mark() { + let mut s = typed("Fine\nThe helo"); + assert_eq!(squiggles(&mut s, 1), Vec::::new()); + // The canvas sets the caret directly on a click. + s.sel = crate::Selection::caret(Pos::body(0, 2)); + assert_eq!(squiggles(&mut s, 1), ["helo"]); + s.sel = crate::Selection::caret(Pos::body(1, 8)); + assert_eq!(squiggles(&mut s, 1), ["helo"], "an existing misspelled word keeps its squiggle"); + // Typing into it makes it the word being typed again. + typ(&mut s, "o"); + assert_eq!(s.doc.plain_text(StoryRef::Body), "Fine\nThe heloo"); + assert_eq!(squiggles(&mut s, 1), Vec::::new()); + typ(&mut s, " "); + assert_eq!(squiggles(&mut s, 1), ["heloo"]); +} + +#[test] +fn backspace_into_a_finished_word_edits_it_again() { + let mut s = typed("The helo "); + assert_eq!(squiggles(&mut s, 0), ["helo"]); + typ(&mut s, "\u{8}"); + assert_eq!(squiggles(&mut s, 0), Vec::::new()); + typ(&mut s, "\u{8}\u{8}llo "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "The hello "); + assert_eq!(squiggles(&mut s, 0), Vec::::new()); +} + +#[test] +fn autocorrect_and_inserted_text_are_marked_as_usual() { + // AutoCorrect fixes the word as the space ends it: nothing to mark. + let mut s = typed("I typed teh "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "I typed the "); + assert_eq!(squiggles(&mut s, 0), Vec::::new()); + // Text inserted whole (a paste, an agent): only the word the caret ends in is being typed. + let mut s = typed(""); + run(&mut s, "text.insert", json!({"text": "The helo wrld"})); + assert_eq!(squiggles(&mut s, 0), ["helo"]); + // Undo moves the caret: whatever is left is marked, as the Spelling pane reports it. + let mut s = typed("The helo"); + run(&mut s, "caret.left", json!({})); + typ(&mut s, "x"); + assert_eq!(s.doc.plain_text(StoryRef::Body), "The helxo"); + assert_eq!(squiggles(&mut s, 0), Vec::::new()); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc.plain_text(StoryRef::Body), "The helo"); + assert_eq!(squiggles(&mut s, 0), ["helo"]); + assert_eq!(reported(&mut s), ["helo"]); +} + +#[test] +fn apostrophes_and_hyphens_stay_part_of_the_word_being_typed() { + // Smart quotes turn the apostrophe into ’, which the spelling check keeps in the word. + let mut s = typed("It doesn'"); + assert_eq!(s.doc.plain_text(StoryRef::Body), "It doesn’"); + assert_eq!(reported(&mut s), ["doesn"], "the stem alone is a misspelling"); + assert_eq!(squiggles(&mut s, 0), Vec::::new()); + let mut s = typed("It doesn't "); + assert_eq!(squiggles(&mut s, 0), Vec::::new()); + // A hyphen joins the parts of a compound being typed. + let mut s = typed("The nonn-"); + assert_eq!(reported(&mut s), ["nonn"]); + assert_eq!(squiggles(&mut s, 0), Vec::::new()); + typ(&mut s, "exist"); + assert_eq!(squiggles(&mut s, 0), Vec::::new()); + typ(&mut s, " "); + assert!(!squiggles(&mut s, 0).is_empty()); + assert_eq!(squiggles(&mut s, 0), reported(&mut s)); + // A hyphen after a space starts no word. + let mut s = typed("The helo -"); + assert_eq!(squiggles(&mut s, 0), ["helo"]); +} + +#[test] +fn text_commands_that_edit_nothing_leave_an_existing_word_marked() { + let mut s = typed("Fine\nThe helo"); + s.sel = crate::Selection::caret(Pos::body(0, 2)); + assert_eq!(squiggles(&mut s, 1), ["helo"]); + s.sel = crate::Selection::caret(Pos::body(1, 8)); + // Insert switches overtype; Delete at the end of the document and Shift+Tab change no text. + run(&mut s, "text.overtype", json!({})); + assert_eq!(squiggles(&mut s, 1), ["helo"], "Insert"); + run(&mut s, "text.overtype", json!({})); + let _ = s.run("text.delete", &json!({})); + assert_eq!(squiggles(&mut s, 1), ["helo"], "Delete with nothing after the caret"); + let _ = s.run("text.backTab", &json!({})); + assert_eq!(squiggles(&mut s, 1), ["helo"], "Shift+Tab"); + assert_eq!(s.doc.plain_text(StoryRef::Body), "Fine\nThe helo"); +} + +#[test] +fn typing_over_backward_selection_keeps_its_font() { + // As in Word, text typed over a selection takes the first selected character's formatting, + // whichever way the selection was made (#2879). + for (anchor, focus) in [(6, 11), (11, 6)] { + let mut s = s(); + typ(&mut s, "Hello world"); + run(&mut s, "select.range", json!({"anchor": {"block": 0, "off": 6}, "focus": {"block": 0, "off": 11}})); + run(&mut s, "format.font", json!({"name": "Courier New"})); + run(&mut s, "select.range", json!({"anchor": {"block": 0, "off": anchor}, "focus": {"block": 0, "off": focus}})); + typ(&mut s, "X"); + let p = s.doc.body.first().and_then(|b| b.as_para()).unwrap(); + assert_eq!(p.text, "Hello X", "anchor={anchor} focus={focus}"); + assert_eq!(p.props_of_char(6).font.as_deref(), Some("Courier New"), "anchor={anchor} focus={focus}"); + assert_eq!(p.props_of_char(0).font, None, "anchor={anchor} focus={focus}"); + } +} + +#[test] +fn right_arrow_moves_through_a_ligature_one_letter_at_a_time() { + // A font that draws "ffi" as one glyph (bundled, so the test doesn't depend on the installed + // fonts); each Right press moves the drawn caret (#1974). + let mut s = s(); + run(&mut s, "format.font", json!({"name": "Source Serif 4"})); + typ(&mut s, "ffi"); + let drawn_as_one_glyph = s + .layout() + .pages + .iter() + .flat_map(|p| p.items.iter()) + .any(|it| matches!(it, wordcraft_layout::Placed::Lines { para, .. } if para.glyphs.len() < para.clusters.len())); + assert!(drawn_as_one_glyph, "Source Serif 4 should draw ffi as a ligature"); + run(&mut s, "caret.home", json!({})); + let mut xs = Vec::new(); + for _ in 0..4 { + let l = s.layout(); + xs.push((s.sel.focus.off, l.caret(&s.sel.focus).unwrap().x)); + run(&mut s, "caret.right", json!({})); + } + let offs: Vec = xs.iter().map(|p| p.0).collect(); + assert_eq!(offs, [0, 1, 2, 3]); + assert!(xs.windows(2).all(|w| w[0].1 < w[1].1), "caret x after each Right press: {xs:?}"); +} + +/// Two original paragraphs whose list comes from a style, optionally through a basedOn chain. +fn style_inherited_list(kind: wordcraft_doc::numbering::ListKind, chain: bool) -> Session { + use wordcraft_doc::props::NumRef; + use wordcraft_doc::styles::Style; + let mut doc = wordcraft_doc::Document::from_text("Alpha\nBeta"); + let num = Arc::make_mut(&mut doc.numbering).try_add_list(kind).unwrap(); + Arc::make_mut(&mut doc.styles).styles.push(Style { + id: "ListBase".into(), + name: "Original List".into(), + para: wordcraft_doc::ParaProps { numbering: Some(NumRef { num, level: 0 }), ..Default::default() }, + ..Default::default() + }); + if chain { + Arc::make_mut(&mut doc.styles).styles.push(Style { + id: "ListChild".into(), + name: "Original Derived".into(), + based_on: Some("ListBase".into()), + ..Default::default() + }); + } + for i in 0..2 { + doc.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(i)).unwrap().props.style = Some(if chain { "ListChild" } else { "ListBase" }.into()); + } + Session::new(doc) +} + +#[test] +fn toggle_style_inherited_list_off_preserves_other_paragraphs_and_round_trips() { + use wordcraft_doc::numbering::ListKind; + for (kind, command) in [(ListKind::Bullet, "para.bullets"), (ListKind::Numbered, "para.numbering")] { + for chain in [false, true] { + let mut s = style_inherited_list(kind, chain); + let before = s.doc.clone(); + s.sel = crate::Selection::caret(Pos::body(0, 2)); + run(&mut s, command, json!({})); + let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); + assert_eq!(p.props.numbering.unwrap().num, 0, "{command}, chain={chain}"); + assert_eq!(p.props.style, before.para_at(&Pos::body(0, 0)).unwrap().props.style); + assert_eq!(s.doc.para_at(&Pos::body(1, 0)), before.para_at(&Pos::body(1, 0))); + assert_eq!(s.doc.styles, before.styles); + assert_eq!(s.doc.numbering, before.numbering); + let after = s.doc.clone(); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc, before); + run(&mut s, "edit.redo", json!({})); + assert_eq!(s.doc, after); + let bytes = crate::io::save_bytes("list.docx", &s.doc).unwrap(); + let saved = crate::io::open_bytes("list.docx", &bytes).unwrap(); + let a = saved.para_at(&Pos::body(0, 0)).unwrap(); + let b = saved.para_at(&Pos::body(1, 0)).unwrap(); + assert_eq!(a.props.numbering.unwrap().num, 0); + assert!(saved.styles.resolve_para(&a.props).numbering.is_none()); + assert!(saved.styles.resolve_para(&b.props).numbering.is_some_and(|n| n.num != 0)); + assert_eq!(a.text, "Alpha"); + assert_eq!(b.text, "Beta"); + } + } +} + +#[test] +fn explicit_list_off_override_is_not_toggled_off_again() { + use wordcraft_doc::numbering::ListKind; + use wordcraft_doc::props::NumRef; + for (kind, command) in [(ListKind::Bullet, "para.bullets"), (ListKind::Numbered, "para.numbering")] { + let mut s = style_inherited_list(kind, true); + s.doc.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().props.numbering = Some(NumRef { num: 0, level: 0 }); + s.sel = crate::Selection::caret(Pos::body(0, 2)); + run(&mut s, command, json!({})); + assert!(s.doc.para_at(&Pos::body(0, 0)).unwrap().props.numbering.is_some_and(|n| n.num != 0)); + // A mixed selection must turn the list on, rather than remove the inherited item. + s.doc.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().props.numbering = Some(NumRef { num: 0, level: 0 }); + s.sel = crate::Selection { anchor: Pos::body(0, 0), focus: Pos::body(1, 4) }; + run(&mut s, command, json!({})); + for i in 0..2 { + let p = s.doc.para_at(&Pos::body(i, 0)).unwrap(); + assert!(s.doc.styles.resolve_para(&p.props).numbering.is_some_and(|n| n.num != 0)); + } + } +} + +#[test] +fn list_toggle_uses_effective_level_and_override() { + use wordcraft_doc::numbering::{ListKind, levels_for}; + use wordcraft_doc::props::NumRef; + for (kind, command, wrong) in [(ListKind::Bullet, "para.bullets", "para.numbering"), (ListKind::Numbered, "para.numbering", "para.bullets")] { + for override_level in [false, true] { + for explicit_kind in [false, true] { + let mut s = style_inherited_list(kind, true); + let opposite = if kind == ListKind::Bullet { ListKind::Numbered } else { ListKind::Bullet }; + let num = Arc::make_mut(&mut s.doc.styles).styles.iter_mut().find(|x| x.id == "ListBase").unwrap().para.numbering.as_mut().unwrap(); + num.level = 1; + let num_id = num.num; + Arc::make_mut(&mut s.doc.numbering).abstracts[0].levels[0] = levels_for(opposite)[0].clone(); + if override_level { + Arc::make_mut(&mut s.doc.numbering).abstracts[0].levels[1] = levels_for(opposite)[1].clone(); + Arc::make_mut(&mut s.doc.numbering).nums[0].level_overrides.push((1, levels_for(kind)[1].clone())); + } + let before = s.doc.clone(); + let params = if explicit_kind { json!({"kind": if kind == ListKind::Bullet { "bullet" } else { "numbered" }}) } else { json!({}) }; + run(&mut s, command, params); + assert_eq!( + s.doc.para_at(&Pos::body(0, 0)).unwrap().props.numbering, + Some(NumRef { num: 0, level: 0 }), + "{command}, override={override_level}, explicit={explicit_kind}" + ); + assert_eq!(s.doc.styles, before.styles); + assert_eq!(s.doc.numbering, before.numbering); + assert_eq!(s.doc.para_at(&Pos::body(1, 0)), before.para_at(&Pos::body(1, 0))); + let mut other = Session::new(before); + run(&mut other, wrong, json!({})); + assert!(other.doc.para_at(&Pos::body(0, 0)).unwrap().props.numbering.is_some_and(|n| n.num != 0)); + assert!(other.doc.numbering.level(num_id, 1).is_some()); + } + } + } +} + +#[test] +fn list_paragraph_style_survives_inherited_toggle_off() { + use wordcraft_doc::numbering::ListKind; + use wordcraft_doc::props::{Align, ParaProps}; + use wordcraft_doc::styles::Style; + for kind in [ListKind::Bullet, ListKind::Numbered] { + let mut s = style_inherited_list(kind, false); + *Arc::make_mut(&mut s.doc.styles).styles.iter_mut().find(|x| x.id == "ListParagraph").unwrap() = Style { + id: "ListParagraph".into(), + name: "Original formatted list".into(), + based_on: Some("ListBase".into()), + para: ParaProps { align: Some(Align::Right), space_after: Some(17.0), ..Default::default() }, + ..Default::default() + }; + s.doc.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().props.style = Some("ListParagraph".into()); + let before = s.doc.clone(); + run(&mut s, if kind == ListKind::Bullet { "para.bullets" } else { "para.numbering" }, json!({})); + let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); + assert_eq!(p.props.style.as_deref(), Some("ListParagraph")); + let effective = s.doc.styles.resolve_para(&p.props); + assert!(effective.numbering.is_none()); + assert_eq!(effective.align, Align::Right); + assert_eq!(effective.space_after, 17.0); + assert_eq!(s.doc.styles, before.styles); + assert_eq!(s.doc.numbering, before.numbering); + assert_eq!(s.doc.para_at(&Pos::body(1, 0)), before.para_at(&Pos::body(1, 0))); + let after = s.doc.clone(); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc, before); + run(&mut s, "edit.redo", json!({})); + assert_eq!(s.doc, after); + let bytes = crate::io::save_bytes("list.docx", &s.doc).unwrap(); + let saved = crate::io::open_bytes("list.docx", &bytes).unwrap(); + let p = saved.para_at(&Pos::body(0, 0)).unwrap(); + assert_eq!(p.props.style.as_deref(), Some("ListParagraph")); + let effective = saved.styles.resolve_para(&p.props); + assert!(effective.numbering.is_none()); + assert_eq!(effective.align, Align::Right); + assert_eq!(effective.space_after, 17.0); + } +} + +#[test] +fn direct_list_toggle_still_clears_the_automatic_list_paragraph_style() { + for command in ["para.bullets", "para.numbering"] { + let mut s = Session::new(wordcraft_doc::Document::from_text("Direct list")); + run(&mut s, command, json!({})); + assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().props.style.as_deref(), Some("ListParagraph")); + run(&mut s, command, json!({})); + let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); + assert!(p.props.style.is_none()); + assert!(s.doc.styles.resolve_para(&p.props).numbering.is_none()); + } +} + +#[test] +fn a_new_list_uses_the_requested_kind_after_a_mixed_format_list() { + use wordcraft_doc::numbering::{ListKind, levels_for}; + use wordcraft_doc::props::{NumRef, ParaProps}; + for (kind, command) in [(ListKind::Bullet, "para.bullets"), (ListKind::Numbered, "para.numbering")] { + let mut s = style_inherited_list(kind, false); + let opposite = if kind == ListKind::Bullet { ListKind::Numbered } else { ListKind::Bullet }; + Arc::make_mut(&mut s.doc.numbering).abstracts[0].levels[0] = levels_for(opposite)[0].clone(); + let num = s.doc.numbering.nums[0].id; + s.doc.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().props.numbering = Some(NumRef { num, level: 1 }); + s.doc.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(1)).unwrap().props = ParaProps::default(); + let before = s.doc.para_at(&Pos::body(0, 0)).unwrap().clone(); + s.sel = crate::Selection::caret(Pos::body(1, 0)); + run(&mut s, command, json!({})); + let n = s.doc.para_at(&Pos::body(1, 0)).unwrap().props.numbering.unwrap(); + assert_eq!(n.level, 0); + let level = s.doc.numbering.level(n.num, n.level).unwrap(); + assert_eq!(level.format == wordcraft_doc::section::NumFormat::Bullet, kind == ListKind::Bullet); + assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap(), &before); + } +} + +/// #4085: the registered shortcut toggles the last selected highlight, while explicit +/// colour commands remain idempotent setters for the palette, CLI and automation. +#[test] +#[cfg(not(target_os = "macos"))] +fn highlight_shortcut_toggles_the_last_colour() { + let mut s = typed("Alpha Beta"); + run(&mut s, "select.all", json!({})); + let colour = |s: &mut Session| s.run("format.state", &json!({})).unwrap()["highlight"].clone(); + press(&mut s, "Mod+Alt+H").unwrap(); + assert_eq!(colour(&mut s), json!("FFFF00")); + press(&mut s, "Mod+Alt+H").unwrap(); + assert_eq!(colour(&mut s), serde_json::Value::Null); + run(&mut s, "edit.undo", json!({})); + assert_eq!(colour(&mut s), json!("FFFF00")); + run(&mut s, "edit.redo", json!({})); + assert_eq!(colour(&mut s), serde_json::Value::Null); + run(&mut s, "format.highlight", json!({"color":"blue"})); + run(&mut s, "format.highlight", json!({"color":"blue"})); + assert_eq!(colour(&mut s), json!("0000FF")); + press(&mut s, "Mod+Alt+H").unwrap(); + assert_eq!(colour(&mut s), serde_json::Value::Null); + press(&mut s, "Mod+Alt+H").unwrap(); + assert_eq!(colour(&mut s), json!("0000FF")); + run(&mut s, "format.highlight", json!({"color":"none"})); + press(&mut s, "Mod+Alt+H").unwrap(); + assert_eq!(colour(&mut s), serde_json::Value::Null); + assert_eq!(s.doc.body[0].as_para().unwrap().text, "Alpha Beta"); +} + +/// #4085: mixed selections take one colour; removing an inherited colour writes an +/// explicit No Color, and a collapsed caret toggles pending formatting for future typing. +#[test] +fn highlight_toggle_handles_mixed_inherited_and_pending_formatting() { + let mut s = typed("Alpha Beta "); + run(&mut s, "select.text", json!({"text":"Alpha"})); + run(&mut s, "format.highlight", json!({"color":"blue"})); + run(&mut s, "select.all", json!({})); + run(&mut s, "format.highlight", json!({})); + assert_eq!(s.run("format.state", &json!({})).unwrap()["highlight"], json!("0000FF")); + run(&mut s, "format.highlight", json!({})); + assert_eq!(s.run("format.state", &json!({})).unwrap()["highlight"], serde_json::Value::Null); + Arc::make_mut(&mut s.doc.styles).default_chr.highlight = Some(wordcraft_doc::props::Highlight::Blue); + run(&mut s, "format.highlight", json!({"color":"blue"})); + run(&mut s, "format.highlight", json!({})); + assert!(s.doc.body[0].as_para().unwrap().runs.iter().all(|r| r.props.highlight == Some(wordcraft_doc::props::Highlight::None))); + run(&mut s, "caret.docEnd", json!({})); + run(&mut s, "format.highlight", json!({})); + assert_eq!(s.pending.as_ref().unwrap().highlight, Some(wordcraft_doc::props::Highlight::Blue)); + run(&mut s, "format.highlight", json!({})); + assert_eq!(s.pending.as_ref().unwrap().highlight, Some(wordcraft_doc::props::Highlight::None)); + run(&mut s, "text.insert", json!({"text":"X"})); + run(&mut s, "select.text", json!({"text":"X"})); + assert_eq!(s.run("format.state", &json!({})).unwrap()["highlight"], serde_json::Value::Null); + let before = serde_json::to_value(&s.doc).unwrap(); + assert!(s.run("format.highlight", &json!({"color":123})).is_err()); + assert_eq!(serde_json::to_value(&s.doc).unwrap(), before); +} + +/// #4086: a caret inside a word highlights what is typed next, not the word; Bold still +/// formats the word. +#[test] +fn highlight_at_a_caret_leaves_the_word_alone() { + let mut s = typed("Alpha Beta"); + run(&mut s, "caret.docStart", json!({})); + run(&mut s, "caret.right", json!({})); + run(&mut s, "format.highlight", json!({"color":"yellow"})); + assert!(s.doc.body[0].as_para().unwrap().runs.iter().all(|r| r.props.highlight.is_none())); + assert_eq!(s.pending.as_ref().unwrap().highlight, Some(wordcraft_doc::props::Highlight::Yellow)); + run(&mut s, "text.insert", json!({"text":"X"})); + run(&mut s, "select.text", json!({"text":"X"})); + assert_eq!(s.run("format.state", &json!({})).unwrap()["highlight"], json!("FFFF00")); + run(&mut s, "select.text", json!({"text":"Beta"})); + assert_eq!(s.run("format.state", &json!({})).unwrap()["highlight"], serde_json::Value::Null); + // The toggle reads the pending colour, not the word's: a second press turns it off. + run(&mut s, "caret.docStart", json!({})); + run(&mut s, "caret.right", json!({})); + run(&mut s, "format.highlight", json!({})); + assert_eq!(s.pending.as_ref().unwrap().highlight, Some(wordcraft_doc::props::Highlight::Yellow)); + run(&mut s, "format.highlight", json!({})); + assert_eq!(s.pending.as_ref().unwrap().highlight, Some(wordcraft_doc::props::Highlight::None)); + // Bold after Highlight formats the word and keeps both for what is typed next. + run(&mut s, "format.highlight", json!({})); + run(&mut s, "format.bold", json!({})); + run(&mut s, "text.insert", json!({"text":"Y"})); + run(&mut s, "select.text", json!({"text":"Y"})); + let state = s.run("format.state", &json!({})).unwrap(); + assert_eq!((state["highlight"].clone(), state["bold"].clone()), (json!("FFFF00"), json!(true))); +} + +/// A differently coloured final paragraph must remain visible to the shortcut beyond +/// the formatting-state query limit, including a style-inherited tail and Undo/Redo. +#[cfg(not(target_os = "macos"))] +#[test] +fn highlight_toggle_checks_past_the_resolved_run_limit() { + use wordcraft_doc::para::Run; + use wordcraft_doc::props::{CharProps, Highlight}; + for count in [10_000, 10_001, 10_002] { + for inherited in [false, true] { + let mut doc = wordcraft_doc::Document::from_text(&format!("{}\nZ", "x".repeat(count))); + doc.para_mut(StoryRef::Body, &Pos::body(0, 0).path).unwrap().runs = (0..count) + .map(|i| Run { len: 1, props: CharProps { highlight: Some(Highlight::Blue), bold: Some(i % 2 == 0), ..Default::default() } }) + .collect(); + if inherited { + Arc::make_mut(&mut doc.styles).default_chr.highlight = Some(Highlight::Yellow); + } else { + doc.para_mut(StoryRef::Body, &Pos::body(1, 0).path).unwrap().runs[0].props.highlight = Some(Highlight::Yellow); + } + doc.para_mut(StoryRef::Body, &Pos::body(0, 0).path).unwrap().mark.highlight = Some(Highlight::Blue); + let original = doc.clone(); + let mut s = Session::new(doc); + s.last_highlight = Highlight::Blue; + run(&mut s, "select.all", json!({})); + press(&mut s, "Mod+Alt+H").unwrap(); + assert!( + s.doc.body.iter().filter_map(|b| b.as_para()).flat_map(|p| &p.runs).all(|r| r.props.highlight == Some(Highlight::Blue)), + "count={count}, inherited={inherited}" + ); + press(&mut s, "Mod+Alt+H").unwrap(); + assert!(s.doc.body.iter().filter_map(|b| b.as_para()).flat_map(|p| &p.runs).all(|r| r.props.highlight == Some(Highlight::None))); + run(&mut s, "edit.undo", json!({})); + assert!(s.doc.body[1].as_para().unwrap().runs.iter().all(|r| r.props.highlight == Some(Highlight::Blue))); + run(&mut s, "edit.undo", json!({})); + assert_eq!(serde_json::to_value(&s.doc).unwrap(), serde_json::to_value(&original).unwrap()); + run(&mut s, "edit.redo", json!({})); + assert!(s.doc.body[1].as_para().unwrap().runs.iter().all(|r| r.props.highlight == Some(Highlight::Blue))); + } + } +} + +/// A command preview uses the real session's remembered colour without changing its +/// document, colour choice or undo history when the preview toggles highlighting off. +#[test] +fn highlight_preview_preserves_the_session_colour_and_document() { + use wordcraft_doc::props::Highlight; + let mut s = typed("Alpha"); + run(&mut s, "select.all", json!({})); + run(&mut s, "format.highlight", json!({"color":"blue"})); + let original = serde_json::to_value(&s.doc).unwrap(); + let depth = s.undo_depth(); + let (preview, _, _) = s.preview_layout("format.highlight", &json!({})).unwrap(); + assert!(preview.body[0].as_para().unwrap().runs.iter().all(|r| r.props.highlight == Some(Highlight::None))); + assert_eq!(serde_json::to_value(&s.doc).unwrap(), original); + assert_eq!(s.last_highlight, Highlight::Blue); + assert_eq!(s.undo_depth(), depth); +} + +/// Whole-cell column selections include paragraph marks, including empty cells and +/// inherited mark colours; adjacent unselected cells and undo/redo keep their formatting. +#[test] +fn highlight_toggle_checks_whole_cell_marks() { + use wordcraft_doc::props::Highlight; + use wordcraft_doc::{CharProps, Paragraph}; + for inherited in [false, true] { + for empty in [false, true] { + let mut s = s(); + run(&mut s, "insert.table", json!({"rows":2,"cols":2})); + let (table, _, _) = s.sel.focus.path.cell().unwrap(); + let pos = |row, col| { + let mut path = table.clone(); + path.0.extend([row, col, 0]); + Pos::new(StoryRef::Body, path, 0) + }; + if inherited { + Arc::make_mut(&mut s.doc.styles).default_chr.highlight = Some(Highlight::Yellow); + } + for row in 0..2 { + for col in 0..2 { + let p = s.doc.para_mut(StoryRef::Body, &pos(row, col).path).unwrap(); + let colour = if col == 0 { Highlight::Blue } else { Highlight::Red }; + *p = Paragraph::with_text( + if empty && row == 1 && col == 0 { "" } else { "AB" }, + CharProps { highlight: Some(colour), ..Default::default() }, + ); + p.mark.highlight = Some(colour); + if row == 1 && col == 0 { + p.mark.highlight = if inherited { None } else { Some(Highlight::Yellow) }; + } + } + } + s.last_highlight = Highlight::Blue; + run(&mut s, "caret.set", json!({"pos":pos(0,0)})); + run(&mut s, "table.selectColumn", json!({})); + assert!(!s.cell_range().unwrap().stream); + let original = serde_json::to_value(&s.doc).unwrap(); + run(&mut s, "format.highlight", json!({})); + for row in 0..2 { + let p = s.doc.para_at(&pos(row, 0)).unwrap(); + assert_eq!(p.mark.highlight, Some(Highlight::Blue), "inherited={inherited}, empty={empty}"); + assert!(p.runs.iter().all(|r| r.props.highlight == Some(Highlight::Blue))); + } + run(&mut s, "format.highlight", json!({})); + for row in 0..2 { + let p = s.doc.para_at(&pos(row, 0)).unwrap(); + assert_eq!(p.mark.highlight, Some(Highlight::None)); + assert!(p.runs.iter().all(|r| r.props.highlight == Some(Highlight::None))); + let other = s.doc.para_at(&pos(row, 1)).unwrap(); + assert_eq!(other.mark.highlight, Some(Highlight::Red)); + assert!(other.runs.iter().all(|r| r.props.highlight == Some(Highlight::Red))); + } + run(&mut s, "edit.undo", json!({})); + run(&mut s, "edit.undo", json!({})); + assert_eq!(serde_json::to_value(&s.doc).unwrap(), original); + run(&mut s, "edit.redo", json!({})); + assert_eq!(s.doc.para_at(&pos(1, 0)).unwrap().mark.highlight, Some(Highlight::Blue)); + } + } +} + +/// Fully selected marks participate even when the selection has no text runs or an +/// empty paragraph between blue runs; repeated toggles and undo preserve these targets. +#[test] +fn highlight_toggle_checks_ordinary_paragraph_marks() { + use wordcraft_doc::props::Highlight; + for text in ["AB", "AB\n\nAB", "\n"] { + let mut doc = wordcraft_doc::Document::from_text(text); + Arc::make_mut(&mut doc.styles).default_chr.highlight = Some(Highlight::Blue); + let paths = doc.para_paths(StoryRef::Body); + for path in &paths { + let p = doc.para_mut(StoryRef::Body, path).unwrap(); + p.mark.highlight = Some(Highlight::Blue); + for r in &mut p.runs { + r.props.highlight = Some(Highlight::Blue); + } + } + let mismatch = if text == "AB\n\nAB" { 1 } else { 0 }; + doc.para_mut(StoryRef::Body, &paths[mismatch]).unwrap().mark.highlight = Some(Highlight::Yellow); + let original = serde_json::to_value(&doc).unwrap(); + let mut s = Session::new(doc); + s.last_highlight = Highlight::Blue; + run(&mut s, "select.all", json!({})); + run(&mut s, "format.highlight", json!({})); + for path in &paths { + let p = s.doc.para(StoryRef::Body, path).unwrap(); + assert_eq!(p.mark.highlight, Some(Highlight::Blue), "text={text:?}"); + assert!(p.runs.iter().all(|r| r.props.highlight == Some(Highlight::Blue))); + } + run(&mut s, "format.highlight", json!({})); + assert!(paths.iter().all(|p| s.doc.para(StoryRef::Body, p).unwrap().mark.highlight == Some(Highlight::None))); + run(&mut s, "edit.undo", json!({})); + run(&mut s, "edit.undo", json!({})); + assert_eq!(serde_json::to_value(&s.doc).unwrap(), original); + } +} + +/// A partial text column excludes paragraph marks and text beyond its selected offsets, +/// so a different mark colour must not prevent clearing the selected blue characters. +#[test] +fn highlight_partial_column_ignores_unselected_marks() { + use wordcraft_doc::props::Highlight; + let mut doc = wordcraft_doc::Document::from_text("AB\nAB"); + for path in doc.para_paths(StoryRef::Body) { + let p = doc.para_mut(StoryRef::Body, &path).unwrap(); + p.mark.highlight = Some(Highlight::Yellow); + for r in &mut p.runs { + r.props.highlight = Some(Highlight::Blue); + } + } + let mut s = Session::new(doc); + s.last_highlight = Highlight::Blue; + run(&mut s, "select.column", json!({"anchor":Pos::body(0,0),"focus":Pos::body(1,1)})); + run(&mut s, "format.highlight", json!({})); + for row in 0..2 { + let p = s.doc.para_at(&Pos::body(row, 0)).unwrap(); + assert_eq!(p.props_of_char(0).highlight, Some(Highlight::None)); + assert_eq!(p.props_of_char(1).highlight, Some(Highlight::Blue)); + assert_eq!(p.mark.highlight, Some(Highlight::Yellow)); + } +} + +#[test] +fn tab_with_a_whole_paragraph_selected_indents_it() { + let mut s = typed("Hello world"); + run(&mut s, "select.paragraph", json!({})); + run(&mut s, "text.tab", json!({})); + assert_eq!(paras(&s), vec![para("Hello world", None)]); + assert_eq!(indents(&s).0, Some(36.0)); +} + +#[test] +fn tab_with_a_partial_selection_still_inserts_a_tab() { + let mut s = typed("Hello world"); + run(&mut s, "select.text", json!({"text": "ello"})); + run(&mut s, "text.tab", json!({})); + assert_eq!(paras(&s), vec![para("H\t world", None)]); +} + +/// Tab with a whole paragraph selected indents it as one Undo step: the selection stays while +/// the indent is applied, and Undo restores both the indentation and that same selection. A +/// partial selection still types a tab character (the test above), and Tab in a table cell still +/// navigates (the table tests above). +#[test] +fn tab_with_a_whole_paragraph_selected_undoes_indent_and_selection_together() { + let mut s = typed("Hello world"); + run(&mut s, "select.paragraph", json!({})); + let selected = s.sel.clone(); + let depth = s.undo_depth(); + run(&mut s, "text.tab", json!({})); + assert_eq!(s.doc.plain_text(StoryRef::Body), "Hello world", "Tab indents, it types no tab"); + assert_eq!(s.sel, selected, "the whole paragraph stays selected"); + assert_eq!(indents(&s), (Some(36.0), None)); + assert_eq!(s.undo_depth(), depth + 1, "one paragraph, one Undo step"); + run(&mut s, "edit.undo", json!({})); + assert_eq!(indents(&s), (None, None), "Undo takes the indent back"); + assert_eq!(s.sel, selected, "and restores the selection the indent was applied to"); + assert_eq!(paras(&s), vec![para("Hello world", None)]); +} + +/// #2848: "Add a period with double-space" (an AutoFormat As You Type switch, off by default): +/// a second Space after a word, a number or a closing bracket or quote turns the first into a +/// period. Undo puts the two spaces back; other punctuation, a lone space and a run of three +/// spaces are left as typed. +#[test] +fn double_space_adds_a_period_when_asked() { + let with_option = |keys: &str| { + let mut s = s(); + run(&mut s, "tools.autocorrect", json!({"doubleSpacePeriod": true})); + typ(&mut s, keys); + s + }; + let text = |s: &Session| s.doc.plain_text(StoryRef::Body); + // Off unless asked for. + assert_eq!(text(&typed("Hello world")), "Hello world"); + assert!(!s().prefs.autocorrect.double_space_period); + // On: the second space makes the period, and the next sentence starts with a capital. + assert_eq!(text(&with_option("Hello world")), "Hello. world"); + assert_eq!(text(&with_option("Hello world ")), "Hello. World "); + assert_eq!(text(&with_option("See page 5 Then")), "See page 5. Then"); + assert_eq!(text(&with_option("(it is done) Next")), "(it is done). Next"); + assert_eq!(text(&with_option("He said \"stop\" Next")), "He said “stop”. Next"); + // Undo brings the two spaces back, in one step. + let mut t = with_option("Hello "); + assert_eq!(text(&t), "Hello. "); + run(&mut t, "edit.undo", json!({})); + assert_eq!(text(&t), "Hello "); + // Left alone: other punctuation before the spaces, a lone space, spaces at the start of a + // paragraph or after a tab, and a third space. + for keys in ["Hello. x", "Hello, x", "Hello? x", "Hello; x", "Hello: x", "Hello x", " x", "\n x", "a\t x"] { + let want = typed(keys); + assert_eq!(text(&with_option(keys)), text(&want), "{keys:?}"); + } + assert_eq!(text(&with_option("Hello x")), "Hello. x"); + // A selection typed over, and the master switch. + let mut t = with_option("Hello world"); + run(&mut t, "select.all", json!({})); + run(&mut t, "text.insert", json!({"text": " "})); + assert_eq!(text(&t), " "); + let mut t = s(); + run(&mut t, "tools.autocorrect", json!({"doubleSpacePeriod": true, "enabled": false})); + typ(&mut t, "Hello world"); + assert_eq!(text(&t), "Hello world"); +} + +#[test] +fn backspace_after_a_floating_shape_anchor_joins_paragraphs_and_keeps_the_shape() { + let mut s = typed("Line one\nLine two"); + run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [1], "off": 0}})); + run(&mut s, "insert.textBox", json!({"text": "Box"})); + let (a, f) = (Pos::body(1, 0), Pos::body(1, 3)); + run(&mut s, "select.range", json!({"anchor": a, "focus": f})); + run(&mut s, "arrange.wrap", json!({"wrap": "inFrontOfText"})); + let objects = |s: &Session| s.doc.para_at(&Pos::body(1, 0)).map(|p| p.object_offsets().len()); + assert_eq!(objects(&s), Some(1)); + run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [1], "off": 3}})); + run(&mut s, "text.backspace", json!({})); + assert_eq!(s.doc.body.iter().filter(|b| b.as_para().is_some()).count(), 1); + assert_eq!(objects(&s), None); + assert_eq!( + s.doc.para_at(&Pos::body(0, 0)).map(|p| (p.text.replace('\u{FFFC}', ""), p.object_offsets().len())), + Some(("Line oneLine two".to_string(), 1)) + ); +} + +#[test] +fn backspace_after_marker_and_floating_anchors_still_joins_paragraphs() { + let mut s = typed("Line one\nLine two"); + run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [1], "off": 0}})); + run(&mut s, "insert.textBox", json!({"text": "Box"})); + let (a, f) = (Pos::body(1, 0), Pos::body(1, 3)); + run(&mut s, "select.range", json!({"anchor": a, "focus": f})); + run(&mut s, "arrange.wrap", json!({"wrap": "inFrontOfText"})); + s.doc.insert_object(&Pos::body(1, 0), wordcraft_doc::InlineObject::BookmarkStart { name: "anchor".into() }, &Default::default()).unwrap(); + s.doc.insert_object(&Pos::body(1, 0), wordcraft_doc::InlineObject::CommentStart { id: 1 }, &Default::default()).unwrap(); + run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [1], "off": 9}})); + run(&mut s, "text.backspace", json!({})); + assert_eq!(s.doc.body.iter().filter(|b| b.as_para().is_some()).count(), 1); + assert_eq!(s.doc.para_at(&Pos::body(0, 0)).map(|p| p.text.replace('\u{FFFC}', "")), Some("Line oneLine two".to_string())); +} + +#[test] +fn backspace_cannot_join_into_locked_content_control() { + let mut s = typed("Above\nInside"); + run(&mut s, "select.range", json!({"anchor": {"story": "body", "path": [1], "off": 0}, "focus": {"story": "body", "path": [1], "off": 6}})); + run(&mut s, "developer.richText", json!({"tag": "locked"})); + run(&mut s, "developer.properties", json!({"control": "locked", "lockEdit": true})); + run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [1], "off": 0}})); + let before = s.doc.clone(); + assert!(s.run("text.backspace", &json!({})).is_err()); + assert_eq!(s.doc, before); +} + +// #3693 is an additional editing shortcut, not a claim of Word's default key behaviour. +#[test] +fn delete_previous_sentence_respects_caret_boundaries_and_unicode() { + for (text, before, expected) in [ + ("First sentence. Second sentence.", "First sentence. Second sentence.", "First sentence. "), + ("First sentence. Second sentence.", "First sentence. Sec", "First sentence. ond sentence."), + ("First sentence. Second sentence.", "First sentence. ", "Second sentence."), + ("Grüße! Überall café.", "Grüße! Überall café.", "Grüße! "), + ("No punctuation here", "No punctuation", " here"), + ("One. Two.", "", "One. Two."), + ("", "", ""), + ] { + let mut s = s(); + run(&mut s, "text.insert", json!({"text": text, "raw": true})); + let mut pos = s.sel.focus.clone(); + pos.off = before.len(); + s.sel = crate::Selection::caret(pos); + run(&mut s, "text.deleteSentenceBack", json!({})); + assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().text, expected, "{text:?} at {before:?}"); + if expected != text { + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().text, text); + run(&mut s, "edit.redo", json!({})); + assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().text, expected); + } + } +} + +#[test] +fn delete_previous_sentence_keeps_paragraph_boundaries_and_deletes_selections() { + let mut s = s(); + run(&mut s, "text.insert", json!({"text": "First.\nSecond sentence.", "raw": true, "paragraphs": true})); + let end = s.sel.focus.clone(); + s.sel = crate::Selection::caret(Pos { off: 0, ..end.clone() }); + let count = s.doc.body.len(); + run(&mut s, "text.deleteSentenceBack", json!({})); + assert_eq!(s.doc.body.len(), count); + assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().text, "Second sentence."); + s.sel = crate::Selection { anchor: Pos { off: 0, ..end.clone() }, focus: Pos { off: 6, ..end } }; + run(&mut s, "text.deleteSentenceBack", json!({})); + assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().text, " sentence."); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().text, "Second sentence."); +} + +#[test] +fn delete_previous_sentence_tracks_changes_and_respects_read_only() { + for accept in [true, false] { + let mut s = s(); + run(&mut s, "text.insert", json!({"text": "One. Two.", "raw": true})); + run(&mut s, "review.trackChanges", json!({"value": true})); + run(&mut s, "text.deleteSentenceBack", json!({})); + let changes = s.run("review.changes", &json!({})).unwrap(); + assert!(changes.as_array().unwrap().iter().any(|c| c["kind"] == "delete")); + run(&mut s, if accept { "review.acceptAll" } else { "review.rejectAll" }, json!({})); + assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().text, if accept { "One. " } else { "One. Two." }); + } + let mut s = s(); + run(&mut s, "text.insert", json!({"text": "Keep this.", "raw": true})); + run(&mut s, "review.restrict", json!({"mode": "readOnly"})); + assert!(s.run("text.deleteSentenceBack", &json!({})).is_err()); + assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().text, "Keep this."); +} + +#[test] +fn delete_previous_sentence_does_not_cross_a_table_cell_boundary() { + let mut s = s(); + run(&mut s, "insert.table", json!({"rows": 1, "cols": 2})); + run(&mut s, "text.insert", json!({"text": "First cell.", "raw": true})); + run(&mut s, "text.tab", json!({})); + run(&mut s, "text.insert", json!({"text": "Second cell.", "raw": true})); + let end = s.sel.focus.clone(); + s.sel = crate::Selection::caret(Pos { off: 0, ..end.clone() }); + let before = s.doc.plain_text(StoryRef::Body); + run(&mut s, "text.deleteSentenceBack", json!({})); + assert_eq!(s.doc.plain_text(StoryRef::Body), before); + s.sel = crate::Selection::caret(end); + run(&mut s, "text.deleteSentenceBack", json!({})); + assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().text, ""); + assert!(s.doc.plain_text(StoryRef::Body).contains("First cell.")); +} + +// English spelling from SCOWL 2026 (CP2): each English reads its own SCOWL dictionary, with +// `en-variants.txt` deciding the other dialect's spellings. Typed text, as a person types it. + +/// The spelling words typed text gets in `lang` (the whole document's language), with the first +/// suggestion of each. +fn typed_spelling_in(lang: &str, keys: &str) -> Vec<(String, String)> { + let mut s = s(); + run(&mut s, "review.language", json!({"lang": lang, "default": true})); + typ(&mut s, keys); + let issues = s.run("review.issues", &json!({})).unwrap(); + issues + .as_array() + .unwrap() + .iter() + .filter(|i| i["kind"] == "spelling") + .map(|i| (i["text"].as_str().unwrap().to_string(), i["suggestions"][0].as_str().unwrap_or("").to_string())) + .collect() +} + +/// #40: the British spellings the issue lists are right in en-GB, and the American ones are +/// flagged with the British form first. +#[test] +fn issue_40_british_spellings_are_right_in_en_gb() { + if super::no_language_data("issue_40_british_spellings_are_right_in_en_gb") { + return; + } + let none: Vec<(String, String)> = Vec::new(); + let text = "The judgement on the defence licence put the programme in the catalogue. "; + assert_eq!(typed_spelling_in("en-GB", text), none); + assert_eq!(typed_spelling_in("en-GB", "Its colour and centre, organised and organized, travelled. "), none); + let flagged = typed_spelling_in("en-GB", "The color of the center. "); + assert_eq!(flagged, [("color".to_string(), "colour".to_string()), ("center".to_string(), "centre".to_string())]); +} + +/// #520: common modern words are right (SCOWL plus our supplement), and real misspellings of +/// them are still caught. +#[test] +fn issue_520_common_words_are_right() { + if super::no_language_data("issue_520_common_words_are_right") { + return; + } + let none: Vec<(String, String)> = Vec::new(); + assert_eq!(typed_spelling_in("en-US", "My desktop email has downloads, a smartphone and a website. "), none); + // (`recieved` would be AutoCorrected as it is typed.) + let flagged = typed_spelling_in("en-US", "My desktpo emial. "); + assert_eq!(flagged.iter().map(|f| f.0.as_str()).collect::>(), ["desktpo", "emial"]); + assert_eq!(flagged[0].1, "desktop"); +} + +/// #1281 and #1045: Canadian and Australian English (and the other British-spelling Englishes) +/// proof by their own rules; ordinals are words in every English. +#[test] +fn issues_1281_1045_english_dialects_proof_typed_text() { + if super::no_language_data("issues_1281_1045_english_dialects_proof_typed_text") { + return; + } + let none: Vec<(String, String)> = Vec::new(); + // Canada: British -our and -re, American -ize. + assert_eq!(typed_spelling_in("en-CA", "We organize the colour in the centre. "), none); + assert_eq!( + typed_spelling_in("en-CA", "We organise the color. "), + [("organise".to_string(), "organize".to_string()), ("color".to_string(), "colour".to_string())] + ); + // Australia, New Zealand, India: British spelling. + for lang in ["en-AU", "en-NZ", "en-IN"] { + assert_eq!(typed_spelling_in(lang, "We analyse the colour of the theatre. "), none, "{lang}"); + assert_eq!(typed_spelling_in(lang, "We analyze it. "), [("analyze".to_string(), "analyse".to_string())], "{lang}"); + } + // US English flags British spellings with the American form first. + assert_eq!(typed_spelling_in("en-US", "The colour. "), [("colour".to_string(), "color".to_string())]); + // SCOWL's ordinals (COMPOUNDRULE) are words, even where numbers are checked. + let mut s = s(); + run(&mut s, "file.options", json!({"ignoreNumbers": false})); + typ(&mut s, "The 21st and 112th times, not the 21th. "); + let issues = s.run("review.issues", &json!({})).unwrap(); + 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, ["21th"]); +} diff --git a/crates/engine/src/tests/wordart.rs b/crates/engine/src/tests/wordart.rs index 488e2cd9c2..9a7a9c2c6d 100644 --- a/crates/engine/src/tests/wordart.rs +++ b/crates/engine/src/tests/wordart.rs @@ -1,202 +1,202 @@ -//! WordArt and connectors through the commands. - -use serde_json::json; -use wordcraft_doc::connector::{ConnEnd, ShapeExtra, ends}; -use wordcraft_doc::para::{Anchor, Float, InlineObject, ShapeKind, Wrap}; -use wordcraft_doc::props::CharProps; -use wordcraft_doc::{Document, Paragraph, Pos, StoryRef, para_block}; - -use crate::Session; - -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}")) -} - -/// A page-placed rectangle (id 1) at x 100 and ellipse (id 2) at x 300, 72 × 36 at y 200, and an -/// elbow connector from the rectangle's right side to the ellipse's left side, all in body -/// paragraph 0 after "Body " (objects at 5, 8, 11). -fn joined() -> Session { - let float = |x: f32| Float { wrap: Wrap::InFrontOfText, h_rel: Anchor::Page, v_rel: Anchor::Page, x, y: 200.0, ..Default::default() }; - let shape = |kind, x, extra| InlineObject::Shape { - kind, - w: 72.0, - h: 36.0, - fill: None, - stroke: None, - stroke_width: 1.0, - float: float(x), - story: None, - freeform: None, - effects: Default::default(), - extra, - }; - let link = ShapeExtra { start: Some(ConnEnd { id: 1, site: 3 }), end: Some(ConnEnd { id: 2, site: 2 }), ..Default::default() }; - let mut p = Paragraph::with_text("Body ", CharProps::default()); - for o in [ - shape(ShapeKind::Rectangle, 100.0, ShapeExtra { id: 1, ..Default::default() }), - shape(ShapeKind::Ellipse, 300.0, ShapeExtra { id: 2, ..Default::default() }), - shape(ShapeKind::ElbowConnector, 0.0, link), - ] { - let end = p.len(); - p.insert_object(end, o, &CharProps::default()).unwrap(); - } - let mut d = Document::new(); - d.body = vec![para_block(p)]; - Session::new(d) -} - -fn select(s: &mut Session, off: usize) { - run(s, "select.range", json!({"anchor": Pos::body(0, off), "focus": Pos::body(0, off + 3)})); -} - -/// The connector's start and end on the page. -fn connector_ends(s: &mut Session) -> ((f32, f32), (f32, f32)) { - let pos = Pos::body(0, 11); - let hit = s.layout().object(&pos, 0).unwrap(); - let Some(InlineObject::Shape { float, .. }) = s.doc.para_at(&pos).and_then(|p| p.object_at(pos.off)) else { panic!() }; - let r = hit.rect; - let (a, b) = ends(r.x, r.y, r.w, r.h, float.flip_h, float.flip_v); - let round = |p: (f32, f32)| ((p.0 * 100.0).round() / 100.0, (p.1 * 100.0).round() / 100.0); - (round(a), round(b)) -} - -#[test] -fn moving_a_joined_shape_reroutes_its_connector() { - let mut s = joined(); - select(&mut s, 5); - run(&mut s, "arrange.nudge", json!({"dx": 0, "dy": 30})); - // From the rectangle's right side (172, 248) to the ellipse's left side (300, 218). - assert_eq!(connector_ends(&mut s), ((172.0, 248.0), (300.0, 218.0))); - // Resizing the ellipse moves the end with its site. - select(&mut s, 8); - run(&mut s, "arrange.bounds", json!({"width": 100, "height": 60})); - assert_eq!(connector_ends(&mut s).1, (300.0, 230.0)); - // One undo takes the resize and the re-route back. - run(&mut s, "edit.undo", json!({})); - assert_eq!(connector_ends(&mut s), ((172.0, 248.0), (300.0, 218.0))); -} - -/// A target on another page, or a missing target, leaves the connector's end free. -#[test] -fn rerouting_keeps_off_page_and_missing_targets_at_their_free_ends() { - let mut s = joined(); - let mut connector = s.doc.para_at(&Pos::body(0, 11)).unwrap().object_at(11).unwrap().clone(); - let InlineObject::Shape { extra, .. } = &mut connector else { panic!("connector") }; - extra.end = Some(ConnEnd { id: 404, site: 2 }); - let mut p = Paragraph::new(); - p.props.page_break_before = Some(true); - p.insert_object(0, connector.clone(), &Default::default()).unwrap(); - s.doc.body.push(para_block(p)); - s.touch(); - select(&mut s, 5); - run(&mut s, "arrange.nudge", json!({"dx": 10, "dy": 30})); - assert_eq!(s.layout().pages.len(), 2); - assert_eq!(s.doc.para_at(&Pos::body(1, 0)).unwrap().object_at(0), Some(&connector)); - assert_eq!(connector_ends(&mut s), ((182.0, 248.0), (300.0, 218.0))); - run(&mut s, "edit.undo", json!({})); - assert_eq!(s.doc.para_at(&Pos::body(1, 0)).unwrap().object_at(0), Some(&connector)); -} - -/// A command that changes nothing but reroutes a stale connector is still an edit: the -/// reroute must not be left without an undo step (#23). -#[test] -fn a_rerouted_connector_is_still_undoable() { - let mut s = joined(); - select(&mut s, 5); - let before = connector_ends(&mut s); - // A nudge by nothing moves nothing, but the connector is stale and gets rerouted. - run(&mut s, "arrange.nudge", json!({"dx": 0, "dy": 0})); - assert_ne!(connector_ends(&mut s), before, "the connector was rerouted"); - assert_eq!(s.undo_depth(), 1, "the reroute is an undo step of its own"); - // One undo takes the reroute back. - run(&mut s, "edit.undo", json!({})); - assert_eq!(connector_ends(&mut s), before, "one undo takes the reroute back"); -} - -#[test] -fn dragging_a_connector_end_glues_it_to_the_nearest_site() { - let mut s = joined(); - select(&mut s, 11); - // Dropped near the ellipse's top: glued there. - run(&mut s, "shape.connect", json!({"end": {"x": 340.0, "y": 195.0}})); - let ext = |s: &Session| match s.doc.para_at(&Pos::body(0, 11)).and_then(|p| p.object_at(11)) { - Some(InlineObject::Shape { extra, .. }) => extra.clone(), - o => panic!("{o:?}"), - }; - assert_eq!(ext(&s).end, Some(ConnEnd { id: 2, site: 0 })); - assert_eq!(connector_ends(&mut s).1, (336.0, 200.0)); - // Dropped in the open: a free end there. - run(&mut s, "shape.connect", json!({"start": {"x": 20.0, "y": 30.0}})); - assert_eq!(ext(&s).start, None); - assert_eq!(connector_ends(&mut s).0, (20.0, 30.0)); - // Glued by reference; a site the shape doesn't have is refused. - run(&mut s, "shape.connect", json!({"start": {"path": [0], "off": 5, "site": 1}})); - assert_eq!(connector_ends(&mut s).0, (100.0, 218.0)); - assert!(s.run("shape.connect", &json!({"start": {"path": [0], "off": 5, "site": 40}})).is_err()); - assert!(s.run("shape.connect", &json!({"start": {"path": [0], "off": 11}})).is_err(), "not to itself"); - // A connection to a shape that is gone is ignored. - select(&mut s, 8); - run(&mut s, "text.delete", json!({})); - select(&mut s, 5); - run(&mut s, "arrange.nudge", json!({"dx": 1})); -} - -#[test] -fn wordart_is_inserted_styled_and_bent() { - let mut s = Session::new(Document::new()); - let r = run(&mut s, "insert.wordArt", json!({"text": "Hello", "style": "gradient", "transform": "textArchUp"})); - let id = r["story"].as_u64().unwrap() as u32; - assert_eq!(s.doc.plain_text(StoryRef::Part(id)), "Hello"); - let props = s.doc.para_at(&s.doc.start_of(StoryRef::Part(id))).unwrap().props_at(0).clone(); - assert!(matches!(props.text_effects.as_deref().and_then(|f| f.fill.as_ref()), Some(wordcraft_doc::wordart::TextFill::Gradient { .. }))); - // Its text is drawn from outlines, bent. - let layout = s.layout(); - let draws = wordcraft_layout::display::page_display(&s.doc, &layout.pages[0], &Default::default()); - assert!(draws.iter().any(|d| matches!(d, wordcraft_layout::display::Draw::Art { .. }))); - // The PDF draws the gradient and keeps the text. - let pdf = wordcraft_pdf::export(&s.doc, &Default::default()).unwrap(); - assert!(pdf.starts_with(b"%PDF")); - // Effects and transforms on the selected text box's text; hostile values are refused or capped. - run(&mut s, "format.textEffects", json!({"outline": {"color": "C00000", "width": 1e9}, "glow": 5})); - let props = s.doc.para_at(&s.doc.start_of(StoryRef::Part(id))).unwrap().props_at(0).clone(); - let fx = props.text_effects.unwrap(); - assert_eq!(fx.outline.map(|o| o.width), Some(wordcraft_doc::wordart::MAX_OUTLINE)); - assert!(fx.glow.is_some() && fx.fill.is_some()); - run(&mut s, "wordArt.transform", json!({"preset": "textWave1", "adj": 1e300})); - assert!(s.run("wordArt.transform", &json!({"preset": "textBogus"})).is_err()); - assert!(s.run("format.textEffects", &json!({"style": "word"})).is_err()); - assert!(s.run("format.textEffects", &json!({"glow": "much"})).is_err()); - let warp = s.doc.para_at(&Pos::body(0, 0)).and_then(|p| p.object_at(0)).and_then(|o| match o { - InlineObject::Shape { extra, .. } => extra.warp.clone(), - _ => None, - }); - assert_eq!( - warp.map(|w| (w.adj_value("adj"), w.preset)).map(|(a, p)| (p, a)), - Some(("textWave1".into(), Some(wordcraft_doc::wordart::MAX_ADJ_VALUE))) - ); - run(&mut s, "wordArt.transform", json!({"preset": "none"})); -} - -#[test] -fn wordart_reflection_and_outline_none_options() { - let mut s = Session::new(Document::new()); - let r = run(&mut s, "insert.wordArt", json!({"text": "Reflect", "style": "solid"})); - let id = r["story"].as_u64().unwrap() as u32; - - // Setting reflection - run(&mut s, "format.textEffects", json!({"reflection": true})); - let props = s.doc.para_at(&s.doc.start_of(StoryRef::Part(id))).unwrap().props_at(0).clone(); - assert!(props.text_effects.as_ref().unwrap().reflection.is_some()); - - // Clearing reflection using "none" - run(&mut s, "format.textEffects", json!({"reflection": "none"})); - let props = s.doc.para_at(&s.doc.start_of(StoryRef::Part(id))).unwrap().props_at(0).clone(); - assert!(props.text_effects.as_ref().and_then(|fx| fx.reflection).is_none()); - - // Setting outline with color "none" - run(&mut s, "wordArt.textOutline", json!({"color": "none", "width": 1.5})); - let props = s.doc.para_at(&s.doc.start_of(StoryRef::Part(id))).unwrap().props_at(0).clone(); - let outline = props.text_effects.as_ref().unwrap().outline.unwrap(); - assert_eq!(outline.color, None); - assert_eq!(outline.width, 1.5); -} +//! WordArt and connectors through the commands. + +use serde_json::json; +use wordcraft_doc::connector::{ConnEnd, ShapeExtra, ends}; +use wordcraft_doc::para::{Anchor, Float, InlineObject, ShapeKind, Wrap}; +use wordcraft_doc::props::CharProps; +use wordcraft_doc::{Document, Paragraph, Pos, StoryRef, para_block}; + +use crate::Session; + +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}")) +} + +/// A page-placed rectangle (id 1) at x 100 and ellipse (id 2) at x 300, 72 × 36 at y 200, and an +/// elbow connector from the rectangle's right side to the ellipse's left side, all in body +/// paragraph 0 after "Body " (objects at 5, 8, 11). +fn joined() -> Session { + let float = |x: f32| Float { wrap: Wrap::InFrontOfText, h_rel: Anchor::Page, v_rel: Anchor::Page, x, y: 200.0, ..Default::default() }; + let shape = |kind, x, extra| InlineObject::Shape { + kind, + w: 72.0, + h: 36.0, + fill: None, + stroke: None, + stroke_width: 1.0, + float: float(x), + story: None, + freeform: None, + effects: Default::default(), + extra, + }; + let link = ShapeExtra { start: Some(ConnEnd { id: 1, site: 3 }), end: Some(ConnEnd { id: 2, site: 2 }), ..Default::default() }; + let mut p = Paragraph::with_text("Body ", CharProps::default()); + for o in [ + shape(ShapeKind::Rectangle, 100.0, ShapeExtra { id: 1, ..Default::default() }), + shape(ShapeKind::Ellipse, 300.0, ShapeExtra { id: 2, ..Default::default() }), + shape(ShapeKind::ElbowConnector, 0.0, link), + ] { + let end = p.len(); + p.insert_object(end, o, &CharProps::default()).unwrap(); + } + let mut d = Document::new(); + d.body = vec![para_block(p)]; + Session::new(d) +} + +fn select(s: &mut Session, off: usize) { + run(s, "select.range", json!({"anchor": Pos::body(0, off), "focus": Pos::body(0, off + 3)})); +} + +/// The connector's start and end on the page. +fn connector_ends(s: &mut Session) -> ((f32, f32), (f32, f32)) { + let pos = Pos::body(0, 11); + let hit = s.layout().object(&pos, 0).unwrap(); + let Some(InlineObject::Shape { float, .. }) = s.doc.para_at(&pos).and_then(|p| p.object_at(pos.off)) else { panic!() }; + let r = hit.rect; + let (a, b) = ends(r.x, r.y, r.w, r.h, float.flip_h, float.flip_v); + let round = |p: (f32, f32)| ((p.0 * 100.0).round() / 100.0, (p.1 * 100.0).round() / 100.0); + (round(a), round(b)) +} + +#[test] +fn moving_a_joined_shape_reroutes_its_connector() { + let mut s = joined(); + select(&mut s, 5); + run(&mut s, "arrange.nudge", json!({"dx": 0, "dy": 30})); + // From the rectangle's right side (172, 248) to the ellipse's left side (300, 218). + assert_eq!(connector_ends(&mut s), ((172.0, 248.0), (300.0, 218.0))); + // Resizing the ellipse moves the end with its site. + select(&mut s, 8); + run(&mut s, "arrange.bounds", json!({"width": 100, "height": 60})); + assert_eq!(connector_ends(&mut s).1, (300.0, 230.0)); + // One undo takes the resize and the re-route back. + run(&mut s, "edit.undo", json!({})); + assert_eq!(connector_ends(&mut s), ((172.0, 248.0), (300.0, 218.0))); +} + +/// A target on another page, or a missing target, leaves the connector's end free. +#[test] +fn rerouting_keeps_off_page_and_missing_targets_at_their_free_ends() { + let mut s = joined(); + let mut connector = s.doc.para_at(&Pos::body(0, 11)).unwrap().object_at(11).unwrap().clone(); + let InlineObject::Shape { extra, .. } = &mut connector else { panic!("connector") }; + extra.end = Some(ConnEnd { id: 404, site: 2 }); + let mut p = Paragraph::new(); + p.props.page_break_before = Some(true); + p.insert_object(0, connector.clone(), &Default::default()).unwrap(); + s.doc.body.push(para_block(p)); + s.touch(); + select(&mut s, 5); + run(&mut s, "arrange.nudge", json!({"dx": 10, "dy": 30})); + assert_eq!(s.layout().pages.len(), 2); + assert_eq!(s.doc.para_at(&Pos::body(1, 0)).unwrap().object_at(0), Some(&connector)); + assert_eq!(connector_ends(&mut s), ((182.0, 248.0), (300.0, 218.0))); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc.para_at(&Pos::body(1, 0)).unwrap().object_at(0), Some(&connector)); +} + +/// A command that changes nothing but reroutes a stale connector is still an edit: the +/// reroute must not be left without an undo step (#23). +#[test] +fn a_rerouted_connector_is_still_undoable() { + let mut s = joined(); + select(&mut s, 5); + let before = connector_ends(&mut s); + // A nudge by nothing moves nothing, but the connector is stale and gets rerouted. + run(&mut s, "arrange.nudge", json!({"dx": 0, "dy": 0})); + assert_ne!(connector_ends(&mut s), before, "the connector was rerouted"); + assert_eq!(s.undo_depth(), 1, "the reroute is an undo step of its own"); + // One undo takes the reroute back. + run(&mut s, "edit.undo", json!({})); + assert_eq!(connector_ends(&mut s), before, "one undo takes the reroute back"); +} + +#[test] +fn dragging_a_connector_end_glues_it_to_the_nearest_site() { + let mut s = joined(); + select(&mut s, 11); + // Dropped near the ellipse's top: glued there. + run(&mut s, "shape.connect", json!({"end": {"x": 340.0, "y": 195.0}})); + let ext = |s: &Session| match s.doc.para_at(&Pos::body(0, 11)).and_then(|p| p.object_at(11)) { + Some(InlineObject::Shape { extra, .. }) => extra.clone(), + o => panic!("{o:?}"), + }; + assert_eq!(ext(&s).end, Some(ConnEnd { id: 2, site: 0 })); + assert_eq!(connector_ends(&mut s).1, (336.0, 200.0)); + // Dropped in the open: a free end there. + run(&mut s, "shape.connect", json!({"start": {"x": 20.0, "y": 30.0}})); + assert_eq!(ext(&s).start, None); + assert_eq!(connector_ends(&mut s).0, (20.0, 30.0)); + // Glued by reference; a site the shape doesn't have is refused. + run(&mut s, "shape.connect", json!({"start": {"path": [0], "off": 5, "site": 1}})); + assert_eq!(connector_ends(&mut s).0, (100.0, 218.0)); + assert!(s.run("shape.connect", &json!({"start": {"path": [0], "off": 5, "site": 40}})).is_err()); + assert!(s.run("shape.connect", &json!({"start": {"path": [0], "off": 11}})).is_err(), "not to itself"); + // A connection to a shape that is gone is ignored. + select(&mut s, 8); + run(&mut s, "text.delete", json!({})); + select(&mut s, 5); + run(&mut s, "arrange.nudge", json!({"dx": 1})); +} + +#[test] +fn wordart_is_inserted_styled_and_bent() { + let mut s = Session::new(Document::new()); + let r = run(&mut s, "insert.wordArt", json!({"text": "Hello", "style": "gradient", "transform": "textArchUp"})); + let id = r["story"].as_u64().unwrap() as u32; + assert_eq!(s.doc.plain_text(StoryRef::Part(id)), "Hello"); + let props = s.doc.para_at(&s.doc.start_of(StoryRef::Part(id))).unwrap().props_at(0).clone(); + assert!(matches!(props.text_effects.as_deref().and_then(|f| f.fill.as_ref()), Some(wordcraft_doc::wordart::TextFill::Gradient { .. }))); + // Its text is drawn from outlines, bent. + let layout = s.layout(); + let draws = wordcraft_layout::display::page_display(&s.doc, &layout.pages[0], &Default::default()); + assert!(draws.iter().any(|d| matches!(d, wordcraft_layout::display::Draw::Art { .. }))); + // The PDF draws the gradient and keeps the text. + let pdf = wordcraft_pdf::export(&s.doc, &Default::default()).unwrap(); + assert!(pdf.starts_with(b"%PDF")); + // Effects and transforms on the selected text box's text; hostile values are refused or capped. + run(&mut s, "format.textEffects", json!({"outline": {"color": "C00000", "width": 1e9}, "glow": 5})); + let props = s.doc.para_at(&s.doc.start_of(StoryRef::Part(id))).unwrap().props_at(0).clone(); + let fx = props.text_effects.unwrap(); + assert_eq!(fx.outline.map(|o| o.width), Some(wordcraft_doc::wordart::MAX_OUTLINE)); + assert!(fx.glow.is_some() && fx.fill.is_some()); + run(&mut s, "wordArt.transform", json!({"preset": "textWave1", "adj": 1e300})); + assert!(s.run("wordArt.transform", &json!({"preset": "textBogus"})).is_err()); + assert!(s.run("format.textEffects", &json!({"style": "word"})).is_err()); + assert!(s.run("format.textEffects", &json!({"glow": "much"})).is_err()); + let warp = s.doc.para_at(&Pos::body(0, 0)).and_then(|p| p.object_at(0)).and_then(|o| match o { + InlineObject::Shape { extra, .. } => extra.warp.clone(), + _ => None, + }); + assert_eq!( + warp.map(|w| (w.adj_value("adj"), w.preset)).map(|(a, p)| (p, a)), + Some(("textWave1".into(), Some(wordcraft_doc::wordart::MAX_ADJ_VALUE))) + ); + run(&mut s, "wordArt.transform", json!({"preset": "none"})); +} + +#[test] +fn wordart_reflection_and_outline_none_options() { + let mut s = Session::new(Document::new()); + let r = run(&mut s, "insert.wordArt", json!({"text": "Reflect", "style": "solid"})); + let id = r["story"].as_u64().unwrap() as u32; + + // Setting reflection + run(&mut s, "format.textEffects", json!({"reflection": true})); + let props = s.doc.para_at(&s.doc.start_of(StoryRef::Part(id))).unwrap().props_at(0).clone(); + assert!(props.text_effects.as_ref().unwrap().reflection.is_some()); + + // Clearing reflection using "none" + run(&mut s, "format.textEffects", json!({"reflection": "none"})); + let props = s.doc.para_at(&s.doc.start_of(StoryRef::Part(id))).unwrap().props_at(0).clone(); + assert!(props.text_effects.as_ref().and_then(|fx| fx.reflection).is_none()); + + // Setting outline with color "none" + run(&mut s, "wordArt.textOutline", json!({"color": "none", "width": 1.5})); + let props = s.doc.para_at(&s.doc.start_of(StoryRef::Part(id))).unwrap().props_at(0).clone(); + let outline = props.text_effects.as_ref().unwrap().outline.unwrap(); + assert_eq!(outline.color, None); + assert_eq!(outline.width, 1.5); +} diff --git a/crates/engine/src/view.rs b/crates/engine/src/view.rs index f30f723340..c10d6d204a 100644 --- a/crates/engine/src/view.rs +++ b/crates/engine/src/view.rs @@ -1,221 +1,221 @@ -use serde::{Deserialize, Serialize}; -use wordcraft_layout::{NumeralMode, ViewMode}; - -/// View state that commands can change (ribbon View tab, status bar). -#[derive(Clone, Debug, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ViewState { - pub zoom: f32, - pub mode: ViewMode, - pub read_mode: bool, - /// View › Focus (#1678): the document alone on screen, with a slim bar to drop the ribbon down. - pub focus_mode: bool, - /// Full Screen View (#1678): the pages alone on screen. Never on together with `focus_mode`. - #[serde(default)] - pub full_screen_view: bool, - pub marks: bool, - pub ruler: bool, - /// View › Show › Gridlines: the drawing grid over the page's text area (on screen only). - pub gridlines: bool, - /// Table Layout › View Gridlines: outlines of table cells (on screen only); on by default, as in Word. - #[serde(default = "on")] - pub table_gridlines: bool, - pub nav_pane: bool, - pub styles_pane: bool, - /// Where the Styles pane sits: docked right (the default) or left, or floating (#1756). - /// Kept in the user's preferences, not the document. - #[serde(default)] - pub styles_pane_dock: StylesPaneDock, - /// Style Inspector pane (Home › Styles). - #[serde(default)] - pub style_inspector: bool, - pub comments_pane: bool, - /// The Clipboard pane (Home › Clipboard): items collected by Copy and Cut. - #[serde(default)] - pub clipboard_pane: bool, - /// SmartArt Design › Text Pane: the selected SmartArt graphic's items as an editable outline. - #[serde(default)] - pub smart_art_pane: bool, - /// The Accessibility pane (Review › Check Accessibility): the checker's findings. - #[serde(default)] - pub accessibility_pane: bool, - /// The Alt Text pane (Picture/Shape Format › Alt Text): the selected object's or table's alt text. - #[serde(default)] - pub alt_text_pane: bool, - /// The Format Shape pane (Shape Fill › More Gradients…, a shape's Format Shape…): the - /// selected shapes' fill. - #[serde(default)] - pub format_shape_pane: bool, - pub multi_page: bool, - /// One Page or Page Width was chosen last: keep one page per row when a typed or wheel zoom replaces the fit mode. - #[serde(default)] - pub single_column: bool, - /// Zoom to fit: "pageWidth", "onePage", "multiplePages", or empty. - pub fit: String, - /// Web Layout's text width, which the UI keeps at the window's width (#1876); never saved. - /// Draft and Outline don't use it: they keep a fixed 800 pt column. - #[serde(skip, default = "default_web_width")] - pub web_width: f32, - /// Transient viewport metrics for reading pagination; never saved in a document or preferences. - #[serde(skip, default = "default_read_width")] - pub read_width: f32, - #[serde(skip, default = "default_read_height")] - pub read_height: f32, - /// Reader-only width, size and spacing controls are neither document formatting nor saved UI prefs. - /// `None` chooses responsive column width and keeps the document's line spacing. - #[serde(skip)] - pub read_column_width: Option, - #[serde(skip, default = "default_read_zoom")] - pub read_zoom: f32, - #[serde(skip)] - pub read_line_spacing: Option, - /// Read Mode's Page Color: the paper the reading columns are drawn on. `Prefs` saves its value. - #[serde(skip)] - pub read_page_color: crate::cmd::view::ReadPageColor, - pub dark_mode: bool, - pub show_markup: bool, - pub track_changes_pane: bool, - /// Check spelling and grammar as you type. - pub proofing: bool, - /// How digits look on screen (Word › File › Options › Advanced › Numeral). - #[serde(default)] - pub numeral: NumeralMode, - /// Review › Hide Ink: ink strokes aren't shown on screen (they stay in the document). - #[serde(default)] - pub hide_ink: bool, - /// Draw tab: the tool dragging on the page uses, and each pen's colour and thickness. - #[serde(default)] - pub draw: crate::cmd::draw::DrawState, - /// Show field codes instead of their results (`view.fieldCodes`, Alt+F9). - #[serde(default)] - pub field_codes: bool, - /// Print Layout › Hide White Space: pages run together, the top and bottom margins (with - /// headers and footers) between pages collapse to a thin line. On screen only; the layout - /// and page count are unchanged. - #[serde(default)] - pub hide_white_space: bool, - /// Outline view: collapsed headings, Show Level, first lines, text formatting. - #[serde(default)] - pub outline: crate::cmd::outline::OutlineState, - /// Developer › Design Mode: every content control shows its frame and title. - #[serde(default)] - pub design_mode: bool, - /// View › Window › Full Screen (F11): the window fills the screen (#980). The front end - /// carries it to the window. - #[serde(default)] - pub full_screen: bool, -} - -/// Where the Styles pane sits (`view.stylesPaneDock`, #1756). -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum StylesPaneDock { - #[default] - Right, - Left, - /// A window of its own over the document, which can be moved and resized. - #[serde(rename = "float")] - Floating, -} - -impl StylesPaneDock { - /// `left`, `right` or `float`; anything else is `None`. - pub fn parse(name: &str) -> Option { - match name { - "right" => Some(Self::Right), - "left" => Some(Self::Left), - "float" => Some(Self::Floating), - _ => None, - } - } - - pub fn name(self) -> &'static str { - match self { - Self::Right => "right", - Self::Left => "left", - Self::Floating => "float", - } - } -} - -fn on() -> bool { - true -} - -/// The text column of Draft and Outline, and of Web Layout in a headless session. -pub(super) const DRAFT_WIDTH: f32 = 800.0; - -fn default_web_width() -> f32 { - DRAFT_WIDTH -} - -fn default_read_width() -> f32 { - 300.0 -} - -fn default_read_height() -> f32 { - 500.0 -} - -fn default_read_zoom() -> f32 { - 1.0 -} - -impl ViewState { - /// Take the app-wide settings a view keeps a copy of from the saved preferences: Read Mode's - /// page colour and Check spelling as you type (#5740). A view made active goes through this - /// (a document switched to, opened or made, a window's view put back), so a change made while - /// another view was active holds in this one too. - pub(crate) fn follow_prefs(&mut self, prefs: &crate::prefs::Prefs) { - self.read_page_color = prefs.read_page_color; - self.proofing = prefs.check_spelling; - } -} - -impl Default for ViewState { - fn default() -> Self { - ViewState { - zoom: 1.0, - mode: ViewMode::Print, - read_mode: false, - focus_mode: false, - full_screen_view: false, - marks: false, - ruler: true, - gridlines: false, - table_gridlines: true, - nav_pane: false, - styles_pane: false, - styles_pane_dock: StylesPaneDock::Right, - style_inspector: false, - comments_pane: false, - clipboard_pane: false, - smart_art_pane: false, - accessibility_pane: false, - alt_text_pane: false, - format_shape_pane: false, - multi_page: false, - single_column: false, - fit: String::new(), - web_width: default_web_width(), - read_width: default_read_width(), - read_height: default_read_height(), - read_column_width: None, - read_zoom: default_read_zoom(), - read_line_spacing: None, - read_page_color: Default::default(), - dark_mode: false, - show_markup: true, - track_changes_pane: false, - proofing: true, - numeral: NumeralMode::default(), - hide_ink: false, - draw: Default::default(), - field_codes: false, - hide_white_space: false, - outline: Default::default(), - design_mode: false, - full_screen: false, - } - } -} +use serde::{Deserialize, Serialize}; +use wordcraft_layout::{NumeralMode, ViewMode}; + +/// View state that commands can change (ribbon View tab, status bar). +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ViewState { + pub zoom: f32, + pub mode: ViewMode, + pub read_mode: bool, + /// View › Focus (#1678): the document alone on screen, with a slim bar to drop the ribbon down. + pub focus_mode: bool, + /// Full Screen View (#1678): the pages alone on screen. Never on together with `focus_mode`. + #[serde(default)] + pub full_screen_view: bool, + pub marks: bool, + pub ruler: bool, + /// View › Show › Gridlines: the drawing grid over the page's text area (on screen only). + pub gridlines: bool, + /// Table Layout › View Gridlines: outlines of table cells (on screen only); on by default, as in Word. + #[serde(default = "on")] + pub table_gridlines: bool, + pub nav_pane: bool, + pub styles_pane: bool, + /// Where the Styles pane sits: docked right (the default) or left, or floating (#1756). + /// Kept in the user's preferences, not the document. + #[serde(default)] + pub styles_pane_dock: StylesPaneDock, + /// Style Inspector pane (Home › Styles). + #[serde(default)] + pub style_inspector: bool, + pub comments_pane: bool, + /// The Clipboard pane (Home › Clipboard): items collected by Copy and Cut. + #[serde(default)] + pub clipboard_pane: bool, + /// SmartArt Design › Text Pane: the selected SmartArt graphic's items as an editable outline. + #[serde(default)] + pub smart_art_pane: bool, + /// The Accessibility pane (Review › Check Accessibility): the checker's findings. + #[serde(default)] + pub accessibility_pane: bool, + /// The Alt Text pane (Picture/Shape Format › Alt Text): the selected object's or table's alt text. + #[serde(default)] + pub alt_text_pane: bool, + /// The Format Shape pane (Shape Fill › More Gradients…, a shape's Format Shape…): the + /// selected shapes' fill. + #[serde(default)] + pub format_shape_pane: bool, + pub multi_page: bool, + /// One Page or Page Width was chosen last: keep one page per row when a typed or wheel zoom replaces the fit mode. + #[serde(default)] + pub single_column: bool, + /// Zoom to fit: "pageWidth", "onePage", "multiplePages", or empty. + pub fit: String, + /// Web Layout's text width, which the UI keeps at the window's width (#1876); never saved. + /// Draft and Outline don't use it: they keep a fixed 800 pt column. + #[serde(skip, default = "default_web_width")] + pub web_width: f32, + /// Transient viewport metrics for reading pagination; never saved in a document or preferences. + #[serde(skip, default = "default_read_width")] + pub read_width: f32, + #[serde(skip, default = "default_read_height")] + pub read_height: f32, + /// Reader-only width, size and spacing controls are neither document formatting nor saved UI prefs. + /// `None` chooses responsive column width and keeps the document's line spacing. + #[serde(skip)] + pub read_column_width: Option, + #[serde(skip, default = "default_read_zoom")] + pub read_zoom: f32, + #[serde(skip)] + pub read_line_spacing: Option, + /// Read Mode's Page Color: the paper the reading columns are drawn on. `Prefs` saves its value. + #[serde(skip)] + pub read_page_color: crate::cmd::view::ReadPageColor, + pub dark_mode: bool, + pub show_markup: bool, + pub track_changes_pane: bool, + /// Check spelling and grammar as you type. + pub proofing: bool, + /// How digits look on screen (Word › File › Options › Advanced › Numeral). + #[serde(default)] + pub numeral: NumeralMode, + /// Review › Hide Ink: ink strokes aren't shown on screen (they stay in the document). + #[serde(default)] + pub hide_ink: bool, + /// Draw tab: the tool dragging on the page uses, and each pen's colour and thickness. + #[serde(default)] + pub draw: crate::cmd::draw::DrawState, + /// Show field codes instead of their results (`view.fieldCodes`, Alt+F9). + #[serde(default)] + pub field_codes: bool, + /// Print Layout › Hide White Space: pages run together, the top and bottom margins (with + /// headers and footers) between pages collapse to a thin line. On screen only; the layout + /// and page count are unchanged. + #[serde(default)] + pub hide_white_space: bool, + /// Outline view: collapsed headings, Show Level, first lines, text formatting. + #[serde(default)] + pub outline: crate::cmd::outline::OutlineState, + /// Developer › Design Mode: every content control shows its frame and title. + #[serde(default)] + pub design_mode: bool, + /// View › Window › Full Screen (F11): the window fills the screen (#980). The front end + /// carries it to the window. + #[serde(default)] + pub full_screen: bool, +} + +/// Where the Styles pane sits (`view.stylesPaneDock`, #1756). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum StylesPaneDock { + #[default] + Right, + Left, + /// A window of its own over the document, which can be moved and resized. + #[serde(rename = "float")] + Floating, +} + +impl StylesPaneDock { + /// `left`, `right` or `float`; anything else is `None`. + pub fn parse(name: &str) -> Option { + match name { + "right" => Some(Self::Right), + "left" => Some(Self::Left), + "float" => Some(Self::Floating), + _ => None, + } + } + + pub fn name(self) -> &'static str { + match self { + Self::Right => "right", + Self::Left => "left", + Self::Floating => "float", + } + } +} + +fn on() -> bool { + true +} + +/// The text column of Draft and Outline, and of Web Layout in a headless session. +pub(super) const DRAFT_WIDTH: f32 = 800.0; + +fn default_web_width() -> f32 { + DRAFT_WIDTH +} + +fn default_read_width() -> f32 { + 300.0 +} + +fn default_read_height() -> f32 { + 500.0 +} + +fn default_read_zoom() -> f32 { + 1.0 +} + +impl ViewState { + /// Take the app-wide settings a view keeps a copy of from the saved preferences: Read Mode's + /// page colour and Check spelling as you type (#5740). A view made active goes through this + /// (a document switched to, opened or made, a window's view put back), so a change made while + /// another view was active holds in this one too. + pub(crate) fn follow_prefs(&mut self, prefs: &crate::prefs::Prefs) { + self.read_page_color = prefs.read_page_color; + self.proofing = prefs.check_spelling; + } +} + +impl Default for ViewState { + fn default() -> Self { + ViewState { + zoom: 1.0, + mode: ViewMode::Print, + read_mode: false, + focus_mode: false, + full_screen_view: false, + marks: false, + ruler: true, + gridlines: false, + table_gridlines: true, + nav_pane: false, + styles_pane: false, + styles_pane_dock: StylesPaneDock::Right, + style_inspector: false, + comments_pane: false, + clipboard_pane: false, + smart_art_pane: false, + accessibility_pane: false, + alt_text_pane: false, + format_shape_pane: false, + multi_page: false, + single_column: false, + fit: String::new(), + web_width: default_web_width(), + read_width: default_read_width(), + read_height: default_read_height(), + read_column_width: None, + read_zoom: default_read_zoom(), + read_line_spacing: None, + read_page_color: Default::default(), + dark_mode: false, + show_markup: true, + track_changes_pane: false, + proofing: true, + numeral: NumeralMode::default(), + hide_ink: false, + draw: Default::default(), + field_codes: false, + hide_white_space: false, + outline: Default::default(), + design_mode: false, + full_screen: false, + } + } +} diff --git a/rustfmt.toml b/rustfmt.toml index 3623a1a1b3..3121924d65 100644 --- a/rustfmt.toml +++ b/rustfmt.toml @@ -1,2 +1,2 @@ -max_width = 150 -use_small_heuristics = "Max" +max_width = 150 +use_small_heuristics = "Max" diff --git a/sonar-project.properties b/sonar-project.properties index aa1e9a7492..bf4496425f 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -1,9 +1,9 @@ -sonar.projectKey=storytold_wordcraft -sonar.organization=storytold - -# S4790 (weak hash): SHA-1 is a start-key and "SHA1/1K" checksum algorithm that ODF package -# encryption (ODF 1.3 Part 2 §4.16) names, and older office suites wrote it. It is needed to open -# those files, not chosen for new security: WordCraft writes a SHA-256 start key and checksum. -sonar.issue.ignore.multicriteria=odt_sha1 -sonar.issue.ignore.multicriteria.odt_sha1.ruleKey=rust:S4790 -sonar.issue.ignore.multicriteria.odt_sha1.resourceKey=crates/formats/src/odt/crypt.rs +sonar.projectKey=storytold_wordcraft +sonar.organization=storytold + +# S4790 (weak hash): SHA-1 is a start-key and "SHA1/1K" checksum algorithm that ODF package +# encryption (ODF 1.3 Part 2 §4.16) names, and older office suites wrote it. It is needed to open +# those files, not chosen for new security: WordCraft writes a SHA-256 start key and checksum. +sonar.issue.ignore.multicriteria=odt_sha1 +sonar.issue.ignore.multicriteria.odt_sha1.ruleKey=rust:S4790 +sonar.issue.ignore.multicriteria.odt_sha1.resourceKey=crates/formats/src/odt/crypt.rs