docparser_core/plugins/
text.rs1use 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
25pub 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
36pub 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 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
69pub 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
90pub 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
123pub 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
165pub 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#[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
218pub 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 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}