From c501c3f707a69d7b59bcc707cbf186e524a1dddf Mon Sep 17 00:00:00 2001 From: Nash Aschenbath Date: Sat, 31 Oct 2026 17:59:08 +0800 Subject: [PATCH 1/4] fix(rtf): omit text of deleted revisions A tracked deletion (\deleted) printed as live text, so the old and new wording of a revised clause both appeared. Text inside a deletion is now left out, the same way the DOC path does in #174; insertions stay. Co-Authored-By: Claude Opus 5.5 --- src/formats/rtf/mod.rs | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/src/formats/rtf/mod.rs b/src/formats/rtf/mod.rs index 3094e67d..847432c1 100644 --- a/src/formats/rtf/mod.rs +++ b/src/formats/rtf/mod.rs @@ -77,6 +77,9 @@ struct CharState { /// Emphasis the paragraph style itself carries, subtracted from headings. style_base: Style, suppress: bool, + /// Inside a `\deleted` revision: text a viewer shows struck out as + /// removed, not part of the document's current text. + deleted: bool, capture: Capture, note: Option, } @@ -96,6 +99,7 @@ impl Default for CharState { block: None, style_base: Style::PLAIN, suppress: false, + deleted: false, capture: Capture::None, note: None, } @@ -781,6 +785,10 @@ impl<'a> Parser<'a> { "b" => self.set_style(|s| s.bold = on), "i" => self.set_style(|s| s.italic = on), "strike" | "striked" => self.set_style(|s| s.strike = on), + "deleted" => { + self.flush_pending(); + self.state.deleted = on; + } "plain" => { self.flush_pending(); let font = self.state.font; @@ -1151,7 +1159,7 @@ impl<'a> Parser<'a> { } } Capture::None => { - if !self.state.suppress { + if !self.state.suppress && !self.state.deleted { self.inlines.push(Inline::Text { text, style: self.state.style }); } } @@ -1342,6 +1350,18 @@ mod tests { assert_eq!(list.items[1].marker_label.as_deref(), Some("2.")); } + #[test] + fn deleted_revision_text_is_omitted() { + // #202: a tracked deletion is removed text; the insertion stays. + for src in [ + r"{\rtf1 The parties agreed {\deleted\revauthdel1 OLDTERM }{\revised\revauth1 NEWTERM }terms.\par}", + r"{\rtf1 The parties agreed \deleted OLDTERM \deleted0 NEWTERM terms.\par}", + ] { + let markdown = crate::to_markdown_bytes(src.as_bytes(), crate::Format::Rtf).unwrap(); + assert_eq!(markdown, "The parties agreed NEWTERM terms.\n", "source: {src}"); + } + } + #[test] fn mid_paragraph_page_and_column_breaks_keep_the_word_boundary() { // \page and \column carry no paragraph mark: without a break of From cc22e5281c8a9920382366cbcc577b61e4e0e793 Mon Sep 17 00:00:00 2001 From: Nash Aschenbath Date: Sat, 31 Oct 2026 18:08:39 +0800 Subject: [PATCH 2/4] fix(rtf): drop line breaks, pictures and equations inside a deletion Co-Authored-By: Claude Opus 5.5 --- src/formats/rtf/mod.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/formats/rtf/mod.rs b/src/formats/rtf/mod.rs index 847432c1..f529eecb 100644 --- a/src/formats/rtf/mod.rs +++ b/src/formats/rtf/mod.rs @@ -831,7 +831,7 @@ impl<'a> Parser<'a> { // boundary they carry is not. "line" | "lbr" | "page" | "column" => { self.flush_pending(); - if !self.state.suppress { + if !self.state.suppress && !self.state.deleted { self.inlines.push(Inline::LineBreak); } } @@ -1055,6 +1055,10 @@ impl<'a> Parser<'a> { let Some(math) = self.dest.math.take() else { return; }; + // Runs after the group closes: the state is the enclosing one. + if self.state.deleted { + return; + } let (lines, display) = math.finish(); for (i, tex) in lines.into_iter().enumerate() { if i > 0 { @@ -1082,6 +1086,9 @@ impl<'a> Parser<'a> { let Some(pict) = self.dest.pict.take() else { return Ok(()); }; + if self.state.deleted { + return Ok(()); + } let Some((media_type, extension)) = pict.format else { log::debug!("skipping picture in an unsupported format"); return Ok(()); @@ -1356,6 +1363,7 @@ mod tests { for src in [ r"{\rtf1 The parties agreed {\deleted\revauthdel1 OLDTERM }{\revised\revauth1 NEWTERM }terms.\par}", r"{\rtf1 The parties agreed \deleted OLDTERM \deleted0 NEWTERM terms.\par}", + r"{\rtf1 The parties agreed {\deleted OLD\line TERM {\pict\pngblip 89504e470d0a1a0a}}NEWTERM terms.\par}", ] { let markdown = crate::to_markdown_bytes(src.as_bytes(), crate::Format::Rtf).unwrap(); assert_eq!(markdown, "The parties agreed NEWTERM terms.\n", "source: {src}"); From 637c79956f4dedf61b821788f28a77bc582f1bb6 Mon Sep 17 00:00:00 2001 From: Nash Aschenbath Date: Sat, 31 Oct 2026 18:17:01 +0800 Subject: [PATCH 3/4] fix(rtf): a wholly deleted paragraph leaves no blank line Co-Authored-By: Claude Opus 5.5 --- src/formats/rtf/mod.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/formats/rtf/mod.rs b/src/formats/rtf/mod.rs index f529eecb..006d44eb 100644 --- a/src/formats/rtf/mod.rs +++ b/src/formats/rtf/mod.rs @@ -811,6 +811,9 @@ impl<'a> Parser<'a> { self.flush_pending(); if self.state.note.is_some() { self.inlines.push(Inline::LineBreak); + } else if self.state.deleted && inlines_are_empty(&self.inlines) { + // A wholly deleted paragraph leaves nothing, not even + // a blank line of a code block. } else if !self.state.suppress { self.end_paragraph()?; } @@ -1370,6 +1373,13 @@ mod tests { } } + #[test] + fn deleted_paragraph_leaves_no_blank_line_in_a_code_block() { + let src = r"{\rtf1{\stylesheet{\s1 Source Code;}}\pard\s1 a = 1\par {\deleted b = 2\par}c = 3\par}"; + let markdown = crate::to_markdown_bytes(src.as_bytes(), crate::Format::Rtf).unwrap(); + assert_eq!(markdown, "```\na = 1\nc = 3\n```\n"); + } + #[test] fn mid_paragraph_page_and_column_breaks_keep_the_word_boundary() { // \page and \column carry no paragraph mark: without a break of From dccaf92659c19a61c2a7cbf9cc3ace3fcafc8c8b Mon Sep 17 00:00:00 2001 From: Nash Aschenbath Date: Sat, 31 Oct 2026 21:27:37 +0800 Subject: [PATCH 4/4] fix(rtf): a deleted paragraph mark joins the paragraphs and drops its list marker Co-Authored-By: Claude Opus 5.5 --- src/formats/rtf/mod.rs | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/src/formats/rtf/mod.rs b/src/formats/rtf/mod.rs index 006d44eb..eef08416 100644 --- a/src/formats/rtf/mod.rs +++ b/src/formats/rtf/mod.rs @@ -811,9 +811,11 @@ impl<'a> Parser<'a> { self.flush_pending(); if self.state.note.is_some() { self.inlines.push(Inline::LineBreak); - } else if self.state.deleted && inlines_are_empty(&self.inlines) { - // A wholly deleted paragraph leaves nothing, not even - // a blank line of a code block. + } else if self.state.deleted { + // A deleted paragraph mark joins this paragraph to the + // next, as accepting the revision does; a wholly deleted + // paragraph leaves nothing, not even its list marker. + self.dest.listtext = None; } else if !self.state.suppress { self.end_paragraph()?; } @@ -1373,6 +1375,18 @@ mod tests { } } + #[test] + fn deleted_paragraph_mark_joins_the_paragraphs_and_drops_its_list_marker() { + let src = r"{\rtf1 Kept{\deleted\par} text.\par}"; + let markdown = crate::to_markdown_bytes(src.as_bytes(), crate::Format::Rtf).unwrap(); + assert_eq!(markdown, "Kept text.\n"); + + let src = r"{\rtf1 \pard{\listtext 1.\tab}one\par {\deleted{\listtext 2.\tab}two\par}\pard plain\par}"; + let markdown = crate::to_markdown_bytes(src.as_bytes(), crate::Format::Rtf).unwrap(); + assert!(!markdown.contains("two") && !markdown.contains("2."), "{markdown}"); + assert!(markdown.contains("plain"), "{markdown}"); + } + #[test] fn deleted_paragraph_leaves_no_blank_line_in_a_code_block() { let src = r"{\rtf1{\stylesheet{\s1 Source Code;}}\pard\s1 a = 1\par {\deleted b = 2\par}c = 3\par}";