Skip to main content

nagisa_render/
markup.rs

1//! 标记语言解析器 —— Markdown 基底(标题 / 列表 / 任务列表 / 引用 / 代码 / 分割线 / 表格 /
2//! 链接)+ 少量扩展(`==高亮==`、`[文字]{属性}`、`::: 对齐` / `::: columns` 围栏),把标记文本
3//! 解析成 [`Document`](crate::Document)。行式扫描:块级在 [`parse_blocks`],嵌套(引用 / 列表项 /
4//! 围栏)靠**抽出内层行 + 递归**实现。行内解析在 [`inline`](mod@inline)。
5//!
6//! 解析很宽容:认不出的写法退化成普通文字,基本不报错(签名仍返回 `Result` 以备将来收严)。
7//! 引用 `>` 后的空格可省;分割线认 `---` / `***` / `___`(3 个起步)。
8
9use crate::error::Result;
10use crate::model::{
11    Align, Block, BlockImage, Cell, ColSpec, Column, Columns, Document, ImageSource, List, ListItem,
12    ListKind, Table, TableStyle,
13};
14
15mod attrs;
16mod inline;
17
18pub(crate) use attrs::{parse_attrs, Attr};
19
20/// 解析标记文本为文档。
21pub fn parse(src: &str) -> Result<Document> {
22    let lines: Vec<String> = src.lines().map(|l| l.to_string()).collect();
23    Ok(Document { blocks: parse_blocks(&lines) })
24}
25
26/// 前导空白的字节数(空格 / Tab 各计 1)。
27fn indent_of(s: &str) -> usize {
28    s.len() - s.trim_start().len()
29}
30
31/// 去掉至多 `n` 个前导空格。
32fn dedent(s: &str, n: usize) -> String {
33    let strip = s.bytes().take_while(|b| *b == b' ').count().min(n);
34    s[strip..].to_string()
35}
36
37/// 把一串行解析成块序列。
38fn parse_blocks(lines: &[String]) -> Vec<Block> {
39    let mut blocks = Vec::new();
40    let mut i = 0;
41    while i < lines.len() {
42        let line = &lines[i];
43        if line.trim().is_empty() {
44            i += 1;
45            continue;
46        }
47        let ind = indent_of(line);
48        let content = line[ind..].to_string();
49
50        // 代码围栏 ```lang ... ```
51        if let Some(lang) = content.strip_prefix("```") {
52            let lang = lang.trim().to_string();
53            let mut text = Vec::new();
54            i += 1;
55            while i < lines.len() && !lines[i].trim_start().starts_with("```") {
56                text.push(lines[i].clone());
57                i += 1;
58            }
59            i += 1; // 跳过闭合 ```(缺失也无妨)
60            blocks.push(Block::Code {
61                lang: if lang.is_empty() { None } else { Some(lang) },
62                text: text.join("\n"),
63            });
64            continue;
65        }
66
67        // 围栏 ::: word ... :::(支持嵌套)。word=对齐 → 对齐下沉;word=columns → 并排栏。
68        if is_fence_open(&content) {
69            let word = content[3..].trim().to_string();
70            let inner = gather_div(lines, &mut i); // i 已跳到闭合之后
71            if word == "columns" {
72                blocks.push(Block::Columns(Columns { cols: parse_columns(&inner), gap: None }));
73            } else if let Some(align) = align_from_word(&word) {
74                let mut sub = parse_blocks(&inner);
75                apply_align(&mut sub, align);
76                blocks.append(&mut sub);
77            } else {
78                blocks.append(&mut parse_blocks(&inner)); // 未知围栏:透明容器
79            }
80            continue;
81        }
82
83        // 标题 #..######
84        if let Some((level, rest)) = heading(&content) {
85            let (text, align) = split_trailing_attrs(rest);
86            blocks.push(Block::Heading { level, inlines: inline::parse_inlines(&text), align });
87            i += 1;
88            continue;
89        }
90
91        // 分割线 ---(也认 *** / ___,3 个起步的同字符行)
92        if is_hr(&content) {
93            blocks.push(Block::Divider);
94            i += 1;
95            continue;
96        }
97
98        // 引用 > ...(`>` 后的一个空格可省;`>>` 嵌套靠递归)
99        if content.starts_with('>') {
100            let mut inner = Vec::new();
101            while i < lines.len() {
102                let t = lines[i].trim_start();
103                let Some(r) = t.strip_prefix('>') else { break };
104                inner.push(r.strip_prefix(' ').unwrap_or(r).to_string());
105                i += 1;
106            }
107            blocks.push(Block::Quote(parse_blocks(&inner)));
108            continue;
109        }
110
111        // 块级图 ![cap](src) 单独成行
112        if let Some(img) = block_image(&content) {
113            blocks.push(Block::Image(img));
114            i += 1;
115            continue;
116        }
117
118        // 列表
119        if list_marker(&content).is_some() {
120            let (list, next) = parse_list(lines, i, ind);
121            blocks.push(Block::List(list));
122            i = next;
123            continue;
124        }
125
126        // 表格(GFM):本行含 `|`,且下一行是分隔行(:?-+:?)。
127        if content.contains('|')
128            && i + 1 < lines.len()
129            && is_table_delim(lines[i + 1].trim())
130        {
131            let (table, next) = parse_table(lines, i);
132            blocks.push(Block::Table(table));
133            i = next;
134            continue;
135        }
136
137        // 段落:聚合连续的普通行。行尾 `\` = 硬换行(往缓冲塞 `\n`,行内解析时变 LineBreak)。
138        let mut para = String::new();
139        while i < lines.len() {
140            let l = &lines[i];
141            if l.trim().is_empty() {
142                break;
143            }
144            let c = l[indent_of(l)..].to_string();
145            if is_block_start(&c) {
146                break;
147            }
148            let mut piece = c.trim();
149            let hard = piece.ends_with('\\');
150            if hard {
151                piece = piece[..piece.len() - 1].trim_end();
152            }
153            append_soft(&mut para, piece);
154            if hard {
155                para.push('\n');
156            }
157            i += 1;
158        }
159        let (text, align) = split_trailing_attrs(&para);
160        blocks.push(Block::Paragraph { inlines: inline::parse_inlines(&text), align });
161    }
162    blocks
163}
164
165/// 某行(去前导空白后的内容)是否是一个非段落块的起始。用于段落聚合时及时收住。
166fn is_block_start(c: &str) -> bool {
167    c.starts_with("```")
168        || is_fence_open(c)
169        || is_hr(c)
170        || c.starts_with('>')
171        || heading(c).is_some()
172        || list_marker(c).is_some()
173        || block_image(c).is_some()
174}
175
176/// 分割线行:3 个起步、清一色的 `-` / `*` / `_`。
177fn is_hr(c: &str) -> bool {
178    let b = c.as_bytes();
179    b.len() >= 3 && matches!(b[0], b'-' | b'*' | b'_') && b.iter().all(|x| *x == b[0])
180}
181
182/// 解析一个列表(从 `lines[start]` 起、缩进 `base`),返回列表与下一行下标。
183/// 列表项内容(含更深缩进的续行 / 子列表)抽出后递归 [`parse_blocks`]。
184fn parse_list(lines: &[String], start: usize, base: usize) -> (List, usize) {
185    let (ordered, first_start, _) = list_marker(&lines[start][base..]).unwrap();
186    let kind = if ordered { ListKind::Ordered } else { ListKind::Unordered };
187    let mut items = Vec::new();
188    let mut i = start;
189    while i < lines.len() {
190        let line = &lines[i];
191        if line.trim().is_empty() {
192            // 项间空行:后面还有同级 / 更深内容才算列表内部,否则列表结束。
193            if next_nonblank_indent(lines, i + 1).map(|n| n >= base).unwrap_or(false) {
194                i += 1;
195                continue;
196            }
197            break;
198        }
199        let ind = indent_of(line);
200        if ind < base {
201            break;
202        }
203        let Some((ord, _, off)) = list_marker(&line[ind..]) else {
204            break; // 同 / 深缩进但不是 marker → 列表到此为止
205        };
206        if ind != base || ord != ordered {
207            break; // 更深缩进的 marker 归上一项续行;有序 / 无序切换则另起一个列表
208        }
209        // 收本项:首行内容 + 后续「更深缩进 / 空行」的续行(去掉本项内容缩进)。
210        let content_indent = base + off;
211        let (first_line, check) = split_task_mark(&line[ind..][off..]);
212        let mut item_lines = vec![first_line];
213        i += 1;
214        while i < lines.len() {
215            let l = &lines[i];
216            if l.trim().is_empty() {
217                if next_nonblank_indent(lines, i + 1).map(|n| n > base).unwrap_or(false) {
218                    item_lines.push(String::new());
219                    i += 1;
220                    continue;
221                }
222                break;
223            }
224            if indent_of(l) > base {
225                item_lines.push(dedent(l, content_indent));
226                i += 1;
227            } else {
228                break;
229            }
230        }
231        items.push(ListItem { blocks: parse_blocks(&item_lines), check });
232    }
233    (List { kind, start: first_start.max(1), items }, i)
234}
235
236/// 摘掉项首的任务标记 `[ ]` / `[x]` / `[X]`(GFM 任务列表),返回 `(剩余内容, 完成态)`。
237/// 标记后须是空白或行尾;不是任务标记则原样返回。
238fn split_task_mark(s: &str) -> (String, Option<bool>) {
239    let done = match s.get(..3) {
240        Some("[ ]") => false,
241        Some("[x]") | Some("[X]") => true,
242        _ => return (s.to_string(), None),
243    };
244    match s[3..].chars().next() {
245        None => (String::new(), Some(done)),
246        Some(c) if c.is_whitespace() => (s[3 + c.len_utf8()..].to_string(), Some(done)),
247        _ => (s.to_string(), None),
248    }
249}
250
251/// 之后第一条非空行的缩进(没有则 `None`)。
252fn next_nonblank_indent(lines: &[String], from: usize) -> Option<usize> {
253    lines[from..].iter().find(|l| !l.trim().is_empty()).map(|l| indent_of(l))
254}
255
256/// 标题:前导 1..=6 个 `#` 且其后跟空格。返回 `(level, 标题文字)`。
257fn heading(c: &str) -> Option<(u8, &str)> {
258    let hashes = c.bytes().take_while(|b| *b == b'#').count();
259    if (1..=6).contains(&hashes) && c.as_bytes().get(hashes) == Some(&b' ') {
260        Some((hashes as u8, c[hashes + 1..].trim()))
261    } else {
262        None
263    }
264}
265
266/// 列表 marker:返回 `(是否有序, 起始序号, marker 含尾分隔的宽度)`。marker 与内容间空格或 Tab 都认。
267fn list_marker(c: &str) -> Option<(bool, u32, usize)> {
268    let b = c.as_bytes();
269    // 无序:- / * / + 后跟空格或 Tab
270    if matches!(b.first(), Some(b'-' | b'*' | b'+')) && matches!(b.get(1), Some(b' ' | b'\t')) {
271        return Some((false, 0, 2));
272    }
273    // 有序:数字 + ('.'|')') + (空格|Tab)
274    let digits = c.bytes().take_while(|x| x.is_ascii_digit()).count();
275    if digits > 0
276        && matches!(b.get(digits), Some(b'.' | b')'))
277        && matches!(b.get(digits + 1), Some(b' ' | b'\t'))
278    {
279        let n = c[..digits].parse::<u32>().unwrap_or(1);
280        return Some((true, n, digits + 2));
281    }
282    None
283}
284
285/// 块级图 `![cap](src)`(整行)。`src` 以 `@` 开头 → 具名引用,否则按磁盘路径。
286fn block_image(c: &str) -> Option<BlockImage> {
287    let c = c.trim();
288    let rest = c.strip_prefix("![")?;
289    let close_alt = rest.find("](")?;
290    if !c.ends_with(')') {
291        return None;
292    }
293    let alt = &rest[..close_alt];
294    let src = &rest[close_alt + 2..rest.len() - 1];
295    if src.is_empty() {
296        return None;
297    }
298    Some(BlockImage {
299        src: image_source(src),
300        width: None,
301        align: Align::Left,
302        caption: if alt.trim().is_empty() { None } else { Some(inline::parse_inlines(alt.trim())) },
303    })
304}
305
306/// `@名字` → `Named`,否则 `Path`。
307pub(crate) fn image_source(src: &str) -> ImageSource {
308    match src.strip_prefix('@') {
309        Some(name) => ImageSource::Named(name.to_string()),
310        None => ImageSource::Path(src.into()),
311    }
312}
313
314/// 把对齐词转成 [`Align`]。
315fn align_from_word(w: &str) -> Option<Align> {
316    match w {
317        "center" | "centre" => Some(Align::Center),
318        "right" => Some(Align::Right),
319        "left" => Some(Align::Left),
320        "justify" => Some(Align::Justify),
321        _ => None,
322    }
323}
324
325/// 是不是一个围栏开启行(`::: word`,word 非空)。裸 `:::` 是闭合,不算开启。
326fn is_fence_open(c: &str) -> bool {
327    c.starts_with(":::") && c.len() > 3 && !c[3..].trim().is_empty()
328}
329
330/// 从围栏开启行(`lines[*i]`)起,深度感知地收集内层行,`*i` 推进到匹配闭合 `:::` 之后。
331fn gather_div(lines: &[String], i: &mut usize) -> Vec<String> {
332    *i += 1;
333    let mut inner = Vec::new();
334    let mut depth = 1usize;
335    while *i < lines.len() {
336        let t = lines[*i].trim();
337        if t == ":::" {
338            depth -= 1;
339            if depth == 0 {
340                *i += 1;
341                break; // 匹配闭合不计入内层
342            }
343        } else if is_fence_open(t) {
344            depth += 1;
345        }
346        inner.push(lines[*i].clone());
347        *i += 1;
348    }
349    inner
350}
351
352/// 把 `::: columns` 的内层解析成若干栏:每个直接的 `::: col [权重]` 子围栏一栏。
353fn parse_columns(inner: &[String]) -> Vec<Column> {
354    let mut cols = Vec::new();
355    let mut i = 0;
356    while i < inner.len() {
357        let mut parts = inner[i].trim().strip_prefix(":::").unwrap_or("").split_whitespace();
358        if parts.next() == Some("col") {
359            let weight =
360                parts.next().and_then(|s| s.parse::<f32>().ok()).filter(|w| *w > 0.0).unwrap_or(1.0);
361            let col_lines = gather_div(inner, &mut i);
362            cols.push(Column { blocks: parse_blocks(&col_lines), weight });
363        } else {
364            i += 1;
365        }
366    }
367    cols
368}
369
370/// GFM 表格分隔行?每个非空单元格只含 `-`/`:` 且至少一个 `-`。
371fn is_table_delim(t: &str) -> bool {
372    let cells = split_row(t);
373    !cells.is_empty()
374        && cells
375            .iter()
376            .all(|c| !c.is_empty() && c.contains('-') && c.bytes().all(|b| b == b'-' || b == b':'))
377}
378
379/// 按 `|` 切一行的单元格(去掉首尾的 `|`,各段去空白)。
380/// `\|` 转义竖线与 `` `行内码` `` 内的竖线不当列分隔(转义本身留给行内解析处理)。
381fn split_row(line: &str) -> Vec<String> {
382    let t = line.trim();
383    let t = t.strip_prefix('|').unwrap_or(t);
384    let t = t.strip_suffix('|').unwrap_or(t);
385    let mut cells = Vec::new();
386    let mut cur = String::new();
387    let mut in_code = false;
388    let mut chars = t.chars();
389    while let Some(ch) = chars.next() {
390        match ch {
391            '`' => {
392                in_code = !in_code;
393                cur.push('`');
394            }
395            // 保留 `\X`(含 `\|`):其中的 `|` 不算列分隔,转义语义交给行内解析。
396            '\\' if !in_code => {
397                cur.push('\\');
398                if let Some(n) = chars.next() {
399                    cur.push(n);
400                }
401            }
402            '|' if !in_code => {
403                cells.push(cur.trim().to_string());
404                cur = String::new();
405            }
406            _ => cur.push(ch),
407        }
408    }
409    cells.push(cur.trim().to_string());
410    cells
411}
412
413/// 分隔行 → 各列对齐(`:--` 左 / `:-:` 中 / `--:` 右)。
414fn parse_align_row(line: &str) -> Vec<Align> {
415    split_row(line)
416        .iter()
417        .map(|c| match (c.starts_with(':'), c.ends_with(':')) {
418            (true, true) => Align::Center,
419            (false, true) => Align::Right,
420            _ => Align::Left,
421        })
422        .collect()
423}
424
425/// 解析一张 GFM 表格(`start` 表头行,`start+1` 分隔行,之后是数据行直到空行 / 无 `|` 行)。
426fn parse_table(lines: &[String], start: usize) -> (Table, usize) {
427    let to_cells = |t: &str| -> Vec<Cell> {
428        split_row(t).iter().map(|s| Cell { inlines: inline::parse_inlines(s), bg: None }).collect()
429    };
430    let header = Some(to_cells(lines[start].trim()));
431    let cols: Vec<ColSpec> = parse_align_row(lines[start + 1].trim())
432        .into_iter()
433        .map(|a| ColSpec { align: a, width: None })
434        .collect();
435    let mut rows = Vec::new();
436    let mut i = start + 2;
437    while i < lines.len() {
438        let t = lines[i].trim();
439        if t.is_empty() || !t.contains('|') {
440            break;
441        }
442        rows.push(to_cells(t));
443        i += 1;
444    }
445    (Table { header, rows, cols, style: TableStyle::default() }, i)
446}
447
448/// 给一串块整体设对齐(围栏对齐下沉用):标题 / 段落直接设;引用 / 列表项递归下沉。
449fn apply_align(blocks: &mut [Block], align: Align) {
450    for b in blocks {
451        match b {
452            Block::Heading { align: a, .. } | Block::Paragraph { align: a, .. } => *a = align,
453            Block::Quote(inner) => apply_align(inner, align),
454            Block::List(list) => {
455                for it in &mut list.items {
456                    apply_align(&mut it.blocks, align);
457                }
458            }
459            _ => {}
460        }
461    }
462}
463
464/// 从文字尾部摘出 `{属性}`(要求 `{` 前是空白),解析其中的 `align`。返回 `(正文, 对齐)`。
465fn split_trailing_attrs(s: &str) -> (String, Align) {
466    let t = s.trim_end();
467    if t.ends_with('}') {
468        if let Some(open) = t.rfind('{') {
469            let before = &t[..open];
470            if before.ends_with(' ') || before.is_empty() {
471                let inside = &t[open + 1..t.len() - 1];
472                let align = parse_attrs(inside)
473                    .iter()
474                    .find_map(|a| match a {
475                        Attr::Kv(k, v) if k == "align" => align_from_word(v),
476                        Attr::Flag(f) => align_from_word(f),
477                        _ => None,
478                    })
479                    .unwrap_or(Align::Left);
480                return (before.trim_end().to_string(), align);
481            }
482        }
483    }
484    (t.to_string(), Align::Left)
485}
486
487/// 段落软换行拼接:两侧都非 CJK 才插空格(CJK 行间不加空格)。
488fn append_soft(buf: &mut String, next: &str) {
489    if next.is_empty() {
490        return;
491    }
492    if let (Some(a), Some(b)) = (buf.chars().last(), next.chars().next()) {
493        // 紧跟硬换行(`\n`)后不加前导空格;否则两侧都非 CJK 才插空格。
494        if a != '\n' && needs_space(a, b) {
495            buf.push(' ');
496        }
497    }
498    buf.push_str(next);
499}
500
501fn needs_space(a: char, b: char) -> bool {
502    // CJK 标点 / 符号 / 表意文字(含 2E80–9FFF)+ 全角形(FF00–FFEF)。
503    fn cjk(c: char) -> bool {
504        matches!(c, '\u{2E80}'..='\u{9FFF}' | '\u{FF00}'..='\u{FFEF}')
505    }
506    !cjk(a) && !cjk(b)
507}
508
509
510