Pandoc 的 Org 模式 Example 块解析:缩进保留、-i 开关与嵌套列表边界判定
【免费下载链接】pandocUniversal markup converter项目地址: https://gitcode.com/gh_mirrors/pa/pandoc
导读
Org-mode 的#+begin_example块用于展示需要原样保留的示例文本,但它在列表、#+name属性和缩进处理上的行为相当微妙,稍不留神就会产生与预期不符的输出。本篇文章以 pandoc 仓库的回归测试 test/command/4186.md 为主线,结合 Org 读取器源码 逐层拆解 pandoc 解析 example 块的完整规则:-i开关如何禁用首行缩进修剪、块内容缩进如何计算、#+name与块归属如何决定块的层级,以及嵌套列表中“块从属于哪个列表项”的判定逻辑。读完本文,你将能够精确预判任意 Org 示例块在 pandoc 转换后的 AST 与 HTML 结构。
测试案例 4186:一个针对 Org Example 块的回归测试
test/command/4186.md 是 pandoc 的命令行回归测试(command test)。这类测试文件的格式约定是:以 ``` 包裹的代码块内,%开头的行为待执行的 pandoc 命令,^D之前是标准输入内容,之后为预期输出。测试通过比对命令真实输出与文件中的预期输出来验证解析器行为是否回归。
4186 号测试包含两个用例,分别针对 Org example 块的两个独立维度:
-i开关对缩进保留的影响:pandoc -f org -t native下,#+begin_example -i中的前导空格必须完整保留。- 嵌套列表中的块归属与缩进对齐:
pandoc -f org -t html下,多层列表内多个 example 块的归属(属于外层还是内层列表项)、#+name生成的id属性、以及块对列表结构的影响。
两个用例缺一不可:第一个验证“缩进保留”这一基础语义,第二个验证“缩进如何决定块归属”这一进阶语义,二者共同构成对 example 块解析的完整约束。
Org Example 块的解析模型
从 Org 块到 Pandoc CodeBlock 的映射
在 pandoc 的 Org 读取器中,#+begin_example与#+end_example之间包裹的内容被解析为带属性的代码块(CodeBlock)。入口处,Org.hs 的readOrg通过parseOrg调用blockList进入块级解析;在 Blocks.hs 的block组合子列表中,orgBlock(处理#+begin_*系列块)和example(处理冒号开头的行内示例)都位列其中。
orgBlock读取块类型后按类型分发,见 Blocks.hs:
orgBlock :: PandocMonad m => OrgParser m (F Blocks) orgBlock = try $ do blockAttrs <- blockAttributes blkType <- blockHeaderStart ($ blkType) $ case T.toLower blkType of "export" -> exportBlock "comment" -> rawBlockLines (const mempty) "html" -> rawBlockLines (return . B.rawBlock (T.toLower blkType)) "latex" -> rawBlockLines (return . B.rawBlock (T.toLower blkType)) "ascii" -> rawBlockLines (return . B.rawBlock (T.toLower blkType)) "example" -> exampleBlock blockAttrs ... "src" -> codeBlock blockAttrsexampleBlock的具体实现位于 Blocks.hs:
exampleBlock :: PandocMonad m => BlockAttributes -> Text -> OrgParser m (F Blocks) exampleBlock blockAttrs _label = do skipSpaces (classes, kv) <- switchesAsAttributes newline content <- rawBlockContent "example" let id' = fromMaybe mempty $ blockAttrName blockAttrs let codeBlck = B.codeBlockWith (id', classes, kv) content return . return $ codeBlck关键点有三:
- 块属性先行解析:
blockAttributes在块头之前读取#+name、#+caption、#+attr_html等属性行(支持的行见 Blocks.hs),其中name/label会映射为最终CodeBlock的 id; - 开关参数解析:
switchesAsAttributes解析块头中-i、-n、+n等开关,转成 classes 与 key-value 属性; - 内容原样读取:
rawBlockContent "example"读取直到#+end_example(大小写不敏感,见 Blocks.hs)为止的原始文本。
这就是 4186 第一个用例预期输出CodeBlock ("", [], []) " This should retain the four leading spaces\n"的来源:属性为空、classes 为空、key-value 为空,但内容中的四个前导空格被原样保留。
缩进修剪机制与 -i 开关
example 块的缩进处理是整个语义的核心。rawBlockContent'(Blocks.hs)实现了一套“按最短缩进去公共前缀”的算法:
rawBlockContent' :: Monad m => OrgParser m Text -> OrgParser m Text rawBlockContent' blockEnder = try $ do blkLines <- manyTill rawLine (try $ skipSpaces <* blockEnder) tabStop <- getOption readerTabStop trimP <- orgStateTrimLeadBlkIndent <$> getState -- split lines into indentation/contents tuples let splitLines = map (T.span (\c -> c == ' ' || c == '\t')) blkLines let countSpaces = T.foldr (\case {'\t' -> (tabStop +); _ -> (1 +)}) 0 let shortestIndent = foldr (min . countSpaces . fst) maxBound . filter (not . T.null . snd) -- ignore empty lines $ splitLines let tabsToSpaces = T.replace "\t" (T.replicate tabStop " ") let reIndent = if trimP then (T.drop shortestIndent . tabsToSpaces) else id ...算法要点:
- 每行先被拆分为“前导空白”与“内容”两部分(制表符按
readerTabStop,默认 4 列折算); - 求出非空行中最短的前导空白宽度
shortestIndent; - 若启用修剪(
trimP为真),则所有行统一去掉这shortestIndent个字符的前缀; - 空行不参与最短缩进计算,因此不会因空行而把缩进裁掉。
而-i开关的作用正是关闭这一修剪行为。在 Blocks.hs 中:
whitespaceSwitch :: Monad m => OrgParser m (Char, Maybe Text, SwitchPolarity) whitespaceSwitch = do string "-i" updateState $ \s -> s { orgStateTrimLeadBlkIndent = False } return ('i', Nothing, SwitchMinus)-i(ignore indentation)将解析器状态orgStateTrimLeadBlkIndent置为False(状态字段定义见 ParserState.hs,默认值为True,见 ParserState.hs)。此时reIndent = id,每一行内容原样保留。这解释了 4186 第一个用例:
% pandoc -f org -t native #+begin_example -i This should retain the four leading spaces #+end_example ^D [ CodeBlock ( "" , [] , [] ) " This should retain the four leading spaces\n" ]若去掉-i,由于整块内容只有一行且该行前导缩进为 4,shortestIndent即为 4,四个空格会被全部裁掉;加上-i后修剪被禁用,四个空格完整进入 CodeBlock 内容。
块级状态的一次性特性
值得注意的一个实现细节:rawBlockContent'在读取结束后会把orgStateTrimLeadBlkIndent重新置回True(Blocks.hs):
T.unlines (map (uncurry T.append . bimap reIndent commaEscaped) splitLines) <$ updateState (\s -> s { orgStateTrimLeadBlkIndent = True })这意味着-i只对当前这一个块生效,而不会泄漏到后续的 example 块。从源码结构可以推断:这是为了隔离开关作用域,避免一个块的-i意外影响文档后面其他块的缩进修剪,保证每个块默认行为的一致性。
逗号转义:块内内容的安全处理
rawBlockContent'中的commaEscaped函数(Blocks.hs)处理了 Org 块内以逗号开头的转义行:
commaEscaped suff = case T.uncons suff of Just (',', cs) | "*" <- T.take 1 cs -> cs | "#+" <- T.take 2 cs -> cs _ -> suff在 Org-mode 中,块内若要书写* 标题或#+begin_*之类的行,需要加逗号转义;pandoc 在读取块内容时会剥掉这一层逗号,恢复真实内容。例如 test/command/9218.md 中就用到了,#+begin_src py这样的转义写法。
嵌套列表中的 Example 块归属判定
4186 的第二个用例远比第一个复杂,它展示了 pandoc 解析器对“块属于哪个列表项”的判定规则。先看完整输入:
% pandoc -f org -t html - depth 1 #+name: bob #+begin_example -i Vertical alignment is four spaces beyond the appearance of the word "depth". #+end_example - depth 2 #+begin_example Vertically aligned with the second appearance of the word "depth". #+end_example #+begin_example -i Vertical alignment is four spaces beyond the second appearance of the word "depth". The "begin" portion is a component of this deeper list element, so that guarantees that the entire block must be a component of the inner list element. #+end_example Still inside the inner list element #+name: carrie #+begin_example This belongs to the outer list element, and is aligned accordingly, since the NAME attribute is not indented deeply enough. It is not enough for the BEGIN alone to be aligned deeply if the block is meant to have a NAME. #+end_example Still in the shallower list element since the preceding example block forced the deeper list element to terminate. Outside all lists. ^D对应的预期 HTML 输出(见 test/command/4186.md)结构为:
depth 1列表项内:<pre id="bob">(#+name: bob映射为 id),内容行前导缩进 4 被保留(-i生效);- 其子列表
depth 2项内:第一个无-i的 example 块,内容行因公共缩进被修剪到列 0; depth 2项内第二个#+begin_example -i块(缩进对齐于外层内容)完整保留缩进;- 紧随其后的
#+name: carrie块,虽然#+begin_example本身缩进很深,但归属于外层列表项(id 为carrie的<pre>出现在depth 1的<li>下、depth 2的</ul>之后); - 两个列表项之间的文本行归属于各自所在层级;
- 最后
Outside all lists.在</ul>之外。
从该用例可以提炼出三条判定规则:
#+name的缩进决定块归属,而非#+begin本身。carrie块中#+begin_example的缩进超过 26 列,但#+name: carrie仅缩进 3 列,解析器据此把块判给外层depth 1列表项——正如用例注释所言:“It is not enough for the BEGIN alone to be aligned deeply if the block is meant to have a NAME”(仅靠 BEGIN 对齐深不够,块若带 NAME 则归属由 NAME 决定)。- 块体内容对齐决定了块属于哪个列表项。第二个
-i块中,#+begin_example -i缩进很深(与depth 2的内容列对齐),因此它“must be a component of the inner list element”(必然属于内层列表项),其后续文本Still inside the inner list element也继续停留在内层。 - 一个块会终结其所在列表项。
carrie块属于外层列表项,它迫使更深的depth 2列表项终止,之后的Still in the shallower list element...回到浅层列表项中继续。
这三条规则共同保证了 Org 文档中“块嵌套在列表里”这种常见结构能够被稳定还原为正确的 HTML 嵌套关系。
关联机制:块属性(#+name 与 #+caption)如何进入 AST
#+name: bob能成为<pre id="bob">,背后是blockAttributes与attrFromBlockAttributes的配合。blockAttributes(Blocks.hs)解析#+name/#+label为blockAttrName,同时解析#+caption为标题、#+attr_html为 HTML 属性键值对。attrFromBlockAttributes(Blocks.hs)进一步把键值对中的id、class提取为 pandoc 属性的 identifier 与 classes:
attrFromBlockAttributes :: BlockAttributes -> Attr attrFromBlockAttributes BlockAttributes{..} = let ident = fromMaybe mempty $ lookup "id" blockAttrKeyValues classes = maybe [] T.words $ lookup "class" blockAttrKeyValues kv = filter ((`notElem` ["id", "class"]) . fst) blockAttrKeyValues in (ident, classes, kv)在exampleBlock中,blockAttrName(即#+name)被直接用作B.codeBlockWith的第一个参数 id:
let id' = fromMaybe mempty $ blockAttrName blockAttrs let codeBlck = B.codeBlockWith (id', classes, kv) content于是#+name: bob最终变成 HTML 输出中的<pre id="bob">,#+name: carrie变成<pre id="carrie">。这与单元测试 test/Tests/Readers/Org/Block/CodeBlock.hs 中“Code block with caption”用例的断言一致:#+name: functor-laws产生codeBlockWith ("functor-laws", ["haskell"], [])。
Example 块与 Source 块的异同
同为#+begin_*块,example 与 source 块共享块头解析、属性解析与rawBlockContent内容读取机制,但存在关键差异:
| 维度 | #+begin_example | #+begin_src |
|---|---|---|
| 内容语义 | 原样文本,不做语法标注 | 带语言标识的代码 |
| 语言参数 | 无(只有开关) | 第一个词作为语言,映射为 classes(如haskell) |
| 结果块 | 无 | 可跟随#+RESULTS:结果块,受:exports控制 |
| 常见开关 | -i(保留缩进)、-n/+n(行号) | 同上,另有:exports、:tangle等 header 参数 |
codeHeaderArgs(Blocks.hs)会解析 src 块的语言词与 babel 参数,例如#+begin_src emacs-lisp :exports both生成 classes["commonlisp"]与 key-value[("org-language","emacs-lisp"),("exports","both")],详见 test/Tests/Readers/Org/Block/CodeBlock.hs。而 example 块不存在语言概念,switchesAsAttributes只会消费-i、-n等开关。
行号开关-n/+n由lineNumberSwitch(Blocks.hs)解析:-n 10生成numberLinesclass 与startFrom=10属性,+n生成continuedSourceBlockclass(见 Blocks.hs)。尽管 4186 未涉及,这套开关机制与-i共享switch组合子(Blocks.hs),行为一致。
实战验证与调试建议
用 native 输出观察 AST
排查 example 块缩进问题时,优先使用-t native查看中间 AST,它比 HTML 更直接地暴露 CodeBlock 的内容与属性:
pandoc -f org -t native input.org4186 第一个用例正是这种调试手法的产物。当内容与预期不符时,重点检查:
- CodeBlock 的 classes 中是否出现
numberLines、continuedSourceBlock等意外 class; - 内容首行的前导空格数量是否符合预期(
-i缺失时会被按最短缩进裁掉); - 属性三元组
(id, classes, kv)中的 id 是否来自#+name。
用 HTML 输出观察块归属
嵌套列表场景下,直接检查输出 HTML 中<pre>与<ul>/<li>的嵌套层级。规则是:<pre>出现在哪个<li>之下,就说明块被判定属于哪个列表项。4186 第二个用例中id="carrie"的<pre>位于depth 1的<li>内、depth 2的</ul>之后,即为归属外层的铁证。
对照既有回归测试
仓库中的相关测试可作为行为基准:
- test/command/4186.md:本文主题,缩进保留 + 嵌套列表归属;
- test/command/4748.md:example 块在 reStructuredText 输出下渲染为
::字面块; - test/command/7810.md:列表内 example 块的 org 往返输出保持嵌套;
- test/Tests/Readers/Org/Block/CodeBlock.hs:单元测试“Example block”,断言基础内容与空属性。
运行命令测试的方式是make test或直接执行测试套件(见 test-pandoc.hs 与 Command.hs),其中 4186 用例由命令测试框架驱动pandoc -f org -t native/html完成比对。
总结
通过 test/command/4186.md 这一个回归测试,可以完整还原 pandoc Org 读取器对 example 块的解析语义:
#+begin_example的内容映射为带属性的CodeBlock,#+name决定 id;- 默认按非空行的最短缩进去除公共前缀,
-i开关禁用该修剪以完整保留前导空格,且-i只作用于当前块; - 在嵌套列表中,块的归属由
#+name与块头/内容的对齐深度共同决定,一个深层块会终结其所在列表项; - 块内容支持逗号转义(
,前缀),结束标记#+end_example大小写不敏感。
理解这些规则,即可在编写 Org 文档时准确预判 pandoc 的转换结果,也能在遇到缩进或层级异常时快速定位问题根源。
参考实现与测试文件
- Org 读取器入口:src/Text/Pandoc/Readers/Org.hs
- 块级解析与 example 块实现:src/Text/Pandoc/Readers/Org/Blocks.hs
- 解析器状态字段定义:src/Text/Pandoc/Readers/Org/ParserState.hs
- 回归测试(本文主题):test/command/4186.md
- 相关回归测试:test/command/4748.md、test/command/7810.md、test/command/5178.md
- 单元测试:test/Tests/Readers/Org/Block/CodeBlock.hs
- Org 写出器(
#+begin_example的生成侧):src/Text/Pandoc/Writers/Org.hs
【免费下载链接】pandocUniversal markup converter项目地址: https://gitcode.com/gh_mirrors/pa/pandoc
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考