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, Color, Column, Columns, Document, ImageBorder, ImageSource, List,
12    ListItem, ListKind, Panel, PanelDecor, Shadow, 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    parse_blocks_at(lines, 0)
40}
41
42/// 嵌套容器(引用 / 围栏 / 列表 / 栏)的最大递归深度:外部输入构造的深嵌套
43/// 会真把解析栈打爆(abort 不可捕获),超限的内层一律按普通段落收。
44const MAX_DEPTH: usize = 64;
45
46fn parse_blocks_at(lines: &[String], depth: usize) -> Vec<Block> {
47    if depth > MAX_DEPTH {
48        let text = lines.join("\n");
49        let t = text.trim();
50        if t.is_empty() {
51            return Vec::new();
52        }
53        return vec![Block::Paragraph { inlines: inline::parse_inlines(t), align: Align::Left }];
54    }
55    let mut blocks = Vec::new();
56    let mut i = 0;
57    while i < lines.len() {
58        let line = &lines[i];
59        if line.trim().is_empty() {
60            i += 1;
61            continue;
62        }
63        let ind = indent_of(line);
64        let content = line[ind..].to_string();
65
66        // 代码围栏 ```lang ... ```(开栏反引号可多于 3,闭栏须同长及以上且不带别的字)
67        if content.starts_with("```") {
68            let ticks = content.bytes().take_while(|b| *b == b'`').count();
69            let lang = content[ticks..].trim().to_string();
70            let mut text = Vec::new();
71            i += 1;
72            while i < lines.len() && !is_code_fence_close(&lines[i], ticks) {
73                text.push(lines[i].clone());
74                i += 1;
75            }
76            i += 1; // 跳过闭合(缺失也无妨)
77            blocks.push(Block::Code { lang: if lang.is_empty() { None } else { Some(lang) }, text: text.join("\n") });
78            continue;
79        }
80
81        // 围栏 ::: word ... :::(支持嵌套)。word=对齐 → 对齐下沉;word=columns → 并排栏;
82        // word=panel → 面板(可带 `{bg=… border=… rounded=…}` 装饰属性)。
83        if is_fence_open(&content) {
84            let (word, attrs) = split_fence_word(content[3..].trim());
85            let inner = gather_div(lines, &mut i); // i 已跳到闭合之后
86            if word == "columns" {
87                let (cols, mut stray) = parse_columns(&inner, depth + 1);
88                blocks.push(Block::Columns(Columns { cols, gap: None }));
89                blocks.append(&mut stray); // 栏外散行不丢:排在栏块之后
90            } else if word == "panel" {
91                blocks.push(Block::Panel(Panel {
92                    blocks: parse_blocks_at(&inner, depth + 1),
93                    decor: panel_decor(attrs),
94                }));
95            } else if let Some(align) = align_from_word(&word) {
96                let mut sub = parse_blocks_at(&inner, depth + 1);
97                apply_align(&mut sub, align);
98                blocks.append(&mut sub);
99            } else {
100                blocks.append(&mut parse_blocks_at(&inner, depth + 1)); // 未知围栏:透明容器
101            }
102            continue;
103        }
104
105        // 标题 #..######
106        if let Some((level, rest)) = heading(&content) {
107            let (text, align) = split_trailing_attrs(rest);
108            blocks.push(Block::Heading { level, inlines: inline::parse_inlines(&text), align });
109            i += 1;
110            continue;
111        }
112
113        // 分割线 ---(也认 *** / ___,3 个起步的同字符行)
114        if is_hr(&content) {
115            blocks.push(Block::Divider);
116            i += 1;
117            continue;
118        }
119
120        // 引用 > ...(`>` 后的一个空格可省;`>>` 嵌套靠递归)
121        if content.starts_with('>') {
122            let mut inner = Vec::new();
123            while i < lines.len() {
124                let t = lines[i].trim_start();
125                let Some(r) = t.strip_prefix('>') else { break };
126                inner.push(r.strip_prefix(' ').unwrap_or(r).to_string());
127                i += 1;
128            }
129            blocks.push(Block::Quote(parse_blocks_at(&inner, depth + 1)));
130            continue;
131        }
132
133        // 块级图 ![cap](src) 单独成行
134        if let Some(img) = block_image(&content) {
135            blocks.push(Block::Image(img));
136            i += 1;
137            continue;
138        }
139
140        // 列表
141        if list_marker(&content).is_some() {
142            let (list, next) = parse_list(lines, i, ind);
143            blocks.push(Block::List(list));
144            i = next;
145            continue;
146        }
147
148        // 表格(GFM):本行含 `|`,且下一行是分隔行(:?-+:?)。
149        if content.contains('|')
150            && i + 1 < lines.len()
151            && is_table_delim(lines[i + 1].trim())
152            && split_row(lines[i + 1].trim()).len() == split_row(content.trim()).len()
153        {
154            let (table, next) = parse_table(lines, i);
155            blocks.push(Block::Table(table));
156            i = next;
157            continue;
158        }
159
160        // 段落:聚合连续的普通行。行尾 `\` = 硬换行(往缓冲塞 `\n`,行内解析时变 LineBreak)。
161        let mut para = String::new();
162        while i < lines.len() {
163            let l = &lines[i];
164            if l.trim().is_empty() {
165                break;
166            }
167            let c = l[indent_of(l)..].to_string();
168            if is_block_start(&c) {
169                break;
170            }
171            let mut piece = c.trim();
172            let hard = piece.ends_with('\\');
173            if hard {
174                piece = piece[..piece.len() - 1].trim_end();
175            }
176            append_soft(&mut para, piece);
177            if hard {
178                para.push('\n');
179            }
180            i += 1;
181        }
182        let (text, align) = split_trailing_attrs(&para);
183        blocks.push(Block::Paragraph { inlines: inline::parse_inlines(&text), align });
184    }
185    blocks
186}
187
188/// 某行(去前导空白后的内容)是否是一个非段落块的起始。用于段落聚合时及时收住。
189fn is_block_start(c: &str) -> bool {
190    c.starts_with("```")
191        || is_fence_open(c)
192        || is_hr(c)
193        || c.starts_with('>')
194        || heading(c).is_some()
195        || list_marker(c).is_some()
196        || block_image(c).is_some()
197}
198
199/// 分割线行:3 个起步、清一色的 `-` / `*` / `_`。
200fn is_hr(c: &str) -> bool {
201    let b = c.as_bytes();
202    b.len() >= 3 && matches!(b[0], b'-' | b'*' | b'_') && b.iter().all(|x| *x == b[0])
203}
204
205/// 解析一个列表(从 `lines[start]` 起、缩进 `base`),返回列表与下一行下标。
206/// 列表项内容(含更深缩进的续行 / 子列表)抽出后递归 [`parse_blocks`]。
207fn parse_list(lines: &[String], start: usize, base: usize) -> (List, usize) {
208    let (ordered, first_start, _) = list_marker(&lines[start][base..]).unwrap();
209    let kind = if ordered { ListKind::Ordered } else { ListKind::Unordered };
210    let mut items = Vec::new();
211    let mut i = start;
212    while i < lines.len() {
213        let line = &lines[i];
214        if line.trim().is_empty() {
215            // 项间空行:后面还有同级 / 更深内容才算列表内部,否则列表结束。
216            if next_nonblank_indent(lines, i + 1).map(|n| n >= base).unwrap_or(false) {
217                i += 1;
218                continue;
219            }
220            break;
221        }
222        let ind = indent_of(line);
223        if ind < base {
224            break;
225        }
226        let Some((ord, _, off)) = list_marker(&line[ind..]) else {
227            break; // 同 / 深缩进但不是 marker → 列表到此为止
228        };
229        if ind != base || ord != ordered {
230            break; // 更深缩进的 marker 归上一项续行;有序 / 无序切换则另起一个列表
231        }
232        // 收本项:首行内容 + 后续「更深缩进 / 空行」的续行(去掉本项内容缩进)。
233        let content_indent = base + off;
234        let (first_line, check) = split_task_mark(&line[ind..][off..]);
235        let mut item_lines = vec![first_line];
236        i += 1;
237        while i < lines.len() {
238            let l = &lines[i];
239            if l.trim().is_empty() {
240                if next_nonblank_indent(lines, i + 1).map(|n| n > base).unwrap_or(false) {
241                    item_lines.push(String::new());
242                    i += 1;
243                    continue;
244                }
245                break;
246            }
247            if indent_of(l) > base {
248                item_lines.push(dedent(l, content_indent));
249                i += 1;
250            } else {
251                break;
252            }
253        }
254        items.push(ListItem { blocks: parse_blocks(&item_lines), check });
255    }
256    (List { kind, start: first_start.max(1), items }, i)
257}
258
259/// 摘掉项首的任务标记 `[ ]` / `[x]` / `[X]`(GFM 任务列表),返回 `(剩余内容, 完成态)`。
260/// 标记后须是空白或行尾;不是任务标记则原样返回。
261fn split_task_mark(s: &str) -> (String, Option<bool>) {
262    let done = match s.get(..3) {
263        Some("[ ]") => false,
264        Some("[x]") | Some("[X]") => true,
265        _ => return (s.to_string(), None),
266    };
267    match s[3..].chars().next() {
268        None => (String::new(), Some(done)),
269        Some(c) if c.is_whitespace() => (s[3 + c.len_utf8()..].to_string(), Some(done)),
270        _ => (s.to_string(), None),
271    }
272}
273
274/// 之后第一条非空行的缩进(没有则 `None`)。
275fn next_nonblank_indent(lines: &[String], from: usize) -> Option<usize> {
276    lines[from..].iter().find(|l| !l.trim().is_empty()).map(|l| indent_of(l))
277}
278
279/// 标题:前导 1..=6 个 `#` 且其后跟空格。返回 `(level, 标题文字)`。
280fn heading(c: &str) -> Option<(u8, &str)> {
281    let hashes = c.bytes().take_while(|b| *b == b'#').count();
282    if (1..=6).contains(&hashes) && c.as_bytes().get(hashes) == Some(&b' ') {
283        Some((hashes as u8, c[hashes + 1..].trim()))
284    } else {
285        None
286    }
287}
288
289/// 列表 marker:返回 `(是否有序, 起始序号, marker 含尾分隔的宽度)`。marker 与内容间空格或 Tab 都认。
290fn list_marker(c: &str) -> Option<(bool, u32, usize)> {
291    let b = c.as_bytes();
292    // 无序:- / * / + 后跟空格或 Tab
293    if matches!(b.first(), Some(b'-' | b'*' | b'+')) && matches!(b.get(1), Some(b' ' | b'\t')) {
294        return Some((false, 0, 2));
295    }
296    // 有序:数字 + ('.'|')') + (空格|Tab)
297    let digits = c.bytes().take_while(|x| x.is_ascii_digit()).count();
298    if digits > 0 && matches!(b.get(digits), Some(b'.' | b')')) && matches!(b.get(digits + 1), Some(b' ' | b'\t')) {
299        let n = c[..digits].parse::<u32>().unwrap_or(1);
300        return Some((true, n, digits + 2));
301    }
302    None
303}
304
305/// 块级图 `![cap](src)`(整行)。`src` 以 `@` 开头 → 具名引用,否则按磁盘路径。
306fn block_image(c: &str) -> Option<BlockImage> {
307    let c = c.trim();
308    let rest = c.strip_prefix("![")?;
309    let close_alt = rest.find("](")?;
310    let after_src = &rest[close_alt + 2..];
311    let close_paren = after_src.find(')')?;
312    let src = &after_src[..close_paren];
313    if src.is_empty() {
314        return None;
315    }
316    // 右括号后只允许空白或 `{属性}`,有别的尾巴就不是块级图(退回段落,不吞文字)。
317    let tail = after_src[close_paren + 1..].trim();
318    let attrs = if tail.is_empty() {
319        ""
320    } else if tail.starts_with('{') && tail.ends_with('}') {
321        &tail[1..tail.len() - 1]
322    } else {
323        return None;
324    };
325    let alt = &rest[..close_alt];
326    let mut img = BlockImage {
327        src: image_source(src),
328        width: None,
329        align: Align::Left,
330        caption: if alt.trim().is_empty() { None } else { Some(inline::parse_inlines(alt.trim())) },
331        decor: crate::model::ImageDecor::default(),
332    };
333    apply_image_attrs(&mut img, attrs);
334    Some(img)
335}
336
337/// 块级图尾部属性:`width=50%|320`(百分比或逻辑像素)、`align=center|right|left`、
338/// `rounded=px`、`shadow`(标志)、`border=#hex`(线宽固定 2,要细调走构建器)。
339fn apply_image_attrs(img: &mut BlockImage, attrs: &str) {
340    for a in parse_attrs(attrs) {
341        match a {
342            Attr::Kv(k, v) => match k.as_str() {
343                "width" => {
344                    if let Some(pct) = v.strip_suffix('%') {
345                        if let Ok(x) = pct.parse::<f32>() {
346                            if x.is_finite() && x > 0.0 {
347                                img.width = Some(crate::model::Length::Percent(x));
348                            }
349                        }
350                    } else if let Ok(x) = v.parse::<f32>() {
351                        if x.is_finite() && x > 0.0 {
352                            img.width = Some(crate::model::Length::Px(x));
353                        }
354                    }
355                }
356                "align" => {
357                    if let Some(al) = align_from_word(&v) {
358                        img.align = al;
359                    }
360                }
361                "rounded" => {
362                    if let Ok(r) = v.parse::<f32>() {
363                        if r.is_finite() && r > 0.0 {
364                            img.decor.radius = r;
365                        }
366                    }
367                }
368                "border" => {
369                    if let Some(color) = Color::hex(&v) {
370                        img.decor.border = Some(ImageBorder { width: 2.0, color });
371                    }
372                }
373                _ => {}
374            },
375            Attr::Flag(f) => {
376                if f == "shadow" {
377                    img.decor.shadow = Some(Shadow::default());
378                }
379            }
380        }
381    }
382}
383
384/// `@名字` → `Named`,否则 `Path`。
385pub(crate) fn image_source(src: &str) -> ImageSource {
386    match src.strip_prefix('@') {
387        Some(name) => ImageSource::Named(name.to_string()),
388        None => ImageSource::Path(src.into()),
389    }
390}
391
392/// 把对齐词转成 [`Align`]。
393fn align_from_word(w: &str) -> Option<Align> {
394    match w {
395        "center" | "centre" => Some(Align::Center),
396        "right" => Some(Align::Right),
397        "left" => Some(Align::Left),
398        "justify" => Some(Align::Justify),
399        _ => None,
400    }
401}
402
403/// 是不是一个围栏开启行(`::: word`,word 非空)。裸 `:::` 是闭合,不算开启。
404fn is_fence_open(c: &str) -> bool {
405    c.starts_with(":::") && c.len() > 3 && !c[3..].trim().is_empty()
406}
407
408/// 从围栏开启行(`lines[*i]`)起,深度感知地收集内层行,`*i` 推进到匹配闭合 `:::` 之后。
409fn gather_div(lines: &[String], i: &mut usize) -> Vec<String> {
410    *i += 1;
411    let mut inner = Vec::new();
412    let mut depth = 1usize;
413    let mut code_ticks = 0usize; // > 0 = 在代码围栏里,::: 不算数
414    while *i < lines.len() {
415        let t = lines[*i].trim();
416        if code_ticks > 0 {
417            if is_code_fence_close(&lines[*i], code_ticks) {
418                code_ticks = 0;
419            }
420        } else if t.starts_with("```") {
421            code_ticks = t.bytes().take_while(|b| *b == b'`').count();
422        } else if t == ":::" {
423            depth -= 1;
424            if depth == 0 {
425                *i += 1;
426                break; // 匹配闭合不计入内层
427            }
428        } else if is_fence_open(t) {
429            depth += 1;
430        }
431        inner.push(lines[*i].clone());
432        *i += 1;
433    }
434    inner
435}
436
437/// 代码围栏闭合行:去缩进后是 ≥ `ticks` 枚反引号、且没有别的非空内容。
438fn is_code_fence_close(line: &str, ticks: usize) -> bool {
439    let t = line.trim();
440    let n = t.bytes().take_while(|b| *b == b'`').count();
441    n >= ticks && t[n..].trim().is_empty()
442}
443
444/// 把 `::: columns` 的内层解析成若干栏:每个直接的 `::: col [权重]` 子围栏一栏。
445fn parse_columns(inner: &[String], depth: usize) -> (Vec<Column>, Vec<Block>) {
446    let mut cols = Vec::new();
447    let mut stray_lines: Vec<String> = Vec::new();
448    let mut i = 0;
449    while i < inner.len() {
450        let (head, attrs) = split_fence_word(inner[i].trim().strip_prefix(":::").unwrap_or("").trim());
451        let mut parts = head.split_whitespace();
452        if parts.next() == Some("col") {
453            let weight =
454                parts.next().and_then(|s| s.parse::<f32>().ok()).filter(|w| w.is_finite() && *w > 0.0).unwrap_or(1.0);
455            let col_lines = gather_div(inner, &mut i);
456            let mut blocks = parse_blocks_at(&col_lines, depth);
457            // 带装饰属性的栏 = 整栏一个面板(layout 把它拉齐到本行最高栏)。
458            if !attrs.is_empty() {
459                blocks = vec![Block::Panel(Panel { blocks, decor: panel_decor(attrs) })];
460            }
461            cols.push(Column { blocks, weight });
462        } else {
463            stray_lines.push(inner[i].clone()); // 栏外行收着,随后按普通块解析
464            i += 1;
465        }
466    }
467    (cols, parse_blocks_at(&stray_lines, depth))
468}
469
470/// 围栏开启词拆成「词(含权重等)+ `{}` 内的属性串」;无属性时属性串为空。
471fn split_fence_word(s: &str) -> (String, &str) {
472    match (s.find('{'), s.rfind('}')) {
473        (Some(a), Some(b)) if b > a => (s[..a].trim().to_string(), &s[a + 1..b]),
474        _ => (s.trim().to_string(), ""),
475    }
476}
477
478/// 解析面板装饰属性:`bg=#hex`、`border=#hex`、`border-width=px`(默认 1.5)、
479/// `rounded=px`、`pad=px`、`shadow`(标志)。非法值忽略。
480fn panel_decor(attrs: &str) -> PanelDecor {
481    let mut d = PanelDecor::default();
482    let mut border_color: Option<Color> = None;
483    let mut border_width = 1.5f32;
484    for a in parse_attrs(attrs) {
485        match a {
486            Attr::Kv(k, v) => match k.as_str() {
487                "bg" => d.bg = Color::hex(&v).or(d.bg),
488                "border" => border_color = Color::hex(&v).or(border_color),
489                "border-width" => {
490                    if let Ok(w) = v.parse::<f32>() {
491                        if w.is_finite() && w > 0.0 {
492                            border_width = w;
493                        }
494                    }
495                }
496                "rounded" => {
497                    if let Ok(r) = v.parse::<f32>() {
498                        if r.is_finite() && r >= 0.0 {
499                            d.radius = Some(r);
500                        }
501                    }
502                }
503                "pad" => {
504                    if let Ok(p) = v.parse::<f32>() {
505                        if p.is_finite() && p >= 0.0 {
506                            d.pad = Some(p);
507                        }
508                    }
509                }
510                _ => {}
511            },
512            Attr::Flag(f) => {
513                if f == "shadow" {
514                    d.shadow = Some(Shadow::default());
515                }
516            }
517        }
518    }
519    d.border = border_color.map(|color| ImageBorder { width: border_width, color });
520    d
521}
522
523/// GFM 表格分隔行?每个非空单元格只含 `-`/`:` 且至少一个 `-`。
524fn is_table_delim(t: &str) -> bool {
525    let cells = split_row(t);
526    !cells.is_empty()
527        && cells.iter().all(|c| !c.is_empty() && c.contains('-') && c.bytes().all(|b| b == b'-' || b == b':'))
528}
529
530/// 按 `|` 切一行的单元格(去掉首尾的 `|`,各段去空白)。
531/// `\|` 转义竖线与 `` `行内码` `` 内的竖线不当列分隔(转义本身留给行内解析处理)。
532fn split_row(line: &str) -> Vec<String> {
533    let t = line.trim();
534    let t = t.strip_prefix('|').unwrap_or(t);
535    let t = t.strip_suffix('|').unwrap_or(t);
536    let mut cells = Vec::new();
537    let mut cur = String::new();
538    let mut in_code = false;
539    let mut chars = t.chars();
540    while let Some(ch) = chars.next() {
541        match ch {
542            '`' => {
543                in_code = !in_code;
544                cur.push('`');
545            }
546            // 保留 `\X`(含 `\|`):其中的 `|` 不算列分隔,转义语义交给行内解析。
547            '\\' if !in_code => {
548                cur.push('\\');
549                if let Some(n) = chars.next() {
550                    cur.push(n);
551                }
552            }
553            '|' if !in_code => {
554                cells.push(cur.trim().to_string());
555                cur = String::new();
556            }
557            _ => cur.push(ch),
558        }
559    }
560    cells.push(cur.trim().to_string());
561    cells
562}
563
564/// 分隔行 → 各列对齐(`:--` 左 / `:-:` 中 / `--:` 右)。
565fn parse_align_row(line: &str) -> Vec<Align> {
566    split_row(line)
567        .iter()
568        .map(|c| match (c.starts_with(':'), c.ends_with(':')) {
569            (true, true) => Align::Center,
570            (false, true) => Align::Right,
571            _ => Align::Left,
572        })
573        .collect()
574}
575
576/// 解析一张 GFM 表格(`start` 表头行,`start+1` 分隔行,之后是数据行直到空行 / 无 `|` 行)。
577fn parse_table(lines: &[String], start: usize) -> (Table, usize) {
578    let to_cells = |t: &str| -> Vec<Cell> {
579        split_row(t).iter().map(|s| Cell { inlines: inline::parse_inlines(s), bg: None }).collect()
580    };
581    let header = Some(to_cells(lines[start].trim()));
582    let cols: Vec<ColSpec> =
583        parse_align_row(lines[start + 1].trim()).into_iter().map(|a| ColSpec { align: a, width: None }).collect();
584    let mut rows = Vec::new();
585    let mut i = start + 2;
586    while i < lines.len() {
587        let t = lines[i].trim();
588        if t.is_empty() || !t.contains('|') {
589            break;
590        }
591        rows.push(to_cells(t));
592        i += 1;
593    }
594    (Table { header, rows, cols, style: TableStyle::default() }, i)
595}
596
597/// 给一串块整体设对齐(围栏对齐下沉用):标题 / 段落直接设;引用 / 列表项 / 面板递归下沉。
598fn apply_align(blocks: &mut [Block], align: Align) {
599    for b in blocks {
600        match b {
601            Block::Heading { align: a, .. } | Block::Paragraph { align: a, .. } => *a = align,
602            Block::Quote(inner) => apply_align(inner, align),
603            Block::Panel(p) => apply_align(&mut p.blocks, align),
604            Block::Image(bi) => bi.align = align,
605            Block::List(list) => {
606                for it in &mut list.items {
607                    apply_align(&mut it.blocks, align);
608                }
609            }
610            _ => {}
611        }
612    }
613}
614
615/// 从文字尾部摘出 `{属性}`(要求 `{` 前是空白),解析其中的 `align`。返回 `(正文, 对齐)`。
616fn split_trailing_attrs(s: &str) -> (String, Align) {
617    let t = s.trim_end();
618    if t.ends_with('}') {
619        if let Some(open) = t.rfind('{') {
620            let before = &t[..open];
621            if before.ends_with(' ') || before.is_empty() {
622                let inside = &t[open + 1..t.len() - 1];
623                // 只认得 align:认不出的 {…} 保留为正文,不吞。
624                if let Some(align) = parse_attrs(inside).iter().find_map(|a| match a {
625                    Attr::Kv(k, v) if k == "align" => align_from_word(v),
626                    Attr::Flag(f) => align_from_word(f),
627                    _ => None,
628                }) {
629                    return (before.trim_end().to_string(), align);
630                }
631            }
632        }
633    }
634    (t.to_string(), Align::Left)
635}
636
637/// 段落软换行拼接:两侧都非 CJK 才插空格(CJK 行间不加空格)。
638fn append_soft(buf: &mut String, next: &str) {
639    if next.is_empty() {
640        return;
641    }
642    if let (Some(a), Some(b)) = (buf.chars().last(), next.chars().next()) {
643        // 紧跟硬换行(`\n`)后不加前导空格;否则两侧都非 CJK 才插空格。
644        if a != '\n' && needs_space(a, b) {
645            buf.push(' ');
646        }
647    }
648    buf.push_str(next);
649}
650
651fn needs_space(a: char, b: char) -> bool {
652    // CJK 标点 / 符号 / 表意文字(含 2E80–9FFF)+ 全角形(FF00–FFEF)。
653    fn cjk(c: char) -> bool {
654        matches!(c, '\u{2E80}'..='\u{9FFF}' | '\u{FF00}'..='\u{FFEF}')
655    }
656    !cjk(a) && !cjk(b)
657}