PandocMarkdown转成Markdown

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

好消息是CommonMarkHTML的超集,而21世纪必备软件Pandoc自带Lua解释器。所以,我可以用Pandoc配合Lua代码(Pandoc filters)把拥有我个人想要的功能的Markdown,转换为CommonMark + HTML碎片。这样就得到了完全自己做主的Markdown方言。

因为依赖于Pandoc filters,而且输入输出都是Markdown,所以我把这套流程称为“用PandocMarkdown转成Markdown

目录

更智能的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

注音标记:ruby.lua

HTML<ruby>标签可以用于显示汉字注音hàn zì zhù yīn。CommonMark不支持它,所以想写的话只能手写。可是这个标签还需要嵌套<rp>、<rt>标签才能正确工作,所以不方便手写。想在Markdown里用,又不方便手写,当然就要使用Pandoc filter了:

local ruby_template = string.gsub([[
<ruby>
%s<rp>(</rp><rt>%s</rt><rp>)</rp>
</ruby>
]], '\r?\n', '')

function Code(el)
    local ruby, rt = el.text:match('(.+)((.+))$')
    if ruby and rt then
        return pandoc.RawInline('html', ruby_template:format(ruby, rt))
    end
end

然后就可以在一对backticks之间插入“绅士(变态”来得到绅士变态。我认为用搭配backtick和中文括号比Pandoc filters文档的示例方便得多。

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

我支持中文与西文间应当有略宽的间距,但我反对使用空格来粗暴地插入该间距。一来,大部分字体的空格对中西文间该有的间距而言实在太宽了;二来,这样插入的空格字符是在污染文章;最后,手动插入难免有失误。我想要的解决方案,其实就是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 and rText 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

标点挤压

中文标点里除了破折号和省略号外,其他标点本身只有半个字宽。大部分字体里给标点加了半个字宽的空白,使标点看起来其他汉字等宽。但有时我们并不想要这个空白,此时就需要对标点进行挤压。

加法和减法

The Type在一篇讲全角半角的文章里提到了调整标点宽度,并且概括出了两种思考模式:

  1. 标点本应是半宽的,调整宽度就是给它们加上空白的加法模式;
  2. 标点本应是全宽的,调整宽度就是给它们削去空白的减法模式。

从这个角度来说,甚至可以认为使用「标点挤压」这个词其实就是默认了「减法模式,因为「加法模式」并不存在「挤压」的问题,而是「加空」的问题,因此,用「标点宽度调整」可能会比「标点挤压」的称呼更合适。

我是认同标点是半宽的那一派,但我仍然要称呼宽度调整为标点挤压。因为我调整宽度的方式是用OpenTypeAlternate Half Widths(halt)特性把默认全宽字形换成半宽字形。从结果上看,使用halt是名副其实的减法模式。

只需要处理引号括号书名号

一般来说连续出现标点时才需要挤压其中的某个标点,而标点连续出现的位置一般是引号、括号或书名号前后。因此,我认为挤压规则应该以引号、括号和书名号入手:

  1. 如果当前字符是“之一:
    1. 且该字符是字符串里的第一个字符——挤压该字符;
    2. 且该字符前面是除)以外的标点——挤压该字符;
  2. 如果当前字符是任意标点,且后面跟着)之一——挤压该字符;
  3. 如果当前字符是)之一,且后面跟着任意标点——挤压该字符。

正好手头有个充满了括号引号还有书名号的段落,可以展示这个规则的效果注意其中标红的部分:如果没有挤压,这三种符号自身的留白会拉长字距,让人读起来难受。

