前言

最近在做一个命令行工具的时候,遇到一个挺有意思的需求:工具输出的帮助文档和日志信息都是 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 None

5. 引用块

引用块以 > 开头,我们给它加个竖线和灰色:

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 None

6. 分割线

一行全是 - 或 * 或 _(至少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 None

7. 把它们串起来

最后,在 _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 颜色码,就这么简单。

源码我就不单独开仓库了,直接复制上面的代码就能跑。如果大家有需要的话,后面可以加上表格支持、图片占位符之类的功能。