Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 54 additions & 2 deletions src/formats/rtf/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Resolved
capture: Capture,
note: Option<NoteKind>,
}
Expand All @@ -96,6 +99,7 @@ impl Default for CharState {
block: None,
style_base: Style::PLAIN,
suppress: false,
deleted: false,
capture: Capture::None,
note: None,
}
Expand Down Expand Up @@ -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;
Expand All @@ -803,6 +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 {
// 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()?;
}
Expand All @@ -823,7 +836,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 {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Resolved
self.inlines.push(Inline::LineBreak);
}
}
Expand Down Expand Up @@ -1047,6 +1060,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 {
Expand Down Expand Up @@ -1074,6 +1091,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(());
Expand Down Expand Up @@ -1151,7 +1171,7 @@ impl<'a> Parser<'a> {
}
}
Capture::None => {
if !self.state.suppress {
if !self.state.suppress && !self.state.deleted {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Resolved

@cubic-dev-ai cubic-dev-ai Bot Oct 31, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The deleted gate covers only text. Pictures (\\pict) and equations (\\mmath) inside a \\deleted group are still emitted, because their finishers never check the flag. Record deleted when the \\pict or \\mmath destination opens, and skip emission in finish_pict/finish_math when it was set.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At src/formats/rtf/mod.rs, line 1162:

<comment>The `deleted` gate covers only text. Pictures (`\\pict`) and equations (`\\mmath`) inside a `\\deleted` group are still emitted, because their finishers never check the flag. Record `deleted` when the `\\pict` or `\\mmath` destination opens, and skip emission in `finish_pict`/`finish_math` when it was set.</comment>

<file context>
@@ -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 });
                 }
</file context>
Fix with cubic

self.inlines.push(Inline::Text { text, style: self.state.style });
}
}
Expand Down Expand Up @@ -1342,6 +1362,38 @@ 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}",
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}");
}
}

#[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}";
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
Expand Down