Python casefold 的位置变化:搜索命中了,为什么原文高亮却多带了一个字符

昨天 2阅读

为了忽略大小写,搜索模块先把原文和查询都做casefold,再拿命中的起止位置去截原文。英文测试正常,遇到Straße!却把末尾感叹号一起高亮。原因是大小写折叠可能改变码点数量:一个原文字符在搜索文本里可以占两个位置。

本文在CPython 3.12.14中对固定短字符串实跑,不读取外部文本。保存为demo.py,运行python3 demo.py。代码先保留错误切片作为对照,再为折叠结果的每个位置记录它来自原文哪一个码点。

Python casefold 的位置变化:搜索命中了,为什么原文高亮却多带了一个字符

AI模型生成概念插图:一个原始色块折叠后对应两个色块,连线保留二者的来源关系;不是实际字符宽度或软件截图。

完整程序与本地结果

完整可运行程序

text = "Straße!"
folded = text.casefold()
assert folded == "strasse!"
assert text.lower() == "straße!"
print("lower:", text.lower())
print("casefold:", folded)
print("lengths:", len(text), len(folded))

query = "SSE"
start = folded.find(query.casefold())
stop = start + len(query.casefold())
assert (start, stop) == (4, 7)
print("folded span:", start, stop)
print("wrong original slice:", text[start:stop])

pieces = []
owners = []
for index, character in enumerate(text):
    piece = character.casefold()
    pieces.append(piece)
    owners.extend([index] * len(piece))
assert "".join(pieces) == folded
original_start = owners[start]
original_stop = owners[stop - 1] + 1
matched = text[original_start:original_stop]
assert matched == "ße"
print("mapped original span:", original_start, original_stop)
print("mapped original text:", matched)

single = folded.find("s", 4)
assert text[owners[single]:owners[single] + 1] == "ß"
print("one folded s covers original:", text[owners[single]:owners[single] + 1])

本地实际输出(以下内容为程序结果)

lower: straße!
casefold: strasse!
lengths: 7 8
folded span: 4 7
wrong original slice: ße!
mapped original span: 4 6
mapped original text: ße
one folded s covers original: ß

忽略大小写不总是逐字变小写

lower得到straße!,其中ß仍保留;casefold得到strasse!,其中ß变成ss。原文长度为7,折叠结果长度为8。这里的长度是Python字符串的码点数,不是UTF-8字节数,也不是屏幕上占用的列宽。

casefold用于无大小写匹配,比仅调用lower覆盖更多等价情况。它返回新字符串,原文并未改变。用于显示的文字仍可以保留用户原始拼写;供搜索使用的折叠副本应视作另一套坐标,不能沿用原文下标的含义。

找到的范围属于折叠文本

查询SSE折叠后是sse,在strasse!中的半开区间是4到7。直接用这个范围切原文,得到ße!,多带了感叹号。搜索结果并没有错,错误发生在把折叠文本的结束位置当作原文结束位置使用。

半开区间的stop位于最后一个匹配位置之后,映射时需要特别注意这个约定。只修正起点、继续把查询长度加上去,也会因扩长字符而出错。需要记录的是匹配覆盖了哪些原文位置,而不是猜一个固定的长度差。

给每个新位置保留来源

循环逐个处理原文码点,将其折叠片段放入pieces,并按片段长度向owners加入对应原文索引。原来的ß贡献两个s,这两个新位置因此都指向同一个原文位置。代码另用断言确认拼出的片段与整串casefold完全一致。

恢复范围时,起点取owners[start],终点取owners[stop - 1]再加一。输出mapped original span为4到6,截取得到ße,恰好覆盖查询命中的原文字段。这个映射使用整数下标,适合本例单一折叠步骤的可追溯展示。

部分折叠命中需要产品规则

最后只搜索展开后的一个s,它仍来自完整的ß,因此映射回原文会覆盖整个ß。原文不存在“半个ß”可供字符串切片单独高亮。界面应明确允许这种覆盖,或者规定只接受完整折叠片段边界,不能悄悄声称一对一对应。

完整搜索接口还需要处理空查询与未命中:空查询没有最后一个匹配位置,find返回-1也不能继续拿来访问owners。本例查询固定且通过断言确认命中,因此直接展示映射计算;复制成通用函数时应先为这些状态设立明确分支。

额外文本转换需要同步维护坐标

若后续再加入Unicode规范化、去标点或空白合并,位置关系还会继续改变,不能直接沿用这里仅针对casefold生成的映射。每一层都应保留来源关系,或使用已经定义偏移语义的搜索实现,并检查界面接收的是码点、码元还是字节偏移。

这里也没有处理完整字素簇边界。组合字符与复杂emoji在视觉上可能属于一个整体,界面高亮还应遵循自己的文字分段规则。程序保留原文并恢复码点覆盖范围,解决的是折叠扩长导致的坐标错配,不代表任意语言的搜索、排序和显示都已完成。

一个折叠结果也可能对应多种原始拼写,因此搜索键适合用于寻找候选,却不应自动覆盖显示文本或把不同记录直接合并。保存原文、查询、折叠结果和映射可以帮助重现高亮错误。升级运行时或变更搜索规则后,重新检查展开字符、纯英文、未命中及连续多次匹配,让同一条命中范围始终能解释到原文。

参考资料

资料核验日期:2026年10月2日。以上输出对应固定输入和明确运行版本,本地执行退出码为0。

文章版权声明:除非注明,否则均为云鹊BLOG原创文章,转载或复制请以超链接形式并注明出处。