《Lua-in-ConTeXt》12:zhfonts 模組備忘錄

garfileo發表於2023-03-02
上一篇:原始碼彩化

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 結點了。

相關文章