优化中文 Markdown 软换行显示效果

2022-12-20 ⏳3.9分钟(1.6千字) 🕸️

Markdown 支持所谓的软换行(soft break)。连续输入的两个换行符表示新起一段,段落之间会有一个空行。如果只输入一个换行符,那就表示软换行。说它是因为它只会影响到 Markdown 源文,转成 HTML 后不会变成<br>,而是对应空格。这在英文环境中没有问题,但在中文环境中却很不方便。今天就分享如何优化中文 Markdown 中软换行的显示效果。

感谢读者 mjoycarry 分享,其实 pandoc 内置 east_asian_line_breaks1 扩展支持处理中文软换行效果。建议大家直接使用该扩展。我的博客系统已经改用这个扩展了。但本文依然可以用作是 lua-filter 的入门阅读材料😄

我的博客使用 pandoc 转换 Markdown 文件。pandoc 支持通过 lua-filter 进行扩展。我之前就分享过通过 lua-filter 实现统计文章字数修复相对引用。今天继续使用 lua-filter 来优化转换行的显示效果。

先给出具体的例子,让大家直观地感受一下这个问题。比如我们有如下 Markdown 原文:

hello
world

世界
你好

它们转成 HTML 会显示为:

hello world

世界 你好

注意世界跟你好之间有个空格,hello 跟 world 之间同样有空格。这个空格对英语来说是必须的,但对汉语来说却很多余。

以前没有办法,写中文内容的时候尽量不换行。一段写成一行,无论是移动还是修改都不方便。而且这种折衷的办法还有一个缺点,那就是在不同的屏幕上展示的效果不一样。如果屏幕比较窄也还可以,如果是很宽的屏幕,那直就是一段一行了。

最近我在翻译Go语言垃圾回收指南的时候实在是忍无可忍,因为每一段的内容都很长😂所以下决心彻底解决这个问题。

解决思路也很简单,就是在生成 HTML 之前扫描所有的软换行,如果换行前后都是汉字就把它强制删除。这个功能可以通过 lua-filter 轻松实现。

考虑到判断是否为汉字略微麻烦,而判断是否为 ASCII 字符比较简单。所以我这边要求,只有在软换行两边都不是 ASCII 字符的时候才会删除。

在 lua 中判断 ASCII 字符的函数如下:

function is_ascii(char)
  local ascii_code = string.byte(char)
  return ascii_code >= 0 and ascii_code <= 127
end

其实 lua 也支持比较字符,比如char >= '!' and char <= '~'也可以判断。但上面的是 ChatGPT 给出的版本,又不是不能用😄

接下来就是要找到 Markdown 中的软换行。查一下官方文档发现有一个 SoftBreak 对象。但说实话我是后来才看到的😂因为之前不知道有它,只能通过调试的办法来看。

最简单的 lua-filter 结构如下:

return {
  {
    Para = function (para)
      print(para)
      return para
    end,
  }
}

代码需要返回一个 table 对象。在 lua 中 table 对象即可以用作数组,也可以用作字典。大家有个概念就行,我也不太清楚🤪列表的每一个元素都是一个 filter,它的键是对应的 Markdown 语法对象,值是一个回调函数。

pandoc 会为每个段落生成一个Para对象,然后会依次调用 lua filter 的回调函数加以处理。在上面的例子,我是简单打印出Para的结构。

运行 lua filter 也非常简单:

pandoc --lua-filter filter.lua demo.md

假如 Markdown 的内容如下:

n

那么 pandoc 输出的内容就是:

Para [Str "n",SoftBreak,Str "\22909"]
<p>n 好</p>

第一行是我们在 lua filter 中输出的内容,第二行是对应的 HTML 输出。

从输出结果来看,这个段落里三个部分,中间是SoftBreak,另外两个是Str。最后的Str感觉像是乱码,应该就是原文中的「你」。

接下来就有思路了。我们应该想办法遍历Para的每个部分,如果发现是SoftBreak就再看看前后的Str是不是中文,如果都是中文那么就想办法把当前的SoftBreak替换掉。

在 lua 中遍历对象使用 for 循环:

for k, v in ipairs(para) do
  print(k, v)
end

但试了一个发现得到的结果不符合预期,什么都没有输出😂没辙了,只能先看看Para的官方文档。原来实际的子节点保存在content里面,所以正确的遍历姿势为:

local cs = para.content
for k, v in ipairs(cs) do
  print(k, v)
end

然后就是在遇到SoftBreak时检查前后的字符类型:

if v.t == 'SoftBreak' then
  local p = cs[k-1].text
  local n = cs[k+1].text
  if p ~= nil and n ~= nil then
    if (not is_ascii(p:sub(-1))) and (not is_ascii(n:sub(1,1))) then
      -- to do
    end
  end
end

lua 的 for 循环不支持 continue 也是醉了,只能多套几层判断。因为软换行前后可能是 Code等类型,直接读取text属性可能返回nil,所以需要事先检查。不然可能报错。

另外在 lua 中,字符串可以调用sub函数提取子串,sub(-1)是提取最后一个字符,而sub(1,1)是提取最前面的字符。

最后一步就是想办法删除SoftBreak。理论上我们可以直接修改Paracontent列表。但在遍历过程中直接修改会引发意想不到的问题2,但如果复制一份再按条件过虑,又可能引起性能问题。所以最简单的办法就是替换!

我们直接把SoftBreak替换成一个空的字符串,对应代码为:

para.content[k] = pandoc.Str("")

这样一来,最终输出结果就变成:

hello world

世界你好

是不是很方便😄现在就可以尽情地换行,随心所欲地换行。嗯,是自由的味道。


  1. https://pandoc.org/MANUAL.html#extension-east_asian_line_breaks↩︎

  2. lua 支持这种操作,删除后所有元素的下标会重新排列,处理起来比较麻烦。↩︎