上一篇:源码凸显
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 结点了。