布尔加科夫首先引起斯大林的注意是因为《图尔宾一家的日子这是根据其长篇小说《白卫军》改编的剧本……但是斯大林视这些为赞美(或者说至少他假装如此,他可能在和布尔加科夫逗着玩儿说实际上,把白卫军刻画成值得尊敬的人再描述他们的失败是在赞美苏维埃政权。这是“布尔什维克主义粉碎性力量的体现唔。像一部你要去看的戏剧,对吧每逢斯大林喜欢(令人瞠目结舌什么并且想进行评论时,他的举止通常不为人所理解。这部戏他看了十五次。

使用halt特性可以实现标点挤压”这件事,我是从另一个博客里学来的。那个博客提出了以标点占据空间的方向为入手点的挤压规则。从结果来看那个规则也很好,可以说是殊途同归了。

另外前文说到的The Type的全角半角文章里提到了挤压所有句内标点的“开明式”挤压规则。GB/T 15834-2011《标点符号用法》就用的开明式——如你所见——这种规则丑到不行。

halt.lua

代码和前面的规则一样。

local opening = charList("“《(")
local closing = charList("”》)")
local puncts = charList(",。、:;?!—…“”《》()")

local function classify(codepoint) 省略 end

function Str(el)
    if 0 == #el.text then return end

    local codes = {}
    for pos, cp in utf8.codes(el.text) do
        table.insert(codes, { pos = pos, cp = cp, type = classify(cp) })
    end
    table.insert(codes, { pos = -1, cp = -1, type = {} }) -- Guard for i == n

    local list, start = {}, 1
    for i = 1, #codes do
        local prev, curr, next = codes[i - 1], codes[i], codes[i + 1]
        if curr.type.opening and (i == 1 or prev.type.opening or prev.type.other_punct) or
        curr.type.closing and (next.type.opening or next.type.closing or next.type.other_punct) or
        curr.type.other_punct and next.type.closing then
            table.insert(list, pandoc.Str(el.text:sub(start, curr.pos - 1)))
            table.insert(list, squash(utf8.char(curr.cp)))
            start = next.pos
        end
    end
    if start ~= -1 then
        table.insert(list, pandoc.Str(el.text:sub(start)))
    end
    return list
end

故意用错间隔号

本段和下一段的内容不会使用Pandoc,也没有进行标点挤压。但它们讨论的标点宽度问题和上一段有所联系,所以我还是把它们记录在这里了。

按照国家标准的话,人名里的间隔号应该使用编码为U+00B7的西文MIDDLE DOT字符。而且宽度应该为半字宽

中西共用一个码位是很多问题的万恶之源,并且我认为间隔号应该占整个字宽。所以我把位于U+30FB的日文KATAKANA MIDDLE DOT当作中文的间隔号使用。

由于我的博客不是什么身份系统,我不遵守国标不会造成少数民族兄弟们买不了火车票。所以我对故意用错间隔号这事并没有感到什么不妥。

英文撇号的处理方法

在浏览英文网页时经常会因为撇号的字距太宽搞得心烦意乱。其原因在于英文的撇号和中文的反单引号是同一个字符(中西共用一个码位

我不想处理这件事,直到我在写游记时写到了需要用到撇号的地名:Hadrianus’ Arch。这事倒是有省力的方法:用CSSunicode-range给撇号指定一个只包含撇号一个字符的英文字体。

因为我的中文字体使用的是思源宋体,它的拉丁字母来自Source Serif。所以撇号的英文字体首选自然是Source Serif。然而,思源宋体的拉丁字母是经过调整的,如果直接拿Source Serif里的撇号过来搭配会有微小的不和谐感。不过乍一眼看上去效果已经够好了,起码比直接用空白巨大的中文反单引号强很多:

Napoleon’s troops were already approaching the city, and you could hear the cannon from the steps of the university library.

使用unicode-range给撇号指定英文字符的代价就是我没法在写中文时正常使用反单引号,不过目前这不是问题。

性能

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

更多功能

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


复制以下链接,并粘贴到你的Mastodon、MisskeyGoToSocial等应用的搜索栏中,即可搜到对应本文的嘟文。对嘟文进行的点赞、转发、评论,都会出现在本文底部。快去试试吧!

链接:https://emptystack.top/note/pandoc-as-markdown-preprocessor