docparser_core/plugins/
markdown.rs1use crate::model::{Block, BlockKind, Document, Page, TableData};
8use crate::plugin::{FormatPlugin, RawDoc};
9
10fn heading(line: &str) -> Option<(u8, String)> {
12 let t = line.trim_start();
13 let hashes = t.chars().take_while(|c| *c == '#').count();
14 if hashes == 0 || hashes > 6 {
15 return None;
16 }
17 let rest = &t[hashes..];
18 if !(rest.starts_with(' ') || rest.starts_with('\t')) {
19 return None;
20 }
21 Some((hashes as u8, rest.trim().to_string()))
22}
23
24fn quote(line: &str) -> Option<String> {
26 let t = line.trim_start();
27 let rest = t.strip_prefix('>')?;
28 Some(rest.strip_prefix(' ').unwrap_or(rest).trim().to_string())
29}
30
31fn ulist_item(line: &str) -> Option<String> {
33 let t = line.trim_start();
34 for marker in ["- ", "* ", "+ "] {
35 if let Some(rest) = t.strip_prefix(marker) {
36 return Some(rest.trim().to_string());
37 }
38 }
39 None
40}
41
42fn olist_item(line: &str) -> Option<String> {
44 let t = line.trim_start();
45 let digits = t.chars().take_while(|c| c.is_ascii_digit()).count();
46 if digits == 0 {
47 return None;
48 }
49 let rest = &t[digits..];
50 let rest = rest.strip_prefix('.').or_else(|| rest.strip_prefix(')'))?;
51 if !(rest.starts_with(' ') || rest.starts_with('\t')) {
52 return None;
53 }
54 Some(rest.trim().to_string())
55}
56
57fn is_table_sep(line: &str) -> bool {
59 let t = line.trim();
60 !t.is_empty()
61 && t.chars().all(|c| matches!(c, '|' | '-' | ':' | ' ' | '\t'))
62 && t.contains('-')
63 && t.contains('|')
64}
65
66fn split_row(line: &str) -> Vec<String> {
67 let t = line.trim();
68 let t = t.strip_prefix('|').unwrap_or(t);
69 let t = t.strip_suffix('|').unwrap_or(t);
70 t.split('|').map(|c| c.trim().to_string()).collect()
71}
72
73fn flush_para(blocks: &mut Vec<Block>, para: &mut Vec<String>) {
74 if para.is_empty() {
75 return;
76 }
77 let content = para.join(" ");
78 para.clear();
79 let content = content.trim();
80 if !content.is_empty() {
81 blocks.push(Block {
82 kind: BlockKind::Paragraph,
83 content: content.to_string(),
84 ..Default::default()
85 });
86 }
87}
88
89pub fn parse(text: &str) -> Vec<Block> {
91 let lines: Vec<&str> = text.lines().collect();
92 let n = lines.len();
93 let mut blocks: Vec<Block> = Vec::new();
94 let mut para: Vec<String> = Vec::new();
95 let mut i = 0usize;
96
97 while i < n {
98 let line = lines[i];
99 let trimmed = line.trim_start();
100
101 if trimmed.starts_with("```") {
103 flush_para(&mut blocks, &mut para);
104 let lang = trimmed.trim_start_matches('`').trim().to_string();
105 let mut buf: Vec<&str> = Vec::new();
106 i += 1;
107 while i < n && !lines[i].trim_start().starts_with("```") {
108 buf.push(lines[i]);
109 i += 1;
110 }
111 if i < n {
112 i += 1; }
114 blocks.push(Block {
115 kind: BlockKind::Code,
116 content: buf.join("\n"),
117 lang: if lang.is_empty() { None } else { Some(lang) },
118 ..Default::default()
119 });
120 continue;
121 }
122
123 if line.trim().is_empty() {
124 flush_para(&mut blocks, &mut para);
125 i += 1;
126 continue;
127 }
128
129 if let Some((level, content)) = heading(line) {
130 flush_para(&mut blocks, &mut para);
131 blocks.push(Block {
132 kind: BlockKind::Heading,
133 content,
134 level: Some(level),
135 ..Default::default()
136 });
137 i += 1;
138 continue;
139 }
140
141 if let Some(content) = quote(line) {
142 flush_para(&mut blocks, &mut para);
143 blocks.push(Block {
144 kind: BlockKind::Quote,
145 content,
146 ..Default::default()
147 });
148 i += 1;
149 continue;
150 }
151
152 if trimmed.starts_with('|') && i + 1 < n && is_table_sep(lines[i + 1]) {
154 flush_para(&mut blocks, &mut para);
155 let header = split_row(line);
156 let mut rows: Vec<Vec<String>> = vec![header.clone()];
157 i += 2;
158 while i < n && lines[i].trim_start().starts_with('|') {
159 rows.push(split_row(lines[i]));
160 i += 1;
161 }
162 blocks.push(Block {
163 kind: BlockKind::Table,
164 content: header.join(" | "),
165 table: Some(TableData {
166 rows,
167 ..Default::default()
168 }),
169 ..Default::default()
170 });
171 continue;
172 }
173
174 if let Some(first) = ulist_item(line) {
175 flush_para(&mut blocks, &mut para);
176 let mut items = vec![first];
177 i += 1;
178 while i < n {
179 match ulist_item(lines[i]) {
180 Some(it) => {
181 items.push(it);
182 i += 1;
183 }
184 None => break,
185 }
186 }
187 blocks.push(Block {
188 kind: BlockKind::List,
189 content: items.join("\n"),
190 ordered: Some(false),
191 ..Default::default()
192 });
193 continue;
194 }
195
196 if let Some(first) = olist_item(line) {
197 flush_para(&mut blocks, &mut para);
198 let mut items = vec![first];
199 i += 1;
200 while i < n {
201 match olist_item(lines[i]) {
202 Some(it) => {
203 items.push(it);
204 i += 1;
205 }
206 None => break,
207 }
208 }
209 blocks.push(Block {
210 kind: BlockKind::List,
211 content: items.join("\n"),
212 ordered: Some(true),
213 ..Default::default()
214 });
215 continue;
216 }
217
218 para.push(line.trim().to_string());
219 i += 1;
220 }
221
222 flush_para(&mut blocks, &mut para);
223 blocks
224}
225
226pub struct MarkdownPlugin;
228
229impl FormatPlugin for MarkdownPlugin {
230 fn id(&self) -> &str {
231 "md"
232 }
233 fn formats(&self) -> &[&str] {
234 &["md"]
235 }
236 fn default_parse_chain(&self) -> Vec<&'static str> {
237 vec!["text.read.md"]
238 }
239 fn default_process_chain(&self) -> Vec<&'static str> {
240 vec![]
241 }
242 fn assemble(&self, raw: &RawDoc) -> Document {
243 let text = raw
244 .pages
245 .first()
246 .and_then(|p| p.items.first())
247 .map(|i| i.text.as_str())
248 .unwrap_or_default();
249 Document {
250 pages: vec![Page {
251 blocks: parse(text),
252 text: text.to_string(),
253 ..Default::default()
254 }],
255 ..Default::default()
256 }
257 }
258}
259
260#[cfg(test)]
261mod tests {
262 use super::*;
263
264 fn kinds(blocks: &[Block]) -> Vec<BlockKind> {
265 blocks.iter().map(|b| b.kind).collect()
266 }
267
268 #[test]
269 fn parses_headings_and_paragraphs() {
270 let blocks = parse("# 标题\n正文一\n正文二\n\n## 二级\n");
271 assert_eq!(
272 kinds(&blocks),
273 vec![BlockKind::Heading, BlockKind::Paragraph, BlockKind::Heading]
274 );
275 assert_eq!(blocks[0].content, "标题");
276 assert_eq!(blocks[0].level, Some(1));
277 assert_eq!(blocks[1].content, "正文一 正文二"); assert_eq!(blocks[2].level, Some(2));
279 }
280
281 #[test]
282 fn parses_lists_and_quote() {
283 let blocks = parse("- a\n- b\n\n1. x\n2. y\n\n> 引用\n");
284 assert_eq!(
285 kinds(&blocks),
286 vec![BlockKind::List, BlockKind::List, BlockKind::Quote]
287 );
288 assert_eq!(blocks[0].content, "a\nb");
289 assert_eq!(blocks[0].ordered, Some(false));
290 assert_eq!(blocks[1].ordered, Some(true));
291 assert_eq!(blocks[2].content, "引用");
292 }
293
294 #[test]
295 fn parses_code_fence_with_lang() {
296 let blocks = parse("```rust\nfn main() {}\n```\n");
297 assert_eq!(blocks.len(), 1);
298 assert_eq!(blocks[0].kind, BlockKind::Code);
299 assert_eq!(blocks[0].lang.as_deref(), Some("rust"));
300 assert_eq!(blocks[0].content, "fn main() {}");
301 }
302
303 #[test]
304 fn parses_pipe_table() {
305 let blocks = parse("| a | b |\n| --- | --- |\n| 1 | 2 |\n");
306 assert_eq!(blocks.len(), 1);
307 let rows = &blocks[0].table.as_ref().unwrap().rows;
308 assert_eq!(rows.len(), 2); assert_eq!(rows[0], vec!["a", "b"]);
310 assert_eq!(rows[1], vec!["1", "2"]);
311 assert_eq!(blocks[0].content, "a | b");
312 }
313
314 #[test]
315 fn plain_pipe_line_without_separator_is_paragraph() {
316 let blocks = parse("| a | b |\nnot a table\n");
317 assert_eq!(kinds(&blocks), vec![BlockKind::Paragraph]);
318 }
319
320 #[test]
321 fn unmatched_fence_does_not_hang() {
322 let blocks = parse("```\nabc");
323 assert_eq!(blocks.len(), 1);
324 assert_eq!(blocks[0].kind, BlockKind::Code);
325 }
326}