Jupytext 的 Pandoc Markdown 格式解析:raw cell 复杂 YAML 内容的表示与往返转换

原创2026-10-07 18:29:11259 阅读
文章标签:开发工具

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(),
),

注意两点:

  1. 没有自定义的 cell 读写器(cell_reader_class=None、cell_exporter_class=None),读写完全交给 Pandoc 本身完成——这就是 md:pandoc 与其它格式的本质区别;
  2. 格式版本号直接取当前 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 格式的全部核心机制:

  1. YAML 头部完整携带 kernelspec 与 nbformat 元数据;
  2. raw cell 用 fenced div + {=ipynb} 保护,使"形似 YAML、含空行多段"的复杂内容原样往返;
  3. code cell 用 fenced div + 围栏代码块呈现,cell id 一一对应;
  4. 往返由 src/jupytext/pandoc.py 委托 Pandoc 完成,--wrap=preserve 是空行与换行保真的关键参数;
  5. 环境要求 pandoc >= 2.7.2,缺失时通过 PandocError 明确报错,并有 tests/external/round_trip/test_mirror_external.py 等测试守护转换等价性。

对于任何需要把 notebook 纳入 Pandoc 文档工作流(报告生成、学术写作、Git 友好版本控制)的团队,md:pandoc 是一条成熟的路径;而理解 raw cell 的 {=ipynb} 表示,是确保这些"非常规内容"在文本与 notebook 之间零丢失的关键。

登录后查看全文
jupytext