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 { rows }),
166 ..Default::default()
167 });
168 continue;
169 }
170
171 if let Some(first) = ulist_item(line) {
172 flush_para(&mut blocks, &mut para);
173 let mut items = vec![first];
174 i += 1;
175 while i < n {
176 match ulist_item(lines[i]) {
177 Some(it) => {
178 items.push(it);
179 i += 1;
180 }
181 None => break,
182 }
183 }
184 blocks.push(Block {
185 kind: BlockKind::List,
186 content: items.join("\n"),
187 ordered: Some(false),
188 ..Default::default()
189 });
190 continue;
191 }
192
193 if let Some(first) = olist_item(line) {
194 flush_para(&mut blocks, &mut para);
195 let mut items = vec![first];
196 i += 1;
197 while i < n {
198 match olist_item(lines[i]) {
199 Some(it) => {
200 items.push(it);
201 i += 1;
202 }
203 None => break,
204 }
205 }
206 blocks.push(Block {
207 kind: BlockKind::List,
208 content: items.join("\n"),
209 ordered: Some(true),
210 ..Default::default()
211 });
212 continue;
213 }
214
215 para.push(line.trim().to_string());
216 i += 1;
217 }
218
219 flush_para(&mut blocks, &mut para);
220 blocks
221}
222
223pub struct MarkdownPlugin;
225
226impl FormatPlugin for MarkdownPlugin {
227 fn id(&self) -> &str {
228 "md"
229 }
230 fn formats(&self) -> &[&str] {
231 &["md"]
232 }
233 fn default_parse_chain(&self) -> Vec<&'static str> {
234 vec!["text.read.md"]
235 }
236 fn default_process_chain(&self) -> Vec<&'static str> {
237 vec![]
238 }
239 fn assemble(&self, raw: &RawDoc) -> Document {
240 let text = raw
241 .pages
242 .first()
243 .and_then(|p| p.items.first())
244 .map(|i| i.text.as_str())
245 .unwrap_or_default();
246 Document {
247 pages: vec![Page {
248 blocks: parse(text),
249 text: text.to_string(),
250 ..Default::default()
251 }],
252 ..Default::default()
253 }
254 }
255}
256
257#[cfg(test)]
258mod tests {
259 use super::*;
260
261 fn kinds(blocks: &[Block]) -> Vec<BlockKind> {
262 blocks.iter().map(|b| b.kind).collect()
263 }
264
265 #[test]
266 fn parses_headings_and_paragraphs() {
267 let blocks = parse("# 标题\n正文一\n正文二\n\n## 二级\n");
268 assert_eq!(
269 kinds(&blocks),
270 vec![BlockKind::Heading, BlockKind::Paragraph, BlockKind::Heading]
271 );
272 assert_eq!(blocks[0].content, "标题");
273 assert_eq!(blocks[0].level, Some(1));
274 assert_eq!(blocks[1].content, "正文一 正文二"); assert_eq!(blocks[2].level, Some(2));
276 }
277
278 #[test]
279 fn parses_lists_and_quote() {
280 let blocks = parse("- a\n- b\n\n1. x\n2. y\n\n> 引用\n");
281 assert_eq!(
282 kinds(&blocks),
283 vec![BlockKind::List, BlockKind::List, BlockKind::Quote]
284 );
285 assert_eq!(blocks[0].content, "a\nb");
286 assert_eq!(blocks[0].ordered, Some(false));
287 assert_eq!(blocks[1].ordered, Some(true));
288 assert_eq!(blocks[2].content, "引用");
289 }
290
291 #[test]
292 fn parses_code_fence_with_lang() {
293 let blocks = parse("```rust\nfn main() {}\n```\n");
294 assert_eq!(blocks.len(), 1);
295 assert_eq!(blocks[0].kind, BlockKind::Code);
296 assert_eq!(blocks[0].lang.as_deref(), Some("rust"));
297 assert_eq!(blocks[0].content, "fn main() {}");
298 }
299
300 #[test]
301 fn parses_pipe_table() {
302 let blocks = parse("| a | b |\n| --- | --- |\n| 1 | 2 |\n");
303 assert_eq!(blocks.len(), 1);
304 let rows = &blocks[0].table.as_ref().unwrap().rows;
305 assert_eq!(rows.len(), 2); assert_eq!(rows[0], vec!["a", "b"]);
307 assert_eq!(rows[1], vec!["1", "2"]);
308 assert_eq!(blocks[0].content, "a | b");
309 }
310
311 #[test]
312 fn plain_pipe_line_without_separator_is_paragraph() {
313 let blocks = parse("| a | b |\nnot a table\n");
314 assert_eq!(kinds(&blocks), vec![BlockKind::Paragraph]);
315 }
316
317 #[test]
318 fn unmatched_fence_does_not_hang() {
319 let blocks = parse("```\nabc");
320 assert_eq!(blocks.len(), 1);
321 assert_eq!(blocks[0].kind, BlockKind::Code);
322 }
323}