上一篇:源码凸显

zhfonts 模块实现了 ConTeXt (>= MkIV) 对汉字字体的加载、简体汉字标点符号(全角)间距的压缩以及边界对齐。该模块成型于 2011 年,2023 年初对代码进行了一番梳理,希望它能工作到 2033 年……安装和使用方法可参考 https://github.com/liyanrui/zhfonts/blob/master/README.md,本文仅对其一些技术细节予以说明,一则备忘,二则或许能帮助一些同好对该模块予以改进。

默认字体

zhfonts 默认使用 simsun.ttc(宋体),simhei.ttf(黑体) 和 simkai.ttf (楷体)三种汉字字体:

  • simsun.ttc 的子字体 nsimsun 作为衬线字族(Serif,对应 ConTeXt 字体切换命令 \tf)的正体(Regular,对应\rm) 和斜体(Italic,对应 \it);
  • simhei.ttf 作为无衬线字族(Sans,对应 \ss)的所有字体;
  • simkai.ttf 作为等宽字族(MonoSpace,对应 \tt)的正体和斜体;
  • simhei.ttf 作为衬线,无衬线以及等宽字族的粗体和粗斜体,对应这三种字族的 \bf\bi 命令。

具体设定可参考 t-zhfonts.lua 的设定:

f.chinese = {
    serif = {regular = {name = "nsimsun", rscale = "1.0"},
             bold = {name = "simhei", rscale = "1.0"},
             italic = {name = "nsimsun", rscale = "1.0"},
             bolditalic = {name = "simhei", rscale = "1.0"}},
    sans = {regular = {name = "simhei", rscale = "1.0"},
            bold = {name = "simhei", rscale = "1.0"},
            italic = {name = "simhei", rscale = "1.0"},
            bolditalic = {name = "simhei", rscale = "1.0"}},
    mono = {regular = {name = "kaiti", rscale = "1.0"},
            bold = {name = "simhei", rscale = "1.0"},
            italic = {name = "kaiti", rscale = "1.0"},
            bolditalic = {name = "simhei", rscale = "1.0"}}
}

至于拉丁字体,zhfonts 默认使用 ConTeXt 自带的 LatinModern 字族,可参考 t-zhfonts.lua 的设定:

f.latin = {
    serif = {regular = "lmroman10regular", bold = "lmroman10bold",
             italic = "lmroman10italic", bolditalic = "lmroman10bolditalic"},
    sans = {regular = "lmsans10regular", bold = "lmsans10bold",
            italic = "lmsans10oblique", bolditalic = "lmsans10boldoblique"},
    mono = {regular = "lmmono10regular", bold = "lmmonolt10bold",
            italic = "lmmonolt10oblique", bolditalic = "lmmonolt10boldoblique"}        
}

边界标点对齐

在 ConTeXt 的段落断行结果中,若标点符号落在一行文字的开头或末尾,对于文字横排,令其向两侧侧伸出,使其近似落在版心右侧边线上,可使排版结果更为精致。例如

\usemodule[zhfonts]
\setuppapersize[A6][A6]
\showframe

\starttext
\dorecurse{10}{“左引号应该与左边界对齐,右引号应该与右边界对齐”}
\stoptext

标点与边界对齐

若取消标点边界对齐,则排版结果为

标点与边界未对齐

ConTeXt 仅对落在版心右侧边线的西方文字的标点提供了伸出支持,详见 https://wiki.contextgarden.net/Protrusion,对汉字全角标点未提供支持,但是在 font-imp-quality.lua 脚本中给出了用户自己控制边界标点伸出的方法,zhfonts 便是利用了该方法实现了汉字简体全角标点的边界伸出支持。

首先,定义标点在左右边界伸出的字宽倍数(\quad 宽度的倍数):

fonts.protrusions.vectors["myvector"] = {  
   [0xFF0c] = { 0, 0.60 },  -- ,
   [0x3002] = { 0, 0.60 },  -- 。
   [0x2018] = { 0.60, 0 },  -- ‘
   [0x2019] = { 0, 0.60 },  -- ’
   [0x201C] = { 0.50, 0 },  -- “
   [0x201D] = { 0, 0.50 },  -- ”
   [0xFF1F] = { 0, 0.60 },  -- ?
   [0x300A] = { 0.60, 0 },  -- 《
   [0x300B] = { 0, 0.60 },  -- 》
   [0xFF08] = { 0.50, 0 },  -- (
   [0xFF09] = { 0, 0.50 },  -- )
   [0x3001] = { 0, 0.50 },  -- 、
   [0xFF0E] = { 0, 0.50 },  -- .
}

