- 文档
- 开发工具
- CLI
【免费下载链接】pandoc
Universal markup converter
本文以 pandoc 仓库中的回归测试用例 test/command/11210.md 为切入点,完整剖析一个真实缺陷的成因与修复:当 man(roff)格式的文档经 pandoc 转为 Typst 时,紧跟在强调元素之后的左括号若不转义,会在 Typst 编译器中被误判为函数调用而报错。读完本文,你将理解 golden 测试的运作方式、Typst 的#emph...语法歧义、以及 pandoc Typst writer 中escapeParens的实现细节。
一、测试用例全貌:一段 man 输入,一行关键输出
test/command/11210.md 是 pandoc 测试体系中典型的「命令式 golden test」:文件内以%开头的第一行声明要执行的 pandoc 命令行,随后是标准输入(以^D结束),^D之后的内容则是期望的标准输出。
用例原文如下:
% pandoc -t typst -f man .PP .IR login (1) .PP and a regular (paren) that should not be escaped. ^D #emph[login]\(1) and a regular (paren) that should not be escaped.该用例对应 GitHub issue #11210,测试的是一条「man → Typst」的转换链路:
- 输入是 man(troff)格式,
.PP表示段落分隔; .IR login (1)是 man 的行内字体宏,期望login以斜体渲染、(1)以正体渲染;- 关键断言在期望输出的第一行:
#emph[login]\(1)——]之后的左括号前多了一个反斜杠转义; - 而第二段普通文本
and a regular (paren)中的括号则不应被转义。
同一行输入、两种截然不同的处理,正是这次修复的核心。
二、本地复现:让同一段 man 输入经过 pandoc
在已构建好 pandoc 的环境中,可以直接复现这一转换。把上文中的输入部分写入文件(或直接通过管道喂给标准输入):
.PP .IR login (1) .PP and a regular (paren) that should not be escaped.然后执行:
pandoc -t typst -f man input.man在修复该缺陷的版本中,第一行输出应为:
#emph[login]\(1)而第二段中的(paren)保持原样输出。如果你使用的是旧版本,则会看到未转义的#emphlogin——这正是 Typst 编译器无法接受的形式。命令格式上,-f man显式指定 man 读取器,-t typst指定 Typst writer,与用例命令行完全一致。
三、为什么这是个 bug:Typst 函数调用语法带来的歧义
要理解修复动机,需要先了解 Typst 标记语言的一个语法特征。在 Typst 中,#emph[login]是对emph函数的调用,方括号是其参数;紧随其后的圆括号(1)会被 Typst 解析为对上一次调用结果的再次调用,即:
#emphlogin等价于试图以参数1调用#emph[login]的返回值。这种写法在 Typst 中会引发类型错误,导致整个文档编译失败。而 man 手册页中「程序名(章节号)」是极常见的写法——login (1)、ls (1)几乎出现在每一份手册的第一行,因此这个问题对 man → Typst 转换是实际且高频的。
pandoc 的变更记录 changelog.md 对此修复的说明是:
Typst writer: Escape open paren after non-space (#11210). This fixes an issue that occurs if an open paren comes right after e.g.
#strong[test].
注意其中的限定词after non-space——这正是用例第二段断言and a regular (paren)不转义的原因:那里的左括号前面是空格,不在修复范围内。
四、修复源码:Typst writer 中的 escapeParens
修复实现位于 Typst writer 的 src/Text/Pandoc/Writers/Typst.hs,核心是新增的escapeParens函数:
-- Add an escape before a parenthesis right after a non-space element. -- Otherwise we risk `#emphtest` which will error. See #11210. escapeParens :: [Inline] -> [Inline] escapeParens [] = [] escapeParens (s : x : xs) | isSpacey s = s : x : escapeParens xs escapeParens (Str t : xs) | Just ('(',_) <- T.uncons t = RawInline (Format "typst") "\\" : Str t : escapeParens xs escapeParens (x : xs) = x : escapeParens xs该函数对 Pandoc 内联元素序列做逐个扫描,逻辑分三层:
- 空列表:直接返回,作为递归终止条件;
- 前一个元素是空白(
isSpacey判断Space、SoftBreak、LineBreak,见 同文件 L446-L450):跳过检查,保持原样递归处理后续——对应「普通文本中的括号不转义」; - 当前元素是
Str且以(开头:在该文本元素之前插入一个RawInline (Format "typst") "\\",即一个 Typst 格式的原生反斜杠,然后再输出该文本。
它被挂接在行内序列渲染的入口 inlinesToTypst 中:所有内联元素在交给inlineToTypst逐项渲染之前,先经过escapeParens预处理。从源码结构看,这是渲染前的「语法消毒」层——pandoc 内部 AST 本身无需感知 Typst 语法,只在最终输出 Typst 文本时做适配。
五、man 读取器侧:.IR 宏如何一步步产生触发 bug 的序列
修复在 writer,但触发条件由 reader 产生。追溯输入.IR login (1)在 man 读取器中的解析路径:
src/Text/Pandoc/Readers/Man.hs 的handleInlineMacro中,行内宏分发表将IR映射为:
"IR" -> parseAlternatingFonts [emph, id] args即交替字体宏:第一个参数用emph(斜体)包装,第二个参数保持id(正体)。因此.IR login (1)的两个参数login与(1)被解析为:
Emph [Str "login"](斜体 login)Str "(1)"(正体 (1))
而在 parseInlines 中,相邻内联之间会以B.space连接,于是最终 AST 序列为Emph [Str "login"]、Space、Str "(1)"。
回到escapeParens的扫描逻辑:
- 第一个元素
Emph [...]不是空白、也不是以(开头的Str,落入兜底分支原样输出; - 第二个元素
Space满足isSpacey,跳过; - 第三个元素
Str "(1)"以(开头,于是插入RawInline "\\"。
最终渲染结果正是#emph[login]后跟空格、再跟\(1)。整个过程可以从 Man.hs 的宏解析与 Typst.hs 的转义预处理中完整还原。
六、边界情况与工程启示
这个 13 行的测试用例背后,是一个值得注意的边界设计:转义只针对「紧贴非空格元素」的左括号。原因在于 Typst 的调用语法中,#emphtest的歧义仅当圆括号与方括号直接相邻时才会出现;一旦中间存在空格,(3)就是独立的普通文本。isSpacey覆盖了Space、SoftBreak、LineBreak三种情况,说明无论是普通空格、自动换行插入的断行还是显式换行,都被视为「安全间隔」。
从工程方法上看,该用例体现了 pandoc 处理跨格式语法冲突的通用套路:
- 在 reader 侧忠实还原源格式语义(man 的字体交替宏 → 统一的
Emph内联),不提前为目标格式做特判; - 在 writer 侧输出前统一做目标格式的语法消毒(
escapeParens),通过插入RawInline原生内容精确控制输出; - 用 golden test 固定「该转义处转义、不该转义处不转义」的边界行为,防止后续改动回归。
类似的「writer 侧转义适配」思路在 Typst writer 中还有多处体现,例如标签前插入零宽空格以避免标签误绑前序元素(见 Typst.hs L427-L431 中对 #11568 的处理注释)。如果你正在为 pandoc 贡献新的 writer 或修改 Typst 输出逻辑,test/command/目录下的此类用例(如 11210.md)是最直接的回归保护网——修改后运行对应用例即可验证是否破坏既有边界行为。
- 文档
- 开发工具
- CLI
【免费下载链接】pandoc
Universal markup converter
相关推荐
Pandoc 与 Typst 引号转义:从测试用例 11463 解析 `'`、`"` 与 `\"` 的读写往返
Pandoc 与 Typst 引号转义:从测试用例 11463 解析 ' 、 " 与 \" 的读写往返 本篇技术指南以 pandoc 仓库中的回归测试用例 te
文档开发工具CLIPandoc Typst 写入器转义机制深度解析:从 link 括号输出到行首句点转义的修复实践
Pandoc Typst 写入器转义机制深度解析:从 link 括号输出到行首句点转义的修复实践 本文以 Pandoc 仓库中的命令测试用例 test/comm
文档开发工具CLIpandoc 转换 Markdown 转义数字到 Typst:`1\.` 与 `--wrap=preserve` 的保真实现剖析
pandoc 转换 Markdown 转义数字到 Typst: 1\. 与 wrap=preserve 的保真实现剖析 导读 本文以 pandoc 官方命令测试
文档开发工具CLI
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考