diff --git a/crates/doc/src/edit.rs b/crates/doc/src/edit.rs index 613a779da1..d52733af61 100644 --- a/crates/doc/src/edit.rs +++ b/crates/doc/src/edit.rs @@ -392,14 +392,36 @@ impl Document { pb.clone() }; let bl = self.container_mut(a.story, &a.path)?; + // Section breaks on the marks in between go with their paragraphs. + let between: Vec<_> = bl + .get(ia + 1..ib) + .unwrap_or_default() + .iter() + .filter_map(|b| match &**b { + Block::Para(p) => p.section.clone(), + Block::Table(_) => None, + }) + .collect(); if ib < bl.len() && ia < ib { bl.drain(ia + 1..=ib); } tail.props = ParaProps::default(); + // The deleted marks include `a`'s, so a section break there goes too: the joined + // paragraph ends where `b`'s mark did, and its text takes the following section's + // setup, as in Word (#2127). + let sect = tail.section.take(); let pa = self.para_mut(a.story, &a.path)?; let keep_props = pa.props.clone(); pa.append(tail); pa.props = keep_props; + let before = std::mem::replace(&mut pa.section, sect); + if a.story == crate::StoryRef::Body && a.path.0.len() == 1 { + // Nearest first, as "link to previous" resolved them before the delete. + let following = self.section_mut(a.path.last()); + for s in between.iter().rev().chain(before.iter()) { + following.absorb(s); + } + } return Ok(a); } // Different containers (range enters or leaves a table): clear text in every paragraph @@ -757,6 +779,29 @@ mod tests { assert_eq!(d.body.len(), 1); } + /// #2127: a range over two section breaks joins three sections into the last; the headers + /// and footers it showed by linking to earlier ones stay, each from the nearest section that + /// named it, as they resolved before the delete. + #[test] + fn delete_over_two_section_breaks_keeps_the_linked_headers() { + let mut d = doc(&["A", "B", "C"]); + let set = |default| crate::section::HeaderSet { default: Some(default), ..Default::default() }; + let s1 = crate::section::SectionProps { headers: set(1), footers: set(5), ..Default::default() }; + let s2 = crate::section::SectionProps { headers: set(2), ..Default::default() }; + let s3 = crate::section::SectionProps { margin_left: 99.0, ..Default::default() }; + for (i, s) in [(0, s1), (1, s2), (2, s3.clone())] { + if let Some(Block::Para(p)) = d.body.get_mut(i).map(Arc::make_mut) { + p.section = Some(Box::new(s)); + } + } + assert_eq!(d.sections().len(), 4); + d.delete_range(&Pos::body(0, 1), &Pos::body(2, 0)).unwrap(); + assert_eq!(d.plain_text(StoryRef::Body), "AC"); + let sections = d.sections(); + assert_eq!(sections.len(), 2); + assert_eq!(*sections[0].1, crate::section::SectionProps { headers: set(2), footers: set(5), ..s3 }); + } + #[test] fn format_across() { let mut d = doc(&["abc", "def"]); diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index deaf42b470..fb3c50e350 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -1022,6 +1022,18 @@ impl Document { &mut self.last_section } + /// Remove the section break on the mark of top-level body paragraph `block`: its section + /// joins the following one ([`SectionProps::absorb`]). False when there is no break there. + pub fn remove_section_break(&mut self, block: usize) -> bool { + if !matches!(self.body.get(block).map(|b| &**b), Some(Block::Para(p)) if p.section.is_some()) { + return false; + } + let Some(Block::Para(p)) = self.body.get_mut(block).map(Arc::make_mut) else { return false }; + let Some(before) = p.section.take() else { return false }; + self.section_mut(block).absorb(&before); + true + } + /// Allocate a new part id and insert the part. Part ids run out only past `u32::MAX` parts, more /// than memory holds; then nothing is added and the id is 0, which [`free_id`] never gives a part. pub fn add_part(&mut self, kind: PartKind, blocks: Blocks) -> u32 { diff --git a/crates/doc/src/section.rs b/crates/doc/src/section.rs index 21591fa422..4fa0849682 100644 --- a/crates/doc/src/section.rs +++ b/crates/doc/src/section.rs @@ -795,6 +795,16 @@ fn is_horizontal(d: &crate::props::TextDirection) -> bool { } impl SectionProps { + /// Join the section before this one into it, as when the break between them is deleted: + /// this section's setup stays, as in Word, and the headers and footers it leaves out (linked + /// to the previous section) are taken from `before`, so its text keeps showing them. + pub fn absorb(&mut self, before: &SectionProps) { + for (own, prev) in [(&mut self.headers, &before.headers), (&mut self.footers, &before.footers)] { + own.default = own.default.or(prev.default); + own.first = own.first.or(prev.first); + own.even = own.even.or(prev.even); + } + } /// Width of the text area, points. pub fn text_width(&self) -> f32 { (self.page_w - self.margin_left - self.margin_right - self.gutter).max(18.0) diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 62753e9c25..3764252a4d 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -792,6 +792,10 @@ fn delete_inner(s: &mut Session, _: &Value) -> CmdResult { path.extend([0, 0, 0]); s.sel = Selection::caret(Pos { story: f.story, path: wordcraft_doc::Path(path), off: 0 }); } + } else if f.story == StoryRef::Body && f.path.0.len() == 1 && !s.doc.settings.track_changes { + // The last paragraph: no paragraph to join, but a section break on its mark (the last + // section then holds no paragraph) still goes, as in Word (#2127). + s.doc.remove_section_break(f.path.last()); } sel_result(s) } diff --git a/crates/engine/src/tests_typing.rs b/crates/engine/src/tests_typing.rs index e5a1c6be0d..60cbfa63a4 100644 --- a/crates/engine/src/tests_typing.rs +++ b/crates/engine/src/tests_typing.rs @@ -1177,3 +1177,131 @@ fn tab_in_a_table_inserted_in_right_to_left_text_moves_left() { assert_eq!(caret_cell_x(&mut s), (0, x0), "{keys:?}: Shift+Tab comes back"); } } + +/// 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"); + } +} diff --git a/docs/typing-parity.md b/docs/typing-parity.md index cd7266dcf8..57b2f6eff8 100644 --- a/docs/typing-parity.md +++ b/docs/typing-parity.md @@ -1,6 +1,6 @@ # Typing parity with Word -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-17 · **Change:** trivial (a table inserted in right-to-left text is right to left, so Tab moves left, #2634; previously trivial: Shift+Delete cuts the selection on every platform, #1669; previously trivial: Select All then Delete removes a table the document starts with, #1198; previously trivial: Alt+numpad character codes, #961; previously trivial: Down and Up from a line start beside a table go into it, #1007; previously trivial: sample-text formulas on Enter, #1067; previously trivial: Track Changes: Accept All after a tracked delete from a paragraph end keeps the next paragraph; Reject All after typing into an empty paragraph keeps it (#864); previously minor: Correct TWo INitial CApitals, cAPS LOCK and table-cell capitals, #544; previously trivial: complex-script caret and Backspace, #604; Move Paragraph Up/Down, Promote/Demote, Go Back and Alt+X keys, #343) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-17 · **Change:** trivial (Backspace after a section break and Delete before it remove the break, also on the last paragraph, #2127; previously trivial: a table inserted in right-to-left text is right to left, so Tab moves left, #2634; previously trivial: Shift+Delete cuts the selection on every platform, #1669; previously trivial: Select All then Delete removes a table the document starts with, #1198; previously trivial: Alt+numpad character codes, #961; previously trivial: Down and Up from a line start beside a table go into it, #1007; previously trivial: sample-text formulas on Enter, #1067; previously trivial: Track Changes: Accept All after a tracked delete from a paragraph end keeps the next paragraph; Reject All after typing into an empty paragraph keeps it (#864); previously minor: Correct TWo INitial CApitals, cAPS LOCK and table-cell capitals, #544; previously trivial: complex-script caret and Backspace, #604; Move Paragraph Up/Down, Promote/Demote, Go Back and Alt+X keys, #343) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 What Word does when you type, observed black-box (same keystrokes typed into Word and WordCraft, results read back paragraph by paragraph) and pinned by tests in @@ -57,6 +57,7 @@ formatting use the existing editing primitives. Tests: `sample_text_formulas_exp | Paste of several paragraphs (tracking on) | every paragraph mark the paste adds is a tracked insertion; Reject All restores the one paragraph, Accept All keeps the pasted ones | `reject_all_takes_out_the_paragraph_breaks_a_tracked_paste_inserted` | | Backspace at a paragraph start / Delete at a paragraph end (tracking on) | the paragraph mark is marked deleted (still shown); the caret moves before it (Backspace) or past it (Delete). Accept joins the paragraphs, Reject keeps them. A mark you inserted yourself is just removed | `tracked_enter_and_backspace_track_the_paragraph_mark`, `tests::tracked_backspace_and_delete_mark_a_paragraph_break_deleted` | | Delete a range from the end of one paragraph into a later one (tracking on) | the paragraph marks and text in the range are tracked deletions. Accept All removes exactly those, so the paragraph after the range stays a paragraph of its own; Reject All restores everything. Typing into an empty paragraph and rejecting leaves that paragraph | `tests::accept_all_after_a_tracked_delete_from_a_paragraph_end_keeps_the_next_paragraph`, `tests::reject_all_after_typing_into_an_empty_paragraph_keeps_the_paragraph` | +| Backspace just after a section break / Delete just before it | the break goes and the two paragraphs join; the text before it takes the following section's page setup (Word's documented behaviour: a break holds the setup of the section before it); headers and footers the following section showed by linking to the previous one stay. Delete at the end of the last paragraph removes a break on its mark too, with no paragraph to join (with Track Changes on it still does nothing there). Undo brings the break back. Breaks read from a .docx and breaks inserted here alike (#2127) | `backspace_and_delete_remove_a_section_break`, `delete_removes_a_section_break_on_the_last_paragraph` | | Select All, then Delete or Backspace | the whole document goes, tables included, also when it starts with a table; one empty paragraph is left | `select_all_then_delete_removes_tables` | | Table Layout › Delete Table / Rows / Columns / Cells (tracking on) | like Word deleting a selected table: the cells' text is marked deleted and the table structure stays (WordCraft has no tracked row or column deletion, `w:trPr/w:del`, so rows and columns keep their cells too, where Word's Accept removes the rows); text you inserted yourself is just removed; Reject All restores the text. A table, row or column with no text at all (empty, or its deletion already accepted) is removed as with tracking off; one whose text is all already marked deleted is left alone with a message (accept or reject first). Delete Columns leaves a merged cell that also spans a kept column alone (it narrows, keeping its text). A content control locked against deletion, or content locked against editing, refuses the delete with tracking on or off | `tests_revision_checks::tracked_table_delete_marks_the_text_deleted_and_reject_all_restores_it`, `tracked_row_column_and_cell_deletes_mark_their_text_deleted`, `tracked_table_delete_over_tracked_insertions_rejects_back_to_the_original`, `table_deletes_refuse_a_control_locked_against_deletion`, `tracked_delete_table_removes_a_table_with_no_live_text`, `delete_columns_leaves_a_merged_cell_that_keeps_a_column_alone`, `tracked_row_and_column_deletes_over_tracked_changes_reject_back_to_the_original` | | Change Case (Shift+F3, Home › Aa) (tracking on) | Word shows no insertion or deletion markup (checked with Formatting markup set to none, so Word may record it as a hidden formatting revision). **Differs:** WordCraft records the changed letters as a tracked deletion of the old and insertion of the new, since case isn't a run property a formatting revision could hold; Reject All restores the old case, Accept All keeps the new. Your own tracked insertion is rewritten in place; already-deleted text is left alone and doesn't count (with tracking off too), so changing case again (or Shift+F3) works from the visible text. Refused in content locked against editing | `tests_revision_checks::tracked_change_case_rejects_back_to_the_original_case`, `tracked_change_case_of_own_insertion_stays_an_insertion`, `tracked_change_case_twice_reads_only_the_live_text`, `tracked_change_case_handles_sharp_s_and_mixed_formatting`, `change_case_refuses_content_locked_against_editing` | @@ -201,6 +202,7 @@ Word. | Date | Change | Summary | |---|---|---| | 2026-10-17 | trivial | A table inserted in a right-to-left paragraph is a right-to-left table, so Tab moves to the cell on the left (#2634) | +| 2026-10-15 | trivial | Backspace after a section break and Delete before it remove the break, also one on the last paragraph's mark; the text takes the following section's setup and keeps the headers it showed (#2127) | | 2026-10-14 | trivial | Shift+Delete cuts the selection, and does nothing without one, on macOS and Linux too, as in Word for Windows (#1669); Shift+Backspace already erased (#707) | | 2026-10-13 | trivial | Select All then Delete or Backspace removes a table the document starts with (#1198) | | 2026-10-12 | trivial | Editing keys: Down and Up from the start of a line beside a table go into it instead of skipping it (#1007) |