然后,让 fonts.protrusions.classes["特性名称"].vector 指向上表:

fonts.protrusions.classes["zhspuncs"] = {
    vector = "myvector",
    factor = 1
}

zhspuncs 即特性名称,可在定义字体特性时使用。例如

\definefontfeature[hanzi][default][protrusion=zhspuncs]

在定义字体时,凡是使用该特性的汉字字体,皆具备标点边界伸出能力,例如

\startluacode
fonts.protrusions.vectors["myvector"] = {  
   ...   ...   ...
   [0x201C] = { 0.50, 0 },  -- “
   [0x201D] = { 0, 0.50 },  -- ”
   ...   ...   ...
}
fonts.protrusions.classes["zhspuncs"] = {
    vector = "myvector",
    factor = 1
}
\stopluacode

\definefontfeature[hanzi][default][mode=node,protrusion=zhspuncs]
\definefont[myfont][name:nsimsun*hanzi at 12pt]
\setupalign[hz,hanging] % 使 protrusion 生效
\setscript[hanzi] % 加载 scrp-cjk.lua 提供的中文断行规则和标点禁则

\starttext
\myfont
\dorecurse{100}{“我能吞下玻璃而不伤身体”}
\stoptext

需要注意的是,上述对边界标点伸出比例的设定未必适合所有汉字字体,应用时可根据审美需求,予以调整。

标点间距压缩

汉字的两个全角标点相邻时,通常需要对其间距予以压缩。例如,

老子说:「道可道也,非恒道也。」

若未压缩标点间距,ConTeXt 的排版结果为

若压缩标点间距,则结果为

标点间距压缩后的结果是否更美观,属于个人偏好,但是显然压缩后,在满足排版需求的前提下,更能节省排版空间,若用于打印,可以少砍许多树……这是我能为排版唯一找回的意义。

在标点符号之间插入负值的 \kern 便可实现标点间距压缩,例如,

老子说:\kern -1em 「道可道也,非恒道也。\kern -.5em 」

可以对 ConTeXt 源文件进行处理,在相邻的汉字标点间插入 \kern 命令实现间距压缩,但此举不适合抄录环境,例如

\starttyping
老子说:\kern -1em 「道可道也,非恒道也。\kern -.5em 」
\stoptyping

\kern 指令非但不起作用,反而扰乱了排版内容。最适合处理标点间距压缩的层面是在 ConTeXt 所用的 TeX 引擎(MkIV 版本用的是 LuaTeX,LMTX 版本用的是 LuaMetaTeX)对 ConTeXt 源文件处理后生成的结点列表。

结点列表

LuaTeX 在将 TeX 源文件处理为 TeX 记号(TeX Token)后,对 TeX 宏予以展开至 TeX 原语层面,待执行完所有原语后,在将排版结果输出至后端(dvi,ps 或 pdf)之前,所有的排版内容以结点列表的形式存储。用户可通过 Lua 程序访问并操作结点列表。LuaMetaTeX 是 LuaTeX 的后继者,它对 LuaTeX 进行了清理,目的是与 ConTeXt 系统取得紧密联系,亦即 LuaMetaTeX 本质上是一个不能独立运行的 TeX 引擎,目前仅能在 ConTeXt 环境里使用。LuaMetaTeX 同样支持用户通过 Lua 程序访问并操作结点列表。

由于 TeX 所处理的排版内容非常复杂,因此结点的类型繁多。例如,\kern 命令对应 kern 结点类型,\hbox 命令对应 hlist 结点类型,字符对应 glyph 结点类型。以下示例有助于直观感受 LuaTeX 的结点类型。

\starttext
\setbox0\hbox{有生于无。}
\startluacode
local box = tex.box[0]
local head = box.list
local types = node.type(box.id) .. ":\n"
for x, id in nodes.traverse(head) do
    types = types .. "\t" .. node.type(id) .. "\n"
end
context.tobuffer("foo", types)
\stopluacode
\typebuffer[foo]
\stoptext

虽然在上述源文件里并未为汉字设定字体,但是在结点列表层面,此时尚未将内容输出至后端,因此 LuaTeX 无需关心字体的问题。

倘若在在上述代码之前添加

\setscript[hanzi]

以加载 ConTeXt 提供的 scrp-cjk.lua 脚本实现的汉字断行功能,结果则是另一番情况:

