Skip to main content

docparser_core/plugins/
text.rs

1//! 纯文本族插件:txt / md / csv / html(零第三方依赖,语义对齐 Python 参考实现)。
2//!
3//! 这四类**不走 PDF**:没有布局与坐标,转 PDF 是纯粹的有损往返,直接按语法解。
4//!
5//! 分工:
6//! * `text.rs`(本文件)—— 编码健壮读取、txt 段落切分、注册入口;
7//! * [`super::markdown`] —— md 块级语法(标题/列表/引用/代码/表格);
8//! * [`super::csv`] —— RFC4180 解析为 `TableData`;
9//! * [`super::html`] —— 标准库级标签扫描(标题/段落/列表/代码/表格,跳过 script/style)。
10
11use std::sync::Arc;
12
13use crate::error::{DocError, Result};
14use crate::model::{Block, BlockKind, Document, Page};
15use crate::plugin::{FormatPlugin, ParseCapability, RawDoc, RawPage, RawTextItem};
16use crate::registry::Registry;
17
18#[cfg(feature = "csv")]
19use super::csv;
20#[cfg(feature = "html")]
21use super::html;
22#[cfg(feature = "md")]
23use super::markdown;
24
25/// 编码健壮读取:UTF-8(含 BOM)→ UTF-16(BOM)→ UTF-8 lossy。
26///
27/// 与 Python 参考实现(`utf-8-sig → gbk → latin-1`)的差异:Rust 核心不引入
28/// `encoding_rs`(保持零第三方依赖),GBK 等单/多字节中文编码会退化为替换字符。
29/// 中文内容请以 UTF-8 保存;需要 GBK 时由调用方先行转码。
30pub fn read_text(path: &str) -> Result<String> {
31    let raw =
32        std::fs::read(path).map_err(|e| DocError::corrupt(format!("cannot read {path}: {e}")))?;
33    Ok(decode(raw))
34}
35
36/// 字节 → 字符串(BOM 感知,失败退化为 lossy)。
37pub fn decode(raw: Vec<u8>) -> String {
38    if let Some(rest) = raw.strip_prefix(&[0xEF, 0xBB, 0xBF]) {
39        return String::from_utf8_lossy(rest).into_owned();
40    }
41    if let Some(rest) = raw.strip_prefix(&[0xFF, 0xFE]) {
42        return decode_utf16(rest, true);
43    }
44    if let Some(rest) = raw.strip_prefix(&[0xFE, 0xFF]) {
45        return decode_utf16(rest, false);
46    }
47    match String::from_utf8(raw) {
48        Ok(s) => s,
49        // 非法字节逐段替换,保留可读部分(与 lossy 语义一致)
50        Err(e) => String::from_utf8_lossy(e.as_bytes()).into_owned(),
51    }
52}
53
54fn decode_utf16(bytes: &[u8], little_endian: bool) -> String {
55    let units: Vec<u16> = bytes
56        .chunks(2)
57        .filter(|c| c.len() == 2)
58        .map(|c| {
59            if little_endian {
60                u16::from_le_bytes([c[0], c[1]])
61            } else {
62                u16::from_be_bytes([c[0], c[1]])
63            }
64        })
65        .collect();
66    String::from_utf16_lossy(&units)
67}
68
69/// 按空行切段落(与 Python `_split_paragraphs` 对齐:段内单换行保留)。
70pub fn split_paragraphs(text: &str) -> Vec<String> {
71    let mut out = Vec::new();
72    let mut buf: Vec<&str> = Vec::new();
73    for line in text.lines() {
74        if line.trim().is_empty() {
75            if !buf.is_empty() {
76                out.push(buf.join("\n").trim().to_string());
77                buf.clear();
78            }
79        } else {
80            buf.push(line);
81        }
82    }
83    if !buf.is_empty() {
84        out.push(buf.join("\n").trim().to_string());
85    }
86    out.retain(|s| !s.is_empty());
87    out
88}
89
90/// 单格式纯文本解析后端(txt)。
91pub struct TextParseCapability {
92    pub id: &'static str,
93    pub fmt: &'static str,
94}
95
96impl ParseCapability for TextParseCapability {
97    fn id(&self) -> &str {
98        self.id
99    }
100    fn formats(&self) -> &[&str] {
101        std::slice::from_ref(&self.fmt)
102    }
103    fn parse(&self, path: &str) -> Result<RawDoc> {
104        let content = read_text(path)?;
105        let items: Vec<RawTextItem> = split_paragraphs(&content)
106            .into_iter()
107            .map(|t| RawTextItem {
108                text: t,
109                bbox: None,
110            })
111            .collect();
112        Ok(RawDoc {
113            pages: vec![RawPage {
114                name: None,
115                items,
116                ..Default::default()
117            }],
118            ..Default::default()
119        })
120    }
121}
122
123/// txt 插件:RawDoc(段)→ IR(每段一个 Paragraph 块)。
124pub struct TextPlugin;
125
126impl FormatPlugin for TextPlugin {
127    fn id(&self) -> &str {
128        "txt"
129    }
130    fn formats(&self) -> &[&str] {
131        &["txt"]
132    }
133    fn default_parse_chain(&self) -> Vec<&'static str> {
134        vec!["text.read.txt"]
135    }
136    fn default_process_chain(&self) -> Vec<&'static str> {
137        vec![]
138    }
139    fn assemble(&self, raw: &RawDoc) -> Document {
140        let items: Vec<&str> = raw
141            .pages
142            .first()
143            .map(|p| p.items.iter().map(|i| i.text.as_str()).collect())
144            .unwrap_or_default();
145        let blocks: Vec<Block> = items
146            .iter()
147            .filter(|t| !t.trim().is_empty())
148            .map(|t| Block {
149                kind: BlockKind::Paragraph,
150                content: (*t).to_string(),
151                ..Default::default()
152            })
153            .collect();
154        Document {
155            pages: vec![Page {
156                blocks,
157                text: items.join("\n\n"),
158                ..Default::default()
159            }],
160            ..Default::default()
161        }
162    }
163}
164
165/// 原文整体交给插件自解析的解析后端(md / html)。
166pub struct WholeTextParseCapability {
167    pub id: &'static str,
168    pub fmt: &'static str,
169}
170
171impl ParseCapability for WholeTextParseCapability {
172    fn id(&self) -> &str {
173        self.id
174    }
175    fn formats(&self) -> &[&str] {
176        std::slice::from_ref(&self.fmt)
177    }
178    fn parse(&self, path: &str) -> Result<RawDoc> {
179        let content = read_text(path)?;
180        Ok(RawDoc {
181            pages: vec![RawPage {
182                items: vec![RawTextItem {
183                    text: content,
184                    bbox: None,
185                }],
186                ..Default::default()
187            }],
188            ..Default::default()
189        })
190    }
191}
192
193/// CSV 解析后端:RFC4180 → `RawPage.tables`。
194#[cfg(feature = "csv")]
195pub struct CsvParseCapability;
196
197#[cfg(feature = "csv")]
198impl ParseCapability for CsvParseCapability {
199    fn id(&self) -> &str {
200        "text.read.csv"
201    }
202    fn formats(&self) -> &[&str] {
203        &["csv"]
204    }
205    fn parse(&self, path: &str) -> Result<RawDoc> {
206        let content = read_text(path)?;
207        let rows = csv::parse_rows(&content);
208        Ok(RawDoc {
209            pages: vec![RawPage {
210                tables: if rows.is_empty() { vec![] } else { vec![rows] },
211                ..Default::default()
212            }],
213            ..Default::default()
214        })
215    }
216}
217
218/// 注册文本族后端与插件(txt 恒开;md / csv / html 随 feature)。
219///
220/// 这是内置文本能力的唯一注册入口,CLI / SDK / Python 绑定都调它。
221pub fn register_defaults(reg: &mut Registry) {
222    let _ = reg.register_parse(
223        Arc::new(TextParseCapability {
224            id: "text.read.txt",
225            fmt: "txt",
226        }),
227        None,
228    );
229    #[cfg(feature = "md")]
230    {
231        let _ = reg.register_parse(
232            Arc::new(WholeTextParseCapability {
233                id: "text.read.md",
234                fmt: "md",
235            }),
236            None,
237        );
238        reg.register_plugin(Arc::new(markdown::MarkdownPlugin));
239    }
240    #[cfg(feature = "csv")]
241    {
242        let _ = reg.register_parse(Arc::new(CsvParseCapability), None);
243        reg.register_plugin(Arc::new(csv::CsvPlugin));
244    }
245    #[cfg(feature = "html")]
246    {
247        let _ = reg.register_parse(
248            Arc::new(WholeTextParseCapability {
249                id: "html.parse",
250                fmt: "html",
251            }),
252            None,
253        );
254        reg.register_plugin(Arc::new(html::HtmlPlugin));
255    }
256
257    reg.register_plugin(Arc::new(TextPlugin));
258}
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263
264    #[test]
265    fn paragraphs_split_on_blank_lines() {
266        assert_eq!(
267            split_paragraphs("a\nb\n\nc\n\n\n d "),
268            vec!["a\nb", "c", "d"]
269        );
270        assert_eq!(split_paragraphs("   \n\n"), Vec::<String>::new());
271    }
272
273    #[test]
274    fn decode_handles_bom_and_utf16() {
275        let mut utf8_bom = vec![0xEF, 0xBB, 0xBF];
276        utf8_bom.extend_from_slice("你好".as_bytes());
277        assert_eq!(decode(utf8_bom), "你好");
278
279        let mut utf16 = vec![0xFF, 0xFE];
280        for u in "ab".encode_utf16() {
281            utf16.extend_from_slice(&u.to_le_bytes());
282        }
283        assert_eq!(decode(utf16), "ab");
284
285        // 非法 UTF-8 不 panic,退化为替换字符
286        assert!(decode(vec![0x61, 0xFF, 0x62]).contains('a'));
287    }
288
289    #[test]
290    fn txt_plugin_splits_paragraphs() {
291        let raw = RawDoc {
292            pages: vec![RawPage {
293                items: vec![
294                    RawTextItem {
295                        text: "p1".into(),
296                        bbox: None,
297                    },
298                    RawTextItem {
299                        text: "p2".into(),
300                        bbox: None,
301                    },
302                ],
303                ..Default::default()
304            }],
305            ..Default::default()
306        };
307        let doc = TextPlugin.assemble(&raw);
308        assert_eq!(doc.pages[0].blocks.len(), 2);
309        assert_eq!(doc.pages[0].text, "p1\n\np2");
310    }
311}