我的第一个开源贡献:修复 DeepSeek-TUI 的 Markdown 渲染问题
背景
DeepSeek-TUI 是一个用 Rust 编写的终端 AI 助手,基于 DeepSeek V4 模型,支持 1M token 上下文。我在日常使用中发现 AI 的回复里 Markdown 格式完全没有被渲染,严重影响阅读体验。这是我第一次接触 Rust,也是我第一次向开源项目提交 PR。
问题现象
使用时发现三类渲染问题:
1. 表格原样输出
| 名称 | 版本 | 状态 |
|----------|------|------|
| React | 18.x | 稳定 |
分隔行 |---| 也直接显示出来,没有任何格式化。
2. 粗体/斜体标记符没有被剥掉
**粗体文本** 和 *斜体文本*
星号原样显示,没有加粗或斜体效果。
3. 水平线没有渲染
---
直接显示成三个横杠,没有渲染成分隔线。
代码分析
项目的渲染逻辑在 crates/tui/src/tui/markdown_render.rs。读完代码后理解了它的设计:
两阶段渲染架构
源文本 → parse() → ParsedMarkdown (AST) → render_parsed(width) → Vec<Line>
- parse 阶段:与终端宽度无关,把源文本分类成
Block枚举的各种变体 - render 阶段:依赖宽度,做折行和样式渲染
这个设计的好处是终端 resize 时只需重新 render,不需要重新 parse,性能更好。
问题根因
问题一:Block 枚举缺少表格和水平线类型
原来的 Block 枚举只有:
pub enum Block {
Heading { level: usize, text: String },
HeadingRule,
ListItem { bullet: String, text: String },
Code { line: String },
Paragraph { text: String },
Blank,
}
没有 HorizontalRule 和 TableRow,所以 --- 和 | col | 都被当成普通段落原样输出。
问题二:内联格式按词分割导致跨词标记识别失败
原来的 render_line_with_links 函数是这样处理的:
for word in line.split_whitespace() {
let is_link = looks_like_link(word);
let style = if is_link { link_style } else { base_style };
// ...
}
按空格分词后逐词判断,**bold text** 会被拆成 **bold 和 text** 两个词,每个词单独看都不符合 **...** 的格式,所以识别失败,星号原样输出。
修复方案
第一步:新增 Block 变体
在 Block 枚举里加入两个新类型:
pub enum Block {
Heading { level: usize, text: String },
HeadingRule,
+ /// A standalone `---` / `***` / `___` horizontal rule.
+ HorizontalRule,
ListItem { bullet: String, text: String },
Code { line: String },
+ /// A table row: cells split on `|`. Separator rows (`|---|`) are dropped.
+ TableRow(Vec<String>),
Paragraph { text: String },
Blank,
}
第二步:在 parse() 里识别新类型
在解析循环里加入水平线和表格的识别逻辑:
+ if is_horizontal_rule(trimmed) {
+ blocks.push(Block::HorizontalRule);
+ continue;
+ }
+
+ match parse_table_row(trimmed) {
+ Some(cells) => {
+ blocks.push(Block::TableRow(cells));
+ continue;
+ }
+ None if trimmed.starts_with('|') => continue, // separator row — drop it
+ None => {}
+ }
+
if raw_line.is_empty() {
关键点:parse_table_row 返回 None 且行以 | 开头时,说明是分隔行(|---|),直接 continue 丢弃,不降级为 Paragraph。
is_horizontal_rule 实现:
fn is_horizontal_rule(line: &str) -> bool {
let stripped: String = line.chars().filter(|c| !c.is_whitespace()).collect();
(stripped.chars().all(|c| c == '-')
|| stripped.chars().all(|c| c == '*')
|| stripped.chars().all(|c| c == '_'))
&& stripped.len() >= 3
}
去掉空白字符后,判断是否全由 -、* 或 _ 组成且长度 ≥ 3。
parse_table_row 实现:
fn parse_table_row(line: &str) -> Option<Vec<String>> {
if !line.starts_with('|') {
return None;
}
let inner = line.trim_matches('|');
let cells: Vec<String> = inner.split('|').map(|c| c.trim().to_string()).collect();
// Separator row: every non-empty cell is only dashes/colons/spaces
if cells
.iter()
.all(|c| c.is_empty() || c.chars().all(|ch| ch == '-' || ch == ':' || ch == ' '))
{
return None;
}
Some(cells)
}
去掉首尾 | 后按 | 分割,trim 每个单元格。如果所有非空单元格都只含 -、: 或空格,说明是分隔行,返回 None。
第三步:重写内联格式解析
原来按词分割的方式无法处理跨词的 **bold text**,需要先扫描整行识别所有内联标记,再做折行。
新增 parse_inline_spans 函数,返回 (文本, 样式) 的列表:
fn parse_inline_spans(line: &str, base_style: Style, link_style: Style) -> Vec<(String, Style)> {
let bold_style = base_style.add_modifier(Modifier::BOLD);
let italic_style = base_style.add_modifier(Modifier::ITALIC);
let mut out = Vec::new();
let mut rest = line;
while !rest.is_empty() {
// **bold**
if let Some(end) = rest.strip_prefix("**").and_then(|s| s.find("**")) {
let inner = &rest[2..2 + end];
out.push((inner.to_string(), bold_style));
rest = &rest[2 + end + 2..];
continue;
}
// __bold__
if let Some(end) = rest.strip_prefix("__").and_then(|s| s.find("__")) {
let inner = &rest[2..2 + end];
out.push((inner.to_string(), bold_style));
rest = &rest[2 + end + 2..];
continue;
}
// *italic*
if rest.starts_with('*')
&& !rest.starts_with("**")
&& let Some(end) = rest[1..].find('*')
{
let inner = &rest[1..1 + end];
out.push((inner.to_string(), italic_style));
rest = &rest[1 + end + 1..];
continue;
}
// _italic_
if rest.starts_with('_')
&& !rest.starts_with("__")
&& let Some(end) = rest[1..].find('_')
{
let inner = &rest[1..1 + end];
out.push((inner.to_string(), italic_style));
rest = &rest[1 + end + 1..];
continue;
}
// URL: consume until whitespace
if rest.starts_with("http://") || rest.starts_with("https://") {
let end = rest.find(char::is_whitespace).unwrap_or(rest.len());
let url = &rest[..end];
let content = if osc8::enabled() {
osc8::wrap_link(url, url)
} else {
url.to_string()
};
out.push((content, link_style));
rest = &rest[end..];
continue;
}
// Plain text: consume until next marker or URL
let next = find_next_marker(rest).max(rest.chars().next().map_or(1, |c| c.len_utf8()));
out.push((rest[..next].to_string(), base_style));
rest = &rest[next..];
}
out
}
render_line_with_links 改为先调用 parse_inline_spans 得到带样式的 token 列表,再做折行:
- for word in line.split_whitespace() {
- let is_link = looks_like_link(word);
- let style = if is_link { link_style } else { base_style };
- // ...
- }
+ let tokens = parse_inline_spans(line, base_style, link_style);
+ let mut words: Vec<(String, Style)> = Vec::new();
+ for (text, style) in tokens {
+ let mut first = true;
+ for part in text.split(' ') {
+ if !first {
+ words.push((" ".to_string(), style));
+ }
+ if !part.is_empty() {
+ words.push((part.to_string(), style));
+ }
+ first = false;
+ }
+ }
第四步:在 render_parsed() 里渲染新类型
+ Block::HorizontalRule => {
+ out.push(Line::from(Span::styled(
+ "─".repeat(width.min(60)),
+ Style::default().fg(palette::TEXT_DIM),
+ )));
+ }
+ Block::TableRow(cells) => {
+ out.extend(render_table_row(cells, width, base_style));
+ }
render_table_row 实现:
fn render_table_row(cells: &[String], width: usize, base_style: Style) -> Vec<Line<'static>> {
if cells.is_empty() {
return vec![Line::from("")];
}
let col_width = (width.saturating_sub(3 * cells.len() + 1)) / cells.len();
let col_width = col_width.max(4);
let sep_style = Style::default().fg(palette::TEXT_DIM);
let mut spans: Vec<Span> = vec![Span::styled("│ ".to_string(), sep_style)];
for (i, cell) in cells.iter().enumerate() {
// 超出列宽时截断并加省略号
let truncated = if cell.width() > col_width {
let mut s = String::new();
let mut w = 0;
for ch in cell.chars() {
let cw = ch.width().unwrap_or(1);
if w + cw + 1 > col_width {
s.push('…');
break;
}
s.push(ch);
w += cw;
}
s
} else {
cell.clone()
};
// 单元格内容也走内联格式解析(支持表格里的粗体)
let cell_spans = parse_inline_spans(&truncated, base_style, link_style());
let cell_width: usize = cell_spans.iter().map(|(t, _)| t.width()).sum();
let pad = col_width.saturating_sub(cell_width);
for (text, style) in cell_spans {
spans.push(Span::styled(text, style));
}
spans.push(Span::raw(" ".repeat(pad)));
if i + 1 < cells.len() {
spans.push(Span::styled(" │ ".to_string(), sep_style));
} else {
spans.push(Span::styled(" │".to_string(), sep_style));
}
}
vec![Line::from(spans)]
}
用 │(Unicode 竖线)而不是 ASCII | 作为分隔符,视觉上更整洁。列宽计算时用 UnicodeWidthChar 按字符实际显示宽度计算,正确处理中文等宽字符。
第五步:PR 审查后的修复
PR 提交后,gemini-code-assist bot 指出了两处小问题:
修复1:空格 token 的样式应继承当前 token 的样式
-words.push((" ".to_string(), base_style));
+words.push((" ".to_string(), style));
修复2:表格列宽计算没有考虑分隔符宽度
-let col_width = (width.saturating_sub(cells.len() + 1)) / cells.len();
+let col_width = (width.saturating_sub(3 * cells.len() + 1)) / cells.len();
每列分隔符占 3 个字符(│),原来只减了 1,导致列宽计算偏大,表格会溢出终端宽度。
测试
新增了三个测试用例:
#[test]
fn table_separator_row_is_dropped() {
let src = "| 项目属性 | 详情 |\n|----------|------|\n| **语言** | Rust 1.85+ |\n";
let parsed = parse(src);
let table_rows: Vec<_> = parsed.blocks.iter()
.filter(|b| matches!(b, Block::TableRow(_)))
.collect();
assert_eq!(table_rows.len(), 2); // 只有表头和数据行,没有分隔行
}
#[test]
fn bold_markers_stripped_in_render() {
let src = "这是一个 **Rust 工作区项目**,包含多个 crate。\n";
let lines = render_markdown(src, 80, Style::default());
let text: String = lines.iter()
.flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
.collect();
assert!(!text.contains("**")); // 星号不能出现在输出里
assert!(text.contains("Rust")); // 内容要保留
}
#[test]
fn table_renders_with_pipe_separator() {
let src = "| 文件 | 改动 |\n|---|---|\n| foo.rs | 重写 |\n";
let lines = render_markdown(src, 60, Style::default());
let text: String = lines.iter()
.flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
.collect();
assert!(text.contains('│')); // 用 Unicode 竖线
assert!(!text.contains("|---|")); // 分隔行不能出现
}
最终 12 个测试全部通过。
结果
PR 被作者 merge,修复从 v0.8.10 起包含在所有版本中。项目当天冲上 GitHub Trending 第一,我的头像也挂在上面。
注意:通过 npm install / deepseek update 安装的预编译二进制可能不包含此修复(取决于作者打包时间)。通过 cargo install 从源码编译可以确保拿到最新版本。
收获
技术层面:
- 第一次读懂并修改 Rust 项目,了解了枚举、模式匹配、字符串切片的基本用法
- 理解了"解析与渲染分离"的架构设计——parse 结果与终端宽度无关,resize 时只需重新 render
- 学会了用
UnicodeWidthChar正确计算中文等宽字符的显示宽度 - 了解了预编译二进制分发(npm)和从源码编译(cargo install)的区别
流程层面:
- 第一次完整走完 fork → 修改 → 测试 → PR → review → merge 的开源贡献流程
- 了解了 CI bot(gemini-code-assist)的代码审查方式,以及如何根据 review 意见修改代码