pandoc Org Writer 自动换行陷阱:脚注标记与列表标记被换行破坏的根因与修复(#4171)
【免费下载链接】pandocUniversal markup converter项目地址: https://gitcode.com/gh_mirrors/pa/pandoc
pandoc 将文档以 org 格式读入再写回 org 时,默认会在 72 列处自动换行。若换行恰好把脚注引用[fn:1]或列表项标记-、1.拆到行首,输出会被 Org 解析器误读为脚注定义或列表项,导致文档语义被破坏。本文以命令级测试文件 test/command/4171.md 为核心,完整呈现这一缺陷的三组复现场景,并结合 Org 写入器源码 剖析换行机制与fixMarkers修复方案,帮助读者理解“行首敏感 token 防换行”这一类布局问题的通用解法。
问题复现:三组 org→org 命令测试
test/command/4171.md 是 pandoc 命令测试(command test)格式的文件。该格式由 test/Tests/Command.hs 驱动:文件中的每个代码块都是一个用例,首行以%开头的行是待执行命令,其后零至多行文本作为 stdin 输入,^D行标记输入结束,再往后的行是期望的 stdout 输出(若期望 stderr,则各行需以2>前缀并放在最前;若期望非零退出码,末行以=>加退出码结尾)。测试框架 Tests/Command.hs 通过goldenTest将实际输出与期望输出逐行比对,失败时打印 unified diff;命令中的pandoc会被 pandocToEmulate 重写为test-pandoc --emulate ...,从而保证测的是当前构建产物而非系统里已安装的旧版本。
以下三组用例全部使用pandoc -f org -t org(org 读入、org 写出,即往返转换),输入均为超过默认 72 列的长行:
用例一:脚注引用不可被换到下一行行首
test/command/4171.md 第 1–11 行:
``` % pandoc -f org -t org Aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa [fn:1] a [fn:1] b ^D Aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa [fn:1] a [fn:1] b ```输入行共 77 字符(62 个A+ 空格 +[fn:1]+ 空格 +a),超出 72 列必须换行。期望输出显示:[fn:1]必须与前面正文同处一行,换行发生在[fn:1]之后。若写入器允许在[fn:1]前的空格处断行,输出就会变成
Aaa...aaa [fn:1] a而 Org 中独立成行、行首为[fn:N]的内容会被解析为脚注定义。于是原本一个脚注引用加一段正文,被换行“变造”出一个新的脚注定义,往返转换结果与输入语义不再等价。
用例二:软换行(SoftBreak)场景下的同样约束
第 14–25 行。与用例一的唯一区别是输入里[fn:1]前是一个行尾软换行(Org 中普通换行在段落内会被读取为 SoftBreak):
``` % pandoc -f org -t org Aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa [fn:1] a [fn:1] b ^D Aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa [fn:1] a [fn:1] b ```这组用例证明修复必须同时覆盖Space与SoftBreak两种可断行位置,而不仅仅是普通空格。
用例三:-同样不可被换到行首
文件第 27–34 行,注释写明 “Similar bug: "-" should not be wrapped”:
``` % pandoc -f org -t org aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa - abc ^D aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa - abc ```输入是 65 个a加- abc(共 77 列)。若换行落在-之前,行首出现- abc,会被解析为列表项标记,段落结构随之被破坏。期望输出要求换行发生在-之后:-与其前面的词保持同行。
根因分析:Org 的行首语法规则遇上自动换行
Org 侧:行首即语法
在 Org 模式中,语法高度依赖“行首”位置:
- 行首
[fn:N]是脚注定义; - 行首的列表标记(
-、有序数字标记等)开启列表项; - 因此只要写入器在错误位置断行,原本位于行中的标记就会获得“行首”身份,触发完全不同的解析。
pandoc 侧:doclayout 的自动断行
Org 写入器并非直接拼接字符串,而是先构造 doclayout 布局树再渲染成文本。pandocToOrg 中:
let colwidth = if writerWrapText opts == WrapAuto then Just $ writerColumns opts else Nothing ... return $ render colwidth $ ...关键参数默认值见 src/Text/Pandoc/App/Opt.hs:
, optWrap = WrapAuto -- 默认自动换行 , optColumns = 72 -- 默认 72 列命令行--wrap仅接受auto、none、preserve三值(见 src/Text/Pandoc/App/CommandLineOptions.hs)。也就是说默认配置下,任何可断行的空格都是潜在断点——这正是 4171 号问题的土壤:Space与SoftBreak在 inlineToOrg 中分别渲染为可断行的space(WrapAuto/WrapNone时)或换行(WrapPreserve时),而 doclayout 渲染器会在 72 列约束下自由选择断点,并不知晓[fn:1]、-等 token 具有“不可置于行首”的约束。
源码级修复:fixMarkers防换行屏障
修复位于 src/Text/Pandoc/Writers/Org.hs 的inlineListToOrg:
-- | Convert list of Pandoc inline elements to Org. inlineListToOrg :: PandocMonad m => [Inline] -> Org m (Doc Text) inlineListToOrg lst = hcat <$> mapM inlineToOrg (fixMarkers lst) where -- Prevent note refs and list markers from wrapping, see #4171 -- and #7132. fixMarkers [] = [] fixMarkers (Space : x : rest) | shouldFix x = Str " " : x : fixMarkers rest fixMarkers (SoftBreak : x : rest) | shouldFix x = Str " " : x : fixMarkers rest fixMarkers (x : rest) = x : fixMarkers rest shouldFix Note{} = True -- Prevent footnotes shouldFix (Str "-") = True -- Prevent bullet list items shouldFix (Str x) -- Prevent ordered list items | Just (cs, c) <- T.unsnoc x = T.all isDigit cs && (c == '.' || c == ')') shouldFix _ = False工作机制可以概括为三步:
- 识别“行首敏感”token:
shouldFix判定三类元素——脚注引用Note、无序列表标记Str "-"、有序列表标记(全数字结尾且以.或)收尾,如5.、23)); - 把可断行位置替换为不可断行:当
Space或SoftBreak紧跟在敏感 token 之前时,用Str " "(一个普通字符串字面量,doclayout 视其为不可拆分的普通字符)替换原来的可断行空格,从而强制“前词 + 空格 + 标记”三者绑定在同一行内; - 其余元素原样透传,保证不改变任何非问题位置的行为。
这一替换直接解释了 4171 三组期望输出的成因:断点被从“标记之前”逼移到“标记之后”,于是[fn:1]与-都得以留在行内(用例一、二、三的输出中,换行均发生在标记之后)。
修复的演进脉络
从源码注释 “see #4171 and #7132” 可见这是分两步完善的。初版实现名为fixNotes,只处理脚注引用一种情况,且仅覆盖Space;提交 d035689a0 “Org writer: do not wrap "-" to avoid accidental bullet lists”(同时新增了 test/command/4171.md 的用例三)把它扩展为fixMarkers并纳入-。当前版本进一步把有序列表标记纳入shouldFix,并补充了SoftBreak分支,对应的回归用例见 test/command/7132.md——其输入是带有序数字结尾的长列表项,命令显式指定--columns=72,期望换行发生在数字标记之后而非之前:
``` % pandoc -f markdown -t org --columns=72 - This line has exactly the wrong number of characters before the number 5. - Long line ending with a number (this time it is in parentheses and a 23) ^D - This line has exactly the wrong number of characters before the number 5. - Long line ending with a number (this time it is in parentheses and a 23) ```验证方式与实操要点
如何验证:本仓库的test/command/*.md均为 golden 命令测试,由 test/test-pandoc.hs 入口聚合、经 Tests/Command.hs 自动发现(扫描command目录下所有.md文件、按代码块编号生成#1、#2… 用例组)。构建后运行测试套件即可复现/验证这三个用例的期望输出;若修改了 Org 写入器导致输出变化,golden 测试会打印--- test/command/4171.md开头的 diff,提示预期与实际输出的差异位置。
实操要点:
- 默认
--wrap=auto+--columns=72下,org→org 往返转换的长段落会发生换行,[fn:N]脚注引用、-与1./23)类列表标记均受fixMarkers保护,不会被拆到行首; - 若希望完全保留原始行结构,可用
--wrap=none关闭自动换行(此时pandocToOrg中colwidth为Nothing,渲染器不做宽度约束); - 该问题模式具有通用性:任何“输出格式把特定 token 放在行首即赋予其特殊语义”的写入器,在自动换行时都需要类似的防断行屏障——从源码结构看,pandoc 其他基于 doclayout 的写入器(如 Markdown 写入器)对同类行首敏感结构也采取了
nowrap包裹的等价手段; - 注意边界:
shouldFix (Str "-")只保护精确等于-的字符串,--(em dash 的 Org special-strings 形式)不在此列;若文档依赖--columns调整列宽,换行位置会随之变化,但标记保护逻辑与列宽无关,任意--columns值下均生效。
小结:test/command/4171.md 三组看似微小的用例,锚定的是一条清晰的工程链路——Org 的行首语法规则(脚注定义、列表项)与 doclayout 自动换行的断点选择相互冲突;修复方案是在 src/Text/Pandoc/Writers/Org.hs 中以fixMarkers将“标记前的可断行空格/软换行”替换为不可断行的普通空格,从布局层面保证[fn:N]、-、1./23)这类行首敏感 token 永远与前行文本保持绑定。
【免费下载链接】pandocUniversal markup converter项目地址: https://gitcode.com/gh_mirrors/pa/pandoc
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考