上一篇:原始碼彩化
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 結點了。