这是因为 scrp-cjk.lua 脚本在相邻汉字之间插入了粘连(glue)结点(对应 \hskip 之类),并在标点前插入了罚点(对应 \penalty)以禁止 LuaTeX 在某些标点之前断行。若非如此,汉字无法断行,因为在 TeX 引擎看来,汉字构成的段落等同于一个西文单词。

注:使用「mtxrun --script base --find scrp-cjk.lua」命令可获得 scrp-cjk.lua 路径。

ConTeXt 的任务回调机制

既然 scrp-cjk.lua 能够在相邻汉字对应的 glyph 结点之间插入粘连结点,借鉴它的工作方式,实现汉字标点间距压缩,不失为上策,不幸的是,像 scrp-cjk.lua 这样的脚本该如何编写,缺乏文档说明。直接修改 scrp-cjk.lua 或仿写,虽然也能解决问题,但是不能确定这些未成文的机制在将来是否会发生变动。zhfonts 模块的做法是使用 ConTeXt 提供的任务回调机制:

nodes.tasks.appendaction("processors","after", ...)

将实现标点间距压缩功能的函数作为回调函数传给 nodes.tasks.appendaction。ConTeXt 的任务回调机制有相关的文档说明,见 hybrid.pdf 的「Callbacks」章。

注:使用「mtxrun --script base --find hybrid.pdf」可获得 hybrid.pdf 路径。

以下示例展示了如何向 ConTeXt 的 processors 任务列表的 after 阶段添加回调函数:

\startluacode
my = my or {}
function my.foo(head)
    print(">>>> my.foo:")
    for x, id in nodes.traverse(head) do
        print(node.type(id))
    end
    return head, true
end
nodes.tasks.appendaction("processors", "after", "my.foo")
\stopluacode

\starttext
\setbox0\hbox{有生于无。}
\stoptext

processors 任务列表里的所有任务都发生在断行之前,此时凡是参与排版的字符皆已与字体有了关联,但是上述示例定义的 \box0 中的内容并未参与排版,因此即使未定义汉字字体,也不会妨碍 my.foo 函数被 ConTeXt 调用执行,只是输出内容是直接输出到终端窗口了,不能再像上一个示例那样写入 ConTeXt 的缓冲区并通过 \getbuffer 获取了。ConTeXt 缓冲区仅在 processors 任务列表之前的时机有效,否则写入缓冲区的内容会再次触发 my.foo 的调用,会形成死循环。

字形结点

glyph 结点将字符与字形关联了起来。下面的示例能够输出每个字符对应的字形的宽度、高度和深度信息:

\startluacode
my = my or {}
function my.foo(head)
    print(">>>> my.foo:")
    for x, id in nodes.traverse(head) do
        if id == nodes.nodecodes.glyph then
            print(x.width, x.height, x.depth)
        end
    end
    return head, true
end
nodes.tasks.appendaction("processors", "after", "my.foo")
\stopluacode

\starttext
\setbox0\hbox{有生于无。}
\stoptext

但是 my.foo 函数的输出结果是

>>>> my.foo:
0    0    0
0    0    0
0    0    0
0    0    0
0    0    0 

因为 ConTeXt 默认加载的西文字族是 Latin Modern 字族,其中任何一个字体都不包含汉字,导致结点 x 没有字形信息。

现在,修改上例的正文部分,

\starttext
% 加载 simsun.ttc 的子字体 nsimsun
\definefont[myfont][name:nsimsun]
\setbox0\hbox{\myfont 有生于无。}
\stoptext

my.foo 函数的输出结果变为

>>>> my.foo:
786432    645120    76800
786432    645120    36864
786432    617472    64512
786432    626688    76800
786432    165888    3072

这些数字都是尺寸值,单位是 sp——TeX 的基本尺寸单位,与单位 pt 的换算关系是 1 pt = 65536 sp。若将 my.foo 函数修改为

function my.foo(head)
    local pt = tex.sp("1pt")
    print(">>>> my.foo:")
    for x, id in nodes.traverse(head) do
        if id == nodes.nodecodes.glyph then
            local sizes = string.format("%.1f\t%.3f\t%.3f",
                                        x.width /pt,
                                        x.height/pt,
                                        x.depth/pt)
            print(sizes)
        end
    end
    return head, true
end

则输出结果变为

>>>> my.foo:
12.0    9.844    1.172
12.0    9.844    0.562
12.0    9.422    0.984
12.0    9.562    1.172
12.0    2.531    0.047

