Srijika:为什么给九种印度文字换一套字体风格这么难?

为什么给九种印度文字换一套字体风格,比给拉丁字母难一个量级?

一份婆罗米系文字的字体,可以画得相当好看,却未必整形可用。整形(shaping)是排版引擎把字符序列变成正确形状的工序,过不了这一关,字图再漂亮也只是图片,不是能用的字体。把这句话说得精确一点:一份婆罗米文字要能用,它必须是一份排版引擎的契约,而不是一张画。这层门槛在现实里留下的痕迹很直观。截至 2026 年 9 月 3 日,Google Fonts 这个全世界最主流的开源字体库之一,收录了 1,946 个字体家族,其中 1,825 个支持拉丁字母,而支持 Odia 文(奥里亚文)的只有 6 个。Odia 是印度东部奥里萨邦的官方文字,按 2011 年人口普查,九种婆罗米系文字合计覆盖超过十亿母语使用者。需求端超过十亿人,供给端屈指可数,这缺口的根子正是整形这一关。Srijika

这个缺口,正是论文 Srijika 要解决的问题:给九种印度文字做字体风格再造(font restyling),也就是不改字符语义、只把字体的视觉风格整体换掉。下面这张九宫格是论文一次 showcase run 的产物,九个格子各是一种脚本的成果字体。

图注:单次运行跨九种脚本各产出一份可安装 TTF,每格用该字体经 HarfBuzz 整形渲染样例文本。最后一行 Tamil 的样例明显混排着超黑与浅色两类字形,这正是论文故意展示的回退行为,不是风格统一的成品。Srijika

整形为什么难,得先看婆罗米文字的结构。拉丁字母打字,a 就是 a,b 就是 b,一个字符对应一个形状。婆罗米文字不是这样,一个音节会被揉成一个整体。几个辅音可能黏合成一个连字,元音记号可能被重排到辅音的上方、下方或左侧,有的辅音只保留一半,给下一个辅音腾位置。也就是说,一份字体里要备好几百到几千个连字、半形和元音变体,还要让它们在排版引擎的规则下彼此一致。

这正好解释开头那句话的反常:画一张漂亮的字图,今天的生成模型早就能做到;让这套字形通过排版引擎的层层整形、在每一个变体上都正确,才是真正卡住人的地方。漂亮是画出来的,能用是规则保证的,两者不是一回事。这套契约具体长什么样,下一节拆开看。

一份字体要「能用」,排版引擎到底在背后做了什么?

先从你天天见到的拉丁排版说起。你在文档里输入 f 和 i,屏幕上却出现一个黏在一起的连接字,这是字体文件里的替换规则在工作。输入 A 和 V,两个字母会比别处靠得更近,这是定位规则在工作。这些规则都写在字体文件内部:cmap 表把字符映射成字形(glyph)编号,GSUB 表写替换规则,GPOS 表写定位规则。字形是字体的基本单元,一个具体形状。

印度文字只是把这份契约放大到整个音节。排版引擎(shaping engine)拿到一段天城文文本,先把它切成音节簇,然后按固定的六阶段流程处理。音节簇是引擎眼里的最小处理单位,一个或多个字符被揉成的一组形状。微软的 Devanagari 文档给过一个标准解剖图:

图注:一个天城文音节簇的解剖图,五个编号箭头分别指向前置形、基础辅音、上置的 reph、后置的元音附标和下置的 vattu,展示一个音节在整形后被揉成的一个整体。MS Devanagari

六阶段是婆罗米系文字通用的整形流程,每一段字符都要依次过关:

关键在阶段三。这一阶段的 GSUB 基础替换特征顺序被规范写死,第一步跑 locl,然后依次是 nukt、akhn、rphf、rkrf、pref、blwf、abvf、half、pstf、vatu、cjct、cfar,任何字体、任何引擎都不能改动这个顺序。OpenType Indic 对应到天城文里,rphf 负责把 ra 变成上置的 reph,half 负责把辅音改成半形,blwf 负责下置形,cjct 负责把辅音黏成连字。

这套模型至少存在两层。微软的 Devanagari 文档把它简化为四个阶段:analyze 切分、reorder 重排、GSUB 替换、GPOS 定位。MS Devanagari 开源的 OpenType 整形文档给出的通用规范是六个阶段,本质是同一件事,只是把重排细分成初始和最终两轮。微软文档里有一句值得原样记住的话:重排是引擎的责任,不是字体的责任,字体文件里的规则必须按重排结束后的顺序来写,而不是自己动手重排。

