Jupytext 的 Pandoc Markdown 格式解析:raw cell 复杂 YAML 内容的表示与往返转换
Jupytext 的 Pandoc Markdown 格式解析:raw cell 复杂 YAML 内容的表示与往返转换
Jupytext 提供了一种独特的 notebook 文本化方案:通过 Pandoc 将 .ipynb 转换为 Pandoc 风格的 Markdown(md:pandoc)。本文以仓库测试数据 tests/data/notebooks/outputs/ipynb_to_pandoc/raw_cell_with_complex_yaml_like_content.md 为切入点,讲解这种格式如何用 YAML 头部、fenced div 与 {=ipynb} 代码块忠实还原一个"内容形似 YAML 文档"的复杂 raw cell,并说明其与普通 Markdown(md)、MyST(md:myst)格式的差异、Pandoc 版本约束以及源码级实现原理。读完本文,你将掌握 md:pandoc 格式的完整结构、CLI 转换方法,以及如何用测试用例验证 raw cell 的往返无损。
一、从问题出发:为什么需要 Pandoc Markdown 格式
Jupytext 原生支持多种文本格式:percent 脚本(py:percent)、light 脚本(py:light)、R Markdown(.Rmd)、MyST Markdown(md:myst)等。它们各有自己的 cell 分隔与头部约定。而 md:pandoc 是另一种重要选择,其定位在 src/jupytext/formats.py 中清晰可见:
NotebookFormatDescription(
format_name="pandoc",
extension=".md",
header_prefix="",
cell_reader_class=None,
cell_exporter_class=None,
current_version_number=pandoc_version(),
),
注意两点:
- 没有自定义的 cell 读写器(
cell_reader_class=None、cell_exporter_class=None),读写完全交给 Pandoc 本身完成——这就是md:pandoc与其它格式的本质区别; - 格式版本号直接取当前 Pandoc 版本(
current_version_number=pandoc_version()),这意味着同一份.md文件的格式版本会随本地 Pandoc 变化,因此对 Pandoc 的版本一致性要求格外敏感。
仓库中的格式别名映射 "pandoc": "md:pandoc"(见 src/jupytext/formats.py)说明:使用 .md 文件时若想走 Pandoc 路线,必须显式声明 md:pandoc,而裸 md 则对应 Jupytext 自己的 Markdown 读写器。
二、读懂转换样例:raw cell 的完整表示
关联文档 tests/data/notebooks/outputs/ipynb_to_pandoc/raw_cell_with_complex_yaml_like_content.md 是一个典型的 md:pandoc 输出,全文只有三部分:YAML 头部、一个 raw cell、一个 code cell。对应的输入 notebook 是 tests/data/notebooks/inputs/ipynb_py/raw_cell_with_complex_yaml_like_content.ipynb,其第一个 cell 的 source 为:
---
<空行>
This is a complex paragraph
that is split over multiple lines.
<空行>
It also includes blank lines.
<空行><空行>
---
这是一个以 --- 开头、包含多行文本和空行、又以 --- 结尾的 raw cell——内容结构上非常像一份残缺的 YAML 文档,因此测试名称为 "complex yaml like content"。它的难点在于:raw cell 内容里既有 ---(也是 Jupytext YAML 头部的分隔符),又有空行、多段文本,任何轻率的文本解析都可能误判或丢失内容。
2.1 文档骨架:Jupytext 标准的 YAML 头部
md:pandoc 文件同样以 Jupytext 约定格式的 YAML 头部开头(header_prefix="",即无注释前缀):
---
jupyter:
kernelspec:
display_name: Python 3
language: python
name: python3
nbformat: 4
nbformat_minor: 5
---
头部记录了 kernelspec 与 nbformat 版本,与输入 ipynb 的 metadata 一致,转换过程中原样保留。
2.2 raw cell 的 Pandoc 表示:fenced div + {=ipynb}
raw cell 被表示为 Pandoc 的 fenced div,外层用 ::: 包裹,属性中携带 cell 的 id 与类型:
::: {#b32297a4 .cell .raw}
```{=ipynb}
---
This is a complex paragraph
that is split over multiple lines.
It also includes blank lines.
---
:::
结构拆解:
- `{#b32297a4 .cell .raw}`:`#b32297a4` 是 cell id(与输入 ipynb 的 `"id": "b32297a4"` 一一对应),`.cell` 标记这是一个 notebook cell,`.raw` 标记 cell 类型为 raw;
- ````{=ipynb}```` 是 Pandoc 的 raw attribute 语法,表示"这段内容是面向 ipynb 目标的原生格式",从而把 raw cell 的原始文本原样保护起来,避免被 Markdown 解析器当作正文处理;
- 内部的 `---` 行、空行、多行段落被完整保留,包括末尾的空行,未被改写。
这正是 raw cell 与 markdown cell 在 Pandoc 表示上的关键差异:markdown cell 直接以普通 Markdown 正文呈现,而 raw cell 必须通过 `{=ipynb}` 保真。
### 2.3 code cell 的 Pandoc 表示:fenced div + python 代码块
```markdown
::: {#0b3bde0a .cell .code}
``` python
print("Hello, World!")
:::
code cell 同样用 fenced div 包裹,属性中 `#0b3bde0a` 对应输入 notebook 中第二个 cell 的 id,`.code` 标记类型;单元格内容以 ` ``` python` 围栏代码块呈现。
## 三、往返转换的源码实现
`md:pandoc` 的读写实现在 <a href="https://link.gitcode.com/i/8bf1f7837d40e7a33d4dbe7f3333c9de" target="_blank">src/jupytext/pandoc.py</a>,核心是两个对称函数:
- `md_to_notebook(text)`(<a href="https://link.gitcode.com/i/aeee9007d6fe1af0ff53ee77a0081b3f" target="_blank">src/jupytext/pandoc.py</a>):把 Pandoc Markdown 文本写入临时文件,调用 `pandoc --from markdown --to ipynb -s ...`,再用 `nbformat.reads` 读回 notebook;
- `notebook_to_md(notebook)`(<a href="https://link.gitcode.com/i/5c701d7700a9df1a670b9002c57ee122" target="_blank">src/jupytext/pandoc.py</a>):把 notebook 序列化为 ipynb 临时文件,调用 `pandoc --from ipynb --to markdown -s ...` 生成 Markdown。
两者在 <a href="https://link.gitcode.com/i/00189c8020168b773dc30cc64a6a98c5" target="_blank">src/jupytext/jupytext.py</a> 被导入,并在 `jupytext.reads/writes` 中根据 `format_name == "pandoc"` 分流调用(见 <a href="https://link.gitcode.com/i/613ae0bba6dbc265d28c2a03d68a1d34" target="_blank">src/jupytext/jupytext.py</a>、<a href="https://link.gitcode.com/i/964d886783305757ccb9264841f520ca" target="_blank">src/jupytext/jupytext.py</a>)。
转换命令的细节值得注意,其中体现了对 Pandoc 版本的兼容处理:
| Pandoc 版本 | ipynb → md 参数 | md → ipynb 参数 |
| --- | --- | --- |
| `>= 2.7.2` 且 `< 2.11.2` | `--from ipynb --to markdown -s --atx-headers --wrap=preserve --preserve-tabs` | `--from markdown --to ipynb -s --atx-headers --wrap=preserve --preserve-tabs` |
| `>= 2.11.2` | `--from ipynb --to markdown -s --markdown-headings=atx --wrap=preserve --preserve-tabs` | `--from markdown --to ipynb -s --markdown-headings=atx --wrap=preserve --preserve-tabs` |
- `--wrap=preserve`:保留源码中的换行位置——这正是样例文档中 raw cell 多行段落与空行能够原样往返的关键;
- `--preserve-tabs`:保留制表符,避免空格化改写;
- `--atx-headers` / `--markdown-headings=atx`:统一 ATX 风格标题(`#`),保证 Jupytext 各格式间标题一致性;
- `-s`(standalone):生成包含元数据头的完整文档,YAML 头部由此而来。
Pandoc 版本门槛在 <a href="https://link.gitcode.com/i/b8d491d774dd19df1ba8d37270b1482c" target="_blank">src/jupytext/formats.py</a> 也有兜底检查:当目标格式为 `pandoc` 而本机未安装 Pandoc 时,会在此处给出明确错误提示。
## 四、版本约束与错误处理
`md:pandoc` 对运行环境有硬性要求。`raise_if_pandoc_is_not_available`(<a href="https://link.gitcode.com/i/a35ffeb10dd2983b00e1fba6a69a2487" target="_blank">src/jupytext/pandoc.py</a>)会依次检查:
1. Pandoc 是否安装——未安装时抛出 `PandocError`:"The Pandoc Markdown format requires 'pandoc>=2.7.2', but pandoc was not found";
2. 版本是否达到最低要求 `2.7.2`;
3. 若设置了 `max_version`,版本不得高于上限(Jupytext 默认不设上限)。
版本探测由 `pandoc_version()`(<a href="https://link.gitcode.com/i/6628c4bfec399c633ba81a361035699f" target="_blank">src/jupytext/pandoc.py</a>)通过执行 `pandoc --version` 完成,失败返回 `"N/A"`。测试 <a href="https://link.gitcode.com/i/ab6d279a9024d18dee0f24b394dffd6a" target="_blank">tests/external/simple_external_notebooks/test_read_simple_pandoc.py</a> 专门验证了未安装 Pandoc 时 CLI 报错的场景:
```python
with pytest.raises(PandocError, match="The Pandoc Markdown format requires 'pandoc>=2.7.2'"):
jupytext_cli([str(nb_file), "--to", "md:pandoc"])
因此,在使用 md:pandoc 之前请确认本机满足 pandoc >= 2.7.2。
五、CLI 实操:完整转换流程
以样例 notebook 为例,仓库通过 CLI 执行往返转换的完整命令链如下。
ipynb → md:pandoc:
jupytext --to md:pandoc "tests/data/notebooks/inputs/ipynb_py/raw_cell_with_complex_yaml_like_content.ipynb"
生成的文件即关联文档 tests/data/notebooks/outputs/ipynb_to_pandoc/raw_cell_with_complex_yaml_like_content.md。
md:pandoc → ipynb(逆向还原):
jupytext --to ipynb "tests/data/notebooks/outputs/ipynb_to_pandoc/raw_cell_with_complex_yaml_like_content.md"
往返一致性校验:仓库测试 tests/external/round_trip/test_mirror_external.py 中的 test_ipynb_to_pandoc 直接复用同一批输入与输出目录,其断言逻辑 assert_conversion_same_as_mirror(ipynb_to_pandoc, "md:pandoc", "ipynb_to_pandoc") 表明:该输出文件正是在 ipynb → md:pandoc → ipynb 全链路下与输入 notebook 等价的结果。此外,函数式测试 tests/functional/round_trip/test_mirror.py 展示了各格式通用的镜像比对模式,pandoc 目录下的样例即这一模式的实例化。
值得注意:ipynb_to_pandoc fixture 在 tests/conftest.py 中对输入 notebook 做了筛选(跳过含复杂 metadata、raw cell、R magic 等特殊用例),而 raw_cell_with_complex_yaml_like_content 恰好属于被 pandoc 化往返测试覆盖的输入——这说明该样例是 Pandoc 转换"保真能力"的正面用例。
六、与 MyST Markdown 的对比:同一 raw cell 的不同写法
Jupytext 的 Markdown 系文本格式并非只有 Pandoc 一种。同为 .md 扩展名,md:myst 使用 MyST 方言:raw cell 通过 {raw-cell} 指令表示,markdown cell 之间用 +++ 分隔。对比如下(以同一 notebook 的 MyST 输出 tests/data/notebooks/outputs/ipynb_to_myst/raw_cell_with_complex_yaml_like_content.md 为参照):
| 维度 | md:pandoc |
md:myst |
|---|---|---|
| raw cell 语法 | fenced div ::: {#id .cell .raw} + {=ipynb} 块 |
MyST {raw-cell} 指令 |
| code cell | ::: {#id .cell .code} + 围栏代码块 |
围栏代码块 + +++ 分隔 |
| cell 分隔方式 | fenced div 包裹(Pandoc 原生) | +++ 标记(Jupytext 约定) |
| 读写实现 | 委托 Pandoc 二进制 | Jupytext 内置 cell reader/exporter |
| 依赖 | 必须安装 pandoc >= 2.7.2 |
无外部二进制依赖 |
选择建议:若项目已依赖 Pandoc(例如用于文档发布),md:pandoc 可让 notebook 的文本形态与 Pandoc 生态直接互操作;若希望保持纯 Python 依赖、并在 VSCode/Jupyter 中享有 Jupytext 原生的 cell 标记,md:myst 更轻量。两种格式都完整保留 cell id 与 cell 类型元数据。
七、小结:从样例看 Pandoc 格式的保真边界
raw_cell_with_complex_yaml_like_content.md 虽然只有约 30 行,却浓缩了 md:pandoc 格式的全部核心机制:
- YAML 头部完整携带 kernelspec 与 nbformat 元数据;
- raw cell 用 fenced div +
{=ipynb}保护,使"形似 YAML、含空行多段"的复杂内容原样往返; - code cell 用 fenced div + 围栏代码块呈现,cell id 一一对应;
- 往返由 src/jupytext/pandoc.py 委托 Pandoc 完成,
--wrap=preserve是空行与换行保真的关键参数; - 环境要求
pandoc >= 2.7.2,缺失时通过PandocError明确报错,并有 tests/external/round_trip/test_mirror_external.py 等测试守护转换等价性。
对于任何需要把 notebook 纳入 Pandoc 文档工作流(报告生成、学术写作、Git 友好版本控制)的团队,md:pandoc 是一条成熟的路径;而理解 raw cell 的 {=ipynb} 表示,是确保这些"非常规内容"在文本与 notebook 之间零丢失的关键。