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