PandocMarkdown转成Markdown

很多软件都支持Markdown,但它们支持的程度各不相同。虽然有CommonMark作为大一统的标准,但该标准功能太少不够用。我想要一种功能完全,可以扩展的Markdown方言,一次编写,到处渲染。所以,我使用PandocPandoc Markdown转成CommonMark。

目录

更智能的softbreak

CommonMark有一项贴心的功能:单次换行会被解析为softbreak,在渲染时会变成行尾或空格使用以空格分词的语言写作时,可以利用这个功能把段落拆成一句一行。这样做从写作上看有诸多好处也易于用diff工具追踪修改过程。

然而,该功能对中文是个累赘。我也想一句一行的话写中文,但是句号右边已经有巨幅空白,CommonMark再给我插一个空格就有如天堑了。我曾试过用博客生成器的模板语言把生成的句号空格替换成句号,但不成功。

east_asian_line_breaks扩展

这时,Pandoc带着它的east_asian_line_breaks闪耀登场。开启该扩展后,Pandoc会把中文间的单次换行省略掉,但保留其他softbreak。使用如下命令就可以把一句一行的Markdown转为中文一段一行,西文继续一句一行的CommonMark:

pandoc --from markdown+east_asian_line_breaks \
    --to commonmark+yaml_metadata_block --standalone \
    one_sentence_one_line.md

softbreak.lua

可惜east_asian_line_breaks的判定过于严苛:它要求行末和行首的都是汉字才会忽略换行。如果当前行以英文开头,上一行以句号结尾,Pandoc仍然会插入softbreak。不过Pandoc内嵌Lua解释器,支持用Lua编写filters修改AST所以可以写个脚本除掉多余的softbreak。

PandocAST主要有Meta、BlockInline三种类型。一般来说一段正文是一个Block类的Para,内含一系列Inline类型的Str、SoftBreak等。所以如果想要删除不需要的SoftBreak,就应该在Inlines节点上操作:

local util = require 'filters.util'

local softbreak = pandoc.SoftBreak()
local period = utf8.codepoint('。')

-- Remove SoftBreak after “。”
function Inlines(inlines)
    for i = #inlines, 2, -1 do
        if inlines[i] == softbreak then
            local lastText = util.getText(inlines[i - 1])
            if period == util.lastCodePoint(lastText) then
                inlines:remove(i)
            end
        end
    end
    return inlines
end

自动插入中西文混排的间距

我支持中文与西文间应当有略宽的间距,但我反对使用空格来粗暴地插入该间距。一来,大部分字体的空格对中西文间该有的间距而言实在太宽了;二来,这样插入的空格字符是在污染文章;最后,手动插入难免有失误。我想要的解决方案,其实就是Microsoft Word的处理:中西文间有小于空格的间距,但复制出来两字是连着的。

什么时候需要插入间距

以这句话为例:Nielsen、Chuang的《Quantum Computation and Quantum Information》很厚。句中有三类字符:汉字、标点和其他。我的习惯是,只有汉字和其他相连时才需要在中间插入间距。

U+4E00-U+9FFF是常用汉字的Unicode range标点codepoint则比较分散,因为在Unicode中汉字部分标点是和其他语言共用的(I’m里撇号和m间距过大的万恶之源。目前我用的标点范围是U+3000-U+303FU+FF00-U+FFEFU+2000-U+206F当前后字符一个是汉字,一个既不是汉字也不是标点时,就插入间距。

我对阿拉伯数字与汉字间是否应该有间距感到犹豫。目前它们有间距,是因为我懒得对数字做特殊处理。

插入什么样的间距

如前所述,空格太宽,我是绝不会考虑的。我的结论是在中西文间插入<span style='margin-left: 0.2ch;'></span>。考虑到这个span什么内容也没包住,这个方法有滥用span的嫌疑。但是它达成了我的目的:窄于空格的间距、复制时不会污染文本。

autospace.lua

网上类似脚本大部分是用正则表达式写的。然而,Lua不支持正则表达式。Panadoc附带一个PEGsLPeg,但要一个字节一个字节地匹配UTF-8——这太老学校了。最后我还是用Lua 5.3新增的utf8模块和for循环写了,很繁琐,但能用。

local util = require 'filters.util'

local SPACER = pandoc.Span(pandoc.Str(''), {
    style = 'margin-left: 0.2ch;'
})

local function identify(codepoint)
    local function inBlock(left, right)
        return left <= codepoint and codepoint <= right
    end
    if inBlock(0x4E00, 0x9FFF) then
        return { han = true }
    elseif inBlock(0x3000, 0x303F) or inBlock(0xFF00, 0xFFEF) or inBlock(0x2000, 0x206F) then
        return { punct = true }
    else
        return { other = true }
    end
end

local function needSpacing(prev, curr)
    return prev.han and curr.other or prev.other and curr.han
end

-- Insert SPACER between Chinese and Western Text inside a Str
function Str(el)
    local list = {}
    local prev = {}
    local start = 1
    for p, c in utf8.codes(el.text) do
        local curr = identify(c)
        if needSpacing(prev, curr) then
            table.insert(list, pandoc.Str(el.text:sub(start, p - 1)))
            table.insert(list, SPACER)
            start = p
        end
        prev = curr
    end
    table.insert(list, pandoc.Str(el.text:sub(start, #el.text)))
    return list
end

-- Insert SPACER between Chinese and Western Text between Inlines
function Inlines(inlines)
    local list = {}
    for i = 1, #inlines - 1 do
        table.insert(list, inlines[i])
        local lText = util.getText(inlines[i])
        local rText = util.getText(inlines[i + 1])
        if lText ~= nil and rText ~= nil and lText ~= "" and rText ~= "" then
            local lCP = util.lastCodePoint(lText)
            local rCP = utf8.codepoint(rText)
            if needSpacing(identify(lCP), identify(rCP)) then
                table.insert(list, SPACER)
            end
        end
    end
    table.insert(list, inlines[#inlines])
    return list
end

组合在一起

Lua支持模块,Pandoc支持在一个filter中多次遍历AST。所以可以把上面两个filters写进两个模块,在一个filter中调用它们:

local autospace = require 'filters.autospace'
local softbreak = require 'filters.softbreak'

return {{
    Inlines = softbreak.Inlines
}, {
    Str = autospace.Str,
    Inlines = autospace.Inlines
}}

性能

我在尝试前最担心的其实是性能,因为在我的印象里Haskell属于编译过后仍然慢得没边的语言。结果用起来后发现还挺快,瓶颈应该是硬盘速度,所以关于性能还是不用担心。

更多功能

上面的两个filters其实只解决了排版中间距的问题,并没有给Markdown加别的功能。RStudio11857Lua给他们的Quarto Markdown加了很多功能。我也想加,但我累了,今天先睡了。