还有一个工程上的关键点:整形行为是可以检验的。HarfBuzz 是开源世界里的事实标准整形引擎,它的核心接口 hb_shape() 接收字体和一段文本,返回排好的字形编号序列。HarfBuzz 把同一段探针文本分别喂给两份字体,比较输出的字形编号序列,就能回答"这两份字体整形行为是否一致"。这个接口后面还会出现,它是整个评估的支点。

现在可以收束这一节了:整形正确性住在字体文件的表结构和特征顺序里,与字形轮廓画得漂不漂亮毫无关系。这个结论为后面"保留规则、只换外观"的做法埋下了原理上的可能。

九种脚本共享同一种骨架,为什么不能一招通吃?

Srijika 要覆盖的九种文字是 Devanagari、Tamil、Bengali、Telugu、Kannada、Malayalam、Gujarati、Gurmukhi 和 Odia。它们都源自婆罗米系,共享音节、连字、元音重排这套骨架,但每一种的整形细节都不同,而且差异就藏在最核心的特征上。

举两个最直观的例子。reph 的落点:Devanagari 和 Gujarati 的 ra 上置形落在后置形之前,Bengali 落在一个下置形之后,Malayalam 和 Odia 落在主辅音之后,Telugu 干脆放弃隐式规则、用显式编码。再看下置形模式:多数脚本允许下置形同时出现在前置和后置位置,Kannada 和 Telugu 只允许后置。这些差异意味着,一份字体里写死的替换规则集合,也就是 GSUB closure,没法从一种脚本原样搬到另一种。

规模问题更现实。Devanagari 有 37 个辅音,两两组合的理论连字数是 37 乘 37,等于 1,369 个潜在两字母连字。Fontbook 传统字体开发者的工作方式,是打开字形编辑器把这些一个字一个字画出来。下面这张图来自 2014 年的印度字体开发指南,左右两半分别是一个马拉雅拉姆语字体和一个孟加拉语字体的编辑器视图:

图注:左右并排的两张 FontForge 字形网格截图,左半是马拉雅拉姆语字体 Meera,右半是孟加拉语字体 Lohit-Bengali,可以看到上千个字形逐格排布,其中还有未映射的空白槽位。Fontbook

这份指南给出了体量参照:传统正字法的马拉雅拉姆语字体需要超过 1,000 个字形,Meera 大约有 1,100 个,现代正字法的 Raghu Malayalam 才降到约 350 个。逐格人工绘制只是工作量的一半,另一半是手写 GSUB 和 GPOS 规则,还要让它们按上一节说的固定顺序正确触发。这就是九种脚本各自的现实:各脚本动辄数百到上千字形、一套不能移植的规则,从零生成一套字体的代价大到不现实。

所以 Srijika 换了一条路。它不打算回答"怎么从零生成一套能用的印度文字字体",而是问一个更聪明的问题:规则已经有人写好了,能不能只把字形换掉?

不生成字体、只重绘字形:排版规则凭什么能原样复用?

Srijika 的输入有两样:一份整形完备的模板字体,加上一段自然语言风格描述,比如"厚重的圆角海报字"。输出是一组可安装的 TTF 文件。做法是三层拆分:cmap 表原样继承,GSUB 的替换闭包原样继承,GPOS 的定位结构在论文明确写出的度量与锚点策略下复用,真正重绘的只有字形轮廓本身。Srijika

这里有一个值得停一下的重构。整个系统里,生成模型的工作单元不是"一张字图",而是"模板字形闭包里的一个 tile"。字形闭包是从 cmap 和 GSUB 出发能到达的所有字形的集合,也就是这套排版规则实际会用到的那批形状。tile 就是这个集合里单个字形的渲染块。可安装性因此按构造成立:规则层原封不动,出错的空间从一开始就被堵死了。

一个结构完全对得上的比方:给键盘换键帽。键帽是长什么样,是圆角还是方形,随便你换;键位映射不能动,不然敲出来的字符全乱;组合键行为更不能动,Ctrl 加 C 必须还是复制。对应到 Srijika:键位映射就是 cmap,组合键行为就是 GSUB 和 GPOS 规则,重绘的键帽外观就是字形轮廓。反过来看端到端的做法,让 AI 直接生成一张字图,等于把键帽、键位和组合键行为一锅端换掉。对拉丁字母这种简单契约,凑合也许能行,对婆罗米文字的整形契约就是灾难。

