前阵子帮人整理一份资料汇编,目录要求每一条前面带个圆圈序号,从 1 一直编到 80 多。我一开始很轻松,直接敲 ①②③,敲到 ⑳ 还挺顺,再往后翻字符表找到 ㉑ ㉒ ㉓,一路撸到 ㊿,然后光标就卡在那儿了——第 51 个带圈数字,翻遍系统自带的符号面板也找不到。去问了一圈,得到的回复基本是“用办公软件的带圈字符功能啊”,试了两次就放弃了:那玩意儿复制出去就散架,换个字体就错位,导出成 PDF 又是一副新面孔。后来我干脆把整个问题拆开重做了一遍:先摸清 Unicode 里究竟给了多少个带圈数字、为什么正好卡在 50,再想办法在没有现成字符的前提下把 51 到 99 稳稳地排出来。这篇就把这条路径完整写一遍,包括码位表、CSS 方案、LaTeX 方案、自造字区方案,以及我在字体缺字和基线漂移上踩过的坑。如果你也在做目录编号、条目清单、试卷选项、标签排版这类事,尤其是被“序号只有 20 个”卡住过,这篇应该能直接拿去用。
1. 从 ① 数到 ㊿:Unicode 只给了 50 个位置,多一个都没有
1.1 三段式分布与那段"被劈开"的编码历史
很多人以为带圈数字是一个连续区块,从 ① 一路排到 ㊿,中间没断过。实际不是。这批字符被切成了三段,分散在 Unicode 的两个不同区块里,这是后面一系列麻烦的根源。
第一段是 ① 到 ⑳,码位从 U+2460 到 U+2473,落在Enclosed Alphanumerics(带圈字母数字)区块内。这个区块的历史很早,在上世纪九十年代初的第一版 Unicode 里就已经存在,收录范围覆盖带圈字母、带圈数字、带括号数字等一批用于东亚排版的符号。这一段是连续的,20 个码位一个不多一个不少,很好记。
第二段是 ㉑ 到 ㉟,码位从 U+3251 到 U+325F,共 15 个。第三段是 ㊱ 到 ㊿,码位从 U+32B1 到 U+32BF,也是 15 个。注意,这两段已经不在原来的区块里了,它们被塞进了Enclosed CJK Letters and Months(带圈 CJK 字母及月份)区块。为什么?因为 U+2460 到 U+24FF 这个区块到 U+24FF 就结束了,根本没给 21 以后留位置。等后来大家发现需求真实存在,只能在别的区块里补,于是就在 CJK 兼容区里见缝插针地开了两段。
我整理了一张对照表,把常用的都列进去了,直接照着查就行:
| 字符范围 | 码位范围 | Unicode 区块 | 加入版本 | 数量 |
|---|---|---|---|---|
| ① 至 ⑳ | U+2460 – U+2473 | Enclosed Alphanumerics | 早期版本 | 20 |
| ㉑ 至 ㉟ | U+3251 – U+325F | Enclosed CJK Letters and Months | 3.2 | 15 |
| ㊱ 至 ㊿ | U+32B1 – U+32BF | Enclosed CJK Letters and Months | 3.2 | 15 |
这三段加起来正好 50 个。也就是说,全世界的计算机系统里,官方意义上的带圈阿拉伯数字,到 50 为止就到头了。你想要的第 51 个,不存在,而且不是"暂时没有",是"以后也不会有"。
1.2 为什么第 51 个带圈数字永远等不到
这一条得说清楚,否则总有人抱着"等等说不定哪天就更新了"的幻想。Unicode 有两个非常硬的原则在挡着这件事。
第一是区块已经饱和。刚才那两段 CJK 兼容区的位置,是 21 到 50 硬挤进去的,前后都被带圈汉字、带圈假名、带圈月份名占满了,没有连续的空位再放 51 到 99。而 Enclosed Alphanumerics 区块本身也早满了,后面还跟着一堆带括号字母、带圈字母、双圈数字、反白数字。想要新开一段连续码位,就得新申请一个区块,这在流程上极其麻烦。
第二是稳定性政策。Unicode 有一条铁律:已经分配的码位永远不改、不删、不重新定义。这意味着就算有人现在提一个新区块的提案,也要走好几年的流程,而且要证明这个需求在全球范围内足够普遍。而"带圈数字 51 到 99"这个需求,说实话,在排版层面已经有大量更灵活的替代方案(下面会讲),从标准化机构的角度看,优先级非常低。
所以结论很干脆:别等字符,去想办法画。想通这一点之后,后面的方案就都顺了。
1.3 几种"看着像"的替代字符,实测都顶不了班
在真正动手之前,我先把几个"看起来能凑合"的方案都试了一遍,结果全军覆没,但过程值得记下来,省得你重复踩。
组合用封闭圆圈 U+20DD。理论上可以把数字和这个组合字符叠在一起,比如数字 5 后面跟一个 U+20DD,渲染出来应该是个带圈的 5。听起来完美,实际上是个灾难:这个组合字符的宽度定义为 0,它的圆圈是画在前一个字符的位置上的,而绝大多数正文字体(尤其是中文字体)根本没有为它做定位调整,结果就是圆圈偏到一边、压住后面的字、或者干脆显示成一个空心方块。更要命的是,不同系统上的渲染位置完全不一致,你这边看着还行,导出到别人电脑上就散架了。
键帽序列(U+20E3)。就是那个"数字外面套一个圆角方框"的组合,用数字加变体选择符再加组合用键帽字符拼出来。它的形状是圆角矩形不是正圆,颜色通常是固定色,而且只覆盖 0 到 9,连两位数都做不了。这条路直接判死。
各种花式圈号(❶ ➀ ➊ 等)。这些是 Dingbats 区块里的装饰性符号,样式确实好看,但数量和样式都受限:每一套基本只到 10,而且有的风格是黑底反白,有的风格是手写体,混在正文里非常跳。用来给标题做装饰可以,用来做连续编号完全不现实。
.、❶、㊀ 这类"借来的"字符。有人会拿带圈汉字数字 ㊀ ㊁ ㊂ 来替代,但这本质上还是汉字序号,不是阿拉伯数字,跟"51""52"这种形式对不上。还有人拿圆圈符号 ○ 拼在数字前面,那已经不算"带圈"了,是"圆圈加数字"。
试完这一圈,我的判断是:只要你的环境允许用样式渲染,就别在字符层面死磕。字符这条路到 50 就是尽头,而样式那条路可以无限延伸。
2. 办公软件的"带圈字符"按钮,坑在它根本没有生成字符
2.1 拆开看:圆圈是画上去的,不是打出来的
办公软件里那个"带圈字符"按钮,第一次用会觉得挺神:选中一个数字,点一下,外面就有圈了,还能选"缩小文字"或"增大圈号"。但它做的事情跟你想的完全不一样。
它并不是把你的数字替换成一个 Unicode 字符。它做的是把两个东西叠在一起:一个圆圈符号,加上你选中的数字,然后把数字缩小、调整位置,再把两者在同一个位置重叠。这个操作在底层通常会生成一段域代码,形如EQ \o\ac(○,51)这种结构(\o是叠加开关,\ac是指定对齐方式)。也就是说,你看到的那个"带圈的 51",本质上是一段可执行指令,不是一个字符。
这个区别看着抽象,实际影响巨大。字符是可移植的、可搜索的、可复制的、在任何地方渲染都一样的;而一段域代码,只有懂它的软件才能正确解释它。这就解释了后面所有的翻车现场。
还有一种情况是,某些版本会对数字做缩放后直接贴在一个固定字号的圆圈里,这样虽然看起来像字符,但本质上是两个独立对象叠加,一旦字号变化,圈的直径和数字的大小就不同步了,比例立刻崩掉。
2.2 复制、换字体、导 PDF 这三种操作下的翻车现场
我把三种最常见的失效场景都复现了一遍,写出来给大家避雷。
场景一:复制到别的地方。域代码选中复制,粘贴到一个不支持域的地方(比如聊天窗口、纯文本编辑器、某些网页的输入框),你有两个可能的结果:要么域代码原样露出来,变成一堆带花括号的指令文本,读者看到的就是{ EQ \o\ac(○,51) };要么域被"扁平化"成两个孤立的字符,圆圈和数字各走各的,变成"○51"或者"5○1"。无论哪种,都很难看。
场景二:换字体。域里的圆圈是字符 ○(U+25CB),数字是你原来的数字。当你把整段文字换成另一种字体时,数字的字体变了,圆圈可能没变,或者两者的度量不同步,结果就是圈偏了,数字跑到圈外,或者被圈切掉一半。我见过最夸张的一次,是一整份目录换字体后,所有两位数序号的数字全跑到了圈外右侧,非常显眼。
场景三:导出 PDF。这个最阴。在源文件里看着完全正常,导出 PDF 之后,有一部分带圈字符的圆圈变成了实心点,或者数字位置整体上移了两三个像素,或者干脆那一段被栅格化成图片,放大后边缘发虚。原因还是同一个:域代码在导出时被"渲染"了一遍,而渲染引擎对叠加位置的理解可能和编辑界面不完全一致。
2.3 条目从 80 条涨到 200 条时,维护成本怎么炸开
单条操作的时候,这些毛病都能忍。真正让人崩溃的是规模化。
设想一下这个场景:你有一份 80 条的清单,每一条都用了带圈字符。三个月后要加 20 条到 100 条,而且要插在中间。这时候你面对的不是"再点 20 次按钮"这么简单,而是:插入新条目后,所有后续条目的编号要重新排;每条都要单独选中、单独点按钮;点完之后还要检查有没有错位;检查完导出 PDF,再逐页看有没有变形。80 条的清单,光是重新编号加检查,一个下午就没了。
更麻烦的是版本维护。如果这份文档要交给别人接手,对方打开看到一堆域代码,第一件事大概率是把它们全清掉重做。这种"看起来能用但无法维护"的方案,在团队协作里是负资产。
我在实际项目里给自己定了一条线:任何需要编号超过 20 个条目的场景,一律不用带圈字符按钮。要么用现成的 Unicode 字符(50 以内),要么用样式渲染(50 以上),绝不走域代码这条路。
3. 50 以上的四条路:CSS、LaTeX、私有字区、SVG,怎么挑
3.1 四条路线的能力边界对照
既然字符到头了,那就只能"画"。我实测下来有四条可行路线,各有各的适用场景,先上一张对照表:
| 方案 | 编号上限 | 文本可选中 | 跨平台一致性 | 维护成本 | 典型场景 |
|---|---|---|---|---|---|
| HTML + CSS 圆圈 | 无上限 | 是 | 高(现代浏览器) | 低 | 网页、Markdown 导出、电子文档 |
| LaTeX 绘图命令 | 无上限 | 是 | 高(PDF 固定) | 中 | 论文、正式出版物、试卷 |
| 私有使用区造字 | 无上限 | 是 | 取决于字体分发 | 高 | 需要像真字符一样打出来的场景 |
| SVG / 图片嵌入 | 无上限 | 否 | 高 | 中 | 图表、封面、固定版式物料 |
HTML + CSS 这条,核心思路是用一个行内块盒子(inline-flex),给它固定宽高、加上border-radius: 50%和一个细边框,这样盒子本身就是个圆。数字放在盒子正中间,靠 flex 居中。优点是完全跟随文本流走,字号变了圈也跟着变,而且可以用 CSS 计数器自动编号,改成 200 条也只是多几行 HTML。这条是我最推荐的。
LaTeX 这条,靠绘图环境现画一个圆,把数字放进去。优点是输出是矢量 PDF,放大多少倍都清晰,印刷质量最好。缺点是需要编译,改一个字要重新跑一遍。
私有使用区造字这条,是用字体编辑工具,把 51 到 99 的"圆圈+数字"做成字形,塞进字体文件的私有使用区(U+E000 到 U+F8FF 这一段,标准留给自定义用途)。做完之后,这些字就跟真字符一样,可以在任何地方打出来、复制、粘贴。听起来完美,但成本很高:你得做一套字形、打包字体、还要保证接收方装了这套字体,否则显示成方块。这条适合内部封闭环境,比如公司内部模板、特定设备的界面。
SVG 这条,是用脚本批量生成一堆小图片,再嵌进去。优点是可以做成任意样式、任意颜色,在不能跑 CSS 的地方(比如某些排版软件、图片类平台)也能用。缺点是无法被选中、无法被搜索,属于"视觉上的序号"而不是"文本上的序号"。
3.2 选型时真正该问的三个问题
面对这四条路,别急着挑技术栈,先问自己三个问题,答案基本就锁定方案了。
第一个问题:这份内容最终以什么形式交付?如果是网页、电子文档、在线协作,CSS 是天然最优解,不用犹豫。如果是印刷品或者要归档的正式文档,LaTeX 或者带嵌入字体的 PDF 更稳。如果是图片、海报、封面,SVG 更合适。如果是内部系统界面,可以考虑造字。
第二个问题:这份内容未来还会不会改?会改的,一定要选"自动编号"的方案。CSS 的计数器和 LaTeX 的自动序号都能做到"插入一条,后面全部自动重排"。而如果用手工一个个填的 SVG 或者手工敲的字符,改一次就得重来一遍。这一点在选型时的权重,很多人低估了。
第三个问题:接收方能不能装字体?私有使用区造字听起来最优雅,但它有硬性依赖:必须装字体。如果文档要发给外部、发到公开平台、或者在不同设备上看,这条基本没戏。反过来,如果只是内部固定设备上渲染,比如某块显示屏上的排队叫号界面,那造字反而是最省事、最稳定的。
我自己在绝大部分场景下会选 CSS,因为它的"无限扩展 + 自动编号 + 跟随字号"这三个特性,恰好对应了这类需求最痛的三点。下面就把这套方案完整展开。
4. 用 CSS 把圆圈序号做成"无限续杯"的一套样式
4.1 单个圆圈的盒模型与基线对齐参数
先从最小的单元开始。一个圆圈数字,本质上就是"一个圆 + 一个数字居中",圆用边框画,数字用文本。
.circled-num { display: inline-flex; align-items: center; justify-content: center; width: 1.55em; height: 1.55em; border: 1px solid currentColor; border-radius: 50%; font-size: 0.8em; line-height: 1; vertical-align: 0.1em; margin-right: 0.28em; font-variant-numeric: tabular-nums; box-sizing: content-box; flex: none; }这几行里,最容易被忽略的是三个参数,我逐个说清楚。
第一是width和height必须相等,否则border-radius: 50%出来的是椭圆。我用1.55em而不是固定像素,是为了让它跟着正文的字号自动缩放——正文 16px 的时候圈是 24.8px 左右,正文调到 20px 圈跟着变大,比例永远是协调的。这个"用 em 不用 px"的习惯,是我在排版上吃过亏之后养成的,改一次全局字号就知道有多省事。
第二是font-size: 0.8em这个缩放。盒子本身是 1.55em,里面数字是 0.8em,这样数字周边留出的空隙大约是数字高度的三分之一,视觉上比较舒服。如果数字占满整个圆圈,会显得很挤;如果数字太小,两位数的 51 会显得空旷。0.8 是我试了十几个值之后觉得最平衡的,你可以按自己的字体微调,范围在 0.75 到 0.85 之间都算合理。
第三是vertical-align: 0.1em,这个值专门用来对付基线漂移。因为圆圈是个盒子,它的默认垂直对齐方式是baseline,也就是盒子的底边和文字基线对齐,结果就是圆圈整体偏下,跟旁边文字对不齐。加一个正的上移量把它往回拉,具体数值和字体、字号有关,我的做法是在实际页面上放大到 200% 目视对齐,然后定下来,写进注释里,以后就不要动了。
还有一个细节:font-variant-numeric: tabular-nums。这行让数字用等宽字形,好处是"1"和"51"和"88"里的数字宽度一致,圆圈里的数字不会因为字符不同而左右晃动。没有这行的话,在一长列序号里,个位数和两位数的圆心会有细微偏移,虽然不明显,但排版密集的时候看得出来。
4.2 让 counter 接管编号,51 到 999 都不用再找字符
单个圆圈搞定了,接下来是最关键的一步:让编号自动生成。这样就不存在"第 51 个找不到字符"的问题了,因为根本没有字符,只有一串顺序号。
ol.circled-list { list-style: none; counter-reset: item; padding-left: 0; margin: 0; } ol.circled-list > li { counter-increment: item; position: relative; padding-left: 2.4em; margin-bottom: 0.6em; } ol.circled-list > li::before { content: counter(item); position: absolute; left: 0; top: 0.08em; display: inline-flex; align-items: center; justify-content: center; width: 1.55em; height: 1.55em; border: 1px solid currentColor; border-radius: 50%; font-size: 0.8em; line-height: 1; font-variant-numeric: tabular-nums; }配套的 HTML 就非常干净了:
<ol class="circled-list"> <li>第一条内容</li> <li>第二条内容</li> <li>第三条内容</li> </ol>这套写法的妙处在于,序号完全由 CSS 计数器counter(item)生成。你写了 50 条它就是 50,写 200 条它就是 200,插入、删除、重排,全都不需要手动改任何东西。而且因为它不是字符,所以不存在"码位不够用"的问题,理论上能撑到四位数。
三位数的时候要注意一个细节:1.55em 的圆圈装三位数字会很挤。我在实际使用里加了一条判断逻辑,用attr()或者干脆手动加一个修饰类:
ol.circled-list > li.wide::before { width: 1.95em; border-radius: 1em; font-size: 0.72em; letter-spacing: -0.02em; }注意这里我把border-radius从50%换成了1em,让盒子变成"胶囊形"而不是正圆。三位数塞进正圆一定会挤,变成横向拉长的胶囊形,既保持了圆润感,又能容纳数字,视觉上也更自然。这个小心思是我在做一个上百条编号的附录时摸索出来的,正圆只能装一到两位,三位必须变形。
4.3 导出 PDF 和黑白打印时必须补的几行
网页上显示正常,不代表打印出来正常。我在第一版里就翻过车:屏幕上漂亮的细圆圈,打印出来后几乎看不见,因为一像素的边框在高分辨率打印机上被渲染得极细,灰度打出来接近空白。
第一个要检查的是边框的呈现方式。如果圆圈是用background加圆角做的(有些方案会这么写以便填充颜色),打印时背景色经常被浏览器默认为"不打印背景",结果圆圈直接消失。用border就没这个问题,边框属于前景元素,打印时一定会输出。这也是我坚持用border而不是background的原因。
第二个是边框粗细。屏幕上1px看着刚好,打印到纸上会偏细。我一般会加一条媒体查询:
@media print { ol.circled-list > li::before { border-width: 0.75pt; -webkit-print-color-adjust: exact; print-color-adjust: exact; } ol.circled-list > li { break-inside: avoid; } }0.75pt大约是 1 像素在 96dpi 下的等效值,但它是物理单位,在不同分辨率下都保持稳定的视觉粗细。break-inside: avoid是防止一个条目被分页切开,如果圆圈正好在页尾、文字在下一页,那看起来很别扭。
第三个是颜色。如果圆圈用了彩色(比如主题色),打印时可能变成浅灰甚至看不见。稳妥的做法是让圆圈的颜色继承currentColor,正文是黑的,圆圈就是黑的,正文是彩色的,圆圈跟着彩色,永远协调。这一条看着是小技巧,实际省了很多次返工。
5. 非网页环境怎么把字符打出来:码位、输入法与批量替换
5.1 一张能直接抄的十进制码位表
有些场景没有 CSS 可用,比如要在纯文本文件里、在某些只认字符的输入框里、在聊天记录里,这时候还是得用真字符。但 21 以后的字符在很多输入法里找不到,得靠码位硬打。这里给一张十进制对照表,因为多数系统的"按住 Alt 敲数字"功能认的是十进制,不是十六进制。
| 数字 | 字符 | 十六进制 | 十进制 |
|---|---|---|---|
| 1 | ① | U+2460 | 9312 |
| 10 | ⑩ | U+2469 | 9321 |
| 20 | ⑳ | U+2473 | 9331 |
| 21 | ㉑ | U+3251 | 12881 |
| 30 | ㉚ | U+325A | 12890 |
| 35 | ㉟ | U+325F | 12895 |
| 36 | ㊱ | U+32B1 | 12991 减 15 修正 |
| 50 | ㊿ | U+32BF | 12991 |
表格里 36 那一行我要特别说明一下,因为这里最容易算错。36 到 50 这一段是从 U+32B1 开始的,U+32B1 的十进制是 12977,不是 12991(12991 是 U+32BF 也就是 ㊿ 的值)。算的时候记住一个公式就够了:十进制 = 十六进制按位展开,U+32B1 展开是 3×4096 + 2×256 + 11×16 + 1 = 12288 + 512 + 176 + 1 = 12977。所以 ㊱ 是 12977,往后每加一个数字加一,㊿ 就是 12991。中间那几个笨办法算不如直接记公式。
顺便给一段 Python 映射函数,比手算可靠得多,需要批量转换的时候直接拿来用:
def to_circled(n: int) -> str: """把 1-50 转成带圈数字字符,越界抛异常""" if 1 <= n <= 20: return chr(0x2460 + n - 1) if 21 <= n <= 35: return chr(0x3251 + n - 21) if 36 <= n <= 50: return chr(0x32B1 + n - 36) raise ValueError(f"{n} 超出了 Unicode 带圈数字的范围(1-50)")这段代码最有价值的地方不是转换,而是那个raise ValueError。很多人写映射的时候喜欢用else: return str(n)兜底,看着不出错,实际上把"越界"这个信息悄悄吞掉了,等你在 200 条清单里发现第 51 条变成了普通数字,排查起来非常痛苦。宁可让它当场报错,也不要静默降级。
5.2 各端输入路径与输入法自造词
知道码位之后,怎么敲进去是另一回事。我把自己常用的几条路径整理一下。
桌面系统的字符映射工具。系统一般都会带一个字符查看器,可以按码位搜索。在搜索框里输入2460或者直接输入汉字"带圈",就能定位到这一批字符。这个工具的优点是可视化,能看到字形长什么样,缺点是每次都要开窗口、找、复制、粘贴,低频使用还行,高频就受不了。
按住 Alt 敲十进制码。在多数桌面系统上,按住 Alt 键不放,用数字小键盘依次敲十进制码位,松开就能出字符。敲 9312 出 ①,敲 12881 出 ㉑。这里有个坑:五位数的码位,有些系统需要先敲一个前导 0,也就是敲 012881 才认。我第一次用的时候敲了 12881 没反应,以为不支持,后来加了前导零才成功。另外笔记本没有独立小键盘的,要先开数字小键盘锁定,不然敲出来全是方向键。
输入法的自造词。这是长期使用最省事的办法。把自己的高频字符(比如 ㉑ 到 ㉟ 这一段)定义成输入法的自定义短语,绑定一个短串,比如输入c21就出 ㉑。定义一次,以后随时能用。我给自己定义了一整套,从 c1 到 c50,做资料的时候直接敲短串,比开字符面板快十倍。
Unicode 直接输入。部分系统支持按住组合键再敲十六进制码位,敲完松开就出字符。这个方式对熟悉十六进制的人效率最高,但需要记十六进制值,我一般只在写脚本调试的时候用。
5.3 用十几行 Python 做整篇替换与越界检测
真实场景里更常见的情况是:手上已经有一份文档,里面全是1.、2.这样的普通编号,需要批量换成带圈数字。手动改一百条是折磨,写个脚本几秒钟。
import re def convert_line(text: str) -> str: def repl(m): n = int(m.group(1)) try: return to_circled(n) + " " except ValueError: # 超出范围的原样保留,但打上标记方便人工处理 return f"[!{n}] " return re.sub(r"^(\d{1,3})[.、]\s*", repl, text, flags=re.M) with open("input.txt", encoding="utf-8") as f: content = f.read() out = "\n".join(convert_line(line) for line in content.splitlines()) with open("output.txt", "w", encoding="utf-8") as f: f.write(out)这段脚本的关键设计是那个[!51]的标记。它不假装自己能处理越界的情况,而是把问题显式地标出来,让你一眼看到"这几条需要特殊处理"。实际跑下来,一份 80 条的清单里,前 50 条自动转换,后 30 条全部被打上标记,然后我就知道这 30 条该走 CSS 方案或者手工处理。这种"明确暴露边界"的做法,比任何聪明的兜底逻辑都可靠。
还有个小提醒:正则在匹配编号的时候,一定要锚定行首(^)并且允许后面跟"点"或者"顿号"。如果不锚定行首,正文里出现的"2024."这种内容会被误伤。我第一次跑脚本没加锚定,结果把所有年份、金额、页码全换成了带圈数字,那份文件基本报废,只能从备份恢复。
6. 上线前必须验的三件事:缺字、宽度、基线
6.1 用 fontTools 把字体里到底有没有这个字查清楚
这一节是给"必须用真字符"的场景准备的。你可能已经发现了一个现象:㉑ 之后的字符,在很多字体里显示成空心方块,也就是俗称的"豆腐块"。原因很简单,那些字体压根就没收录这批码位。
问题在于,你没法靠肉眼一个个去试。我的做法是直接用脚本把目标字体的字符表读出来,看看它到底覆盖了哪些码位。
from fontTools.ttLib import TTFont def check_coverage(font_path: str): font = TTFont(font_path) cmap = font.getBestCmap() ranges = { "1-20": range(0x2460, 0x2474), "21-35": range(0x3251, 0x3260), "36-50": range(0x32B1, 0x32C0), } for name, rng in ranges.items(): missing = [hex(cp) for cp in rng if cp not in cmap] print(f"{name}: 缺失 {len(missing)} 个", missing[:5])跑完之后你会看到一个非常典型的分布:西文字体通常三段全缺;老旧的中文字体可能只覆盖 1-20;比较新的中文字体三段齐全。这个结果直接决定了你能不能放心用这批字符。
我实测下来的经验是,1-20 是安全区,21-50 是风险区。前者几乎所有支持中文的字体都有,后者在部分字体、部分系统上会缺。所以如果你的文档要发给不确定的接收方,20 以内可以放心用真字符,20 以上建议改成样式方案,或者至少准备好字体兜底。
6.2 半角与全角混排时那半个字的错位
这个坑比缺字更隐蔽,因为它不会显示成方块,而是显示得"有点歪",你盯着看半天才能发现哪里不对。
根本原因是这批字符的宽度属性不一致。① 到 ⑳ 这一段的东亚宽度属性是"模糊"(Ambiguous),意思是它在东亚环境下按全角渲染,在西文环境下按半角渲染,具体多宽取决于上下文和字体。而 ㉑ 到 ㊿ 这一段是明确的"宽"(Wide),在哪儿都是全角宽度。
结果就是:一份从 1 编到 50 的清单,前 20 条的圆圈是半角宽度,后 30 条是全角宽度。如果字体回退到了不同字体,两段的圆圈直径、字重、基线位置全都不一样。最明显的表现是,序号后面的文字起始位置在 20 和 21 之间有一个明显的跳动,视觉上像换了个排版风格。
处理办法有三个。一是统一字体链,明确指定一个三段都覆盖的字体,并且把它放在字体列表最前面,避免回退。二是手动补空格,在窄的那一段后面加一个细空格调宽度,但这属于硬凑,改一次就得重调。三是干脆放弃混用,20 以内用真字符,20 以上全部用 CSS 圆圈,让视觉风格统一。
我选的是第三条。虽然要多写一点样式,但换来的是整篇一致的观感,而且再也不用担心字体会不会缺字。做排版这件事,一致性比"用了多少种技巧"重要得多。
6.3 字体链兜底的正确写法
如果你确实要混用真字符和样式渲染,那字体链的写法就得讲究一点。一个稳妥的写法是这样:
.circled-text { font-family: "自定义中文字体", "备选中文字体", "无衬线字体族", sans-serif; font-variant-east-asian: normal; text-spacing-trim: space-all; }这里面的要点是:把覆盖完整的中文字体放在前面,通用字体族放在最后兜底;font-variant-east-asian明确设置成normal,防止某些环境对东亚字符做额外的宽度调整;text-spacing-trim用来控制标点压缩,避免全角的圆圈被当成标点做压缩处理(某些排版引擎确实会这么干,导致序号和文字挤在一起)。
说实话,这一套下来你就能体会到为什么我前面说"能画就别用字符"了。字符方案要操心的细节,比样式方案多出一个数量级,而且每一条都跟你用的字体、系统、渲染引擎强绑定,换个环境就得重新验证一遍。样式方案虽然也要调参数,但参数是可控的、可复现的、写进代码就固定下来了。
7. 我的取舍:真字符优先,能画就不造字
折腾完这一整圈,我给自己定了一套明确的决策规则,用起来不用每次重新纠结。
第一,20 条以内,用真字符。① 到 ⑳ 覆盖率高、宽度一致、输入方便,没有任何理由绕远路。直接敲,或者用输入法自造词,最省事。
第二,21 到 50 条,看交付环境。如果是内部文档、发给确定的人、能控制字体,用真字符没问题,但要在发出之前自己先在一台干净设备上看一眼。如果是对外发布、不确定读者环境,直接跳到样式方案,别在真字符上赌运气。
第三,50 条以上,一律用样式方案。不管什么场景,超过 50 就不要再找字符了,找不到的。CSS 或者 LaTeX,按交付形式选一个,把编号交给计数器管。
第四,只有在"必须像真字符一样可复制、可搜索、且环境封闭"的情况下,才考虑造字。这个条件的组合非常窄,我做了这么多项目,真正需要造字的场景只有两次,都是内部系统的固定界面。
最后分享一个我用了很久的小技巧:不要把圆圈序号当成"字符问题",把它当成"版式问题"。一旦这么想,你会发现可选方案一下子多了起来——它可以是一个带边框的行内盒子