diff --git a/ROADMAP.md b/ROADMAP.md index 64000e722f..61ab68bcbc 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -357,6 +357,7 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target | Date | What landed | | 2026-11-04 | The CLI opens a non-Unicode input filename even after options such as `--page 2` or `--format flatopc`; text option values still require Unicode; `run --file` and `run --script` retain native paths. | | 2026-11-06 | Accept All and Reject All no longer restyle the paragraph after an empty one carrying a section break (#5667): a Compare that added a section, then Reject All, left `Body` in the removed paragraph's Normal style and indents. The empty paragraph now goes whole and its section joins the following one, as deleting the break does. | +| 2026-11-07 | Deleting a preceding empty paragraph keeps the following paragraph's style, outline and mark formatting rather than promoting body text to a heading (Leaf #152). Non-empty merges and section absorption retain their existing rules; synthetic model, command, DOCX and Undo/Redo coverage. | | 2026-11-04 | Pictures keep the brightness, contrast and transparency their file gives them (#1711, #1766): a "Washout" picture watermark (VML `gain`/`blacklevel`) and a washed-out letterhead picture behind the text (`a:lum`) are drawn faded on screen and in PDF instead of at full colour, with the pixel curve measured on Word's own PDF export; `a:alphaModFix` transparency too. Saved back as DrawingML (and as VML `gain`/`blacklevel` in Word 2003 XML, without the transparency). Picture Format › Corrections, Transparency and Color › Washout replace the file's values (other Color modes leave them alone), and Reset Picture discards them all, as in Word; the PDF embeds the corrected pixels once per picture | |---|---| | 2026-10-17 | Copies keep their formatting in programs that read RTF rather than HTML: Copy and Cut put RTF beside the text and HTML on the system clipboard on Windows, macOS and Wayland (an X11 copy stays text and HTML, since arboard owns the selection there; a copy of only pictures or shapes carries no HTML or RTF, so pasting it back keeps every detail), and Paste reads another program's RTF on macOS and X11 too, not only on Windows and Wayland | diff --git a/crates/doc/src/edit.rs b/crates/doc/src/edit.rs index 0a869ee70a..fc8fa2bb26 100644 --- a/crates/doc/src/edit.rs +++ b/crates/doc/src/edit.rs @@ -772,6 +772,10 @@ impl Document { if a.path.parent() == b.path.parent() { // Same container: trim the ends, drop the blocks between, join. let (ia, ib) = (a.path.last(), b.path.last()); + // Removing an already-empty paragraph must not impose its style or mark formatting + // on the following content. Test before trimming: deleting actual heading text is + // still an ordinary merge that keeps the first paragraph's properties. + let empty_start = self.para(a.story, &a.path).is_some_and(Paragraph::is_empty); // Kept markers of the paragraphs in between move to the join. let mut moved: Vec<(InlineObject, CharProps)> = Vec::new(); // `kept` is sorted: one walk of each paragraph's objects and runs (#3614). @@ -819,15 +823,18 @@ impl Document { 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(); + let keep_props = if empty_start { tail.props.clone() } else { pa.props.clone() }; + let keep_mark = empty_start.then(|| tail.mark.clone()); pa.append(tail); pa.props = keep_props; + if let Some(mark) = keep_mark { + pa.mark = mark; + } 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. diff --git a/crates/doc/tests/empty_paragraph_merge.rs b/crates/doc/tests/empty_paragraph_merge.rs new file mode 100644 index 0000000000..7368ad1be1 --- /dev/null +++ b/crates/doc/tests/empty_paragraph_merge.rs @@ -0,0 +1,158 @@ +use std::sync::Arc; +use wordcraft_doc::props::{Align, CharProps, ParaProps}; +use wordcraft_doc::{Block, Document, Paragraph, Path, Pos, StoryRef, Table, para_block}; + +/// Build an empty heading followed by explicitly formatted, mixed-run body text. +fn fixture(first: &str, style: Option<&str>) -> Document { + let mut d = Document::from_text(&format!("{first}\nBody paragraph.")); + let p = d.para_mut(StoryRef::Body, &Path::top(0)).unwrap(); + p.props.style = Some("Heading1".into()); + p.props.outline_level = Some(0); + p.mark = CharProps { size: Some(40.0), bold: Some(true), ..Default::default() }; + let p = d.para_mut(StoryRef::Body, &Path::top(1)).unwrap(); + p.props = ParaProps { + style: style.map(str::to_string), + align: Some(Align::Right), + indent_left: Some(18.0), + space_after: Some(7.0), + keep_next: Some(false), + ..Default::default() + }; + p.mark = CharProps { size: Some(11.0), bold: Some(false), lang: Some("en-GB".into()), ..Default::default() }; + p.format(0, 4, &|c| c.italic = Some(true)).unwrap(); + d +} + +/// Compare paragraph, mark and inline-run formatting independently of text. +fn assert_format(p: &Paragraph, expected: &Paragraph) { + assert_eq!(p.props, expected.props, "the following paragraph keeps all its properties"); + assert_eq!(p.mark, expected.mark, "the empty heading's mark must not replace the following mark"); + assert_eq!(serde_json::to_value(&p.runs).unwrap(), serde_json::to_value(&expected.runs).unwrap()); +} + +/// Deleting an empty paragraph preserves the following style, including implicit Normal. +#[test] +fn deleting_an_empty_paragraph_keeps_the_following_format() { + for first_style in ["Heading1", "Heading3", "Normal"] { + for style in [None, Some("Normal"), Some("Heading2")] { + let mut d = fixture("", style); + d.para_mut(StoryRef::Body, &Path::top(0)).unwrap().props.style = Some(first_style.into()); + let expected = d.para_at(&Pos::body(1, 0)).unwrap().clone(); + assert_eq!(d.delete_range(&Pos::body(0, 0), &Pos::body(1, 0)).unwrap(), Pos::body(0, 0)); + let p = d.para_at(&Pos::body(0, 0)).unwrap(); + assert_eq!(p.text, expected.text); + assert_format(p, &expected); + } + } +} + +/// A selection spanning several paragraphs keeps the surviving tail format in both directions. +#[test] +fn an_empty_start_preserves_the_last_paragraph_over_a_multi_paragraph_selection() { + for backwards in [false, true] { + let mut d = fixture("", None); + d.body.insert(1, para_block(Paragraph::with_text("Removed middle paragraph", Default::default()).styled("Heading2"))); + let mut expected = d.para_at(&Pos::body(2, 0)).unwrap().clone(); + expected.delete(0, 5).unwrap(); + let (a, b) = (Pos::body(0, 0), Pos::body(2, 5)); + d.delete_range(if backwards { &b } else { &a }, if backwards { &a } else { &b }).unwrap(); + assert_eq!(d.body.len(), 1); + let p = d.para_at(&Pos::body(0, 0)).unwrap(); + assert_eq!(p.text, "paragraph."); + assert_format(p, &expected); + } +} + +/// A control whose closing marker survives must move across the join without replacing tail formatting. +#[test] +fn an_empty_start_keeps_a_control_from_a_deleted_middle_paragraph() { + use wordcraft_doc::control::ContentControl; + use wordcraft_doc::{InlineObject, Revision, RevisionKind}; + + let mut d = fixture("", Some("Normal")); + d.body.insert(1, para_block(Paragraph::with_text("Removed", Default::default()))); + d.body.push(para_block(Paragraph::with_text("After", Default::default()))); + d.revisions.push(Revision { kind: RevisionKind::Insert, ..Default::default() }); + d.revisions.push(Revision { kind: RevisionKind::Delete, ..Default::default() }); + let p = d.para_mut(StoryRef::Body, &Path::top(2)).unwrap(); + p.mark.ins = Some(0); + p.mark.del = Some(1); + let expected = p.clone(); + let marker = CharProps { bold: Some(true), ins: Some(0), ..Default::default() }; + d.insert_object(&Pos::body(1, 0), InlineObject::ControlStart { control: Box::new(ContentControl::default()) }, &marker).unwrap(); + d.insert_object(&Pos::body(3, 5), InlineObject::ControlEnd, &Default::default()).unwrap(); + assert_eq!(d.control_ranges(StoryRef::Body).len(), 1); + + d.delete_range(&Pos::body(0, 0), &Pos::body(2, 0)).unwrap(); + let p = d.para_at(&Pos::body(0, 0)).unwrap(); + assert_eq!(p.props, expected.props); + assert_eq!(p.mark, expected.mark, "tail revision IDs and formatting survive the moved marker"); + assert_eq!(p.props_of_char(0), &marker); + assert!(matches!(p.objects.as_slice(), [InlineObject::ControlStart { .. }])); + assert_eq!(p.text, format!("\u{fffc}{}", expected.text)); + let ranges = d.control_ranges(StoryRef::Body); + assert_eq!(ranges.len(), 1); + assert_eq!(ranges[0].start, Pos::body(0, 0)); + assert_eq!(ranges[0].end, Pos::body(1, 5)); + assert!(!d.has_unbalanced_controls()); +} + +/// The empty-paragraph rule applies in header stories and table-cell containers. +#[test] +fn empty_paragraph_merges_work_in_a_header_and_a_table_cell() { + for cell in [false, true] { + let mut d = fixture("", Some("Normal")); + let expected = d.para_at(&Pos::body(1, 0)).unwrap().clone(); + let (story, a, b) = if cell { + let mut table = Table::new(1, 1, 300.0); + table.rows[0].cells[0].blocks = d.body.clone(); + d.body = vec![Arc::new(Block::Table(table)), para_block(Paragraph::new())]; + (StoryRef::Body, Path(vec![0, 0, 0, 0]), Path(vec![0, 0, 0, 1])) + } else { + let id = d.add_part(wordcraft_doc::PartKind::Header, d.body.clone()); + (StoryRef::Part(id), Path::top(0), Path::top(1)) + }; + d.delete_range(&Pos { story, path: a.clone(), off: 0 }, &Pos { story, path: b, off: 0 }).unwrap(); + assert_format(d.para(story, &a).unwrap(), &expected); + } +} + +/// Absorbing an empty section keeps the following format and inherited header. +#[test] +fn deleting_an_empty_section_paragraph_keeps_format_and_linked_headers() { + let mut d = fixture("", None); + let expected = d.para_at(&Pos::body(1, 0)).unwrap().clone(); + let header = d.add_part(wordcraft_doc::PartKind::Header, vec![para_block(Paragraph::with_text("Header", Default::default()))]); + let mut section = d.last_section.clone(); + section.headers.default = Some(header); + d.para_mut(StoryRef::Body, &Path::top(0)).unwrap().section = Some(Box::new(section)); + d.last_section.margin_left = 96.0; + d.delete_range(&Pos::body(0, 0), &Pos::body(1, 0)).unwrap(); + assert_format(d.para_at(&Pos::body(0, 0)).unwrap(), &expected); + assert!(d.para_at(&Pos::body(0, 0)).unwrap().section.is_none()); + assert_eq!(d.last_section.headers.default, Some(header)); + assert_eq!(d.last_section.margin_left, 96.0); +} + +/// Deleting actual text continues to use the first paragraph merge rule. +#[test] +fn joining_nonempty_paragraphs_keeps_the_existing_first_paragraph_rule() { + for off in [0, 7] { + let mut d = fixture("Heading", None); + let expected = d.para_at(&Pos::body(0, 0)).unwrap().props.clone(); + d.delete_range(&Pos::body(0, off), &Pos::body(1, 0)).unwrap(); + assert_eq!(d.para_at(&Pos::body(0, 0)).unwrap().props, expected); + } +} + +/// Removing a soft line break must not change paragraph formatting. +#[test] +fn a_soft_break_is_not_an_empty_paragraph_merge() { + let mut d = fixture("", None); + let p = d.para_mut(StoryRef::Body, &Path::top(0)).unwrap(); + p.insert_text(0, "\nHeading", &Default::default()).unwrap(); + let expected = p.props.clone(); + d.delete_range(&Pos::body(0, 0), &Pos::body(0, 1)).unwrap(); + assert_eq!(d.body.len(), 2); + assert_eq!(d.para_at(&Pos::body(0, 0)).unwrap().props, expected); +} diff --git a/crates/engine/src/tests/typing.rs b/crates/engine/src/tests/typing.rs index 01ab762111..e2594492dc 100644 --- a/crates/engine/src/tests/typing.rs +++ b/crates/engine/src/tests/typing.rs @@ -9,6 +9,19 @@ 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()) diff --git a/crates/engine/tests/empty_paragraph_merge.rs b/crates/engine/tests/empty_paragraph_merge.rs new file mode 100644 index 0000000000..40f81088c5 --- /dev/null +++ b/crates/engine/tests/empty_paragraph_merge.rs @@ -0,0 +1,80 @@ +use serde_json::{Value, json}; +use wordcraft_doc::props::CharProps; +use wordcraft_doc::{Document, Path, Pos, StoryRef}; +use wordcraft_engine::{Selection, Session}; +use wordcraft_layout::outline; + +/// Dispatch an engine command and report its ID if it fails. +fn run(s: &mut Session, id: &str, params: Value) { + s.run(id, ¶ms).unwrap_or_else(|e| panic!("{id}: {e}")); +} + +/// Create an empty heading, mixed-run body text and a genuine TOC heading. +fn fixture() -> Session { + let mut s = Session::new(Document::from_text("\nBody paragraph.\nReal heading\n")); + run(&mut s, "para.style", json!({"style": "Heading1"})); + s.doc.para_mut(StoryRef::Body, &Path::top(0)).unwrap().mark = + CharProps { font: Some("Source Sans 3".into()), size: Some(40.0), bold: Some(true), ..Default::default() }; + let p = s.doc.para_mut(StoryRef::Body, &Path::top(1)).unwrap(); + p.mark = CharProps { size: Some(11.0), bold: Some(false), ..Default::default() }; + p.format(5, 14, &|c| c.italic = Some(true)).unwrap(); + s.doc.para_mut(StoryRef::Body, &Path::top(2)).unwrap().props.style = Some("Heading2".into()); + s +} + +/// Keyboard and selection deletes preserve body formatting through Undo and Redo. +#[test] +fn empty_heading_delete_backspace_and_selection_preserve_body_format_and_history() { + for mode in ["delete", "backspace", "selection", "reverse selection"] { + for pending in [false, true] { + let mut s = fixture(); + let expected = s.doc.para_at(&Pos::body(1, 0)).unwrap().clone(); + s.sel = match mode { + "backspace" => Selection::caret(Pos::body(1, 0)), + "selection" => Selection { anchor: Pos::body(0, 0), focus: Pos::body(1, 0) }, + "reverse selection" => Selection { anchor: Pos::body(1, 0), focus: Pos::body(0, 0) }, + _ => Selection::caret(Pos::body(0, 0)), + }; + if pending { + s.pending = Some(CharProps { size: Some(48.0), bold: Some(true), ..Default::default() }); + } + let before = serde_json::to_value(&s.doc.body).unwrap(); + run(&mut s, if mode == "backspace" { "text.backspace" } else { "text.delete" }, json!({})); + let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); + assert_eq!(p.text, expected.text, "{mode}, pending={pending}"); + assert_eq!(p.props, expected.props); + assert_eq!(p.mark, expected.mark); + assert_eq!(serde_json::to_value(&p.runs).unwrap(), serde_json::to_value(&expected.runs).unwrap()); + let actual = s.doc.styles.resolve_char(p.props.style.as_deref(), p.props_of_char(0)); + let old = s.doc.styles.resolve_char(expected.props.style.as_deref(), expected.props_of_char(0)); + assert_eq!((actual.font, actual.size, actual.bold, actual.italic), (old.font, old.size, old.bold, old.italic)); + assert!(s.pending.is_none()); + assert_eq!(outline::levels(&s.doc)[..2], [outline::BODY, 2]); + let after = serde_json::to_value(&s.doc.body).unwrap(); + run(&mut s, "edit.undo", json!({})); + assert_eq!(serde_json::to_value(&s.doc.body).unwrap(), before); + run(&mut s, "edit.redo", json!({})); + assert_eq!(serde_json::to_value(&s.doc.body).unwrap(), after); + } + } +} + +/// Saving to DOCX and generating a TOC must keep the surviving body text out of the outline. +#[test] +fn deleted_empty_heading_does_not_promote_body_text_into_toc_or_docx() { + let mut s = fixture(); + run(&mut s, "text.delete", json!({})); + assert_eq!(outline::levels(&s.doc)[..2], [outline::BODY, 2]); + let bytes = wordcraft_docx::write(&s.doc).unwrap(); + let mut s = Session::new(wordcraft_docx::read(&bytes).unwrap()); + assert_eq!(outline::levels(&s.doc)[..2], [outline::BODY, 2]); + let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); + assert_eq!(p.props.style.as_deref(), None); + assert_eq!(p.mark.size, Some(11.0)); + assert_eq!(p.props_of_char(6).italic, Some(true)); + s.sel = Selection::caret(Pos::body(2, 0)); + run(&mut s, "references.toc", json!({"levels": 3})); + let text = s.doc.plain_text(StoryRef::Body); + assert_eq!(text.matches("Body paragraph.").count(), 1, "body text is not a TOC entry: {text}"); + assert_eq!(text.matches("Real heading").count(), 2, "the genuine heading stays in the TOC: {text}"); +} diff --git a/docs/gaps.md b/docs/gaps.md index e4ed791d1b..55f8757d42 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -204,6 +204,7 @@ Page Setup's margin, paper-size and document-grid number boxes scroll long text | 2026-11-02 | trivial | Picture hyperlinks show on hover, follow on Ctrl/⌘+click and round-trip as Word stores them (#5267); no ranked gap changed; readiness estimates unchanged | | 2026-11-02 | trivial | #26 narrowed: MHT opens and `w:altChunk` content is brought in on open; saving MHT remains | | 2026-11-01 | trivial | Preserve table rows and cells inside selected alternate-content branches (#5219); readiness estimates unchanged. | +| 2026-11-07 | trivial | Empty paragraph deletion preserves following paragraph/mark formatting (Leaf #152); synthetic regressions only, no ranked gap closed or estimate changed. | | 2026-11-06 | trivial | Print helpers remove loaded PDF/settings files before driver work, tolerate prior removal and report cleanup errors; a held-driver regression checks the files are already absent (#5710) | | 2026-11-06 | trivial | Windows print submissions use a separate helper per job, preserving provider serialization inside the helper. An acknowledged cancellation stops that helper, so a stalled cancelled driver cannot block a later job; normal shutdown reaps helpers and removes temporary PDFs. The helper erases its plaintext inputs before driver work; a hard app crash during startup can still leave private temporary files. Startup requires an explicit start grant; after sending begins, jobs complete (#5710) | | 2026-11-06 | trivial | Gap 6: File › Print: before sending starts, Cancel printing (`ui.printCancel`) stops a waiting job and frees the Print page while keeping its settings, including after the last document closes; a successfully cancelled job is never sent. Quitting cancels waiting jobs and joins jobs already sending (#5710). On Windows, winprint repeats the DEVMODE conversion after sending starts. A stall during that repeat prevents cancellation and can keep shutdown waiting, even before a native job exists. No ranked gap changed; readiness estimates unchanged | diff --git a/docs/typing-parity.md b/docs/typing-parity.md index 266d9cf9a4..e1b2096742 100644 --- a/docs/typing-parity.md +++ b/docs/typing-parity.md @@ -584,11 +584,21 @@ command is one Undo step and still obeys editing protection and the 1–1638 pt limits. Original-fixture engine and Linux CLI/headless UI save/reopen checks cover this correction; native Windows interaction and Microsoft Word remain untested. +## Empty paragraph removal + +Deleting a paragraph mark whose preceding paragraph was already empty preserves the +following paragraph's paragraph properties and mark formatting. Existing inline runs +remain intact. Delete, Backspace and selected marks share the model operation; joining +two originally non-empty paragraphs retains the first paragraph's properties. Synthetic +regressions cover body, header and cell containers, sections, outline/TOC membership, +DOCX and Undo/Redo (Leaf #152). No new Word or native UI comparison was run. + ## Revision history | Date | Change | Summary | |---|---|---| | 2026-11-04 | trivial | Ctrl+K on a selection: a changed Text to display replaces the selected text before it is linked | +| 2026-11-07 | trivial | Removing an already-empty paragraph preserves the following paragraph properties, mark and inline formatting. Delete, Backspace, selected marks, outline/TOC, DOCX and Undo/Redo have synthetic coverage (Leaf #152); no new Word or native acceptance run. | | 2026-10-30 | trivial | Equations: `\int` + Space waits for its limits; `_x^y` then Space builds them, Space alone builds the operator without, typing goes into its operand, and Enter or an arrow key builds it without going into the operand (#4917). Review: any character that can't continue `\name` corrects it (`\int_0^1`, `\alpha+`), and `∫∫` takes the operand into the inner operator; the Word rows are now observed in Word for Windows on #4938, Enter, arrows and `∫∫` are not | | 2026-11-06 | trivial | Track Changes: resolving away an empty paragraph with a section break removes it whole; the next paragraph keeps its style (#5667) | | 2026-11-03 | trivial | Alt+numpad codes type their character in text boxes too (Find what, dialog fields), not the digits or nothing (#4403) |