这条范式还带出一个全篇的关键约束:输出字体必须在连字密集的探针文本上复现模板的字形编号序列。理由就在上一节,整形行为可检验,那么"没搞坏"就有明确的判据,不是你我说了算,是引擎说了算。回看开头的九宫格,Tamil 一行为什么深浅混排?因为那一格的风格参考落在训练数据边缘,部分字形重绘失败了,被系统回退成模板轮廓。这不是事故,是这套设计有意为之的兜底行为,下一节讲它怎么实现。

从一句风格描述到一份可安装字体:四道闸门怎么拦住 AI 的错?

整条流水线可以浓缩成一张图:风格检索、扩散重绘、四道验证闸门、模板兜底。

第一步,把语言描述变成具体的参考字形。这一步用的是 Lipika,作者自家开源的印度文字字体识别与检索系统。注意它只检索、不生成,它的活儿是告诉你"这段风格描述最接近哪几款真实字体",然后把它们的字形当参考喂给后面的生成器。Lipika Lipika 的规模在不同文件里有三个口径:论文的检索索引约 650 个字体家族,Hugging Face 模型卡写 595 个,GitHub README 较早的计数是 563 个。三个数字都对,区别在于计数对象不同,一个是检索索引,一个是分类器目录,一个是旧快照。

第二步,参考条件扩散模型以闭包 tile 为单位重绘字形。说人话就是,每个 tile 的模板字形作为内容输入,donor 的风格字形作为参考输入,扩散模型在两者之间做一次风格迁移。这一步的输出是渲染块,还不是字形轮廓,后面还要做轮廓拟合。

从第三步开始是这套系统真正特别的地方,四道缺陷容忍闸门。第一道内容闸,检测扩散模型有没有把内容搞丢,比如把笔画画成了曲线,丢了就按重试阶梯再画一次。第二道风格协调,把风格拉齐并设一个一致性封顶,防止风格飘得太远。第三道基线吸附,抓系统性漂移,比如所有字形整体偏离了基线。第四道整形簇验证,用 HarfBuzz 跑一遍闭包清单,核对生成字形是否还复现模板的字形编号序列,这一步不过就按单个字形回退成模板轮廓,而不是整个重来。

哲学就藏在最后一步里:用"通常正确,错了可验证、可回退"取代"必须一次正确"。生成模型出错是常态,工程上真正重要的是让错不至于毁掉整份字体。第四道闸门的验证接口,就是前文说的 hb_shape(),第 6 章会给它完整兑现。

重绘这一步确实跑通了。论文展示了一组从训练中拿掉的 Odia 字体重建实验,每组的五行分别对应模板、oracle 目标形态、纯几何变换、生成结果和 ground truth:

图注:三组 held-out Odia 字体的重建对比,每组自上而下五行:模板字形、目标 oracle 形态、纯几何变换、生成 tile 与 ground truth,能看到生成在继承模板内容的同时向目标风格移动。Srijika

顺带回答一个必然的疑问:为什么不直接沿用现成的扩散字体生成模型?因为那批方法走的是 image-to-image 路线,FontDiffuser 就是代表,把字形当纯像素处理,一张字图进、一张字图出;更早的 Diff-Font 也是像素层的条件生成。像素层风格迁移没有任何"字形编号必须不变"的约束,这条路线本身不保留也不验证 OpenType 布局,因此不能保证连字与整形行为仍正确。FontDiffuser Diff-Font 这是两条路线最本质的分野:一个保布局、换外观,一个连布局都不保留、也不验证。

66 份字体全部通过安检,为什么论文最诚实的发现是「照抄模板更好」?

论文交付了 66 份字体文件,57 份人工挑选的预设加 9 份开放词汇的 showcase 字体,证据分成四类,各自回答不同的问题。

第一类是安装闸。全部 66 个 TTF 通过 OpenType Sanitizer(OTS),也就是浏览器加载网页字体前那道安全检查。OTS 是 Chromium 和 Firefox 内置的净化器,把字体文件解析一遍、验证字段、再重新序列化,目的是拦下会崩渲染器的坏文件。OTS 通过 OTS 意味着这 66 份字体至少能被主流浏览器接受并加载,这是一个可安装性里程碑。但它不证明整形正确,OTS 根本不检查字形能不能被正确整形。

