Skip to main content

piki_gui/
rtf.rs

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
27/// Convert raw RTF bytes into a [`tdoc::Document`].
28/// Styling support currently covers bold, italic, underline, and strikethrough runs.
29pub 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    // rtf-parser expects a &str; conversions happen internally.
33    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
121/// Repair mis-decoded Windows-1252 C1 characters (`U+0080`–`U+009F`) in text
122/// produced by `rtf-parser`.
123///
124/// RTF encodes non-ASCII bytes as `\'xx` hex escapes in the document's codepage
125/// (almost always Windows-1252 on macOS/Windows). `rtf-parser` decodes them with
126/// `byte as char`, i.e. it treats the codepage byte as a Unicode scalar. For
127/// bytes `0xA0`–`0xFF` that happens to match Latin-1/Unicode, but the C1 block
128/// `0x80`–`0x9F` does not: `\'92` (a curly apostrophe in Windows-1252) becomes
129/// the control character `U+0092` instead of `U+2019`, `\'85` becomes `U+0085`
130/// instead of `…`, and so on — these render as inert boxes rather than real
131/// glyphs. Because `\'xx` escapes *are* codepage bytes, remapping this block is
132/// the correct decoding the parser skipped, not a heuristic. Codepoints the
133/// caller already decoded correctly (everything outside the C1 block) pass
134/// through untouched, and the borrow is only cloned when a repair is needed.
135fn 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
142/// Map a single character through the Windows-1252 C1 remap. Characters outside
143/// the C1 block are returned unchanged; the five codepoints Windows-1252 leaves
144/// undefined (`0x81`, `0x8D`, `0x8F`, `0x90`, `0x9D`) are dropped.
145fn cp1252_c1_char(ch: char) -> Option<char> {
146    Some(match ch {
147        '\u{80}' => '\u{20AC}', // €
148        '\u{82}' => '\u{201A}', // ‚
149        '\u{83}' => '\u{0192}', // ƒ
150        '\u{84}' => '\u{201E}', // „
151        '\u{85}' => '\u{2026}', // …
152        '\u{86}' => '\u{2020}', // †
153        '\u{87}' => '\u{2021}', // ‡
154        '\u{88}' => '\u{02C6}', // ˆ
155        '\u{89}' => '\u{2030}', // ‰
156        '\u{8A}' => '\u{0160}', // Š
157        '\u{8B}' => '\u{2039}', // ‹
158        '\u{8C}' => '\u{0152}', // Œ
159        '\u{8E}' => '\u{017D}', // Ž
160        '\u{91}' => '\u{2018}', // '
161        '\u{92}' => '\u{2019}', // '
162        '\u{93}' => '\u{201C}', // "
163        '\u{94}' => '\u{201D}', // "
164        '\u{95}' => '\u{2022}', // •
165        '\u{96}' => '\u{2013}', // –
166        '\u{97}' => '\u{2014}', // —
167        '\u{98}' => '\u{02DC}', // ˜
168        '\u{99}' => '\u{2122}', // ™
169        '\u{9A}' => '\u{0161}', // š
170        '\u{9B}' => '\u{203A}', // ›
171        '\u{9C}' => '\u{0153}', // œ
172        '\u{9E}' => '\u{017E}', // ž
173        '\u{9F}' => '\u{0178}', // Ÿ
174        '\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            // Skip \pard, \parshape, etc.
240            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        // In Windows-1252: \'92 = curly apostrophe, \'85 = ellipsis,
303        // \'97 = em dash. rtf-parser decodes these to the raw C1 control
304        // codepoints; parse_rtf_document must map them back to real glyphs.
305        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}