带圈数字只到50:Unicode码位、CSS计数器与LaTeX方案 前阵子帮人整理一份资料汇编目录要求每一条前面带个圆圈序号从 1 一直编到 80 多。我一开始很轻松直接敲 ①②③敲到 ⑳ 还挺顺再往后翻字符表找到 ㉑ ㉒ ㉓一路撸到 ㊿然后光标就卡在那儿了——第 51 个带圈数字翻遍系统自带的符号面板也找不到。去问了一圈得到的回复基本是“用办公软件的带圈字符功能啊”试了两次就放弃了那玩意儿复制出去就散架换个字体就错位导出成 PDF 又是一副新面孔。后来我干脆把整个问题拆开重做了一遍先摸清 Unicode 里究竟给了多少个带圈数字、为什么正好卡在 50再想办法在没有现成字符的前提下把 51 到 99 稳稳地排出来。这篇就把这条路径完整写一遍包括码位表、CSS 方案、LaTeX 方案、自造字区方案以及我在字体缺字和基线漂移上踩过的坑。如果你也在做目录编号、条目清单、试卷选项、标签排版这类事尤其是被“序号只有 20 个”卡住过这篇应该能直接拿去用。1. 从 ① 数到 ㊿Unicode 只给了 50 个位置多一个都没有1.1 三段式分布与那段被劈开的编码历史很多人以为带圈数字是一个连续区块从 ① 一路排到 ㊿中间没断过。实际不是。这批字符被切成了三段分散在 Unicode 的两个不同区块里这是后面一系列麻烦的根源。第一段是 ① 到 ⑳码位从 U2460 到 U2473落在Enclosed Alphanumerics带圈字母数字区块内。这个区块的历史很早在上世纪九十年代初的第一版 Unicode 里就已经存在收录范围覆盖带圈字母、带圈数字、带括号数字等一批用于东亚排版的符号。这一段是连续的20 个码位一个不多一个不少很好记。第二段是 ㉑ 到 ㉟码位从 U3251 到 U325F共 15 个。第三段是 ㊱ 到 ㊿码位从 U32B1 到 U32BF也是 15 个。注意这两段已经不在原来的区块里了它们被塞进了Enclosed CJK Letters and Months带圈 CJK 字母及月份区块。为什么因为 U2460 到 U24FF 这个区块到 U24FF 就结束了根本没给 21 以后留位置。等后来大家发现需求真实存在只能在别的区块里补于是就在 CJK 兼容区里见缝插针地开了两段。我整理了一张对照表把常用的都列进去了直接照着查就行字符范围码位范围Unicode 区块加入版本数量① 至 ⑳U2460 – U2473Enclosed Alphanumerics早期版本20㉑ 至 ㉟U3251 – U325FEnclosed CJK Letters and Months3.215㊱ 至 ㊿U32B1 – U32BFEnclosed CJK Letters and Months3.215这三段加起来正好 50 个。也就是说全世界的计算机系统里官方意义上的带圈阿拉伯数字到 50 为止就到头了。你想要的第 51 个不存在而且不是暂时没有是以后也不会有。1.2 为什么第 51 个带圈数字永远等不到这一条得说清楚否则总有人抱着等等说不定哪天就更新了的幻想。Unicode 有两个非常硬的原则在挡着这件事。第一是区块已经饱和。刚才那两段 CJK 兼容区的位置是 21 到 50 硬挤进去的前后都被带圈汉字、带圈假名、带圈月份名占满了没有连续的空位再放 51 到 99。而 Enclosed Alphanumerics 区块本身也早满了后面还跟着一堆带括号字母、带圈字母、双圈数字、反白数字。想要新开一段连续码位就得新申请一个区块这在流程上极其麻烦。第二是稳定性政策。Unicode 有一条铁律已经分配的码位永远不改、不删、不重新定义。这意味着就算有人现在提一个新区块的提案也要走好几年的流程而且要证明这个需求在全球范围内足够普遍。而带圈数字 51 到 99这个需求说实话在排版层面已经有大量更灵活的替代方案下面会讲从标准化机构的角度看优先级非常低。所以结论很干脆别等字符去想办法画。想通这一点之后后面的方案就都顺了。1.3 几种看着像的替代字符实测都顶不了班在真正动手之前我先把几个看起来能凑合的方案都试了一遍结果全军覆没但过程值得记下来省得你重复踩。组合用封闭圆圈 U20DD。理论上可以把数字和这个组合字符叠在一起比如数字 5 后面跟一个 U20DD渲染出来应该是个带圈的 5。听起来完美实际上是个灾难这个组合字符的宽度定义为 0它的圆圈是画在前一个字符的位置上的而绝大多数正文字体尤其是中文字体根本没有为它做定位调整结果就是圆圈偏到一边、压住后面的字、或者干脆显示成一个空心方块。更要命的是不同系统上的渲染位置完全不一致你这边看着还行导出到别人电脑上就散架了。键帽序列U20E3。就是那个数字外面套一个圆角方框的组合用数字加变体选择符再加组合用键帽字符拼出来。它的形状是圆角矩形不是正圆颜色通常是固定色而且只覆盖 0 到 9连两位数都做不了。这条路直接判死。各种花式圈号❶ ➀ ➊ 等。这些是 Dingbats 区块里的装饰性符号样式确实好看但数量和样式都受限每一套基本只到 10而且有的风格是黑底反白有的风格是手写体混在正文里非常跳。用来给标题做装饰可以用来做连续编号完全不现实。.、❶、㊀ 这类借来的字符。有人会拿带圈汉字数字 ㊀ ㊁ ㊂ 来替代但这本质上还是汉字序号不是阿拉伯数字跟5152这种形式对不上。还有人拿圆圈符号 ○ 拼在数字前面那已经不算带圈了是圆圈加数字。试完这一圈我的判断是只要你的环境允许用样式渲染就别在字符层面死磕。字符这条路到 50 就是尽头而样式那条路可以无限延伸。2. 办公软件的带圈字符按钮坑在它根本没有生成字符2.1 拆开看圆圈是画上去的不是打出来的办公软件里那个带圈字符按钮第一次用会觉得挺神选中一个数字点一下外面就有圈了还能选缩小文字或增大圈号。但它做的事情跟你想的完全不一样。它并不是把你的数字替换成一个 Unicode 字符。它做的是把两个东西叠在一起一个圆圈符号加上你选中的数字然后把数字缩小、调整位置再把两者在同一个位置重叠。这个操作在底层通常会生成一段域代码形如EQ \o\ac(○,51)这种结构\o是叠加开关\ac是指定对齐方式。也就是说你看到的那个带圈的 51本质上是一段可执行指令不是一个字符。这个区别看着抽象实际影响巨大。字符是可移植的、可搜索的、可复制的、在任何地方渲染都一样的而一段域代码只有懂它的软件才能正确解释它。这就解释了后面所有的翻车现场。还有一种情况是某些版本会对数字做缩放后直接贴在一个固定字号的圆圈里这样虽然看起来像字符但本质上是两个独立对象叠加一旦字号变化圈的直径和数字的大小就不同步了比例立刻崩掉。2.2 复制、换字体、导 PDF 这三种操作下的翻车现场我把三种最常见的失效场景都复现了一遍写出来给大家避雷。场景一复制到别的地方。域代码选中复制粘贴到一个不支持域的地方比如聊天窗口、纯文本编辑器、某些网页的输入框你有两个可能的结果要么域代码原样露出来变成一堆带花括号的指令文本读者看到的就是{ EQ \o\ac(○,51) }要么域被扁平化成两个孤立的字符圆圈和数字各走各的变成○51或者5○1。无论哪种都很难看。场景二换字体。域里的圆圈是字符 ○U25CB数字是你原来的数字。当你把整段文字换成另一种字体时数字的字体变了圆圈可能没变或者两者的度量不同步结果就是圈偏了数字跑到圈外或者被圈切掉一半。我见过最夸张的一次是一整份目录换字体后所有两位数序号的数字全跑到了圈外右侧非常显眼。场景三导出 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 的圆圈数字做成字形塞进字体文件的私有使用区UE000 到 UF8FF 这一段标准留给自定义用途。做完之后这些字就跟真字符一样可以在任何地方打出来、复制、粘贴。听起来完美但成本很高你得做一套字形、打包字体、还要保证接收方装了这套字体否则显示成方块。这条适合内部封闭环境比如公司内部模板、特定设备的界面。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 classcircled-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①U2460931210⑩U2469932120⑳U2473933121㉑U32511288130㉚U325A1289035㉟U325F1289536㊱U32B112991 减 15 修正50㊿U32BF12991表格里 36 那一行我要特别说明一下因为这里最容易算错。36 到 50 这一段是从 U32B1 开始的U32B1 的十进制是 12977不是 1299112991 是 U32BF 也就是 ㊿ 的值。算的时候记住一个公式就够了十进制 十六进制按位展开U32B1 展开是 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, flagsre.M) with open(input.txt, encodingutf-8) as f: content f.read() out \n.join(convert_line(line) for line in content.splitlines()) with open(output.txt, w, encodingutf-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按交付形式选一个把编号交给计数器管。第四只有在必须像真字符一样可复制、可搜索、且环境封闭的情况下才考虑造字。这个条件的组合非常窄我做了这么多项目真正需要造字的场景只有两次都是内部系统的固定界面。最后分享一个我用了很久的小技巧不要把圆圈序号当成字符问题把它当成版式问题。一旦这么想你会发现可选方案一下子多了起来——它可以是一个带边框的行内盒子