1use std::borrow::Cow;
2
3use rtf_parser::paragraph::Paragraph as RtfParagraph;
4use rtf_parser::{Painter, RtfDocument};
5use tdoc::{Document, InlineStyle, Paragraph, Span};
6
7const PARAGRAPH_BREAK_SENTINEL: char = '\u{001E}';
8const PARAGRAPH_BREAK_ESCAPE: &str = "\\'1e";
9
10#[derive(Debug)]
11pub enum RtfImportError {
12 Decode,
13 Parse(String),
14}
15
16impl std::fmt::Display for RtfImportError {
17 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
18 match self {
19 RtfImportError::Decode => write!(f, "RTF data was not valid UTF-8"),
20 RtfImportError::Parse(msg) => write!(f, "RTF parse error: {msg}"),
21 }
22 }
23}
24
25impl std::error::Error for RtfImportError {}
26
27pub fn parse_rtf_document(bytes: &[u8]) -> Result<Document, RtfImportError> {
30 let cow = String::from_utf8_lossy(bytes);
31 let normalized = inject_paragraph_sentinels(cow.as_ref());
32 let rtf = RtfDocument::try_from(normalized.as_ref())
34 .map_err(|err| RtfImportError::Parse(err.to_string()))?;
35
36 let mut paragraphs = Vec::new();
37 let mut current_spans: Vec<Span> = Vec::new();
38 let mut last_paragraph_state: Option<RtfParagraph> = None;
39
40 for block in rtf.body.iter() {
41 if last_paragraph_state
42 .as_ref()
43 .map(|prev| prev != &block.paragraph)
44 .unwrap_or(false)
45 && !current_spans.is_empty()
46 {
47 finalize_paragraph(&mut current_spans, &mut paragraphs, false);
48 }
49
50 let appended = append_style_block(
51 block.text.as_str(),
52 &block.painter,
53 &mut current_spans,
54 &mut paragraphs,
55 );
56
57 if appended {
58 last_paragraph_state = Some(block.paragraph);
59 }
60 }
61
62 finalize_paragraph(&mut current_spans, &mut paragraphs, false);
63
64 Ok(Document::new().with_paragraphs(paragraphs))
65}
66
67fn append_style_block(
68 text: &str,
69 painter: &Painter,
70 current_spans: &mut Vec<Span>,
71 paragraphs: &mut Vec<Paragraph>,
72) -> bool {
73 let normalized = if text.contains(PARAGRAPH_BREAK_SENTINEL) {
74 Cow::Owned(text.replace(PARAGRAPH_BREAK_SENTINEL, "\n"))
75 } else {
76 Cow::Borrowed(text)
77 };
78 let text = normalized.as_ref();
79 let mut start = 0usize;
80 let mut appended = false;
81 for (idx, ch) in text.char_indices() {
82 match ch {
83 '\r' => {
84 if start < idx {
85 appended |= push_span(&text[start..idx], painter, current_spans);
86 }
87 start = idx + ch.len_utf8();
88 }
89 '\n' => {
90 if start < idx {
91 appended |= push_span(&text[start..idx], painter, current_spans);
92 }
93 finalize_paragraph(current_spans, paragraphs, true);
94 start = idx + ch.len_utf8();
95 }
96 _ => {}
97 }
98 }
99
100 if start < text.len() {
101 appended |= push_span(&text[start..], painter, current_spans);
102 }
103
104 appended
105}
106
107fn push_span(text: &str, painter: &Painter, spans: &mut Vec<Span>) -> bool {
108 if text.is_empty() {
109 return false;
110 }
111
112 let text = repair_cp1252_c1(text);
113 if text.is_empty() {
114 return false;
115 }
116
117 spans.push(apply_styles(Span::new_text(text), painter));
118 true
119}
120
121fn repair_cp1252_c1(text: &str) -> Cow<'_, str> {
136 if !text.chars().any(|ch| ('\u{80}'..='\u{9F}').contains(&ch)) {
137 return Cow::Borrowed(text);
138 }
139 Cow::Owned(text.chars().filter_map(cp1252_c1_char).collect())
140}
141
142fn cp1252_c1_char(ch: char) -> Option<char> {
146 Some(match ch {
147 '\u{80}' => '\u{20AC}', '\u{82}' => '\u{201A}', '\u{83}' => '\u{0192}', '\u{84}' => '\u{201E}', '\u{85}' => '\u{2026}', '\u{86}' => '\u{2020}', '\u{87}' => '\u{2021}', '\u{88}' => '\u{02C6}', '\u{89}' => '\u{2030}', '\u{8A}' => '\u{0160}', '\u{8B}' => '\u{2039}', '\u{8C}' => '\u{0152}', '\u{8E}' => '\u{017D}', '\u{91}' => '\u{2018}', '\u{92}' => '\u{2019}', '\u{93}' => '\u{201C}', '\u{94}' => '\u{201D}', '\u{95}' => '\u{2022}', '\u{96}' => '\u{2013}', '\u{97}' => '\u{2014}', '\u{98}' => '\u{02DC}', '\u{99}' => '\u{2122}', '\u{9A}' => '\u{0161}', '\u{9B}' => '\u{203A}', '\u{9C}' => '\u{0153}', '\u{9E}' => '\u{017E}', '\u{9F}' => '\u{0178}', '\u{81}' | '\u{8D}' | '\u{8F}' | '\u{90}' | '\u{9D}' => return None,
175 other => other,
176 })
177}
178
179fn apply_styles(mut span: Span, painter: &Painter) -> Span {
180 let wrap = |style: InlineStyle, enabled: bool, span: Span| -> Span {
181 if !enabled {
182 span
183 } else {
184 Span::new_styled(style).with_children(vec![span])
185 }
186 };
187
188 span = wrap(InlineStyle::Strike, painter.strike, span);
189 span = wrap(InlineStyle::Underline, painter.underline, span);
190 span = wrap(InlineStyle::Italic, painter.italic, span);
191 span = wrap(InlineStyle::Bold, painter.bold, span);
192 span
193}
194
195fn finalize_paragraph(
196 current_spans: &mut Vec<Span>,
197 paragraphs: &mut Vec<Paragraph>,
198 allow_empty: bool,
199) {
200 if current_spans.is_empty() {
201 if allow_empty {
202 paragraphs.push(Paragraph::new_text());
203 }
204 return;
205 }
206
207 let spans = std::mem::take(current_spans);
208 paragraphs.push(Paragraph::new_text().with_content(spans));
209}
210
211fn inject_paragraph_sentinels(input: &str) -> Cow<'_, str> {
212 const NEEDLE: &[u8] = b"par";
213 let bytes = input.as_bytes();
214 let mut i = 0;
215 let mut last_copied = 0;
216 let mut output: Option<String> = None;
217
218 while i + NEEDLE.len() < bytes.len() {
219 if bytes[i] != b'\\' {
220 i += 1;
221 continue;
222 }
223 let slice = &bytes[i + 1..];
224 if slice.len() < NEEDLE.len() {
225 break;
226 }
227 if slice[..NEEDLE.len()]
228 .iter()
229 .map(|b| b.to_ascii_lowercase())
230 .ne(NEEDLE.iter().copied())
231 {
232 i += 1;
233 continue;
234 }
235 let after_word = i + 1 + NEEDLE.len();
236 if let Some(next) = bytes.get(after_word)
237 && next.is_ascii_alphabetic()
238 {
239 i += 1;
241 continue;
242 }
243 let mut after_space = after_word;
244 if bytes.get(after_space) == Some(&b' ') {
245 after_space += 1;
246 }
247 let sentinel_len = PARAGRAPH_BREAK_ESCAPE.len();
248 let already_tagged = bytes
249 .get(after_space..after_space + sentinel_len)
250 .map(|segment| segment == PARAGRAPH_BREAK_ESCAPE.as_bytes())
251 .unwrap_or(false);
252 if already_tagged {
253 i = after_space;
254 continue;
255 }
256
257 let out = output.get_or_insert_with(|| String::with_capacity(input.len() + 8));
258 out.push_str(&input[last_copied..after_space]);
259 out.push_str(PARAGRAPH_BREAK_ESCAPE);
260 last_copied = after_space;
261 i = after_space;
262 }
263
264 if let Some(mut out) = output {
265 out.push_str(&input[last_copied..]);
266 Cow::Owned(out)
267 } else {
268 Cow::Borrowed(input)
269 }
270}
271
272#[cfg(test)]
273mod tests {
274 use super::*;
275
276 #[test]
277 fn parses_basic_rtf() {
278 let rtf = br#"{\rtf1\ansi{\fonttbl{\f0 Arial;}}\f0\pard Simple {\b bold} and {\i italic}.\par Next line.}"#;
279 let document = parse_rtf_document(rtf).unwrap();
280 assert_eq!(document.paragraphs.len(), 2);
281
282 let first = &document.paragraphs[0];
283 if let Paragraph::Text { content } = first {
284 assert_eq!(content.len(), 5);
285 assert_eq!(content[0].text, "Simple ");
286 assert_eq!(content[1].children[0].text, "bold");
287 assert_eq!(content[2].text, " and ");
288 assert_eq!(content[3].children[0].text, "italic");
289 assert_eq!(content[4].text, ".");
290 } else {
291 panic!("expected text paragraph");
292 }
293
294 let second = &document.paragraphs[1];
295 if let Paragraph::Text { content } = second {
296 assert_eq!(content[0].text, "Next line.");
297 }
298 }
299
300 #[test]
301 fn repairs_windows_1252_c1_punctuation() {
302 let rtf = br#"{\rtf1\ansi\ansicpg1252 It\'92s done\'85 or\'97maybe.\par}"#;
306 let document = parse_rtf_document(rtf).unwrap();
307
308 let Paragraph::Text { content } = &document.paragraphs[0] else {
309 panic!("expected text paragraph");
310 };
311 let combined: String = content.iter().map(|span| span.text.as_str()).collect();
312 assert_eq!(combined, "It\u{2019}s done\u{2026} or\u{2014}maybe.");
313 assert!(
314 !combined
315 .chars()
316 .any(|ch| ('\u{80}'..='\u{9F}').contains(&ch)),
317 "no C1 control characters should survive"
318 );
319 }
320
321 #[test]
322 fn injects_sentinels_after_par_control() {
323 let raw = r"{\rtf1 Foo\par Bar\par
324}";
325 let normalized = super::inject_paragraph_sentinels(raw);
326
327 assert_eq!(normalized, "{\\rtf1 Foo\\par \\'1eBar\\par\\'1e\n}");
328 }
329}