前几天帮一位朋友收拾他那个自动生成测试报告的脚本输出用例序号的部分用的是最朴素的做法for i in range(1, 51): print(f{i}., case_title)。他说老板嫌弃这个序号太朴素要求改成 ①、②、③ 一直到 ㊿ 那种带圈的样子看着正式一点。他以为这是三行代码的事结果在 Windows 上跑起来先是UnicodeEncodeError改完编码之后 21 号往后又变成一堆方块最后发现不同字体下同一个字符的宽度还不一样表格全歪了。这个需求看起来像个玩具题——用 Python 输出 1~50 的带圈序号但真正动手做过一遍的人都知道它把Unicode 码位分布、编码表覆盖范围、终端与控制台的渲染差异、东亚字符宽度、字体字形覆盖、文件编码 BOM这六件事一次性全招了出来。它不是一道算法题是一道环境适配题。我自己前后在 Linux、macOS、Windows 三套环境下各跑过一遍中间踩的坑够写一篇正经的笔记了。下面我按我实际做的顺序复盘一遍先把码位摸清楚再解决输出环境的问题然后是反查和封装最后是我整理的踩坑清单。文中会给到可直接抄的全部代码和参数也会解释每一个选择背后的原因——毕竟你下次遇到 ㊿ 显示成豆腐块的时候得知道该往哪个方向查。1. 需求拆解为什么这件事没有想象中简单1.1 第一个直觉为什么必然失败绝大多数人拿到这个需求的第一反应是chr(9312 i)之类或者干脆用\u2460开始往后数。这个直觉只对前 20 个成立。你从 U2460①开始连续数到 U2473正好是 ⑳这一段确实是漂亮的等差数列步长 1数量 20。但当你想继续数第 21 个的时候U2474 已经不是带圈数字了那个位置放的是带括号的拉丁字母 ⑴ 之类的东西准确说是 PARENTHESIZED LATIN SMALL LETTER A。所以第一层认知必须是1 到 50 的带圈数字在 Unicode 里根本不是一段连续的码位而是三段互不相邻的区间拼起来的。如果你默认它是连续的代码不会报错但会安静地输出一堆完全不相关甚至不可见的字符——这是最讨厌的一类 bug因为程序没崩你以为是渲染问题其实是数据本身就错了。我在第一次写的时候就很自然地写了chr(0x2460 n - 1)1 到 20 完美21 开始输出的是 ⑴ ⑵ ⑶ 那一类视觉上还能看于是差点就漏过去了。后来是打印到文件里做 diff 才发现的。这个教训值得记一下凡是涉及码位的代码写完第一件事就是把整个列表打出来肉眼看一遍不要只看头尾。1.2 带圈数字在 Unicode 里的三段真实分布把三段区间列清楚是这件事的地基。下面这张表是我从实际运行结果里核对过的你可以直接拿去用数值范围起始码位结束码位十进制字符数量段内规律1 ~ 20U2460U24739312 ~ 9331200x2460 (n - 1)21 ~ 35U3251U325F12881 ~ 12895150x3251 (n - 21)36 ~ 50U32B1U32BF12977 ~ 12991150x32B1 (n - 36)三段的偏移量完全不同这是必须分三个分支处理的根本原因。为什么当初会拆成这样这跟 Unicode 的收录节奏有关。最早期的 Unicode 版本1.1 时代就把 ① 到 ⑳ 收进去了因为这是欧美排版里最常用的枚举符号而 21 到 35 那一段㉑ 到 ㉟是后来补充的编码时选了 U3251 附近的一块空位36 到 50㊱ 到 ㊿更晚被安排在 U32B1 附近的 CJK 兼容区块里。而且最后一段的字符名甚至都不叫CIRCLED DIGIT了改叫CIRCLED NUMBER命名上也不连续。理解了这层历史你就明白为什么不能靠猜规律来写这段代码——它不是设计出来的是长出来的。在 Unicode 里凡是涉及看起来应该连续的符号序列一律按码表核对不按直觉推断。1.3 动手前先花一分钟做环境自检在写正式代码之前我建议先做一个最小验证看看你的运行环境到底能不能把这些字符显示出来。这一步花不到一分钟但能省掉后面大量到底是代码错了还是渲染错了的纠结。# 环境自检一次性检查三段区间的生成、编码与终端宽度 import sys import unicodedata def build_circled_list(): part1 [chr(0x2460 i) for i in range(20)] # ①..⑳ part2 [chr(0x3251 i) for i in range(15)] # ㉑..㉟ part3 [chr(0x32B1 i) for i in range(15)] # ㊱..㊿ return part1 part2 part3 circled build_circled_list() print(总数量:, len(circled)) print(首尾:, circled[0], circled[-1]) print(stdout 编码:, sys.stdout.encoding) for idx, ch in enumerate(circled, start1): width unicodedata.east_asian_width(ch) name unicodedata.name(ch, UNKNOWN) print(f{idx:2} {ch} U{ord(ch):04X} width{width} {name})跑出来之后看三件事总数量是不是 50stdout 编码是不是 UTF-8Windows 上很可能是 cp936 或 gbk终端里第 21 个和第 36 个字符有没有变成方块或问号。这三项基本就决定了你后面要不要绕路。提示如果stdout.encoding显示的是cp936、gbk、ascii中的任何一个先别急着写业务代码先把输出编码的问题解决掉否则你会在打印这一步反复翻车。2. Unicode 布局解析搞懂三段区间才好写代码2.1 三段区间各自的属性差异上一节只讲了码位但光知道码位还不够因为这三段字符在 Unicode 的属性表里还有别的差异而这些差异会直接影响排版。第一段 ①~⑳ 的East_Asian_Width属性是A也就是Ambiguous含糊宽度。这个词的意思很直白这个字符在不同语言环境下应该被当成一个字符宽还是两个字符宽Unicode 不替你决定交给渲染端。在中文、日文语境下渲染端通常会按**全角宽度 2处理在纯英文语境下很多终端按半角宽度 1**处理。同一个字符换个 locale 就对不齐了。第二段 ㉑~㉟ 同样是A。第三段 ㊱~㊿ 就变了它是WWide也就是无条件全角。这意味着如果你把三段拼在一起做等宽对齐前两段的宽度取决于环境第三段永远是 2——三段的宽度行为都不一致。这个细节在我第一次做表格输出的时候坑了我很久。我用f{ch:3}做右对齐结果在中文 Windows 终端里前三段对得挺齐换到某个 Linux 容器的默认终端里前 20 个突然变成半角整个表格往左缩了 20 列。当时以为是ljust的问题折腾了半小时才想到查east_asian_width。补一个可直接拿来算显示宽度的函数import unicodedata def display_width(text: str, cjk_context: bool True) - int: 估算字符串在等宽终端里的显示宽度。 cjk_contextTrue 表示把 Ambiguous 字符按全角处理中文终端常见行为。 total 0 for ch in text: eaw unicodedata.east_asian_width(ch) if eaw in (W, F): total 2 elif eaw A: total 2 if cjk_context else 1 else: total 1 return total def pad_right(text: str, width: int, cjk_context: bool True) - str: gap width - display_width(text, cjk_context) return text * max(gap, 0)这个函数的用法很简单所有要做列对齐的地方用它算宽度而不是用内置的len()。因为len(①)返回 1但它在绝大多数中文终端里实际占 2 列。这就是为什么很多中文表格在 Python 里ljust之后歪得离谱——len()是字符数不是显示宽度。2.2 容易混淆的几组变体别拿错了带圈数字在 Unicode 里不止一套。如果你不事先确认需求方到底要哪一种很容易做完了才发现风格不对。我把常见几组列出来对照一下变体名称示例码位范围数量典型用途标准带圈数字① ② ③U2460 起等三段1~50通用枚举、列表序号带圈数字零⓪U24EA1 个需要从 0 开始的场景双圈数字⓵ ⓶ ⓷U24F5 ~ U24FE1~10强调主序号下的小项反白圈数字⓫ ⓬ ⓭U24EB ~ U24F411~20视觉上比标准版更重中文数字带圈㊀ ㊁ ㊂U3280 ~ U3289一到十中文古籍、章节编号组合围圈符1⃝ 2⃝数字 U20DD理论上无限超出 50 的兜底方案这里有个坑要提反白圈数字的码位顺序是反的而且它的覆盖范围是 11 到 20不是 1 到 10。很多人在看到⓫的时候会下意识以为它是第 1 个因为它长得像1但它的实际语义是 11。而 1 到 10 的双圈版本在 U24F5 那一段。这两段码位还挨着写循环的时候特别容易混。还有个更隐蔽的⑪U246A是标准带圈数字的 11而⓫U24EB是反白圈的 11。它们在码表里差了 0x80但语义相同、字形不同。如果你从别处复制了一串带圈数字来处理一定要连同码位一起验证不要只靠肉眼认形状。2.3 三种生成写法与各自的取舍确认了码位之后生成方式其实有好几种各有适用场景我按实际使用频率排一下。第一种直接按码位表达式硬算。这是我最常用的快、无依赖、可读性够用def circled_number(n: int) - str: if not isinstance(n, int): raise TypeError(f需要整数收到 {type(n).__name__}) if not 1 n 50: raise ValueError(f数值越界{n}当前实现只覆盖 1~50) if n 20: return chr(0x2460 n - 1) if n 35: return chr(0x3251 n - 21) return chr(0x32B1 n - 36) full_list [circled_number(i) for i in range(1, 51)]第二种在模块加载时预生成一个列表或字典。如果你要反复取用函数调用其实是有开销的更实际的理由是预生成之后可以顺手做一次完整性校验_CIRCLED_CACHE {i: circled_number(i) for i in range(1, 51)}第三种从码表里查而不是算。也就是不依赖硬编码的十六进制常量而是遍历一个基础区间用unicodedata.name()筛出真正的带圈数字。这种方式慢但不容易写错import unicodedata def discover_circled(limit: int 50) - dict: buckets {} ranges [(0x2460, 0x2473), (0x3251, 0x325F), (0x32B1, 0x32BF)] for start, end in ranges: for cp in range(start, end 1): ch chr(cp) name unicodedata.name(ch, ) if CIRCLED not in name or DIGIT not in name and NUMBER not in name: continue try: value unicodedata.numeric(ch) except (TypeError, ValueError): continue if 1 value limit: buckets[int(value)] ch return dict(sorted(buckets.items()))我平时写生产代码用第一种写校验脚本用第三种。第三种的价值在于它把哪一段是带圈数字这件事从我的记忆转移到了 Unicode 数据本身即使我哪天记错了码位它也能跑对。这种让数据自己说话的写法在涉及字符集时特别值得因为人的记忆在码表面前不值钱。3. 输出环境适配终端、文件、表格、图片各有一套生成出字符只是第一步能不能正确落到目标载体上是另一回事。这一章按四种最常见的落地场景分别讲。3.1 终端打印编码是第一道关在 Linux 和 macOS 上只要 locale 是 UTF-8print基本不会有问题。真正麻烦的是 Windows。Windows 的命令行历史上默认代码页是 936GBK 系而 GBK 对带圈数字的覆盖是残缺的——它大致上收了 ① 到 ⑩ 这一小段11 往后就基本没有了。所以你会看到一种很诡异的现象循环打印到第 10 个都好好的第 11 个突然raise UnicodeEncodeError程序直接中断。第一次遇到的人往往会以为是代码哪写错了其实是编码表不支持。处理方式有几种我按推荐程度排方案一改 Python 的 I/O 编码。前提是 Python 3.7import sys sys.stdout.reconfigure(encodingutf-8, errorsreplace)errorsreplace的意思是遇到编不进去的字符用替代符顶上不抛异常。这个参数很关键——生产环境里输出格式不对是可以接受的程序崩掉通常是不可以接受的。我在写批处理脚本时默认都会带这个参数。方案二设置环境变量。在启动脚本之前设PYTHONIOENCODINGutf-8或者在代码里配合PYTHONUTF81开启 UTF-8 模式Python 3.7 支持需要在进程启动前设置运行时改无效。方案三改控制台代码页。命令行执行chcp 65001切到 UTF-8。这个我一般只用在自己临时调试的时候因为它会影响整个控制台会话而且部分老版本 Windows 上切完之后中文字体渲染会变得很怪。注意errorsreplace是把双刃剑。它在救场的同时也会悄悄吞掉问题。如果你的脚本需要严格保证输出完整那就别用它改成显式捕获UnicodeEncodeError并记录日志把出问题的字符和位置报出来。顺便说一句即便编码通过了显示成豆腐块□是另一回事那是字体问题不是编码问题。编码错了抛异常字体缺字形画方块这两个现象要分清楚排查方向完全相反。3.2 写入文本文件BOM 与换行的取舍写文件比打印省心一些因为你完全控制编码with open(circled.txt, w, encodingutf-8, newline) as f: for i in range(1, 51): f.write(f{circled_number(i)} 第{i}项\n)这里有两个参数值得解释一下。encodingutf-8是明确的不要靠系统默认——Windows 上默认编码随环境变你写了不显式指定换台机器就可能出问题。newline的作用是关闭 Python 的换行转换。默认情况下Python 在 Windows 上写文本会把\n转成\r\n读到\r\n又会转回\n。这在纯 Python 读写闭环里没问题但如果文件要给别的程序尤其是跨平台工具消费这种隐式转换会造成困扰。我个人的习惯是只要文件可能被外部程序读取就显式写newline自己控制换行符。想让 Windows 记事本正常显示就自己写\r\n。至于 BOM除非目标读者是用 Excel 打开这个 txt很少见否则不要加 BOM。utf-8-sig是给 Excel 这种不认无 BOM UTF-8 的老软件准备的妥协方案用在不该用的地方会给下游程序制造麻烦——很多解析器不认 BOM会把文件头当成非法字节。3.3 进 CSV 和 Excel兼容性最差的一环这一环的坑最多因为中间隔了个 Excel而 Excel 对 CSV 的编码识别一直是个历史遗留问题。写 CSV 的正确姿势import csv with open(report.csv, w, encodingutf-8-sig, newline) as f: writer csv.writer(f) writer.writerow([序号, 带圈序号, 条目名称]) for i in range(1, 51): writer.writerow([i, circled_number(i), f模拟条目{i}])utf-8-sig就是带 BOM 的 UTF-8。只有在明确目标是让人用 Excel 双击打开的时候才用它其他场合一律用utf-8。这个判断标准我用了好几年还没翻过车。另一个隐性问题是字体。哪怕编码对了如果 Excel 当前单元格的字体不含这些字形你还是会看到方块。这时候换字体就行比如切到系统自带的黑体、宋体、思源黑体之类覆盖较好的中文字体。判断方法也简单在 Excel 里搜一下那个字符改字体看着变不变。如果目标就是 xlsx 而不是 csv直接用openpyxl或xlsxwriter写完全绕开编码问题from openpyxl import Workbook wb Workbook() ws wb.active ws.append([序号, 带圈序号]) for i in range(1, 51): ws.append([i, circled_number(i)]) wb.save(report.xlsx)这条路干净得多我个人更推荐——能生成 xlsx 就别生成 csvcsv 的编码和分隔符问题在所有环境下都是不稳定因素。3.4 渲染进图片先查字体有没有字形如果最终产物是图片比如生成一张序号对照表、或者给报告做个封面就要走 PIL 这条路。这里最关键的不是代码而是在渲染之前先确认字体支不支持这些字符。from fontTools.ttLib import TTFont import unicodedata def check_font_support(font_path: str, chars: str) - list: font TTFont(font_path, fontNumber0) cmap font.getBestCmap() font.close() return [ch for ch in chars if ord(ch) not in cmap] if __name__ __main__: target .join(circled_number(i) for i in range(1, 51)) missing check_font_support(/path/to/your/font.ttc, target) print(缺失字形数量:, len(missing)) if missing: print(缺失字符:, .join(missing)) print(缺失码位:, [fU{ord(c):04X} for c in missing])这段代码的价值在于它把渲染出来是方块这件事从事后发现变成了事前预防。我在做批量图片生成的时候会在任务开始前先跑一遍这个检查如果字体缺字形就直接报错退出而不是跑了几千张图之后再回来发现全是方块。实测下来的经验是中文黑体类字体对 ①~⑳ 覆盖较好但 ㉑ 往后经常有缺。原因是后半段属于 CJK 兼容区块一些精简版字体在制作时把这部分裁剪掉了。所以如果你的图片里必须包含 36 到 50建议优先选体积较大的完整版字体或者干脆分成两块渲染前半段用当前字体后半段换一个确认支持的字体。渲染代码本身不复杂from PIL import Image, ImageDraw, ImageFont img Image.new(RGB, (900, 700), white) draw ImageDraw.Draw(img) font ImageFont.truetype(/path/to/your/font.ttc, 30) for i in range(1, 51): row, col divmod(i - 1, 10) x 40 col * 85 y 30 row * 120 draw.text((x, y), circled_number(i), fillblack, fontfont) draw.text((x, y 42), str(i), fillgray, fontfont) img.save(circled_table.png)排版的时候用divmod把线性的序号拆成行列这是最省事的方式。如果你要做成等宽网格还需要考虑\u32B1起那一段字符的视觉宽度稍大可以给最后一段单独留宽一点的位置。4. 反向操作与工程化封装4.1 从带圈字符反查数字实际项目里正向生成往往只是需求的一半。更常见的场景是从一段已有的文本里把带圈序号解析出来转成普通数字。比如别人发来一份用带圈编号写的清单你的程序要按序号排序。最笨也最稳的办法是建反向字典把前面生成的映射反过来_TO_NUMBER {circled_number(i): i for i in range(1, 51)} def parse_circled(ch: str) - int: return _TO_NUMBER.get(ch, -1)但更优雅的做法是用unicodedata.numeric()因为它不用维护映射表import unicodedata def parse_circled_flexible(ch: str) - int | None: try: value unicodedata.numeric(ch) except (TypeError, ValueError): return None if value is None or value ! int(value) or not 1 int(value) 50: return None return int(value)unicodedata.numeric()的工作原理是读取字符定义里的Numeric_Value属性。带圈数字这一类字符在定义时就带了这个属性所以能直接查到。这个方法的优点是覆盖面广——⓵、⓫、㊀这些变体它都能认返回的都是对应的数值不需要你逐个去匹配码位。但要注意两个前提第一numeric()只对定义了数值的字符生效普通字母会抛ValueError第二它返回浮点数需要自己判整并做范围过滤。我在第一次用的时候就没判整结果某个形似数字的字符返回了类似 1.5 的值后面用int()转完就悄悄错了。还有个小细节unicodedata.numeric(ch)在某些版本里接受第二个参数作为默认值写成unicodedata.numeric(ch, None)就可以省掉一次 try/except代码更清爽。4.2 封装成一个顺手的小模块把这个需求做干净大概就是下面这个模块的样子。我刻意把码位常量放在顶部方便以后扩展带圈序号工具覆盖 1~50 的标准带圈数字。 import unicodedata _RANGES ( (1, 20, 0x2460), (21, 35, 0x3251), (36, 50, 0x32B1), ) MIN_VALUE, MAX_VALUE 1, 50 def circled(n: int) - str: 把 1~50 的整数转成对应的带圈序号字符。 if not isinstance(n, int) or isinstance(n, bool): raise TypeError(f需要整数收到 {type(n).__name__}) if not MIN_VALUE n MAX_VALUE: raise ValueError(f越界{n}支持范围 {MIN_VALUE}~{MAX_VALUE}) for low, high, base in _RANGES: if low n high: return chr(base n - low) raise ValueError(f未匹配到码位{n}) def circled_list() - list: return [circled(i) for i in range(MIN_VALUE, MAX_VALUE 1)] def uncover(ch: str) - int: 从单个字符反解出数值失败返回 0。 try: value unicodedata.numeric(ch, None) except TypeError: return 0 if value is None: return 0 value_int int(value) return value_int if value_int value else 0 def _self_check() - bool: 启动时自检正向生成与反向解析必须完全一致。 for i in range(MIN_VALUE, MAX_VALUE 1): ch circled(i) if unicodedata.name(ch, ).find(CIRCLED) 0: return False if uncover(ch) ! i: return False return True if __name__ __main__: assert _self_check(), 带圈序号映射自检失败 for idx, ch in enumerate(circled_list(), start1): print(f{idx:2} {ch} U{ord(ch):04X})这个模块里我认为最值得抄的是_self_check()。它的思路是凡是依赖硬编码常量表的功能在模块加载时用另一种独立途径验证一遍。这里的另一种途径就是unicodedata.name()和unicodedata.numeric()——它们读的是 Unicode 数据不是我的常量。万一哪天我手抖把0x3251打成了0x3521自检立刻就会失败不用等到业务跑错数据才发现。这个模式我在做字符映射、颜色表、状态机常量表的时候都会用。代价是一次几十毫秒的自检收益是把常量打错这类低级但极难排查的 bug 挡在门外。4.3 超过 50 怎么办组合围圈符这条路需求方说1 到 50但业务总有长大的一天。第 51 个的时候标准带圈字符就真的没有了。这时候有两个兜底思路。第一种是降级成别的形式比如 51 之后用(51)、[51]或者循环回 ① 再加个前缀。这是最稳妥的跨平台兼容性最好缺点是不够好看。第二种是用组合字符把阿拉伯数字加上 U20DDCOMBINING ENCLOSING CIRCLEdef circled_fallback(n: int) - str: 超过 50 时用组合围圈符兜底。 return f{n}\u20dd原理是 U20DD 是一个组合用封闭圆它作用于前一个字符把那个字符圈起来。理论上能圈任意数字而且支持多位数。但这条路我要如实提醒兼容性很差不推荐用在正式交付物里。实测结果是——在支持良好的浏览器和现代文字排版引擎里效果不错但在很多终端、部分 PDF 阅读器、以及不少字体里组合符会被渲染成独立的一个圆或者干脆丢掉变成51后面跟个空心圈。而且它的实际显示宽度不可控5⃝ 在不同环境里宽度能差出一倍。我的建议是如果序号上限有可能突破 50从一开始就跟需求方确认表现方式把它变成一个显式需求而不是等技术上走投无路时再临时打补丁。我在一个项目里就吃过这个亏当时默认50 肯定够了结果半年后业务扩展到 120 条最后是整体改成①和自增数字混排的方案返工面比一开始就定下来大得多。5. 常见问题排查速查表与踩坑记录5.1 现象、原因与处理对照下面这张表是我自己整理的也是这篇文章里我认为最实用的部分。遇到问题时按现象这一列去对基本能直接定位到原因现象最可能的原因处理方式第 11 个之后抛UnicodeEncodeError输出编码是 GBK 系编码表覆盖不全把 stdout 重设为 utf-8或改控制台代码页字符显示成方块 □字体缺字形不是编码问题换字体用 fontTools 预检查 cmap字符显示成问号 ?编码转换过程中被替换掉了检查是否用了errorsreplace21 号往后输出的是 ⑴⑵⑶误以为码位连续用了单段等差数列按三段区间分分支处理表格列对不齐Ambiguous宽度在不同环境判定不一致用east_asian_width自己算显示宽度CSV 在 Excel 里打开是乱码无 BOM 的 UTF-8 不被老版本 Excel 识别改用utf-8-sig或直接输出 xlsx排序结果不对混入了⓵、㊀等其它变体排序前先做字符归一化只保留目标区间len()算出来的宽度和显示不符len()是字符数不是显示列数用东亚宽度函数重算其中最容易被误判的是方块和问号这两个。看起来都是乱码但成因完全不同方块是编码通过了、字形没有问号是编码环节就已经失败了。判断方法很简单——把字符写进文件再用十六进制查看器看一眼。如果文件里字节是对的① 的 UTF-8 是 E2 91 A0那就是字体问题如果字节变成了 3F问号的 ASCII那就是编码环节丢了信息。5.2 几条只有动手做过才知道的细节关于排序。前面三段区间的码位是 0x2460 0x3251 0x32B1恰好是递增的所以只包含这三段的话直接sorted()出来的顺序天然就是 1 到 50 的正确顺序。这个特性挺方便但一旦混入了双圈符0x24F5或中文带圈0x3280顺序立刻就乱了——0x3280 落在第二段和第三段之间。所以如果文本来源不可控排序前一定先做过滤和归一化别直接对原始字符排序。关于复制粘贴。从网页或文档里复制带圈字符进代码是这类 bug 的高发区。我遇到过一次某同事把㉑从 PDF 里复制进字符串字面量看着没问题但实际复制过来的是另一个形近字符码位差了几十。结果那个字符既不等于生成出来的值在编码时又因为字体问题看不见差异。我的做法是代码里一律不直接写带圈字符全部用\u转义或者chr()生成。这样肉眼可查、diff 可查、也不会引入隐形字符。关于日志。如果你把这个字符写进日志要特别小心日志框架的编码设置——Python 的logging默认走sys.stderr而stderr的编码在 Windows 上可能和stdout不一样。我遇到过一次print正常但日志里全是问号的情况最后发现是FileHandler没指定encoding参数。给FileHandler显式传encodingutf-8一年能省你好几次困惑。关于数据库。如果这些字符要落库先确认字段的字符集。MySQL 的utf8三字节实现在存某些四字节字符时会出问题虽然这三段区间都在 BMP 内三字节够用但如果未来用组合符或更高的符号就会踩到。统一用utf8mb4是最省心的选择代价只是每列多占一点空间。关于正则。提取时不要用\d它只匹配 ASCII 数字。要匹配带圈数字写字符类[\u2460-\u2473\u3251-\u325F\u32B1-\u32BF]。如果有混入其它变体的风险更稳的写法是先用unicodedata.numeric()尝试取值取不到就跳过这样不依赖码位区间扩展性更好。最后分享一个我自己用得很顺的小技巧。这个需求本质上是字符集生成我在遇到任何类似的需求特殊符号、数学符号、货币符号时都会先花五分钟写一个一次性脚本把所有候选字符连同码位、名称、宽度、字体支持情况打一张表出来。这张表定下来之后正式代码只是查表。这样做的好处是把调研和实现彻底分开——调研阶段允许我犯错、允许我查文档、允许我改主意实现阶段就只剩纯粹的映射简单到不会出错。那次帮朋友改脚本最后交付的就是一个不到 40 行的模块加一张对照表。他后来跟我说真正省事的不是那段生成代码而是我把 Windows 编码和字体这两块提前写成了检查函数让他后面在别的机器上跑的时候一出问题就知道该看哪儿。这话我挺认同的——这类活儿的技术含量不在算法里在你对环境脾气的熟悉程度里。
阅读完成 · 觉得有帮助?