给一批素材文件批量加序号前缀,我第一反应是拿码点直接算:chr(0x2460 + i),前二十个跑得好好的,第二十一个开始拿到的就不是带圈的 21 了,而是一个左括号数字 ⑴。这个现象当年让我盯了半分钟才反应过来——Python 输出 1~50 的带圈序号(①~㊿),根本不是一次等差数列能覆盖的事。这 50 个字符在 Unicode 里被切成了三段互不相邻的码位区间,分别落在两个不同的区块里,中间还夹着一大堆带括号数字、带圈字母、双圈数字。你要是按"从①开始往后数 49 个"的思路写代码,能跑对前 20 个纯属运气。
这篇东西我打算把这件事从头拆一遍:这 50 个字符到底散在哪、三种能落地的生成写法各有什么取舍、终端里显示成方框到底是谁的锅、以及真正把带圈序号塞进文件名和清单文本时会撞上哪些坑。适合已经会一点 Python、但没深究过 Unicode 细节的人看,也适合手上有个小脚本要加编号功能、只想找份能直接抄的代码的人。
1. ①~㊿ 分散在三个互不相邻的码位区间
1.1 拆开看:三段区间,两个区块
先把事实摆出来。1 到 50 的带圈数字,在 Unicode 字符集里分成三段连续区间,而不是一段。
| 序号范围 | 首字符 | 首码点 | 末字符 | 末码点 | 所在区块 |
|---|---|---|---|---|---|
| 1 ~ 20 | ① | U+2460 | ⑳ | U+2473 | Enclosed Alphanumerics |
| 21 ~ 35 | ㉑ | U+3251 | ㉟ | U+325F | Enclosed CJK Letters and Months |
| 36 ~ 50 | ㊱ | U+32B1 | ㊿ | U+32BF | Enclosed CJK Letters and Months |
你会注意到两件事。第一,21 到 35 这一段的起始码点从 0x2473 直接跳到了 0x3251,中间隔了一万多個码位,那不是留白,那里塞满了各种带圈字母、双圈数字、括号字母、带点数字。第二,后两段虽然序号是连续的(35 接 36),但它们居然分属两个不相邻的区间,0x325F 之后跳到 0x32B1,中间还夹着㉠㉡㉢那套韩文字母圈号、㋐那套片假名圈号。
一段式区间总共只有 20 个字符这件事,直接决定了任何"偏移相加"的写法都必须写成三段分支。这是本节最核心的结论,后面所有代码都是围绕它展开的。
我在第一次处理这个需求时,脑子里默认了"带圈数字是一个完整的、从 1 到 50 的连续字符序列",这个先入为主的假设是所有错误的源头。Unicode 的码位分配是按历史批次和书写系统需求来的,不是按"数字大小"来的,它更像是一个不断扩建的老城区——先盖了一片住宅,二十年后再在旁边另找块地补建二期、三期,门牌号自然接不上。
1.2 为什么 chr(0x2460 + i) 写到第 20 个就断了
理解了这个分区,chr(0x2460 + 19)得到 ⑳ 是对的,因为 0x2460 + 0x13 = 0x2473,正好落在第一段的末端;但chr(0x2460 + 20)得到 0x2474,那是 ⑴,一个带全角括号的 1,完全不是带圈的 21。类似的误判还有一串:
chr(0x2460 + 20)→ ⑴(PARENTHESIZED DIGIT ONE),不是 ㉑chr(0x2460 + 30)→ ⒈(DIGIT ONE FULL STOP),不是 ㉛chr(0x2460 + 40)→ ⒜(PARENTHESIZED LATIN SMALL LETTER A),不是 ㊱
这类 bug 的恶劣之处在于它不会抛异常、不会崩溃,只是安安静静地吐出一堆长得有点像的字符。你要是把结果直接写进 CSV 或者文件名,肉眼扫一眼可能还觉得"没毛病啊,就是圈号嘛",等到别人打开看到一堆 ⒜ ⒝ ⒞ 才发现不对。
我踩过一次更隐蔽的版本:脚本把结果写进文本文件,我自己在编辑器里预览,前 20 行是标准的 ① 到 ⑳,后面全是各种奇形怪状的括号字母,但因为编辑器字体宽度不一致,行首没对齐,我以为是制表符没处理好,跑去调了半天格式,最后才发现是根本的字符选错了。这个教训挺深:当输出"看起来有点歪"的时候,先验证字符本身对不对,再怀疑排版。
为什么后两段会被放进"带圈 CJK 字母及月份"这个区块?因为这两段是后期补充的,当时那个区块里还有空位,而且带圈数字在东亚排版里更常出现(公文、清单、编号体系里用得很多),索性就补在那儿了。这个背景不影响写代码,但能解释为什么它们和 ①~⑳ 隔得那么远——这不是设计失误,是历史遗留的补丁痕迹,很多字符集里的"不连续"都是这么来的。
1.3 用 unicodedata 把编码这件事验证清楚
与其背三个码点,不如让 Python 自己告诉你答案。标准库里的unicodedata能反查每个字符的官方名称,用它跑一遍就能把上面的表格完整验证出来:
import unicodedata def to_circled(n: int) -> str: 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"超出 1~50 的带圈数字范围: {n}") for i in range(1, 51): ch = to_circled(i) print(i, ch, f"U+{ord(ch):04X}", unicodedata.name(ch))跑出来的name()字段很说明问题:前 20 个叫CIRCLED DIGIT ONE到CIRCLED DIGIT TWENTY,21 到 35 叫CIRCLED NUMBER TWENTY ONE到CIRCLED NUMBER THIRTY FIVE,36 往后是CIRCLED NUMBER THIRTY SIX到CIRCLED NUMBER FIFTY。注意命名规则也变了:1~20 是DIGIT,21 往后是NUMBER。这个细节不是我抠字眼,它其实给了你一个很实用的校验手段——如果你的代码产出的字符名字里既没有CIRCLED DIGIT也没有CIRCLED NUMBER前缀,那这字符一定是错的。
我在自己的工具脚本里会把这个校验做成一个轻量的断言,只在开发模式下开启:
def assert_circled(ch: str) -> None: name = unicodedata.name(ch, "") if not (name.startswith("CIRCLED DIGIT") or name.startswith("CIRCLED NUMBER")): raise AssertionError(f"{ch} 不是带圈数字: {name!r}")这么写的好处是,将来如果谁手贱改动了码点常量,测试会立刻炸掉,而不是等上线后让文件名里冒出几个⒤。
2. 三种能直接用的生成写法,各自适合什么场景
2.1 硬编码常量表:可读性最好,也最不容易出错
最笨也最稳的办法,就是把 50 个字符原样贴进代码里。它没有计算、没有分支,不可能算错:
CIRCLED = ( "①②③④⑤⑥⑦⑧⑨⑩" "⑪⑫⑬⑭⑮⑯⑰⑱⑲⑳" "㉑㉒㉓㉔㉕㉖㉗㉘㉙㉚" "㉛㉜㉝㉞㉟" "㊱㊲㊳㊴㊵㊶㊷㊸㊹㊺" "㊻㊼㊽㊾㊿" ) def circled(n: int) -> str: if not 1 <= n <= 50: raise ValueError(f"只支持 1~50,收到 {n}") return CIRCLED[n - 1]有个细节要提醒:贴这段字符串的时候必须确认你的源文件是 UTF-8 编码,并且文件头没有奇怪的 BOM 残留。我在一个老项目里遇到过源文件被某个编辑器另存成别的编码,字符串被截得乱七八糟,len(CIRCLED)返回的值比 50 小,索引越界报错,但报错信息里显示的字符本身又是正确的——因为终端把它转回来了。这类"文件本身坏了但显示正常"的情况,排查起来最费劲,最快的办法就是加一句assert len(CIRCLED) == 50。
分行的写法不是为了好看,是为了让每个物理行的字符数固定下来,方便你数、方便 diff。50 个字符分 6 行,每行 8 到 10 个,哪天要改成 1~99(后 49 个自定义)的时候,直接在对应位置补就行。
常量表法真正的优势场景是:这些字符在代码里出现的频率不高,但一旦出现就必须绝对正确。比如生成合同编号、章节标题、导出报告的序号列,出错的代价远高于多写 50 个字符的代价。
2.2 区间偏移法:代码短,但必须老老实实写成三段判断
上一节的to_circled()就是这一派。它的思路是把三段区间各自的"起点码点"和"起点序号"记下来,做一次线性偏移。写起来紧凑,逻辑一目了然,缺点是三段 if 分支看着有点啰嗦,而且没做越界校验的话,to_circled(0)会给你 0x245F,那是个奇怪的符号。
我一般会把三段区间抽成一张表来驱动,这样新增区间的时候只改数据不改逻辑:
SEGMENTS = [ # (起始序号, 结束序号, 起始码点) (1, 20, 0x2460), (21, 35, 0x3251), (36, 50, 0x32B1), ] def to_circled(n: int) -> str: for lo, hi, base in SEGMENTS: if lo <= n <= hi: return chr(base + n - lo) raise ValueError(f"超出范围: {n}")这个变体比硬写三个 if 更好维护,而且把"哪些区间是真实的"这件事显式化了——它本身就是一份可读的规格说明。哪天你要扩展到 1~99,只要把第 51 到 99 号的字符区间补进去,函数一行都不用动。
偏移法的风险点全在"码点算错"上。我见过的最常见的错法是少减 1:写成chr(0x2460 + n),结果 1 号变成了 ②,全套往后错一位,而且因为所有的字符都是合法的带圈数字,你盯着输出根本看不出问题,直到有人发现编号是从 ② 开始的。所以不管用哪种写法,跑一遍for i in range(1, 6)打印前五个,肉眼确认第一个是 ①、第二个是 ②,这一步别省。
2.3 动态反查法:适合要顺带做校验、或者字符集不确定的场景
第三种路子是不预设码点,而是在启动时遍历一遍候选范围,用unicodedata.name()判断字符叫什么名字,把符合"带圈数字"特征的挑出来按顺序排好。这种做法最"笨重",但也最不容易被代码里的手误坑到:
import unicodedata def build_circled_table(limit: int = 50) -> dict[int, str]: table: dict[int, str] = {} for cp in range(0x2460, 0x3300): ch = chr(cp) name = unicodedata.name(ch, "") if not name.startswith("CIRCLED NUMBER"): continue # 只取 1~50 这段纯数字命名的,跳过二十几这类写法之外的干扰项 words = name.replace("CIRCLED NUMBER ", "").split() table.setdefault(cp, ch) return table实话说,这个版本要真正可用还得处理英文数字单词转阿拉伯数字的问题(TWENTY ONE→ 21),写起来比前两种长不少。它的价值在别处:当你需要处理的不只是 1~50,而是"所有带圈数字类字符"时,比如做一个字符集字典、做一个输入法候选表、做一个字体覆盖率检测工具,动态反查就是唯一合理的做法。日常生成序号,我真心建议用前两种,别为了"优雅"把简单问题复杂化。
三种写法我做个对比:
| 写法 | 代码长度 | 出错概率 | 适合场景 | 扩展成本 |
|---|---|---|---|---|
| 常量表 | 中(要贴 50 个字符) | 极低 | 固定范围、要求绝对正确 | 高(要手工补字符) |
| 区间偏移 | 短 | 低(易少减 1) | 通用、可能扩展范围 | 低(加区间即可) |
| 动态反查 | 长 | 中(名称解析易漏) | 字符集分析类工具 | 极低 |
3. 终端里输出一片方框,锅多半不在 Python
3.1 从码点到字形,中间还隔着字体这一层
代码跑通、ord()拿到的码点也对,但屏幕上就是不显示,或者显示成一个空心方块、一个问号、一个带四个小方块的"豆腐块"——这个现象太常见了。它的本质是:Python 只负责把码点写出去,真正把码点画成图形的是字体,而绝大多数编程用的等宽字体根本没收录 ㉑ 到 ㊿ 这 30 个字符。
这一点特别容易误解。①~⑳ 因为出现频率高,很多字体都覆盖了,所以前 20 个显示正常;21 往后,尤其是 36 往后的 ㊱~㊿,覆盖率断崖式下跌。于是你会看到一个非常迷惑的现象:同一个脚本,输出前 20 行完美,后 30 行全是方框。这时候很多人第一反应是"Python 的编码问题""是不是要设 PYTHONIOENCODING",其实方向就错了。
判断方法很简单:把同一个字符复制到浏览器里、或者粘进一个支持富文本的编辑器里看。如果浏览器里能正常显示,说明字符本身没问题,是终端字体的事;如果浏览器里也是方块,那才需要怀疑码点选错了。这一步能省掉大量瞎折腾的时间。
3.2 一条可以照着走的排查链路
我把这套排查固化成了四步,每次遇到显示异常就按顺序走,基本不会绕远路。
第一步,确认码点。打印hex(ord(ch)),对照本文第 1 节的表格,确认落在正确区间。这一步排除"算错数"。
第二步,确认字符名称。打印unicodedata.name(ch),确认名称是CIRCLED DIGIT或CIRCLED NUMBER开头。这一步排除"算出了合法但错误的字符"。
第三步,确认终端编码。在主流 Python 3 环境里,终端输出的编码一般已经是 UTF-8,可以直接print(sys.stdout.encoding)看一眼。如果显示的不是 UTF-8 系,说明输出环节可能会把字符替换成?,这时候要么改环境变量,要么干脆把结果写进文件再看。
第四步,换字体。这是最关键也最容易被跳过的一步。找一款覆盖范围广的字体(优先挑那些同时覆盖中日韩字符和符号区的),把终端或编辑器的显示字体切过去,重启终端再看。
第四步有个小技巧:如果你只是想在终端里确认脚本逻辑对不对,不想折腾字体,那就别print到屏幕,直接写文件再用浏览器或者支持富文本的地方打开。我调试这类脚本的时候基本都这么干,open("out.txt", "w", encoding="utf-8")写完打开一看,一目了然,省去了跟终端字体较劲的时间。
3.3 落到文件、CSV、JSON 上的编码细节
写到文件这一步反而简单,只要守住一条:所有文本输出一律显式指定encoding="utf-8"。Python 在部分桌面系统上打开文件时的默认编码不是 UTF-8,不写这个参数,遇到 ㊿ 这种字符时会在写入阶段直接抛UnicodeEncodeError,报错信息里还带着一串看不懂的字节。
with open("circled.txt", "w", encoding="utf-8") as f: for i in range(1, 51): f.write(f"{to_circled(i)} 第 {i} 项\n")如果目标文件是要给别人用表格软件打开的 CSV,建议用utf-8-sig,带 BOM 的 UTF-8,这样打开时中文和符号都不会乱:
with open("list.csv", "w", encoding="utf-8-sig", newline="") as f: writer = csv.writer(f) writer.writerow(["序号", "带圈", "内容"]) for i in range(1, 51): writer.writerow([i, to_circled(i), f"第{i}项"])JSON 那边有个反向的坑:json.dumps()默认ensure_ascii=True,会把所有非 ASCII 字符转成\u2460这种转义形式。数据本身没错,反序列化回来还是原字符,但如果这份 JSON 是给人看的、或者是直接拼进前端的,转义后的可读性就很差。生成给人看的 JSON 时加ensure_ascii=False,并且输出时同样指定 UTF-8。
我个人的习惯是:凡是涉及非 ASCII 字符的落盘操作,encoding=参数一律显式写出来,不留默认值。这条规矩看起来啰嗦,但它挡掉的问题比它带来的麻烦多得多。
4. 对齐、排序、正则:带圈字符在数据处理里的三个暗坑
4.1 等宽字体里它们并不等宽
这是我在做对齐表格时踩得最狠的一个坑。逻辑上"等宽字体"意味着每个字符占一样宽,但带圈字符是个例外——它们的东亚宽度属性(East Asian Width)不统一。前 20 个 ①~⑳ 在很多环境下被当作"模糊宽度"(Ambiguous),而后 30 个 ㉑~㊿ 大多被标成"宽"(Wide)。结果就是,同一列里混排 ⑳ 和 ㉑,在中文环境下宽度会明显跳一档。
你可以自己验一下:
import unicodedata for ch in ["①", "⑳", "㉑", "㉟", "㊱", "㊿"]: print(ch, unicodedata.east_asian_width(ch))不同环境下结果可能略有差异,因为宽度属性存在"东亚歧义"的语境依赖,但"前后两段不一致"这个现象是稳定的。这意味着你用str.rjust(width)或者f"{ch:>3}"做出来的对齐,在视觉上会是歪的。
解决办法有两条。如果你只是想让列看起来整齐,别用字符数 Padding,改用固定宽度的制表符或者干脆输出成表格文件让别人去渲染。如果你必须在纯文本里对齐,那就得自己算显示宽度,wcwidth这个第三方库能给出比较准的结果,按"显示宽度"而不是"字符个数"来补空格。
4.2 直接 sorted() 排出来的顺序,恰好对了但没道理
第三个坑更隐蔽。50 个带圈字符,如果你把它们放进一个列表里做sorted(),得到的结果是 ① 到 ⑳,然后 ㉑ 到 ㉟,然后 ㊱ 到 ㊿——看起来完全正确。
为什么?因为排序是按码点比的,而三段区间的码点本身就是递增的(0x2460 < 0x3251 < 0x32B1),序号也是递增的,两者恰好同向。这是个"巧合般的正确",它让你误以为字符串排序可以处理带圈序号。
一旦你把别的字符混进来,这套就崩了。比如混入 ⓪(0x24EA,带圈零)、⑴(0x2474)、⓵(0x24F5),排序结果会变得完全无法解释——⓪ 排到了 ① 前面(0x24EA < 0x2460),⑴ 夹在 ⑳ 和 ㉑ 中间,⓵ 又跑到别的地方。
我吃过一次亏:一个脚本把不同来源的标签混在一起排序,其中一部分用了带圈数字、一部分用了带括号数字,排出来的顺序在第三十几号突然乱跳,我以为是列表被污染了,查了两个钟头才反应过来是码点排序的锅。
正确的做法从来不是靠字符本身排序,而是维护一份"字符 → 序号"的映射,排序时按序号排:
INDEX = {to_circled(i): i for i in range(1, 51)} items = ["㊿ 收尾项", "① 首项", "㉑ 中段项"] items.sort(key=lambda s: INDEX.get(s.split()[0], 999))这个模式在真实项目里很重要:带圈序号是用来给眼睛看的,不是用来给程序比较的。程序内部做排序、去重、索引时,一律用整数;只在最终输出那一步才转成带圈字符。把这两件事分开,能省掉一整个人生的调试时间。
4.3 正则要按码位区间写,别指望字符类简写
第四个坑落在正则上。如果你想从一段文本里把带圈序号抓出来,写\d是没用的——\d匹配的是数字类字符(Nd),而带圈数字的 Unicode 类别是No(Other Number),很多正则引擎的\d不认它。
import re, unicodedata print(unicodedata.category("①")) # No print(unicodedata.category("1")) # Nd(全角数字,这才是 \d 会匹配的)这段对照很能说明问题:全角1是正儿八经的十进制数字,\d认;带圈的①是"其他数字",\d不认。很多人在这里的判断是反的。
保险的写法是直接按码位区间写字符类:
CIRCLED_RE = re.compile(r"[\u2460-\u2473\u3251-\u325f\u32b1-\u32bf]") text = "① 第一项,㉑ 第二十一项,㊿ 第五十项" print(CIRCLED_RE.findall(text)) # ['①', '㉑', '㊿']如果你还想顺手把序号数字也提出来,用映射反查比用正则捕获更靠谱,因为这三段区间的"数字"和"码点"不是线性关系,正则没法直接算出来。
5. 圈号不够用、或者不想被字体绑住的时候
5.1 Unicode 里现成的其他圈号,够撑不少场面
50 个带圈数字确实是这个样式的上限,但 Unicode 里还有别的圈号家族,很多场景下可以拿来顶。
| 样式 | 示例 | 覆盖范围 | 码位区间 |
|---|---|---|---|
| 实心带圈数字 | ❶ ❷ ❸ … ❿ | 1 ~ 10 | U+2776 ~ U+277F |
| 实心带圈数字(续) | ⓫ ⓬ … ⓴ | 11 ~ 20 | U+24EB ~ U+24F4 |
| 双圈数字 | ⓵ ⓶ … ⓾ | 1 ~ 10 | U+24F5 ~ U+24FE |
| 括号数字 | ⑴ ⑵ … ⒇ | 1 ~ 20 | U+2474 ~ U+247F |
| 带点数字 | ⒈ ⒉ … ⒛ | 1 ~ 20 | U+2488 ~ U+249B |
| 带圈数字零 | ⓪ | 仅 0 | U+24EA |
这套东西在实际排版里有明确的分工:实心带圈(❶)适合做强调标记,白色带圈(①)适合做正文序号,双圈(⓵)适合做二级标记,括号数字(⑴)在书目和法条引用里用得很多。如果你需要三层嵌套的编号体系,比如㊱→❻→⑹,其实是可以拼出来的——每一层各用一套字符,视觉上层次分明,而且每套都不超过 20 个,刚好在覆盖范围内,不会碰到字体缺字的边界。
我做过一个多层清单的排版,第一层用 ①~⑳,第二层用 ⑴~⒇,第三层用 ⒈~⒛,三层组合起来能表达 20×20×20 共 8000 个不同的位置,而且是纯文本、可搜索、可复制,比用图片做标记强太多。
5.2 用 PIL 自己画圈,彻底绕开字体缺字
如果目标产出是图片(比如给视频配序号图、给商品图加角标),别指望字体了,直接画。PIL 里画一个"数字加圆圈"的组合图,几十行代码就能搞定,而且 1 到 999 都能画,不受 Unicode 上限约束:
from PIL import Image, ImageDraw, ImageFont def make_badge(n: int, size: int = 128, font_path: str = "") -> Image.Image: img = Image.new("RGBA", (size, size), (0, 0, 0, 0)) draw = ImageDraw.Draw(img) pad = size // 16 draw.ellipse([pad, pad, size - pad, size - pad], outline=(30, 30, 30, 255), width=max(2, size // 32)) font = ImageFont.truetype(font_path, size // 2) text = str(n) # 用 anchor 让文字精确居中,避免手工测量误差 draw.text((size / 2, size / 2), text, font=font, fill=(30, 30, 30, 255), anchor="mm") return imganchor="mm"这个参数值得单独说一下。早年我用draw.textsize()手动算宽度再减半来做居中,结果遇到数字 "1" 这种窄字形时总是偏左,因为textsize返回的是文本框宽度,而数字的字形在框内不一定居中。用anchor="mm"直接把"文字锚点"设在中心,省掉了所有手工计算,也不受字形宽度差异影响。这个细节在 PIL 文档里写得不显眼,但用过一次就再也回不去了。
字体路径这地方要挑一个覆盖数字和标点都完整的字体文件,路径按你的系统实际情况填。批量生成的时候,make_badge返回的图可以直接img.save(f"badge_{n:02d}.png"),编号用零填充保证文件名排序正确——这点别偷懒,用n直接拼的话,badge_1.png会排在badge_10.png后面,在某些文件管理器里看着特别难受。
5.3 给正式文档和表格的降级策略
还有一个现实问题必须面对:如果这批带圈序号最终要进的是正式文档、表格、或者发给不确定什么环境的人看,直接塞 ㉑~㊿ 是有风险的,收件人那边字体缺字就变成方框了。
我的降级策略是分层的:
第一层,1~20 用 ①~⑳,这一段字体覆盖率高,基本没人会看到方框。第二层,21 往后不用带圈数字,改用(21)或者全角(21),虽然不够好看,但绝对不出错。第三层,如果场景允许,用21.这种最朴素的写法反而最安全。
还有个折中方案:用组合字符数字 + U+20DD(COMBINING ENCLOSING CIRCLE)来拼。理论上"1" + "\u20dd"会渲染成一个带圈的 1,看起来和 ① 一样。但实测下来这个方案非常不靠谱——多数常见字体对组合圈号的支持很差,要么不渲染,要么把圈画在错误的位置,要么把圈画得特别大。更要命的是字符串长度变成了 2,所有按字符数算宽度的逻辑都会错位。
我试过一次,做完发现所有len()判断都失效了,"1\u20dd".strip()也去不掉那个圈,最后整批回滚重写。结论很明确:这个方案可以在纯排版软件里手动用一下,但绝对不要写进程序的字符串处理逻辑里。
6. 把它接进真实工作流,两个用得上的场景
6.1 批量文件重命名的编号前缀
这是带圈序号最实用的场景之一。给一批素材文件加序号前缀,比起01_02_这种,①②在目录里扫一眼就能定位,视觉效率高得多。
from pathlib import Path def rename_with_circled(folder: str) -> None: root = Path(folder) files = sorted([p for p in root.iterdir() if p.is_file()]) if len(files) > 50: raise ValueError(f"共 {len(files)} 个文件,超出 50 个带圈序号的上限") for i, path in enumerate(files, start=1): new_name = f"{to_circled(i)}_{path.name}" target = path.with_name(new_name) if target.exists(): raise FileExistsError(f"目标已存在: {target.name}") path.rename(target)这段代码里有三个地方是我特意加的防护,都是踩过坑之后补的。
第一个是len(files) > 50的检查。我一开始没加,结果跑到一批 80 多个文件的目录上,第 51 个直接抛ValueError中断,前面 50 个已经改完名了,整个目录处于"一半改了、一半没改"的状态,回滚起来很麻烦。要么提前检查,要么让函数具备事务性,我选了前者,简单直接。
第二个是path.exists()的判断。不加的话,万一目录里已经有一个叫①_a.jpg的文件,rename在某些系统上会直接覆盖,原始文件就没了。这个错误一旦发生是不可逆的。
第三个是sorted()那一行。文件列表如果不排序,iterdir()返回的顺序是文件系统的顺序,不同系统上不一致,同一系统上也可能因为增删文件而变化。不排序就意味着每次跑出来的编号前缀都不一样,这显然不是你要的效果。
还有个细节:文件名里用这些字符,在不同的压缩工具里表现不同。如果这批文件要打包发给别人,注意有些老一些的打包工具不会在压缩包里标注 UTF-8 文件名,解压的时候带圈字符会变成乱码,甚至连文件名都读不出来。这种情况我的做法是打包前把前缀换回纯 ASCII 数字,等对方解压完再处理——听起来倒退,但这是最省事的处理方式。
6.2 生成一份带圈序号的清单文本
第二个场景是快速生成一份清单草稿,比如给某个方案列 50 条要点、给某个流程写 50 个步骤、或者给某个目录生成一份说明文档的骨架。手写这 50 个字符是不可能的,用脚本批量产出,再在编辑器里逐条往里填内容,效率差别非常大。
def build_checklist(n: int, title: str = "清单") -> str: lines = [f"# {title}", ""] for i in range(1, n + 1): lines.append(f"{to_circled(i)} ____") return "\n".join(lines) print(build_checklist(50))我经常用这个套路给写作任务打草稿。50 个下划线占位,打开编辑器逐个填空,填完删掉剩下的空行。比起从零开始写 50 个编号,这个流程能省掉不少机械劳动,而且编号绝对不会错位——手写编号写到第三十几个的时候,出错率是很高的。
如果要输出到表格软件里,同一个函数改一下分隔符就行,用制表符分隔,粘贴进去会自动分列。这一步别用逗号,因为内容里很可能带逗号,会切错列。用制表符是最省心的。
还有一个我常加的小功能:给每个序号后面留一个固定宽度的备注位。因为 ① 和 ㊿ 的显示宽度不同,直接拼接会导致后面的备注列对不齐,所以横排输出的时候,我会按显示宽度补齐,而不是按字符数补。这个处理看起来是小事,但当你盯着一个歪掉的表格看了十分钟之后,就会明白多加这几行代码有多值。
最后一句话留给我自己的经验:带圈序号这类"看起来只是换个字符"的需求,真正的成本从来不在写代码那几行,而在搞清楚字符的码位分布、搞清楚显示环境的字体覆盖、搞清楚下游系统认不认。把这三件事的边界摸清楚,剩下的就是抄代码的事了。