Jupytext Pandoc 格式下的 Raw Cell 与 YAML 内容表示:从非字典 YAML 到 ipynb 的完整往返
Jupytext Pandoc 格式下的 Raw Cell 与 YAML 内容表示:从非字典 YAML 到 ipynb 的完整往返
导读
在 Jupytext 的多种文本表示格式中,pandoc 格式是依赖外部 Pandoc 工具链、与 Pandoc Markdown 生态深度互操作的一种 Notebook 文本形式。本指南围绕仓库中的典型转换样例 raw_cell_with_non_dict_yaml_content.md 展开,剖析一个内容为 YAML 形态(但并非字典结构)的 raw cell 在从 ipynb 转换为 Pandoc Markdown 时如何被无损表示,并深入讲解背后的 pandoc 命令参数、Jupyter 元数据 front matter、::: div 包装与 {=ipynb} 原始代码块机制,以及仓库中验证该转换正确性的镜像往返测试。读者读完可掌握 Jupytext pandoc 格式的读写原理、raw cell 的表示约定,以及如何通过测试镜像文件验证自定义 notebook 的转换一致性。
一、样例文档速览:一次转换的完整足迹
1.1 源 notebook:raw cell 里藏着"非字典 YAML"
转换的源头是 raw_cell_with_non_dict_yaml_content.ipynb,它只包含两个 cell:
| Cell ID | 类型 | 源码内容 |
|---|---|---|
b32297a4 |
raw | ---\nContent.\n--- |
0b3bde0a |
code | print("Hello, World!") |
注意第一个 raw cell 的内容:它以 --- 开头和结尾,形式上与 YAML front matter 高度相似,但内部只是普通文本 Content.,并不是一个合法的字典结构(没有 key: value 对)。这正是测试文件命名中 non_dict_yaml_content 的含义——它专门用于检验:当一个 raw cell 的内容长得像 YAML、却不是字典时,文本格式能否原样保住它。
notebook 顶层元数据中带有 Python 3 的 kernelspec(display_name: Python 3、language: python、name: python3)以及 nbformat: 4、nbformat_minor: 5。
1.2 转换输出:pandoc 如何表示这两种 cell
经过 ipynb -> pandoc markdown 转换后,得到的 raw_cell_with_non_dict_yaml_content.md 全文如下:
---
jupyter:
kernelspec:
display_name: Python 3
language: python
name: python3
nbformat: 4
nbformat_minor: 5
---
::: {#b32297a4 .cell .raw}
```{=ipynb}
---
Content.
---
:::
::: {#0b3bde0a .cell .code}
print("Hello, World!")
:::
这份输出浓缩了 Pandoc 格式的三个核心表示规则,下面逐一展开。
## 二、Jupyter 元数据的 YAML front matter 头部
转换结果的顶部是 `---` 包裹的 YAML front matter,其中 `jupyter:` 键下完整保留了三类元数据:
- `kernelspec`:`display_name`、`language`、`name` 三个字段与源 notebook 完全一致;
- `nbformat: 4`:notebook 格式版本;
- `nbformat_minor: 5`:notebook 格式次版本。
这与仓库中 <a href="https://link.gitcode.com/i/ae3642ca236fcd2d4d8388d65344f098" target="_blank">pandoc.py</a> 的实现直接相关:`notebook_to_md` 先把整个 notebook 通过 `ipynb_writes`(来自 `nbformat` 的 `writes`,代码中特意 `from nbformat import writes as ipynb_writes` 以避免被 contents manager 打补丁)序列化,再交给 Pandoc 用 `--from ipynb --to markdown -s` 转换。其中 `-s`(standalone)选项正是生成完整 front matter 头部的原因——它让输出成为一个独立的 Markdown 文档,而不是 fragment。同理,反向的 `md_to_notebook` 使用 `--from markdown --to ipynb -s`,使 front matter 头部能再被解析回 notebook 元数据。
**要点**:头部元数据是 Jupytext 判断 notebook 语言与格式版本的依据之一,读写两侧必须保持一致,否则转换后的 notebook 会丢失 kernelspec 信息。这也是为什么输出文件与源 notebook 的 `nbformat_minor` 完全一致。
## 三、raw cell 的 `:::` div 与 `{=ipynb}` 原始代码块
### 3.1 表示结构拆解
转换后,raw cell 被 Pandoc 渲染为:
```markdown
::: {#b32297a4 .cell .raw}
```{=ipynb}
---
Content.
---
:::
三个层次缺一不可:
1. **外层 `:::` div**:Pandoc 的 fenced div 语法,属性包含 `#b32297a4`(cell 的 ID)和两个类名 `.cell`、`.raw`,明确标记该区域是一个 raw cell;
2. **内层 ````{=ipynb}```` 代码块**:Pandoc 的"原始格式"(raw attribute)语法,`ipynb` 表示这段内容应按 ipynb 原始内容处理,而非普通 Markdown;
3. **块内文本**:原样保留了 `---\nContent.\n---`,包括首尾的 `---` 分隔线。这正是 `non_dict_yaml_content` 场景的验证核心——**一个长得像 YAML 但非字典的文本块,在 Pandoc 格式下不会被误解析,而是以原始代码块形式逐字保留**。
相比之下,同一 notebook 中的代码 cell 则表示为标准的 fenced code block:
```markdown
::: {#0b3bde0a .cell .code}
``` python
print("Hello, World!")
:::
属性中类名变为 `.code`,语言为 `python`。可以看到:**代码 cell 走的是"语言标注代码块"路径,raw cell 走的是"原始格式代码块"路径**,二者在 Markdown 中区分得清清楚楚。
### 3.2 为什么需要两层嵌套
仅靠 `:::` div 不足以表达"这段内容属于 ipynb 的原始 cell 内容"这一语义,因为 Markdown 解析器会把 div 内的普通文本当作 Markdown 处理,`---` 可能被解释为水平分割线。而 `{=ipynb}` 原始代码块明确告诉 Pandoc:这段内容不属于任何 Markdown 元素,必须以 ipynb 原生格式处理。反向转换(`md_to_notebook`)时,Pandoc 遇到 `{=ipynb}` 块即可还原出 raw cell 的原始文本。两层嵌套合在一起,实现了 raw cell 的**无损往返**。
## 四、pandoc 格式的运行前提与命令细节
### 4.1 版本要求与可用性检测
`pandoc` 格式强依赖外部 Pandoc 可执行文件,<a href="https://link.gitcode.com/i/ae3642ca236fcd2d4d8388d65344f098" target="_blank">pandoc.py</a> 中的 `raise_if_pandoc_is_not_available` 做了严格的版本门槛:
- 最低版本 `pandoc>=2.7.2`;
- 若系统未安装 pandoc,或版本低于阈值,会抛出带明确提示的 `PandocError`("The Pandoc Markdown format requires 'pandoc>=2.7.2'...");
- `is_pandoc_available` 则以布尔形式返回可用性,供上层在配置阶段快速探测。
### 4.2 转换命令参数(按 pandoc 版本分支)
`notebook_to_md` 与 `md_to_notebook` 都根据 pandoc 版本选择不同参数:
```python
if parse(pandoc_version()) < parse("2.11.2"):
pandoc_args = "--from ipynb --to markdown -s --atx-headers --wrap=preserve --preserve-tabs"
else:
pandoc_args = "--from ipynb --to markdown -s --markdown-headings=atx --wrap=preserve --preserve-tabs"
关键参数逐一说明:
| 参数 | 作用 |
|---|---|
--from ipynb / --from markdown |
指定输入格式,pandoc 格式在两种方向上分别使用 ipynb 和 markdown 作为输入源 |
--to markdown / --to ipynb |
指定输出格式,完成 ipynb 与 Markdown 的双向桥接 |
-s(standalone) |
生成包含 front matter 的完整文档,是本样例头部 YAML 的来源 |
--atx-headers(< 2.11.2)/ --markdown-headings=atx(≥ 2.11.2) |
使用 ATX 风格的 # 标题,这是 Pandoc 2.11.2 后对旧选项的替代写法 |
--wrap=preserve |
保留源文本的换行,避免 Pandoc 重新折行破坏 raw cell 内容 |
--preserve-tabs |
保留制表符,防止内容被规范化 |
从源码可以看出:--wrap=preserve 与 --preserve-tabs 正是保证 ---\nContent.\n--- 这种带空行与分隔线的 raw 内容逐字保留的关键开关。整个转换通过临时文件完成:notebook_to_md 将 ipynb_writes 的序列化结果写入 NamedTemporaryFile,调用 pandoc(...) 原地覆盖同一临时文件后再读取,最后 os.unlink 清理。
五、镜像往返测试:输出文件如何被验证
这份输出文件并非孤立样例,而是镜像(mirror)测试体系的产物。仓库中 tests/external/round_trip/test_mirror_external.py 定义了:
@pytest.mark.requires_pandoc
def test_ipynb_to_pandoc(ipynb_to_pandoc, no_jupytext_version_number):
assert_conversion_same_as_mirror(ipynb_to_pandoc, "md:pandoc", "ipynb_to_pandoc")
其执行逻辑位于 compare.py 的 assert_conversion_same_as_mirror:
- 从 conftest.py 的
ipynb_to_pandocfixture 获取所有待测 ipynb(该 fixture 通过list_notebooks("ipynb", skip="(functional|Notebook with|flavors|invalid|305|jupyterlab-slideshow)")过滤了部分不适合 pandoc 往返的 notebook); - 用
md:pandoc格式读取源 notebook; - 计算目标镜像文件路径:
dirname/../outputs/ipynb_to_pandoc/<文件名>.md,即本样例所在的 ipynb_to_pandoc 目录; - 若镜像文件缺失,则自动用当前转换结果生成(
create_mirror_file_if_missing); - 将实时转换结果与镜像文件逐字节对比,不一致即测试失败。
也就是说,raw_cell_with_non_dict_yaml_content.md 既是"期望输出"的存档,也是回归测试的基准——任何对 pandoc 格式实现的改动,如果改变了 raw cell 或元数据头部的表示方式,这个测试就会立刻暴露差异。测试还通过 no_jupytext_version_number fixture(在 conftest.py 中将 jupytext.header.INSERT_AND_CHECK_VERSION_NUMBER 置为 False)屏蔽了版本号差异,保证镜像文件内容稳定可比。
六、实践要点与适用边界
综合以上源码与测试事实,围绕该样例可以总结出几条可直接落地的实践结论:
- raw cell 内容不会在 pandoc 格式中丢失:无论是合法 YAML 还是"长得像 YAML 的普通文本",
{=ipynb}原始代码块都会把它逐字承载。因此含---、特殊符号的 raw cell 可以放心使用 pandoc 格式进行版本管理。 - cell ID 被保留在
:::div 属性中:#b32297a4这类 ID 在往返过程中不会丢失,这一点对依赖 cell ID 的 notebook 工具链很重要。 - 使用前检查 pandoc 环境:pandoc 格式要求系统安装
pandoc>=2.7.2,低于 2.11.2 时命令参数有差异(--atx-headersvs--markdown-headings=atx),跨环境使用时注意版本一致性。 - 镜像测试目录可当作"格式规范文档"使用:ipynb_to_pandoc 下所有
.md文件都是各类型 notebook 的权威转换基准,需要确认某个 cell 结构(raw、code、markdown、含复杂元数据等)在 pandoc 格式中的表示时,直接查阅对应镜像文件是最准确的途径。 - 对比同一 notebook 的其他格式输出:仓库中同一源文件在 ipynb_to_md、ipynb_to_myst 等目录下也有对应镜像,可与 pandoc 版本对照,理解不同文本格式对 raw cell 表示策略的差异(例如 md 格式将内容直接展开在 front matter 之后,而 pandoc 格式用 div + 原始代码块包装)。
需要说明的边界是:本样例只覆盖了"非字典 YAML 内容"这一种 raw cell 形态,且 notebook 不包含输出(outputs);含富文本输出、图片的 notebook 在 pandoc 格式下的表现不在本文讨论范围内,如有需要可查阅镜像目录中 text_outputs_and_images.md 等对应样例。
延伸阅读
- 转换核心实现:pandoc.py(
notebook_to_md/md_to_notebook/pandoc_version/raise_if_pandoc_is_not_available) - 往返测试定义:test_mirror_external.py 中的
test_ipynb_to_pandoc - 镜像对比逻辑:compare.py 中的
assert_conversion_same_as_mirror - 测试数据装配:conftest.py 中的
ipynb_to_pandocfixture - 同源不同格式镜像:ipynb_to_md、ipynb_to_myst、ipynb_to_percent