Skip to main content

docparser_core/plugins/
rtf.rs

1//! RTF 插件:`.rtf`(纯 Rust 状态机,语义对齐 Python `docparser.plugins.rtf`)。
2//!
3//! 零第三方依赖:RTF 正文是「控制词 + 组 + 转义字节」的字节流,抽纯文本不需要
4//! `striprtf` 级别的完整实现,也就不为它引依赖。
5//!
6//! 口径(两载体一致,`bench/office_parity.py` 逐块比对):
7//!
8//! * 输出**单页**,每个 `\par` / `\row` / `\sect` 结束一个段落(一个 `Paragraph` 块),
9//!   `\line` 是段内换行,`\tab` / `\cell` → 制表符;
10//! * 页文本 = 段落以空行连接;空段落丢弃;整份文件无正文 → `corrupt`;
11//! * 跳过目标组:`{\*...}`、`\fonttbl` `\colortbl` `\stylesheet` `\info` `\pict`
12//!   `\object` `\fldinst` 页眉页脚/脚注/批注等(见 [`SKIP_DESTS`]);
13//!   `{\fldrslt 显示文字}` 保留文字;
14//! * `\uN` 走 Unicode(`\ucN` 回退跳过、代理对合并);`\'hh` 按 `\ansicpg` 解:
15//!   1252(缺省,查表单字节映射)/ 65001(收集原始字节,段尾统一校验 UTF-8),
16//!   其它代码页(如 936)显式 `corrupt` 而不是吐乱码;同一段里「原生 UTF-8 汉字 +
17//!   `\'hh` 拉丁字符」能各自正确解出;
18//! * 转义:`\\` `\{` `\}` 取字面;`\~` → 不换行空格;`\_` → `-`;`\-` 丢弃;
19//!   `\` + 换行 ≡ `\par`;`\binN` 跳过 N 字节原始数据;
20//! * 源文件里的裸 CR/LF/TAB 是排版空白,忽略;未知控制词忽略。
21//!
22//! 已知不做:样式/字体/颜色、页眉页脚内容、图片、表格结构(摊平成制表符+行)、
23//! 多节(`\sect` 只断段不换页)、`\uc` 的组作用域(按全局处理)。
24
25use std::sync::Arc;
26
27use crate::error::{DocError, Result};
28use crate::model::{Block, BlockKind, Document, Page};
29use crate::plugin::{FormatPlugin, ParseCapability, RawDoc, RawPage, RawTextItem};
30use crate::registry::Registry;
31
32/// RTF 文件头(`{\rtf`)。
33pub const MAGIC: &[u8] = b"{\\rtf";
34
35/// 这些目标组只装元数据/二进制,正文不该进 IR(`\*` 已单独处理)。
36pub const SKIP_DESTS: &[&str] = &[
37    "fonttbl",
38    "colortbl",
39    "stylesheet",
40    "info",
41    "pict",
42    "object",
43    "header",
44    "footer",
45    "headerl",
46    "headerr",
47    "headerf",
48    "footerl",
49    "footerr",
50    "footerf",
51    "footnote",
52    "annotation",
53    "atnid",
54    "atnauthor",
55    "fldinst",
56    "themedata",
57    "datastore",
58    "latentstyles",
59    "listtable",
60    "listoverridetable",
61    "rsidtbl",
62    "generator",
63    "xmlnstbl",
64    "filetbl",
65    "revtbl",
66    "pn",
67    "sp",
68    "sn",
69    "sv",
70    "shpinst",
71    "background",
72    "do",
73    "buptim",
74    "userprops",
75];
76
77fn hexval(b: u8) -> Option<u8> {
78    match b {
79        b'0'..=b'9' => Some(b - b'0'),
80        b'a'..=b'f' => Some(b - b'a' + 10),
81        b'A'..=b'F' => Some(b - b'A' + 10),
82        _ => None,
83    }
84}
85
86fn push_char(buf: &mut Vec<u8>, ch: char) {
87    let mut tmp = [0u8; 4];
88    buf.extend_from_slice(ch.encode_utf8(&mut tmp).as_bytes());
89}
90
91/// CP1252 单字节映射(未定义字节 0x81/0x8D/0x8F/0x90/0x9D → U+FFFD)。
92///
93/// 必须与 Python `bytes.decode("cp1252", errors="replace")` 完全一致。
94fn cp1252_char(b: u8) -> char {
95    match b {
96        0x80 => '\u{20AC}',
97        0x81 => '\u{FFFD}',
98        0x82 => '\u{201A}',
99        0x83 => '\u{0192}',
100        0x84 => '\u{201E}',
101        0x85 => '\u{2026}',
102        0x86 => '\u{2020}',
103        0x87 => '\u{2021}',
104        0x88 => '\u{02C6}',
105        0x89 => '\u{2030}',
106        0x8A => '\u{0160}',
107        0x8B => '\u{2039}',
108        0x8C => '\u{0152}',
109        0x8D => '\u{FFFD}',
110        0x8E => '\u{017D}',
111        0x8F => '\u{FFFD}',
112        0x90 => '\u{FFFD}',
113        0x91 => '\u{2018}',
114        0x92 => '\u{2019}',
115        0x93 => '\u{201C}',
116        0x94 => '\u{201D}',
117        0x95 => '\u{2022}',
118        0x96 => '\u{2013}',
119        0x97 => '\u{2014}',
120        0x98 => '\u{02DC}',
121        0x99 => '\u{2122}',
122        0x9A => '\u{0161}',
123        0x9B => '\u{203A}',
124        0x9C => '\u{0153}',
125        0x9D => '\u{FFFD}',
126        0x9E => '\u{017E}',
127        0x9F => '\u{0178}',
128        _ => b as char, // 0x00-0x7F 与 0xA0-0xFF 与 Latin-1 相同
129    }
130}
131
132fn flush(buf: &mut Vec<u8>, pending_high: &mut Option<u32>, out: &mut Vec<String>) -> Result<()> {
133    if pending_high.is_some() {
134        push_char(buf, '\u{FFFD}');
135        *pending_high = None;
136    }
137    let text = std::str::from_utf8(buf).map_err(|_| {
138        DocError::corrupt("RTF 正文含非法 UTF-8 字节(ansicpg65001 下 \\'hh 序列不完整)")
139    })?;
140    let trimmed = text.trim();
141    let paragraph = trimmed.to_string();
142    buf.clear();
143    if !paragraph.is_empty() {
144        out.push(paragraph);
145    }
146    Ok(())
147}
148
149/// RTF 字节 → 段落列表(已 trim、空段落已丢弃)。
150pub fn extract_paragraphs(raw: &[u8]) -> Result<Vec<String>> {
151    let data = raw.strip_prefix(&[0xEF, 0xBB, 0xBF]).unwrap_or(raw);
152    let mut start = 0;
153    while start < data.len() && matches!(data[start], b' ' | b'\t' | b'\r' | b'\n') {
154        start += 1;
155    }
156    let data = &data[start..];
157    if !data.starts_with(MAGIC) {
158        return Err(DocError::corrupt("不是合法的 RTF(缺少 {\\rtf 头)"));
159    }
160
161    let n = data.len();
162    let mut paragraphs: Vec<String> = Vec::new();
163    let mut buf: Vec<u8> = Vec::new();
164    let mut codepage: Option<i32> = None;
165    let mut uc: i32 = 1;
166    let mut skip_left: i32 = 0;
167    let mut pending_high: Option<u32> = None;
168    let mut depth: usize = 0;
169    let mut skip_from: Option<usize> = None;
170    let mut dest_pending: Vec<bool> = Vec::new();
171
172    let mut i = 0usize;
173    while i < n {
174        let c = data[i];
175
176        if c == b'{' {
177            depth += 1;
178            dest_pending.push(true);
179            i += 1;
180            continue;
181        }
182
183        if c == b'}' {
184            if let Some(s) = skip_from {
185                if s >= depth {
186                    skip_from = None;
187                }
188            }
189            if depth > 0 {
190                depth -= 1;
191                dest_pending.pop();
192            }
193            i += 1;
194            continue;
195        }
196
197        if c == b'\\' {
198            if i + 1 >= n {
199                i += 1;
200                continue;
201            }
202            let nxt = data[i + 1];
203
204            // 控制词:[a-zA-Z]+ 可选 -?digits 可选单个空格分隔符
205            if nxt.is_ascii_alphabetic() {
206                let mut j = i + 1;
207                while j < n && data[j].is_ascii_alphabetic() {
208                    j += 1;
209                }
210                let word = String::from_utf8_lossy(&data[i + 1..j]).to_ascii_lowercase();
211                let mut k = j;
212                let mut neg = false;
213                if k < n && data[k] == b'-' {
214                    neg = true;
215                    k += 1;
216                }
217                let digits_at = k;
218                while k < n && data[k].is_ascii_digit() {
219                    k += 1;
220                }
221                let mut param: Option<i32> = None;
222                if k > digits_at {
223                    let v: i32 = String::from_utf8_lossy(&data[digits_at..k])
224                        .parse()
225                        .unwrap_or(0);
226                    param = Some(if neg { -v } else { v });
227                }
228                if k < n && data[k] == b' ' {
229                    k += 1;
230                }
231                i = k;
232
233                let first_in_group = dest_pending.last().copied().unwrap_or(false);
234                if first_in_group {
235                    if let Some(last) = dest_pending.last_mut() {
236                        *last = false;
237                    }
238                    if SKIP_DESTS.contains(&word.as_str()) {
239                        if skip_from.is_none() {
240                            skip_from = Some(depth);
241                        }
242                        continue;
243                    }
244                }
245                if skip_from.is_some() {
246                    if word == "bin" {
247                        if let Some(p) = param {
248                            if p > 0 {
249                                i = (i + p as usize).min(n);
250                            }
251                        }
252                    }
253                    continue;
254                }
255
256                match word.as_str() {
257                    "par" | "row" | "sect" => {
258                        flush(&mut buf, &mut pending_high, &mut paragraphs)?;
259                    }
260                    "line" => buf.push(b'\n'),
261                    "tab" | "cell" => buf.push(b'\t'),
262                    "uc" => uc = param.unwrap_or(0).max(0),
263                    "ansicpg" => {
264                        if let Some(p) = param {
265                            codepage = Some(p);
266                        }
267                    }
268                    "bin" => {
269                        if let Some(p) = param {
270                            if p > 0 {
271                                i = (i + p as usize).min(n);
272                            }
273                        }
274                    }
275                    "u" => {
276                        let mut raw_cp = param.unwrap_or(0);
277                        if raw_cp < 0 {
278                            raw_cp += 0x10000;
279                        }
280                        let cp = raw_cp as u32;
281                        if (0xD800..=0xDBFF).contains(&cp) {
282                            if pending_high.is_some() {
283                                push_char(&mut buf, '\u{FFFD}');
284                            }
285                            pending_high = Some(cp);
286                        } else if (0xDC00..=0xDFFF).contains(&cp) && pending_high.is_some() {
287                            let hi = pending_high.take().unwrap_or(0xD800);
288                            let full = 0x10000 + ((hi - 0xD800) << 10) + (cp - 0xDC00);
289                            push_char(&mut buf, char::from_u32(full).unwrap_or('\u{FFFD}'));
290                        } else {
291                            if pending_high.is_some() {
292                                push_char(&mut buf, '\u{FFFD}');
293                                pending_high = None;
294                            }
295                            let ch = if (0xDC00..=0xDFFF).contains(&cp) {
296                                '\u{FFFD}'
297                            } else {
298                                char::from_u32(cp).unwrap_or('\u{FFFD}')
299                            };
300                            push_char(&mut buf, ch);
301                        }
302                        skip_left = uc;
303                    }
304                    _ => {}
305                }
306                continue;
307            }
308
309            // 控制符号
310            if nxt == b'\n' || nxt == b'\r' {
311                let mut j = i + 1;
312                while j < n && (data[j] == b'\n' || data[j] == b'\r') {
313                    j += 1;
314                }
315                i = j;
316                if skip_from.is_none() {
317                    flush(&mut buf, &mut pending_high, &mut paragraphs)?;
318                }
319                continue;
320            }
321
322            if nxt == b'\'' {
323                if i + 3 >= n {
324                    return Err(DocError::corrupt("RTF 的 \\'hh 转义不完整"));
325                }
326                let hi = hexval(data[i + 2])
327                    .ok_or_else(|| DocError::corrupt("RTF 的 \\'hh 转义含非十六进制字符"))?;
328                let lo = hexval(data[i + 3])
329                    .ok_or_else(|| DocError::corrupt("RTF 的 \\'hh 转义含非十六进制字符"))?;
330                i += 4;
331                if skip_from.is_some() {
332                    continue;
333                }
334                if skip_left > 0 {
335                    skip_left -= 1;
336                    continue;
337                }
338                let byte = hi * 16 + lo;
339                match codepage {
340                    Some(65001) => buf.push(byte), // UTF-8 字节流,段尾统一校验
341                    Some(cp) if cp != 1252 => {
342                        return Err(DocError::corrupt(format!(
343                            "RTF 声明了不支持的代码页 {cp}(仅支持 1252 / 65001);请另存为 Unicode RTF"
344                        )));
345                    }
346                    _ => push_char(&mut buf, cp1252_char(byte)),
347                }
348                continue;
349            }
350
351            if nxt == b'*' {
352                let first_in_group = dest_pending.last().copied().unwrap_or(false);
353                if first_in_group {
354                    if let Some(last) = dest_pending.last_mut() {
355                        *last = false;
356                    }
357                    if skip_from.is_none() {
358                        skip_from = Some(depth);
359                    }
360                }
361                i += 2;
362                continue;
363            }
364
365            if skip_from.is_some() {
366                i += 2;
367                continue;
368            }
369
370            match nxt {
371                b'\\' => buf.push(b'\\'),
372                b'{' => buf.push(b'{'),
373                b'}' => buf.push(b'}'),
374                b'~' => push_char(&mut buf, '\u{00A0}'),
375                b'_' => buf.push(b'-'),
376                _ => {}
377            }
378            i += 2;
379            continue;
380        }
381
382        // 普通字节
383        if c < 0x20 {
384            i += 1;
385            continue;
386        }
387        if skip_from.is_some() {
388            i += 1;
389            continue;
390        }
391        if skip_left > 0 {
392            skip_left -= 1;
393            i += 1;
394            continue;
395        }
396        buf.push(c);
397        i += 1;
398    }
399
400    flush(&mut buf, &mut pending_high, &mut paragraphs)?;
401    if paragraphs.is_empty() {
402        return Err(DocError::corrupt("RTF 未找到任何正文文本"));
403    }
404    Ok(paragraphs)
405}
406
407/// RTF 解析后端:段落 → `RawTextItem`。
408pub struct RtfParseCapability;
409
410impl ParseCapability for RtfParseCapability {
411    fn id(&self) -> &str {
412        "rtf.read.rtf"
413    }
414    fn formats(&self) -> &[&str] {
415        &["rtf"]
416    }
417    fn parse(&self, path: &str) -> Result<RawDoc> {
418        let raw = std::fs::read(path)
419            .map_err(|e| DocError::corrupt(format!("无法读取 RTF 文件: {e}")))?;
420        let items = extract_paragraphs(&raw)?
421            .into_iter()
422            .map(|text| RawTextItem {
423                text,
424                ..Default::default()
425            })
426            .collect();
427        Ok(RawDoc {
428            pages: vec![RawPage {
429                name: None,
430                items,
431                ..Default::default()
432            }],
433            ..Default::default()
434        })
435    }
436}
437
438/// RTF 插件:RawDoc(段落)→ IR(每段一个 `Paragraph` 块,单页)。
439pub struct RtfPlugin;
440
441impl FormatPlugin for RtfPlugin {
442    fn id(&self) -> &str {
443        "rtf"
444    }
445    fn formats(&self) -> &[&str] {
446        &["rtf"]
447    }
448    fn default_parse_chain(&self) -> Vec<&'static str> {
449        vec!["rtf.read.rtf"]
450    }
451    fn default_process_chain(&self) -> Vec<&'static str> {
452        vec![]
453    }
454    fn assemble(&self, raw: &RawDoc) -> Document {
455        let items: Vec<&str> = raw
456            .pages
457            .first()
458            .map(|p| p.items.iter().map(|i| i.text.as_str()).collect())
459            .unwrap_or_default();
460        let blocks: Vec<Block> = items
461            .iter()
462            .filter(|t| !t.trim().is_empty())
463            .map(|t| Block {
464                kind: BlockKind::Paragraph,
465                content: (*t).to_string(),
466                ..Default::default()
467            })
468            .collect();
469        Document {
470            pages: vec![Page {
471                name: None,
472                blocks,
473                text: items.join("\n\n"),
474                ..Default::default()
475            }],
476            ..Default::default()
477        }
478    }
479}
480
481/// 注册 RTF 解析后端与插件。
482pub fn register_defaults(reg: &mut Registry) {
483    let _ = reg.register_parse(Arc::new(RtfParseCapability), None);
484    reg.register_plugin(Arc::new(RtfPlugin));
485}
486
487#[cfg(test)]
488mod tests {
489    use super::*;
490
491    #[test]
492    fn parses_paragraphs_and_skips_destinations() {
493        let src = br"{\rtf1\ansi\ansicpg1252{\fonttbl{\f0 Arial;}}{\*\generator Foo;}\pard
494first paragraph\par
495{\b bold} text with \{braces\} and \\slash\par
496}";
497        let paras = extract_paragraphs(src).unwrap();
498        assert_eq!(
499            paras,
500            vec!["first paragraph", "bold text with {braces} and \\slash"]
501        );
502    }
503
504    #[test]
505    fn unicode_and_hex_escapes_decode() {
506        let src = br"{\rtf1\u20320?\u22909?\par caf\'e9 \'93quoted\'94\par}";
507        let paras = extract_paragraphs(src).unwrap();
508        assert_eq!(paras, vec!["你好", "caf\u{e9} \u{201c}quoted\u{201d}"]);
509    }
510
511    #[test]
512    fn surrogate_pairs_merge_and_uc_skips_fallback() {
513        // U+1F600 = D83D DE00,\uc1 时每个 \u 各跳 1 个回退字符
514        let src = br"{\rtf1\uc1\u-10179?\u-8704?\par}";
515        let paras = extract_paragraphs(src).unwrap();
516        assert_eq!(paras, vec!["\u{1F600}"]);
517    }
518
519    #[test]
520    fn controls_map_to_tabs_and_soft_breaks() {
521        let src = br"{\rtf1 a\tab b\line c\par\cell d\row}";
522        let paras = extract_paragraphs(src).unwrap();
523        assert_eq!(paras, vec!["a\tb\nc", "d"]);
524    }
525
526    #[test]
527    fn missing_header_is_corrupt() {
528        let err = extract_paragraphs(b"hello world").unwrap_err();
529        assert_eq!(err.code.as_str(), "corrupt");
530    }
531
532    #[test]
533    fn empty_body_is_corrupt() {
534        let err = extract_paragraphs(br"{\rtf1\ansi{\fonttbl{\f0 Arial;}}}").unwrap_err();
535        assert_eq!(err.code.as_str(), "corrupt");
536    }
537
538    #[test]
539    fn unsupported_codepage_with_raw_bytes_is_corrupt() {
540        // 声明 936 且正文含非法 UTF-8 字节:宁可报错也不吐乱码
541        let err = extract_paragraphs(br"{\rtf1\ansicpg936 \'d6\'d0\par}").unwrap_err();
542        assert_eq!(err.code.as_str(), "corrupt");
543        assert!(err.to_string().contains("936"));
544    }
545
546    #[test]
547    fn registry_resolves_rtf_format() {
548        let mut reg = Registry::new();
549        crate::plugins::register_defaults(&mut reg);
550        let pol = crate::policy::BackendPolicy::default();
551        let chain = reg.resolve_parse_chain("rtf", &pol, &["rtf.read.rtf"]);
552        assert_eq!(chain.len(), 1, "rtf 解析链为空");
553        assert_eq!(chain[0].id(), "rtf.read.rtf");
554    }
555
556    #[test]
557    fn engine_parses_rtf_end_to_end() {
558        let p = std::env::temp_dir().join("docparser_e2e.rtf");
559        std::fs::write(
560            &p,
561            br"{\rtf1\ansi{\fonttbl{\f0 Arial;}}Hello\par World\par}",
562        )
563        .unwrap();
564        let mut reg = Registry::new();
565        crate::plugins::register_defaults(&mut reg);
566        let doc = crate::engine::DocumentEngine::new(reg)
567            .parse(p.to_str().unwrap())
568            .unwrap_or_else(|e| panic!("rtf 解析失败: {e}"));
569        assert_eq!(doc.pages.len(), 1);
570        assert_eq!(doc.pages[0].text, "Hello\n\nWorld");
571        assert_eq!(doc.pages[0].blocks.len(), 2);
572        std::fs::remove_file(&p).ok();
573    }
574
575    #[test]
576    fn plugin_makes_one_block_per_paragraph() {
577        let raw = RawDoc {
578            pages: vec![RawPage {
579                items: vec![
580                    RawTextItem {
581                        text: "p1".into(),
582                        ..Default::default()
583                    },
584                    RawTextItem {
585                        text: "p2".into(),
586                        ..Default::default()
587                    },
588                ],
589                ..Default::default()
590            }],
591            ..Default::default()
592        };
593        let doc = RtfPlugin.assemble(&raw);
594        assert_eq!(doc.pages.len(), 1);
595        assert_eq!(doc.pages[0].blocks.len(), 2);
596        assert_eq!(doc.pages[0].text, "p1\n\np2");
597    }
598}