在字体层面处理标点挤压

我之前有提到这个博客的标点挤压是用Pandoc filter + CSS实现的。那种方法表面看起来不错,但会在HTML上留下很多<span>——就像掀起一张美丽的地毯,发现下面全是小蜘蛛。呕,太可怕了。那有没有不包含小蜘蛛的方法?有:直接写OpenType特性。

做法

OpenType特性最方便的途径是编写OpenType Feature File——它的语法没有名字,但一般人管它叫fea——然后用fontTools把特性编译进字体文件里:

vim features.fea # 编辑你想要的特性
fonttools feaLib -o "$OUTPUT_FONT" features.fea "$INPUT_FONT"

fea中可以用字的名字指代字,而不同字体的同一个字不一定有同一个名字所以我有必要声明下我用的字体是思源宋体 CN VF 2.001版。如果你不知道你想改的字体里每个字都叫什么,可以用fontTools把字体的Character to Glyph Index Mapping Table(cmap)转成人类可读的XML:

ttx -t cmap -o cmap.xml "$INPUT_FONT"
cat cmap.xml
# ……
# <map code="0x300a" name="uni300A"/><!-- LEFT DOUBLE ANGLE BRACKET -->
# ……

知晓名字后就可以写fea了!

连用标点

回顾一下我在之前提到的挤压算法

  1. 如果当前字符是“之一:
    1. 且该字符是字符串里的第一个字符——挤压该字符;
    2. 且该字符前面是除)以外的标点——挤压该字符;
  2. 如果当前字符是任意标点,且后面跟着)之一——挤压该字符;
  3. 如果当前字符是)之一,且后面跟着任意标点——挤压该字符。

fea中,出现a字符紧接着b字符时,挤压a的命令是pos a b <valuerecord>;;挤压b的命令是pos a b' <valuerecord>;注意撇号。所以除去被划掉的1.1,其他三条可以如下表达:

@open = [
  quoteleft
  quotedblleft
  uni300A #《
  uniFF08 #(
  uni300C #「
];

@close = [
  quoteright
  quotedblright
  uni300B # 》
  uniFF09 # )
  uni300D # 」
];

@full = [
  uni2E3A # two em dash
  ellipsis
];

@other = [
  uniFF0C # ,
  uni3002 # 。
  uni3001 # 、
  uniFF1A # :
  uniFF1B # ;
  uniFF1F # ?
  uniFF01 # !
];

valueRecordDef < 0 0 -500 0 > HALF;
valueRecordDef < -500 0 -500 0 > OPEN_HALF;

feature chws {
  lookup chws_normal {
    # 2 Current char is any punctuation followed by a closing bracket
    pos @other @close < HALF >;

    # 3 Current char is a closing bracket followed by any punctuation
    pos @close @open < HALF >;
    pos @close @close < HALF >;
    pos @close @other < HALF >;
    pos @close @full < HALF >;
  } chws_normal;

  lookup chws_backtrack {
    # 1.2 Current char is following any punctuation except a closing bracket
    pos @open @open' < OPEN_HALF >;
    pos @full @open' < OPEN_HALF >;
    pos @other @open' < OPEN_HALF >;
  } chws_backtrack;
} chws;

问:

为什么要把这个特性命名为chws?

答:

因为Contextual Half-width Spacing(chws)是一个已经被注册专门用于汉字标点挤压的特性。其实这里介绍的方法就来自注册该特性的小林剑博士Dr. Ken Lunde写的《Contextual Spacing GPOS Features—Redux(文中的链接坏掉了,应该是这个。不过貌似没什么字体支持这个特性,而且火狐甚至不会默认开启它(尽管按标准来看它应该开启。所以其实叫个别的名字也无所谓。

问:

为什么要划掉1.1?

答:

因为我不清楚如何在fea中表达句首这一概念。OpenType有个Initial Forms(init)特性会作用于词首的第一个字母。但貌似shaper认为中文每个字都是一个词,所以init特性的效果会在每一个字上体现。

也可以调转思路,把@open的默认宽度改成半宽。然后用程序提取字体里的所有字写进@all。最后在@open前面有@all出现时,补上@open的空白。我之所以放弃是因为这样做要列举每个字,有些恶心。

问:

还有什么局限么?

答:

lookup会在<sup>前停下,所以没法对注释标号前的句号进行挤压

撇号

除去连续出现的括号引号书名号,还有一个标点需要挤压:撇号。撇号、英文单反引号还有中文单反引号在UTF-8中是同一个字,所以用默认字体是中文的浏览器浏览英文网页时经常能看到撇号后面留了巨大空白。我之前讨论过解决方法,但不够本质。既然可以用fea解决中文标点的挤压,也能用它根据撇号后面的字符是否是英文来判定消不消去中文单反引号的空白:

feature kern {
  @apostrophe_suffix = [space a-z A-Z];

  lookup apostrophe {
    pos [quoteright] @apostrophe_suffix -700;
  } apostrophe;
} kern;

其中-700就是< 0 0 -700 0 >的简写。Kerning(kern)特性是专门用来调整不同字母间间距的,也许把这条规则写在kern里并不好,但反正fontTools会替换原字体的所有特性,所以也没关系。

问:

什么?不是添加特性,而是替换特性?

答:

没错,把自己写的特性编译进思源宋体之后,连破折号都会断开——因为hwid特性没了。其实用sub命令把两个em dash合并成一个two-em dash,再写个pos命令调整two-em dash的位置后充当破折号效果也不错。

问:

sub命令又是什么?

答:

就是替换字形,比如1/2变成½就是用的sub命令。你还可以用它把瘦了吧唧的“·”换成更适合中文的“・

问:

说了半天,你怎么不在自己的博客里用?

答:

其实我短暂用了一天,但是对OpenType有所了解后我不满足于此了。我想干一件更彻底的事,而且我不喜欢展示半成品,所以现在退回以前的方法了。


复制以下链接,并粘贴到你的Mastodon、MisskeyGoToSocial等应用的搜索栏中,即可搜到对应本文的嘟文。对嘟文进行的点赞、转发、评论,都会出现在本文底部。快去试试吧!

链接:https://emptystack.top/note/chws-fea


三条评论:

注:点击昵称可以查看对评论的回复。

  1. Coelacanthus :archlinux: 🏳️‍⚧️

    @actor 如果有软件能导出已有字体的 fea 是不是就可以实现修改而不是替换了。

  2. Coelacanthus :archlinux: 🏳️‍⚧️

    @actor 引号撇号宽度的问题还是在于 Unicode 错误地为全半角引号分配了同一个码点,我是真的想不通他们给几乎所有的全半角标点都分配了分开的码点,却给引号分配了一样的码点。
    我现在为了一致地显示效果(毕竟我无法完全控制读者使用的字体以及渲染规则),对于所有的英文引号和撇号,一律使用 ASCII 里的直引号,对于中文环境的引号,采用直角引号。

  3. 冥王星爱丽

    @actor 谢谢你,虽然我对满地span还是没有什么感觉,但是你的小蜘蛛比喻非常有力,让人非常切身地体会到了你的心情)

    用OpenType特性感觉是很棒的主意,链接的小林健先生的文章也很有意思,我也想这么搞了,坐等你的更彻底的方法www

    你是看什么东西学的OpenType?我最近其实也在看类似的东西,不过同时还有很多别的要忙,进度很慢。

    simoncozens.github.io/fonts-an