交互式介绍:渲染阿拉伯字体排版的精彩体验及其技术债
摘要
一位前端工程师深入探讨在网络上渲染阿拉伯文字的历史与技术挑战,涵盖CSS限制、字体造型以及数个世纪的排版演变。
<p><a href="https://lobste.rs/s/ptkd7x/interactive_introduction_terrific">评论</a></p>
查看缓存全文
缓存时间: 2026/06/10 23:51
# 交互式引入:渲染阿拉伯字体排版的绝妙体验及其技术债务
来源: https://lr0.org/blog/p/arabic/
很久以前,一个前端工单落入我的队列——这工单本不属于我,但团队里另一位阿拉伯语读者正在休假。工单大致是这样说的:客户面向的仪表板上有一段混合内容的阿拉伯语散文,在浏览器中渲染时左边缘参差不齐(在阿拉伯语中不齐边缘在左侧,因为行从右边距开始排列;工单却写了"右边参差不齐"),而设计团队明确指定了两端对齐。附带了来自三个浏览器的三张截图,以及产品经理的一封客气留言,指出同一段拉丁字母版本看起来,我引用原话,"完全正常。"
在那同一半年里,我关闭了针对同一产品的另外三个工单,每个工单的提交者都认为那是唯一的 bug。一位客户的名字在打印的协议上显示成了字母不连笔的样子,就像油漆工在1962年可能会摆放的那样——这是因为收据服务器上的 PDF 库,其语言运行时在字形塑造引擎出现之前就存在了。一个搜索索引对客户服务团队能在数据库中看到的账户返回空结果,这是因为一次2017年的导入使用1991年已过时的 Unicode 码点(而非1995年的常规码点)对一万两千个名字进行了编码,而索引非常合理地视两种编码为不同字符串。所以,那个左边参差不齐的工单是四个中最无关紧要的一个。然而,它位于同一座冰山之上,指向的是同一件事。
这里就是分歧所在,现场复现。我用了随机文本,原文间距更大,我懒得挑选单词来最大化参差不齐和间距效果。
## 实际产品,任何浏览器
الخط هندسة روحانية ظهرت بآلة جسمانية، وهو لسان اليد ورسول العقل، وسفير الضمير ووحي الفكر، وسلاح المعرفة وأنس الإخوان عند الفرقة.
## 应用工单要求的修复
## 设计批准的效果图
الخـــــط هندســـــة روحانيـــــة ظهــــرت بآلــــة جسمانيــــة، وهــــو لســـان اليــــد ورســـــول العقـــــل، وسفيــــر الضميــــر ووحـــــي الفكـــــر، وســـــلاح المعــــرفة وأنـــــس الإخـــــوان عنــــد الفــــرقــــة.
右边是已批准的设计:两边距对齐,每一行通过延长*字母内部*的笔画来填满,而不是通过拉宽单词之间的间距。它能在你的浏览器中渲染,只因为我手动放置了每一处延长——我将在下文坦白这一点。左边是实际产品所呈现的。勾选框以应用 CSS 提供的唯一工具,`text-align: justify`(为了这些演示,本站首次提供自托管的 webfont:Amiri,一百五十千字节,来自一个人无偿的夜晚,根据 OFL 许可证再分发。我想说清楚的是,要向你展示你的操作系统无法自行完成的东西,需要这些资源本身,就是论证的一部分。我认为这是一百五十千字节令人愉悦的字体。)
确实看起来正常。我花了大约半小时,检查了渲染的 DOM,在许多不同的 font-family 和 direction 声明组合中设置了 `text-align: justify`,最后写了一篇回复,大致诚实解释了问题不是我们样式表的 bug,而是网络上的阿拉伯语排版的现状。回复和关闭工单花了大约半小时。而其背后的原因用了五百年才堆积起来,涉及一位被两次伤残的大臣、一本消失了四个世纪的《古兰经》、一位有截稿期限的贝鲁特报人、以及一位自学字体工程学(据我猜测)的埃及医生。梳理这些,最终成为我那份工作中最愉快的几周。这里我也想走一遍这个过程。
## 抄写员解决了什么
这段历史值得记录,因为几乎阿拉伯字体工程这个小圈子之外没人知道,而且它很精彩。古典阿拉伯字体排印,我指的是早期伊斯坦布尔和布拉克的印刷工们毕生追求的手稿传统,在实现一行两端对齐时,完全不拉伸单词间的空白。拉伸空白是拉丁文的惯例,在阿拉伯语中会产生抄写员会认为很难看的效果。相反,抄写员沿着基线延长字母本身的形状,使用一种称为 *tatwīl* 或现代技术术语中的 *kashida* 的技术:某些字母对之间的连接笔画可以被拉长,有时甚至华丽地拉长,以将行延长到边距。17 世纪一页排布良好的 Naskh 体手稿,每一行两端都是对齐的,结果是密集而规整的编织感,任何花时间读过优秀手写《古兰经》的人一眼就能认出。
图 1:一幅 14 世纪的《古兰经》对开页,Naskh 家族字体,每一行两端对齐。现在收藏于大都会艺术博物馆。沿着左边边缘看:每一行都落齐,没有一个词间距被拉伸。对齐发生在单词内部。
这并非即兴之作,而是一个有书面记录的系统。这个系统由阿拔斯王朝的大臣兼首席书法家伊本·穆格莱写下,他先后为三位哈里发服务,被其中两位囚禁;第三位以叛国通信的罪名砍掉了他的右手,之后伊本·穆格莱将芦苇笔绑在断腕上继续写了数月,并因此被割掉舌头,最终于公元 940 年左右死于狱中。他的尸体被埋葬了三次,每次都被他的女儿移走,以防坟墓落入警方之手。他写下的系统比所有伤害他的人多存活了一千年。它被称为 *al-khaṭṭ al-mansūb*,即比例字体:每个字母形状用芦苇笔尖的菱形点测量,每条曲线都是一个定义圆的定义弧线,alif 是固定数量的点高,其他一切从 alif 衍生而来。在该系统中,延长是一个有自身规则的绘制笔画——哪些字母对可以延长,曲线如何膨胀和变细,一行可以承载多少延长,它们可以放在哪里。抄写员还通过*选择不同形状*来实现对齐,因为大多数字母都有不同宽度的替代形式,熟练的手会根据边距接近程度从中选择。在此传统中,对齐不是间距问题,而是形状问题。
伊本·穆格莱开创的传统并未止于他;在接下来的六百年中,被署名的人类通过书面形式不断完善。大约公元 1022 年,巴格达的伊本·巴瓦卜简化了比例,并产生了定义了后来千年 Naskh 体的手稿;他亲手写就的一本《古兰经》现存于伊斯坦布尔的切斯特·比蒂图书馆,你可以通过波斯、奥斯曼和马穆鲁克传统与它的接近程度来判定它们的年代。雅库特·穆斯塔西米(Yāqūt al-Mustaʿṣimī)在 1258 年蒙古人洗劫巴格达时,通过爬上宣礼塔并继续写字而幸存,他将后来学者称为"六支笔"的经典字体编纂成典:*Naskh*、*Thuluth*、*Muḥaqqaq*、*Rayḥān*、*Tawqīʿ*、*Riqāʿ*,每种都有自己的度量标准,每种都有自己的对齐语法。随后,波斯抄写员在 14 世纪发明了 *Nastaʿlīq*,这是一种悬挂式字体,通过在每个短语末尾向下倾斜基线来对齐,与普通对齐的关系大致相当于垂直花园与草坪的关系。奥斯曼人发展了用于大臣文书的 *Dīwānī* 和用于苏丹印章的紧密打结的 *Dīwānī Jalī*,两者都通过将字母交错在普通基线从未到达的高度来填充空间。所有这些都属于同一个由 28 个字母组成的字母表;每种都有自己关于哪些字母可以接受 kashida、哪些永远不能以及行如何呼吸的规则。
拉丁文排版从来不需要这些,因为拉丁字母不会"牵手"。而阿拉伯字母会,而网络在 2026 年,看着它们牵手,却仍然拉宽了文字之间的空白。这意味着你已经知道页面顶部的设计效果图在做什么了:它是这个手稿传统的一页,用 HTML 伪造,每一行通过笔画而非空白延伸到设定宽度。因为我已经承诺坦白:这些延长是 U+0640 TATWEEL 字符,我手动放置和调整了大小。
## 每个字母四个形状
要理解为什么自古腾堡以来的每一台机器都与这种文字搏斗且大多失败,你需要一个结构上的事实,而且这个事实很美妙:阿拉伯语*一直都是*连笔体。没有印刷体与手写体的区别,没有大写字母。字母在石碑铭文、手稿、金属、屏幕上都是连接的。因此,每个字母根据其邻居改变形状(独立形、首形、中形、尾形),而六个字母拒绝向前连接,这打破了单词的连笔簇,给文字带来了节奏。这些形状并不是覆盖在某些底层"真正的"字母上的服装。位置变化*就是*字母本身。而且这个字母表比阿拉伯语言本身要大。波斯语扩展了四个阿拉伯语没有的字母(پ pe、چ che、ژ zhe、گ gaf),并以微妙不同的形式使用了两个现有字母(ی 用于 final yāʾ,ک 用于 kaf)。乌尔都语增加了一个送气的 *do-chashmī he*(ھ)、一组卷舌音(ٹ ڈ ڑ)和一个悬挂的 *ye barree*(ے),并且其大部分日常文字用 *Nastaʿlīq* 书写,而一个为 Naskh 体设计的字体将产生音位上正确但视觉上无法辨认的近似。信德语还有更多。普什图语、库尔德语、维吾尔语、克什米尔语和旁遮普语各自取用字母表,添加它们音位学所需的内容,然后交付。任何自称"阿拉伯语"却不咨询波斯和乌尔都社区的字体,都将为数亿在伊朗和南亚的读者产生技术上"渲染了"但功能性错误的文字:kaf 有错误的尾形,heh 在不该连的地方连,数字来自错误的区域。Noto Sans Arabic 字体家族提供了单独的子字体来覆盖这些需求(NotoNaskhArabic、NotoNastaliqUrdu、NotoSansArabicUI),而操作系统的字体回退链通常能处理正确;"通常"在这个句子中承担了很大的工作量。
| 存储的码点 | 独立形 | 首形 | 中形 | 尾形 |
|------------|--------|--------|--------|--------|
| U+0639 ʿAYN | ع | عــ | ـعــ | ـع |
| U+0647 HEH | ه | هــ | ـهــ | ـه |
一个码点,四个形状,在渲染时由字形塑造引擎选择。中形 heh 和独立形 heh,对未经训练的眼睛来说完全是不同的字母;我曾见过学阿拉伯语的学生在第三周遇到中形 heh 并向管理层投诉。一个提供 26 个小写形状的拉丁字母字体对这些不需要任何意见。一个阿拉伯语字体除非对所有这一切有意见,否则就是错误的。
现代答案,正确的答案,是在几十年的错误答案之后才得出的:*编码*存储抽象字母,而*字体*提供形状。Unicode 给你一个用于 ʿayn 的码点;字体携带四个位置字形;字形塑造引擎在渲染时应用 OpenType 特性(`isol`、`init`、`medi`、`fina`,加上 `rlig` 用于文字所需的连字,再加上 `mark` 和 `mkmk` 用于堆叠元音符号)。一个阿拉伯语字体是一个小程序。你存储的文本是它的输入,而不是它的输出。这个词在你每次看到它时被重新演绎,就像乐谱演奏出的音乐。
感受这一点最清晰的方式是逐个字母组装一个词,观察每个先前的字母在下一个字母到来时如何重新协商其形状:点击字母以将它们添加到词中,按照它们在码点流中出现的顺序:
**由字形塑造引擎渲染** — 尝试: م, 然后 ح, 然后 م, 然后 د: 构建名字 Muḥammad。第一个 م 在你在添加 ح 时立即变成首形,ح 在下一个 م 到来时变为中形,直到第四个 د,六个不连字母之一,它打断了连笔,迫使原本会成为第三个 م 的字母变成尾形。存储中的四个码点,考虑了十一个不同的字形选择,查询了七个 OpenType 查找,在屏幕上呈现出一条连续的笔画。
没有字形塑造引擎这一切都不会发生;没有 HarfBuzz 的 HTML 页面,或者没有字形塑造引擎的 PDF 生成器,会将相同的四个码点渲染成四个不相连的独立形,这正是六百多年后重现的 Fano 1514 体验。错误的答案仍然存在于标准中,已化石般固化,成了极好的纪念品。
在字形塑造引擎存在之前,DOS 和早期 Windows 时代的 8 位代码页*直接编码了形状本身*:一个单独的字符用于首形 ʿayn、中形 ʿayn 等等。Unicode 承诺与一切进行往返兼容,不得不整体吞下那些集,它们存活在 U+FB50 至 U+FEFF 之间,名称为"阿拉伯语呈现形式":几百个码点,任何新文档都不应包含它们,而 PDF 文本提取器至今愉快地输出它们,这是搜索阿拉伯语 PDF 常常沉默失败的原因之一。数据堆以形状编码,而你的搜索词以字母编码。我在此块中最钟爱的居民,也是我在整个 Unicode 中最钟爱的字符之一,是 U+FDFD,﷽:整个四个单词的祈祷词 *bismillāh ar-raḥmān ar-raḥīm*,作为一个单一码点。它来自渲染被烘焙进编码的时代,因为没人信任渲染器能做任何事的一个纪念碑,被永远保存,就像一只会念诵的琥珀里的苍蝇。
这比听起来重要的原因是,两种编码渲染得一模一样,但比较结果却不同。我在这篇文章开头提到的客户搜索 bug 具体来说是这样一个小型客户数据库:有些名字是通过现代阿拉伯语键盘输入的。另一些则在 2017 年从一个将名字存储在阿拉伯语呈现形式中的旧系统迁移过来。看起来一模一样,不是吗?
| 姓名(渲染后) | 存储中的编码 | 账户 |
|----------------|---------------|------|
| محمد علي | 现代 Unicode | EGP-9341-0021 |
| محمد علي | 呈现形式 | EGP-2014-7732 |
| سارة أحمد | 现代 Unicode | EGP-9341-0044 |
| سارة أحمد | 呈现形式 | EGP-2014-8810 |
**按姓名搜索:**
[搜索框]
**应用 NFKC 标准化**
输入一个名字进行搜索。或者点击下面的种子按钮,粘贴一个恰好匹配其中一个记录的查询,就像客户服务代理人从短信复制名字那样。没有标准化,你只能得到与查询编码一致的记录,而错过其他的。勾选后,搜索将在应用 NFKC(将呈现形式折叠回其抽象字母)后运行。治愈方法是一个 Unicode 调用。花了一个季度才发现的原因是,该 bug 表现为"客户不在系统中",而客户服务工单并不附带码点转储。
如果你想知道当软件跳过所有这些(字形塑造引擎、双向算法、整个机制)时世界是什么样子,你无需想象,因为还有大量软件仍然跳过所有这些:
**按文本栈之外的软件方式渲染**
مرحبا بالعالم، هذا نص عربي
文本说"hello, world, this is Arabic text."勾选以查看每个阿拉伯语读者都在现实世界中见过的版本,在店招牌、登机牌、水印、涉及阿拉伯语的旧电影上;每个字母都变成了独立形,行从左到右排列,顺序反了。这就是当程序逐个绘制字符且从未咨询字形塑造引擎时产生的样子:旧版 Photoshop 这么做,matplotlib 开箱即这么做,npm 上的许多 PDF 生成器这么做,收据打印机坚决这么做。标准的 Python 变通方案是 `arabic_reshaper` 加 `python-bidi`,它通过使用那个来自前一段的化石块中预烘焙好的形状到字符串中
相似文章
解读太斯米
本文探讨了阿拉伯文字编排在历史和技术上的挑战,重点论述了连字对太斯米短语的重要性,以及Unicode在解决这一问题中的作用。
逆境中的优秀字体
一篇探讨意外字体现象与工艺的文章,包括显示屏上的卷帘快门效应以及对字体局限性的创造性应用。
终端文本渲染的各种缺陷(2024)
本文探讨了终端模拟器中文本渲染的各种根本性问题,包括字符定义歧义、Unicode处理问题、有缺陷的二维网格假设以及光标不同步,凸显了支持复杂脚本和字体的困难。
Show HN:一个ASCII 3D渲染引擎
GlyphCSS是一个JavaScript库,它使用ASCII字符在DOM中渲染带纹理的3D网格,支持多种3D格式,并与原生JS、React和Vue集成。
《银翼杀手》的标题卡简直太他妈的棒了
对《银翼杀手》标题卡片中字体排印的欣赏,同时还有对终端编程中使用的等宽字体的个人探索。