tex.sp 函数可将文本形式的尺寸转换为单位为 sp 的尺寸。my.foo 输出的信息说,在上例中,\box0 中的 5 个汉字,它们的字形宽度皆为 12.0 pt,汉字字体基本每个字形都是等宽的,但高度和深度不等。在定义字体时,若设定字体尺寸,例如

\definefont[myfont][name:nsimsun at 11pt]

my.fooo 输出信息会发生变化。

字形边界盒

glyph 结点 x 将字符 x.char 和字体 x.font 关联了起来。通过 fonts.hashes.identifiers[x.font] 便可访问 x.char 对应的字形信息。

给 ConTeXt 安装新字体时,需要将字体文件复制到 TeX 目录结构的适当位置,然后执行

$ mtxrun --script fonts --reload --force

该命令可从字体文件获取字形信息并将其以 Lua 表结构存储在 ConTeXt 的 cache 目录。这些 Lua 表可通过 fonts.hashes.identifiers[字体 id] 访问,例如

\startluacode
my = my or {}
local tfmdata = fonts.hashes.identifiers
function my.foo(head)
    print(">>>> my.foo:")
    for x, id in nodes.traverse(head) do
        if id == nodes.nodecodes.glyph then
            -- x.font 是字体 id, x.char 是字符的 Unicode 编码
            local desc = tfmdata[x.font].descriptions[x.char]
            if desc then
                local bbox = desc.boundingbox
                print(bbox[1], bbox[2], bbox[3], bbox[4])
            end
        end
    end
    return head, true
end
nodes.tasks.appendaction("processors", "after", "my.foo")
\stopluacode

\setuppapersize[A7,landscape][A7,landscape]
\definefont[myfont][name:simsun at 11pt]
\starttext
\myfont 有生于无。
\stoptext

排版结果为

my.foo 的输出结果为

>>>> my.foo:
11    -25    243    210
12    -12    244    210
14    -21    241    201
12    -25    245    204
35    -1    91    54
>>>> my.foo:
89    0    419    666

之所以有两次输出,是因为 ConTeXt 传给 my.foo 的除了正文字符,还有页码。

my.foo 中的 bbox 字符对应字形的边界盒信息,它的前两个数值是边界盒左下角顶点的坐标,后两个数值则是边界盒右上角顶点的坐标。例如

35    -1    91    54

是汉字句号的边界盒坐标。

如果使用 fontforge 打开 simsun.ttc 的子字体 nsimsun。在菜单「View/Goto」打开的对话框里输入汉字句号的 Unicode 码 0x3002,便可定位到汉字句号对应的字形,双击打开该字形的设计界面,然后通过菜单「View/Show/Side Bearing」显示该字形的边界尺寸,结果如下图所示:

显然 ConTeXt 是将字体基线的纵坐标作为纵轴的 0 点,所以汉字句号边界盒左下角顶点的纵坐标是 -1。另外,在 fontforge 中,每个字形的设计空间是 256 x 256(至少 simsun.ttc 如此),据此可以印证 ConTeXt 给出的字形边界盒信息是正确的。

在 ConTeXt 安装目录的 tex/texmf-cache/luatex 或 luametatex 路径中可以找到 ConTeXt 每次加载新字体时生成的字形信息文件,其扩展名为 .tma,例如 simsun.ttc 的子字体 nsumsun 对应的字形信息文件是 simsun-nsimsun.tma,其格式如下:

return {
 ["cache_uuid"]="36b92b1d-4f4e-9e43-9147-2fe016be390c",
 ["cache_version"]=0x1.910624dd2f1aap+1,
 ["compacted"]=true,
 ["condensed"]=true,
 ["creator"]="context mkiv",
 ["descriptions"]={
  [32]={               -- 10 进制编码的 Unicode 码
   ["boundingbox"]=1,  -- 无字形边界盒
   ["index"]=3,
   ["unicode"]=32,
   ["vheight"]=256,
   ["width"]=128,
  },
  [33]={
   ["boundingbox"]={ 49, 1, 77, 180 },  -- 字形边界盒坐标
   ["index"]=4,
   ["unicode"]=33,
   ["vheight"]=256,
   ["width"]=128,
  },
  ... ... ...
}

能获得每个字形的边界盒信息,对单个汉字标点以及多个汉字标点的间距压缩便时,便可根据字形边界盒构造相对尺寸的 kern 结点了。


garfileo
6k 声望1.9k 粉丝

这里可能不会再更新了。


引用和评论

0 条评论