Pandoc LaTeX 宏解析边界探秘:从\parbox与\newcommand的 Token 级处理看 5845 号修复
【免费下载链接】pandocUniversal markup converter项目地址: https://gitcode.com/gh_mirrors/pa/pandoc
导读
test/command/5845.md是 Pandoc 命令行回归测试(command test)套件中的一份 Golden 测试用例,它用两段原生(native)AST 输出,精确锁定了 LaTeX 阅读器在遇到\parbox{1em}{#1}以及“宏定义 + 正文”混合输入时的解析行为。本文以该测试为骨架,逐步拆解 Pandoc LaTeX 阅读器的 token 级解析流程、rawLaTeXInline/rawLaTeXBlock与macroDef的分工、parbox等块级命令的处理逻辑,并结合 changelog.md 中记录的 #5845 修复背景,说明为什么一个两段式的回归测试能同时守护“解析正确性”与“性能稳定性”两条防线。读完本文,你将掌握如何阅读和编写 Pandoc command test,并能从 AST 输出反推阅读器内部的分词与命令分派机制。
测试文件的结构:一份可执行的规格说明
Pandoc 的命令行测试采用一种紧凑的“脚本 + 期望输出”格式。根据 test/Tests/Command.hs 中的注释,每个测试就是一个 Markdown 代码块:以%开头的行是待执行的命令行,随后是从标准输入(以^D表示 EOF)读入的内容,代码块内余下的内容则是期望的标准输出。测试框架会将实际输出与期望输出做 golden 对比(goldenTest,见 test/Tests/Command.hs),任何差异都会导致测试失败。
test/command/5845.md恰好包含两个这样的代码块,因此它既是两份独立的回归用例,也是一份"可执行"的 LaTeX 解析规格:
% pandoc -t native \parbox{1em}{#1} ^D [ Para [ Str "\\parbox{1em}{#1}" ] ]% pandoc -t native \newcommand{\highlight}[1]{\colorbox{yellow}{\parbox{\dimexpr\linewidth-2\fboxsep}{#1}} Hello World ^D [ Para [ Str "\\newcommand{" , RawInline (Format "tex") "\\highlight" , Str "}[1]{\\colorbox{yellow}{\\parbox{" , RawInline (Format "tex") "\\dimexpr" , RawInline (Format "tex") "\\linewidth-2" , RawInline (Format "tex") "\\fboxsep" , Str "}{#1}}" ] , Para [ Str "Hello" , Space , Str "World" ] ]两份用例分别验证了两种截然不同的输入形态,共同勾勒出 LaTeX 阅读器对宏相关内容的处理边界。下面逐一深入。
用例一:无法识别的\parbox被整体吞成Str
期望输出中的关键信息
第一个用例的输入只有一行\parbox{1em}{#1},期望输出是:
[ Para [ Str "\\parbox{1em}{#1}" ] ]注意这里出现了一个值得推敲的细节:输入文本中的反斜杠在 native 输出里被转义成了\\parbox{1em}{#1}。native writer 会对字符串字面量中的反斜杠做转义,因此这仍然表示一个Str节点,其内容就是原始文本\parbox{1em}{#1}。
也就是说:当\parbox出现在“文本段落”语境中时,阅读器并没有把它解析成任何结构化的命令,而是原封不动地把整段字符当作普通字符串文本吞掉了。这与直觉(也许你会以为它会变成RawInline)不同,原因在于 LaTeX 阅读器对命令的处理分为两套并行的通道,\parbox恰好不在行内通道的识别范围内。
从源码看\parbox的“两副面孔”
在 src/Text/Pandoc/Readers/LaTeX.hs 中,parbox是一个块级命令(block command)处理器:
parbox :: PandocMonad m => LP m Blocks parbox = try $ do skipopts braced -- size oldInTableCell <- sInTableCell <$> getState -- see #5711 updateState $ \st -> st{ sInTableCell = False } res <- grouped block updateState $ \st -> st{ sInTableCell = oldInTableCell } return res它被注册进blockCommands映射(src/Text/Pandoc/Readers/LaTeX.hs),处理流程是:跳过可选参数(skipopts)→ 消费作为尺寸参数的{1em}(braced)→ 临时将sInTableCell置为False(规避 issue #5711 涉及的表格内解析问题)→ 用grouped block把花括号内的内容当作块级内容解析 → 恢复状态。
因此,\parbox只有进入块级命令分派表时才会被结构化处理。而在行内解析(inline通道)中,\parbox并没有对应的行内处理器。第一个用例中\parbox{1em}{#1}位于段落中间,阅读器尝试按行内命令处理失败后,便走“普通文本”分支,把包括反斜杠在内的整段内容作为Str保留——这正是期望 AST 的含义。
这个用例实际守护的是:块级命令不能被错误地在行内语境中激活,同时普通文本的吞并路径不能因为遇到反斜杠而卡死或产生重复 token。
用例二:宏定义与正文混合输入的分层输出
第二个用例的输入是:
\newcommand{\highlight}[1]{\colorbox{yellow}{\parbox{\dimexpr\linewidth-2\fboxsep}{#1}} Hello World这是一段典型的"宏定义 + 正文"混合文本:第一行定义了一个名为\highlight的宏,参数#1会被展开为\colorbox{yellow}{\parbox{\dimexpr\linewidth-2\fboxsep}{#1}}(一个黄色背景、宽度为\linewidth减去两倍\fboxsep的 parbox);第二行开始才是真正的正文Hello World。
期望输出把这个定义拆成了 7 个 token 级节点:
Str "\\newcommand{"RawInline (Format "tex") "\\highlight"Str "}[1]{\\colorbox{yellow}{\\parbox{"RawInline (Format "tex") "\\dimexpr"RawInline (Format "tex") "\\linewidth-2"RawInline (Format "tex") "\\fboxsep"Str "}{#1}}"
三种节点的分工
这份 AST 展示了 LaTeX 阅读器处理"无法完全识别的宏"时的三层机制:
Str(普通字符串):\newcommand本身、参数列表[1]、花括号与普通字符({、}、\colorbox{yellow}等)被当作普通文本保留。它们没有被识别为任何结构化命令,也不属于任何 RawInline 的边界。RawInline (Format "tex")(TeX 原始片段):\highlight、\dimexpr、\linewidth-2、\fboxsep这四处被标记为"保持原样的 LaTeX 代码"。这些控制序列(control sequence)是阅读器认识名字但不知道完整语义(或刻意不展开)的命令,例如\dimexpr/\fboxsep属于 TeX 底层长度计算原语,\linewidth-2是带后缀的参数。阅读器无法把它们安全地映射为 Pandoc AST 结构,于是选择原样保留为RawInline,以便后续 writer 能完整回写。- 宏定义的"不展开"策略:整个
\newcommand没有作为宏被真正注册并展开(\highlight并没有在后面的Hello World中被替换成 colorbox 内容),而是以"文本 + RawInline"的形式平铺在文档流中。
为什么是 RawInline 而不是展开?
要理解这一点,需要看 LaTeX 阅读器的宏处理入口。在 src/Text/Pandoc/Readers/LaTeX.hs 附近,块级解析路径中有:
macroDef (const mempty) <|> ...其中macroDef来自 src/Text/Pandoc/Readers/LaTeX/Macro.hs,负责把\newcommand等定义注册进宏环境(HasMacros)。而rawLaTeXInline(src/Text/Pandoc/Readers/LaTeX.hs)与rawLaTeXBlock(src/Text/Pandoc/Readers/LaTeX.hs)则是"兜底"通道:当常规结构化解析失败时,它们会基于 token 流把无法解析的控制序列整段提取为RawInline/RawBlock。
\parbox在块级语境中注册了parbox处理器,但在行内语境里没有对应处理,于是用例一中的行内\parbox走文本通道。而\dimexpr、\fboxsep等 TeX 原语在任何语境都没有结构化处理器,它们会通过rawLaTeXInline变成RawInline。至于\highlight这个宏名本身——因为它出现在\newcommand的参数位({\highlight}),此时阅读器处于"读宏定义签名"的上下文,把宏名当作原始控制序列输出为RawInline是符合预期的:宏名不是一个会被展开的正文片段。
第二段用例因此守护的是:宏定义在没有启用宏展开扩展时,不得被静默展开或丢弃,且必须以不丢失信息的方式(Str + RawInline 混合)完整保留在 AST 中,使pandoc -t latex这类 round-trip 转换能够把宏定义原样带回。
背后的修复背景:#5845 与 token 复用
test/command/5845.md的编号直接对应 changelog.md 中记录的修复:
LaTeX reader: Fix a hang/memory leak in certain circumstances (#5845).
也就是说,这两个用例最初是作为#5845 回归测试加入的。同一段 changelog 还记录了一个密切相关的内部重构:
Text.Pandoc.Readers.LaTeX.Parsing: add
[Tok]parameter torawLaTeXParser. This allows us to repeat retokenizing unnecessarily in e.g.rawLaTeXBlock.
结合源码可见其脉络:LaTeX 阅读器先把输入切分为 token 流(Sources、Tok),随后在rawLaTeXBlock/rawLaTeXInline等路径中反复调用rawLaTeXParser去匹配环境、命令或宏定义(src/Text/Pandoc/Readers/LaTeX.hs)。修复前,某些"高失败率"输入(例如包含大量未知控制序列的宏定义)会导致rawLaTeXParser反复重新分词(retokenize),在最坏情况下表现为挂起(hang)或内存泄漏;修复方式是显式传入已切好的[Tok],避免重复分词。
test/command/5845.md的两个用例恰好覆盖了这类输入的两面:
- 用例一的
\parbox{1em}{#1}是"已知命令名的块级用法出现在行内"; - 用例二的
\newcommand宏定义混合了已知/未知控制序列、可选参数与嵌套花括号。
两者都是当年触发 #5845 问题的典型形态。若回归修复导致解析路径重复分词或命令分派顺序改变,这两个用例的 golden 输出会立刻漂移,从而在 CI 中捕获问题。因此,这份测试文件不仅是行为规格,也是性能回归的哨兵。
从 AST 反推阅读器机制:三个可验证的结论
综合两份用例与源码,可以得出以下可验证结论(每个结论都能在当前仓库中找到对应证据):
\parbox是块级命令,行内不识别:处理器parbox仅注册在blockCommands(src/Text/Pandoc/Readers/LaTeX.hs)。行内出现的\parbox会整体并入Str,见用例一的期望输出。- 未知控制序列 →
RawInline (Format "tex"):\dimexpr、\fboxsep等无结构化语义的 TeX 原语由rawLaTeXInline兜底提取,见用例二的期望输出第 4~6 个节点。 - 未启用宏展开时,宏定义完整保留:
\newcommand不会被展开或丢弃,而是以Str与RawInline混合的扁平序列留在文档流中,保证 round-trip 无损。
如何运行与扩展这份测试
本地复现
在已构建的 Pandoc 源码树中,可以手工复现两个用例(与测试脚本等价):
echo '\parbox{1em}{#1}' | pandoc -t native printf '\\newcommand{\\highlight}[1]{...}\n\nHello World\n' | pandoc -t native更规范的运行方式是通过测试套件执行 command 测试(golden 对比由 test/Tests/Command.hs 驱动,所有test/command/*.md文件都会被自动收集,见其filter (".md"isSuffixOf)的逻辑):
cabal test pandoc-tests --test-options='-p command'编写同类回归用例的要点
- 每个用例 =
%命令行 + 输入 +^D+ 期望输出,一个代码块一个用例; - 期望输出必须是真实运行
pandoc -t native的结果,不要手工臆造 AST; - 命名遵循
test/command/<issue编号>.md,用例应覆盖"修复前的 bug 输入"与"修复后的正确输出"两方面,这样既防功能回归,也防性能类问题(如 #5845)复发; - 若用例涉及宏、表格、环境等边界行为,务必同时给出"已知命令的正确路径"与"未知命令的兜底路径",因为它们分属不同的解析通道。
小结
test/command/5845.md以两段精炼的 golden 输出,完整锁定了 Pandoc LaTeX 阅读器在宏与命令解析上的行为边界:块级命令\parbox在行内被吞并、未知控制序列被保留为RawInline、未启用的宏定义被无损平铺。它既是 #5845 挂起/内存泄漏修复的回归哨兵,也是一份浓缩的"token 级解析规格",与 src/Text/Pandoc/Readers/LaTeX.hs 中的parbox、blockCommands、rawLaTeXInline/rawLaTeXBlock以及 changelog.md 中的修复记录互为印证。读懂这份测试,你就掌握了阅读 Pandoc 阅读器行为、以及为它编写高质量回归用例的完整方法论。
【免费下载链接】pandocUniversal markup converter项目地址: https://gitcode.com/gh_mirrors/pa/pandoc
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考