Backspace and Delete remove a section break, imported or not (#2133)

* Backspace and Delete remove a section break, imported or not (fixes #2127)

* Delete removes a section break on the last paragraph too (#2127)

A continuous break on the mark of the document's last paragraph, with the
last section holding no paragraph (as in the file attached to #2127), had
nothing for Delete to join, so it stayed. Delete at the end now removes it.
The text takes the last section's setup, as in Word, and the headers and
footers that section showed by linking to the removed one stay, here and
when the break goes by joining two paragraphs. Undo brings it back.

* Deleting over several section breaks keeps the headers they linked to (#2127)

A range across two or more breaks dropped the paragraphs in between with
their sections, so only the first section's headers and footers were carried
over. The joined section now takes those it leaves out from every removed
section, nearest first, as "link to previous" resolved them before the delete.
The typing-parity row says that Delete with Track Changes on still leaves a
break on the last paragraph alone.

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
Marco ArnulfoandBrandon Thomas authored and GitHub committed 2026-10-18 04:00:49 -04:00
1 parent 357deba3c2
commit 7bd3eb8ca7
6 files changed
+202 -1

No files matched your search

+45
View File
@@ -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"]);
+12
View File
@@ -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 {
+10
View File
@@ -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)
+4
View File
@@ -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)
}
+128
View File
@@ -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<u8> {
const W: &str = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
let files = [
(
"[Content_Types].xml",
r#"<?xml version="1.0" encoding="UTF-8"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>"#.to_string(),
),
(
"_rels/.rels",
r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#.to_string(),
),
(
"word/document.xml",
format!(
r#"<w:document xmlns:w="{W}"><w:body><w:p><w:pPr><w:sectPr><w:pgSz w:w="12240" w:h="15840"/></w:sectPr></w:pPr><w:r><w:t>One.</w:t></w:r></w:p><w:p><w:r><w:t>Two.</w:t></w:r></w:p><w:sectPr><w:type w:val="{kind}"/><w:pgSz w:w="15840" w:h="12240" w:orient="landscape"/></w:sectPr></w:body></w:document>"#
),
),
];
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<u8> {
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!("<w:p><w:r><w:t>{t}</w:t></w:r></w:p>")).unwrap_or_default();
let files = [
(
"[Content_Types].xml",
r#"<?xml version="1.0" encoding="UTF-8"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/><Override PartName="/word/header1.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.header+xml"/></Types>"#.to_string(),
),
(
"_rels/.rels",
r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#.to_string(),
),
(
"word/_rels/document.xml.rels",
r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/header" Target="header1.xml"/></Relationships>"#.to_string(),
),
("word/header1.xml", format!(r#"<w:hdr xmlns:w="{W}"><w:p><w:r><w:t>Lab report</w:t></w:r></w:p></w:hdr>"#)),
(
"word/document.xml",
format!(
r#"<w:document xmlns:w="{W}" xmlns:r="{R}"><w:body><w:p><w:r><w:t>One.</w:t></w:r></w:p><w:p><w:pPr><w:sectPr><w:headerReference w:type="default" r:id="rId2"/><w:type w:val="continuous"/><w:pgSz w:w="11920" w:h="16850"/><w:pgMar w:top="1320" w:right="1275" w:bottom="280" w:left="1417" w:header="480" w:footer="0" w:gutter="0"/></w:sectPr></w:pPr></w:p>{then}<w:sectPr><w:type w:val="nextPage"/><w:pgSz w:w="11920" w:h="16850"/><w:pgMar w:top="1320" w:right="1275" w:bottom="280" w:left="1417" w:header="480" w:footer="0" w:gutter="0"/></w:sectPr></w:body></w:document>"#
),
),
];
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");
}
}
+3 -1
View File
@@ -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) |