第二类是行为闸,回答整形是否正确。论文用 HarfBuzz(具体是 uharfbuzz 0.56.0)和苹果的 CoreText(macOS 26.5)各跑一遍连字密集的探针文本,两类引擎输出的字形编号序列与模板完全一致,66 份字体无一例外,也没有触发系统字体回退。在这批连字密集探针上,两个引擎都复现了模板行为;这为所测覆盖范围提供强证据,但不等于穷尽所有文本。

第三类是结构审计。论文对全部闭包做了完整清点:80,915 个字形、54,812 个锚点。其中有 50,066 个回退字形的度量差值严格为零,因为它们就是模板轮廓本身;剩余 30,849 个被接受的重绘字形,步进宽度差的中位数只有 0.045 em。锚点位移的垂直分量(Δy)在全部 54,812 个对比锚点上严格为零——因为锚点平移被限制为纯水平方向;水平分量 |Δx| 的 p95 为 0.089 em。整套数字的意思是:重绘没有把排版度量搞乱。

第四类闸门的结果最反直觉,也最值得细看。在扣除训练家族的 SSIM 闸门上,什么都不做的模板复制基线,在全部脚本上得分都比生成模型高,高出 0.003 到 0.069,56 个 held-out face 里赢下 50 个。点破这层反常:一个生成模型在主任务上游不过"什么都不做"。论文自己的解释是:当 held-out face 偏离模板足够远时,SSIM 排序会翻转(如 Meera Inimai,style ratio 0.98)。至于“生成确实吸收了 donor 的重圆风格、但付出被 SSIM 惩罚的结构代价”,这句在论文里指向的是 Alkatra 越域图,而不是这 6 个分布内例外。更关键的 caveat 必须原样保留:风格移动只能用内部同模型 embedding 衡量,而它的训练语料本身就包含这些 held-out 家族。也就是说,对"风格迁移质量"的任何表述都带这个限定,别把 50/56 读成对风格的全面否定,那也不是作者的立场。

越域行为长什么样?下面这张图的上半部分是 Alkatra,一个在训练数据边缘之外的超黑重字重家族:五行的对比能看到几何变换和形态基线几乎没动,生成却明显偏向了 donor 的圆润风格。下半部分是同一轮配置组装出的真实字体样例:

图注:上半部分五行对比模板、oracle 形态、几何变换、生成 tile 与 Alkatra 真实字形,生成相对基线明显吸收了重圆风格;下半部分是一段经 HarfBuzz 整形的样例文本,字形深浅明显混排,那是回退机制在真实文件里的样子。Srijika

Alkatra 的闭包里有 562 个字形,只有 210 个通过内容闸被重绘,其余全部回退成模板轮廓。重量混排不是生产事故,是缺陷容忍流水线在越域输入面前最诚实的表现。论文把这类发现郑重写进了第五项贡献:负结果目录,包括六个试过且不奏效的杠杆族,比如把骨架条件信号接到扩散模型入口,那个通道直接死掉,最终范数差距达 340 倍;还包括训练目标污染的取证研究,以及数据覆盖边界的分析:位于数据包络内接近 p90 的参考字体(如 Lobster)回退率约 11%,真正越出数据包络的超黑 donor(AlfaSlab,0.757)回退率则达 54%–59%。

诚实不止于负结果。这是单作者的预印本,尚未经过同行评审;论文也明确承认没有跑过带学习的基线对比,所以任何"Srijika 打败了 FontDiffuser"的说法都不是论文的主张。连公开 demo 都留着一条缝隙:demo 代码里的脚本常量只有 Devanagari、Tamil、Bengali 三种,与论文九种脚本的宣示有落差,代码才是 ground truth。Srijika demo

把这一切放回开头的问题。传统做法是把"做一套印度文字字体"分解成人工画几千个字形加手写整形规则,现在被压缩成挑一份模板再加写一句 prompt。模板补齐了排版契约这一层,AI 只管重绘外观,四道闸门和模板兜底管住出错。这份工作真正的分量不是某个指标,而是一个判断:在低资源复杂文字上,把字体生成重定义为布局复用加字形重绘,提供了一条在本文测试条件下可运行、可验证的实用途径。这条路尚未和学习式基线同协议比较,但 AI 画的字形仍然常常不如"什么都不做",至少它把错误关在了能在引擎里证明的地方。

想深入可以读原文:Srijika: OpenType-Layout-Reusing Font Restyling for Nine Indic Scripts — arXiv:2609.05661

参考文献

Srijika:为什么给九种印度文字换一套字体风格这么难?