如何在控制台解析并按照格式输出 Markdown 内容
前言
最近在做一个命令行工具的时候,遇到一个挺有意思的需求:工具输出的帮助文档和日志信息都是 Markdown 格式的,但是直接 print 到终端上,一大堆 #、**、` 符号堆在一起,看得人眼花缭乱。
终端本身是支持颜色和样式的呀,那能不能在控制台里把 Markdown 渲染得好看一点?今天记录一下整个实现过程,顺便把代码分享给大家,拿去就能用~
先聊聊思路
Markdown 的语法其实不多,我们日常用到的无非就是这几种:
| 语法 | 含义 |
|---|---|
# ~ ###### | 标题(H1~H6) |
**文字** | 加粗 |
*文字* 或 _文字_ | 斜体 |
`代码` | 行内代码 |
``` | 代码块 |
- item 或 * item | 无序列表 |
1. item | 有序列表 |
> 引用 | 引用块 |
[文字](链接) | 超链接 |
--- | 分割线 |
在终端里,我们要做的事情就是:把这些 Markdown 符号替换成对应的 ANSI 转义序列,让终端显示出颜色和样式来。
比如 **加粗** 变成终端里的加粗文字,# 标题 变成带颜色的大号标题。
ANSI 转义序列,简单复习一下
终端里的颜色和样式,都是靠 ANSI 转义码来控制的。格式很简单:
\033[<代码>m常用的几个:
RESET = "\033[0m" # 重置所有样式
BOLD = "\033[1m" # 加粗
DIM = "\033[2m" # 暗淡
ITALIC = "\033[3m" # 斜体(部分终端不支持)
UNDERLINE = "\033[4m" # 下划线
# 前景色
RED = "\033[31m"
GREEN = "\033[32m"
YELLOW = "\033[33m"
BLUE = "\033[34m"
MAGENTA = "\033[35m"
CYAN = "\033[36m"
WHITE = "\033[37m"
# 背景色
BG_DARK = "\033[48;5;236m" # 深灰背景,用于代码块把文字包在这些码中间,终端就会用对应样式渲染。比如 \033[1m\033[31m文字\033[0m 就是红色加粗的文字。
原理就这么简单,接下来我们开始写解析器。
开始写代码
我用 Python 来实现,主要是正则表达式 + 逐行处理,思路很清晰。
先把整体结构搭起来:
import re
import sys
class MarkdownConsoleRenderer:
"""在终端中渲染 Markdown 内容"""
# ── ANSI 颜色定义 ──
RESET = "\033[0m"
BOLD = "\033[1m"
DIM = "\033[2m"
ITALIC = "\033[3m"
UNDERLINE = "\033[4m"
RED = "\033[31m"
GREEN = "\033[32m"
YELLOW = "\033[33m"
BLUE = "\033[34m"
MAGENTA = "\033[35m"
CYAN = "\033[36m"
WHITE = "\033[37m"
GRAY = "\033[90m"
BG_CODE = "\033[48;5;236m"
# 标题的颜色映射,H1~H6 各来一个
HEADING_COLORS = [
CYAN + BOLD, # H1
GREEN + BOLD, # H2
YELLOW + BOLD, # H3
MAGENTA + BOLD, # H4
BLUE + BOLD, # H5
RED + BOLD, # H6
]
def __init__(self):
self.in_code_block = False
self.code_block_lang = ""
self.code_block_lines = []
self.list_counter = 0
def render(self, text: str) -> str:
"""渲染整个 Markdown 文本"""
lines = text.split("\n")
output_lines = []
for line in lines:
result = self._process_line(line)
output_lines.append(result)
return "\n".join(output_lines)好,骨架有了。接下来一个一个实现各个语法的解析。
1. 标题的处理
标题是最容易识别的,行首一个或多个 # 开头:
def _process_heading(self, line: str) -> str:
match = re.match(r'^(#{1,6})\s+(.*)', line)
if match:
level = len(match.group(1)) # 几个 # 就是几级标题
content = match.group(2).strip()
color = self.HEADING_COLORS[level - 1]
# H1 和 H2 加个下划线装饰一下
if level == 1:
underline = self.DIM + "─" * len(content) + self.RESET
return f"\n{color} {content} {self.RESET}\n{underline}"
elif level == 2:
underline = self.DIM + "╌" * len(content) + self.RESET
return f"\n{color} {content} {self.RESET}\n{underline}"
else:
return f"\n{color} {'#' * level} {content}{self.RESET}"
return None效果大概是这样的:
标题一 ← 青色加粗
───────────── ← 灰色下划线2. 行内样式:加粗、斜体、行内代码
这一步用正则替换就行,把 Markdown 语法替换成 ANSI 码:
def _apply_inline_styles(self, text: str) -> str:
"""处理行内样式"""
# 行内代码 `code`
text = re.sub(
r'`([^`]+)`',
f'{self.BG_CODE}{self.YELLOW} \\1 {self.RESET}',
text
)
# 加粗 **text** 或 __text__
text = re.sub(
r'\*\*(.+?)\*\*|__(.+?)__',
lambda m: f'{self.BOLD}{self.WHITE}{m.group(1) or m.group(2)}{self.RESET}',
text
)
# 斜体 *text* 或 _text_
text = re.sub(
r'(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)|(?<!_)_(?!_)(.+?)(?<!_)_(?!_)',
lambda m: f'{self.ITALIC}{self.CYAN}{m.group(1) or m.group(2)}{self.RESET}',
text
)
# 超链接 [text](url)
text = re.sub(
r'\[([^\]]+)\]\(([^)]+)\)',
lambda m: f'{self.UNDERLINE}{self.BLUE}{m.group(1)}{self.RESET} {self.GRAY}({m.group(2)}){self.RESET}',
text
)
return text注意一下正则的顺序——行内代码要放在最前面处理,否则里面的 * 号可能会被误当成斜体标记。
3. 代码块的处理
代码块是跨行的,用 ``` 开始和结束。我们需要一个状态来追踪当前是否在代码块中:
def _process_code_block(self, line: str) -> str | None:
"""处理代码块"""
# 代码块开始
if re.match(r'^```', line):
if not self.in_code_block:
self.in_code_block = True
self.code_block_lang = line.strip('` \n')
self.code_block_lines = []
return None # 开始标记不输出
else:
# 代码块结束,输出收集的内容
self.in_code_block = False
return self._render_code_block()
# 在代码块内部,收集行
if self.in_code_block:
self.code_block_lines.append(line)
return None
return "NOT_IN_CODE_BLOCK" # 不是代码块的行代码块结束的时候,我们给它加个深色背景、行号,好看一点:
def _render_code_block(self) -> str:
"""渲染收集好的代码块"""
header = ""
if self.code_block_lang:
header = f" {self.GRAY}{self.code_block_lang}{self.RESET}"
lines = []
lines.append(f"{header}")
lines.append(f"{self.BG_CODE}{self.GRAY} ┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄ {self.RESET}")
for i, code_line in enumerate(self.code_block_lines, 1):
line_no = f"{self.GRAY}{i:>3} │{self.RESET}"
lines.append(f"{self.BG_CODE}{line_no} {code_line} {self.RESET}")
lines.append(f"{self.BG_CODE}{self.GRAY} ┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄ {self.RESET}")
return "\n".join(lines)效果大概长这样:
python
┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄
1 │ print("hello")
2 │ x = 42
┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄是不是有点内味了~
4. 列表的处理
无序列表和有序列表:
def _process_list(self, line: str) -> str | None:
# 无序列表 - item 或 * item
match = re.match(r'^(\s*)[-*]\s+(.*)', line)
if match:
indent = len(match.group(1))
content = self._apply_inline_styles(match.group(2))
bullet = f"{self.GREEN}●{self.RESET}"
padding = " " * (indent // 2)
return f" {padding}{bullet} {content}"
# 有序列表 1. item
match = re.match(r'^(\s*)\d+\.\s+(.*)', line)
if match:
self.list_counter += 1
content = self._apply_inline_styles(match.group(2))
num = f"{self.CYAN}{self.list_counter}.{self.RESET}"
return f" {num} {content}"
# 不是列表行,重置计数器
self.list_counter = 0
return None5. 引用块
引用块以 > 开头,我们给它加个竖线和灰色:
def _process_blockquote(self, line: str) -> str | None:
match = re.match(r'^>\s*(.*)', line)
if match:
content = self._apply_inline_styles(match.group(1))
return f" {self.YELLOW}│{self.RESET} {self.DIM}{content}{self.RESET}"
return None6. 分割线
一行全是 - 或 * 或 _(至少3个):
def _process_horizontal_rule(self, line: str) -> str | None:
if re.match(r'^[-*_]{3,}\s*$', line):
width = 60
return f"\n{self.GRAY}{'─' * width}{self.RESET}\n"
return None7. 把它们串起来
最后,在 _process_line 里把所有处理逻辑串起来:
def _process_line(self, line: str) -> str:
# 代码块优先处理
code_result = self._process_code_block(line)
if code_result is None:
return "" # 代码块内部的行已被收集
if code_result != "NOT_IN_CODE_BLOCK":
return code_result # 代码块的边界行
# 空行
if not line.strip():
return ""
# 标题
heading = self._process_heading(line)
if heading:
return heading
# 分割线
hr = self._process_horizontal_rule(line)
if hr:
return hr
# 引用
quote = self._process_blockquote(line)
if quote:
return quote
# 列表
lst = self._process_list(line)
if lst:
return lst
# 普通行,应用行内样式
return f" {self._apply_inline_styles(line)}"跑一下看看效果
我发现缩略内容会包含```符号,所以解析会失败,你可以去 https://markdownlivepreview.com/ 复制下来它的占位内容测试。
从文件读取并渲染
实际使用的时候,一般是从 .md 文件读取的,加个简单的命令行入口就行:
def main():
if len(sys.argv) > 1:
filepath = sys.argv[1]
try:
with open(filepath, "r", encoding="utf-8") as f:
content = f.read()
except FileNotFoundError:
print(f"\033[31m文件不存在: {filepath}\033[0m")
sys.exit(1)
else:
# 从 stdin 读取,支持管道
content = sys.stdin.read()
renderer = MarkdownConsoleRenderer()
print(renderer.render(content))
if __name__ == "__main__":
main()用法就很灵活了:
# 直接渲染文件
python md_render.py README.md
# 配合管道使用
curl -s https://raw.githubusercontent.com/xxx/xxx/main/README.md | python md_render.py写在最后
整个实现下来,代码量不多,核心就是一个正则替换 + ANSI 转义码的拼接。虽然比起真正的 Markdown 解析器(比如 markdown-it、CommonMark)来说,这个实现非常简陋,嵌套样式、复杂列表、表格这些都没处理,但是对于日常的命令行工具帮助文档、README 预览这些场景来说,够用了。
核心思想就一句话:把 Markdown 符号换成 ANSI 颜色码,就这么简单。
源码我就不单独开仓库了,直接复制上面的代码就能跑。如果大家有需要的话,后面可以加上表格支持、图片占位符之类的功能。