# MdxBuilder.exe 和的 mdict-utils 排序规则对照

**URL:** <https://forum.freemdict.com/t/topic/45183>\
**Category:** 软件经验交流展望\
**Created:** [2026 年9 月 25 日 16:18 UTC](https://forum.freemdict.com/t/topic/45183 "2026-09-25T16:18:17Z")\
**Posts on this page:** 1\
**Page:** 1

<div class="post-metadata">

**Author:** ![glacierlee](https://forumcdn.freemdict.com/user_avatar/forum.freemdict.com/glacierlee/32/74595_2.png) [@glacierlee](https://forum.freemdict.com/u/glacierlee)\
**Post date:** [2026 年9 月 25 日 16:18 UTC](https://forum.freemdict.com/t/topic/45183/1 "2026-09-25T16:18:17Z")

</div>

整理了一份 **MdxBuilder.exe** 和的 **mdict-utils** 排序规则对照，供开发者们参考，如有错漏，实属正常。

## 1. 总体流程

| 项目 | MdxBuilder | mdict-utils |
| --- | --- | --- |
| 排序前处理 | 根据 Wide/Narrow 模式处理 | Python `str.lower()` |
| Case 转换 | **自定义 `MdxBuilderToLower`** 或 ASCII lower | Python Unicode `lower()` |
| 去标点 | 默认不去；`stripKey=true` 时去除非字母数字 | **固定去除 ASCII punctuation + ASCII space** |
| 排序单位 | Wide：UTF-16 code unit；Narrow：byte | **Unicode code point** |
| Locale | 无 | `locale.strxfrm()`，但默认 `LC_COLLATE=C` 时实际等同 identity |
| Stable sort | 是 | 是 |
| 原始 Key 是否修改 | 否 | 否 |
| Sort Key 是否写入 MDX | 否 | 否 |

* * *

# 2. Case / 大小写处理

这是两者最重要的差异之一。

### MdxBuilder Wide

```plaintext
MdxBuilderToLower()

```

它不是现代 Unicode lowercase，也不是 Unicode CaseFold。

一共验证了 **827 个发生变化的 UTF-16 code points** 。

而且里面存在一些历史/实现上的特殊行为。

例如：

| 字符 | MdxBuilder |
| --- | --- |
| `A` | `a` |
| `É` | `é` |
| `Σ` | `σ` |
| `ς` | `σ` |
| `µ` | `μ` |
| `×` | **`÷`** |
| `ſ` | `s` |
| `ß` | `ß` |
| `ẞ` | `ẞ` |

其中最值得注意的是：

```plaintext
× U+00D7 → ÷ U+00F7

```

这是 MdxBuilder 中的 bug/quirk，已经验证过，不能“修正”。

### mdict-utils

使用：

```python
key.lower()

```

所以：

| 字符 | Python `lower()` |
| --- | --- |
| `A` | `a` |
| `É` | `é` |
| `Σ` | `σ` |
| `ς` | `ς` |
| `µ` | `µ` |
| `×` | `×` |
| `ſ` | `ſ` |
| `ß` | `ß` |
| `ẞ` | `ß` |

所以例如：

```plaintext
µ vs μ

```

MdxBuilder：

```plaintext
µ → μ
μ → μ

=> 两者相同

```

mdict-utils：

```plaintext
µ → µ
μ → μ

=> 两者不同

```

* * *

# 3. `lower()` vs CaseFold

这里特别容易混淆。

### MdxBuilder

不是：

```plaintext
Unicode lower()

```

也不是：

```plaintext
Unicode casefold()

```

而是自己的历史 mapping。

### mdict-utils

也是：

```python
str.lower()

```

**不是：**

```python
str.casefold()

```

所以：

```plaintext
ß

```

mdict-utils：

```plaintext
ß → ß

```

不会变成：

```plaintext
ss

```

而：

```plaintext
ẞ → ß

```

因此：

```plaintext
ẞ
ß

```

会产生相同 sort key。

* * *

# 4. Final Sigma

这是另一个非常重要的差异。

### MdxBuilder

MdxBuilder 的自定义 mapping 中：

```plaintext
Σ → σ
ς → σ

```

因此：

```plaintext
Σ
σ
ς

```

最终会进入同一个 sort key。

### mdict-utils

Python：

```python
"Σ".lower() → "σ"
"σ".lower() → "σ"
"ς".lower() → "ς"

```

所以：

```plaintext
Σ == σ

```

但：

```plaintext
ς != σ

```

而且 Python `lower()` 对 Greek final sigma 存在 **上下文相关行为** 。

这也是确认：

> MdictUtils 的 sort key 不具备严格的 prefix-preserving 特性。

* * *

# 5. 标点处理

这是另一个非常大的区别。

## MdxBuilder

默认：

```plaintext
stripKey = false

```

时：

**标点保留。**

例如：

```plaintext
a-b
a b
a_b
a$b

```

sort key 中仍然存在：

```plaintext
a-b
a b
a_b
a$b

```

如果：

```plaintext
stripKey = true

```

才进行过滤。

* * *

## mdict-utils

MDX 路径固定执行：

```python
string.punctuation + " "

```

也就是：

```plaintext
!"#$%&'()*+,-./:;<=>?@[\]^_`{|}~

```

再加：

```plaintext
ASCII space U+0020

```

因此：

```plaintext
a-b → ab
a b → ab
a_b → ab
a$b → ab
(a)b → ab

```

所以：

```plaintext
a-b
ab
a b
a_b

```

在 mdict-utils 中可能产生相同 sort key。

* * *

# 6. Unicode 标点

这里很容易误以为 mdict-utils 会删除“所有标点”。

实际上不是。

它只删除 Python：

```python
string.punctuation

```

也就是 ASCII 32 个 punctuation。

因此：

```plaintext
中，文
中。文
中、文
em—dash
«x»
‘x’

```

这些 Unicode punctuation **都会保留** 。

同样：

```plaintext
×
÷

```

也不会被删除。

* * *

# 7. 空格和 whitespace

### MdxBuilder

默认情况下空格可以参与排序。

### mdict-utils

只有：

```plaintext
U+0020 ASCII space

```

会被删除。

例如：

```plaintext
"a b" → "ab"

```

但：

```plaintext
"a\tb"
"a b" NBSP
"a b" U+3000

```

不会因为 punctuation stripping 而被删除。

另外，`pack_mdx_txt()` 在 **解析 TXT 时** 还有一个不同的行为：

```python
line.strip()

```

它会先去除 ASCII whitespace 两端。

所以这里要区分：

```plaintext
TXT parsing
vs
sorting

```

这两个阶段不是一回事。

* * *

# 8. Unicode accent / diacritic

这是你最近发现那个问题的关键。

### MdxBuilder

**不会自动进行 accent stripping。**

例如：

```plaintext
e
é

```

通常仍然是不同 sort key。

### mdict-utils

也 **不会** ：

```plaintext
é → e

```

它只是：

```plaintext
lower()
+
ASCII punctuation stripping

```

因此：

```plaintext
e = U+0065
é = U+00E9

```

排序时：

```plaintext
e < é

```

所以正常的 mdict-utils 结果应该是：

```plaintext
delegue...
délégue...

```

两个 block 分开。

你实际看到的：

```plaintext
delegue
délégué
délégue
...

```

不是 mdict-utils comparator 的行为，而更像阅读器自己的 accent-insensitive index/sort。你上传的调查报告也通过实际 MDX 构建验证了这一点。

* * *

# 9. 排序单位

这是 MdxBuilder 和 mdict-utils **结构上最大的区别之一** 。

## MdxBuilder Wide

比较：

```plaintext
unsigned UTF-16 code unit

```

例如 supplementary character：

```plaintext
𐐀 = U+10400

```

UTF-16：

```plaintext
D801 DC00

```

所以 MdxBuilder 实际比较的是：

```plaintext
D801 ...

```

而不是：

```plaintext
00010400

```

* * *

## mdict-utils

Python `str` 比较的是：

```plaintext
Unicode code point

```

因此：

```plaintext
𐐀 = U+10400

```

就是直接按照：

```plaintext
0x10400

```

比较。

这会导致 supplementary characters 在两个系统中的排序不同。

例如：

```plaintext
Ａ = U+FF21
𐐀 = U+10400

```

Unicode code point：

```plaintext
FF21 < 10400

```

所以 mdict-utils：

```plaintext
Ａ < 𐐀

```

但是 MdxBuilder UTF-16：

```plaintext
Ａ = FF21
𐐀 = D801 DC00

```

所以：

```plaintext
𐐀 < Ａ

```

这不是 bug，而是两个实现定义不同。

* * *

# 10. Narrow MdxBuilder 更特殊

MdxBuilder 还有一个 **Narrow pipeline** 。

它不是 Wide 的 Unicode 排序。

### ASCII

只做：

```plaintext
A-Z → a-z

```

### Big5

识别 Big5 lead/trail，然后保留原始双字节。

### GBK

根据原始 byte pair 判断。

最后：

```plaintext
unsigned byte comparison

```

所以：

```plaintext
MdxBuilder Wide
    → UTF-16 code units

MdxBuilder Narrow
    → raw bytes

mdict-utils
    → Unicode code points

```

三个完全不同的排序单位。

* * *

# 11. GBK / Big5

### MdxBuilder Narrow

如果输入是 GBK / Big5：

```plaintext
decode → ❌

```

不是先转换成 Unicode 再排序。

而是直接按照：

```plaintext
encoded bytes

```

排序。

### mdict-utils

相反。

它先：

```plaintext
bytes
 ↓
Python str
 ↓
lower()
 ↓
sort
 ↓
encode GBK/Big5

```

所以即使最终 MDX 是 GBK：

**排序仍然是 Unicode code-point ordering。**

Claude 实际测试 UTF-8、UTF-16、GBK 三种输出，排序结果相同，也验证了这一点。

* * *

# 12. Locale

### MdxBuilder

没有 locale collation。

就是直接：

```plaintext
unsigned UTF-16 / byte comparison

```

### mdict-utils

代码中有：

```python
locale.strxfrm()

```

但当前实际环境：

```plaintext
LC_COLLATE = C

```

所以：

```plaintext
strxfrm(s) == s

```

实际效果仍然是：

```plaintext
Unicode code-point comparison

```

而且 mdict-utils 自己没有调用：

```python
setlocale()

```

所以实现 native backend 时， **不应该真的调用 `std::locale` 或 `strxfrm`** ，否则反而可能引入 Android device locale 依赖。

* * *

# 13. Tie / 相同 sort key

两者都是：

**stable sorting。**

### MdxBuilder

使用 stable sort。

如果：

```plaintext
key1 == key2

```

保持 source order。

### mdict-utils

Python：

```python
list.sort()

```

是 stable Timsort。

例如：

```plaintext
deleguee
deleguee
delegue, e

```

因为：

```plaintext
delegue, e
    ↓ remove punctuation
deleguee

```

所以三个 sort key 相同。

最终顺序取决于 source order。Claude 的实际 MDX 验证也确认了这一点。

* * *

# 14. MdictUtils 一个“看起来有 tie-break，实际上没有”的地方

原始代码还有：

```python
if len(key1) > len(key2):
    return -1
elif len(key1) < len(key2):
    return 1

```

以及后面的：

```python
rstrip(string.punctuation)

```

但这些代码实际上是 **unreachable** 。

因为前面已经：

```python
if key1 > key2:
    return 1
elif key1 < key2:
    return -1

```

如果两个 Python strings 既不大于也不小于，那么它们就是完全相等。

因此：

```plaintext
len(key1) == len(key2)

```

后面的 rstrip 也不会产生区别。

所以最终就是：

```plaintext
sort key equal
    ↓
return 0
    ↓
stable sort

```

报告中的 200,000 次 instrumented comparison 也验证了这些 branch 从未触发。

* * *

# 15. Original Key / 存储内容

这一点两者其实相同：

**排序 key ≠ 存储 key。**

### MdxBuilder

例如：

```plaintext
Apple

```

可能生成：

```plaintext
apple

```

作为 sort key。

但最终 MDX 保存：

```plaintext
Apple

```

### mdict-utils

例如：

```plaintext
a-b

```

sort key：

```plaintext
ab

```

但最终 MDX 保存：

```plaintext
a-b

```

所以：

```plaintext
Original Key
    ├──→ Sort Key → sorting
    │
    └──→ Stored Key → MDX

```

* * *

# 16. 最终完整对照

| 排序规则 | **MdxBuilder Wide** | **MdxBuilder Narrow** | **MdictUtils** |
| --- | --- | --- | --- |
| Case conversion | 自定义 `MdxBuilderToLower` | ASCII A-Z → a-z | Python `str.lower()` |
| Unicode CaseFold | ❌ | ❌ | ❌ |
| `ß → ss` | ❌ | ❌ | ❌ |
| `µ → μ` | ✅ | ❌ | ❌ |
| `ς → σ` | ✅ | ❌ | ❌ |
| `ſ → s` | ✅ | ❌ | ❌ |
| `ẞ → ß` | ❌ | ❌ | ✅ |
| `× → ÷` | **✅ 特殊 bug** | ❌ | ❌ |
| ASCII punctuation | 默认保留 | 默认保留 | **删除** |
| ASCII space | 默认保留 | 默认保留 | **删除** |
| Unicode punctuation | 默认保留 | 默认保留 | **保留** |
| Accent stripping | ❌ | ❌ | ❌ |
| Locale collation | ❌ | ❌ | 实际 `C` → identity |
| Sort unit | **UTF-16 code unit** | **byte** | **Unicode code point** |
| Supplementary characters | surrogate units | raw bytes | code points |
| GBK/Big5 | Wide decode / Narrow raw bytes | **raw bytes** | **Unicode** |
| UTF-8 | Wide | 不属于 Narrow | Unicode |
| Tie-break | stable | stable | stable |
| Equal key | source order | source order | source order |
| Original key modified? | ❌ | ❌ | ❌ |
| Sort key stored? | ❌ | ❌ | ❌ |
| stripKey | 有 | 有 | **没有，忽略该参数** |
| Prefix preserving | 原有行为 | 原有行为 | **不保证** |
| External dependency | 无 | 无 | 无 |

* * *

## 17. 最容易混淆的几个例子

### `µ` vs `μ`

```plaintext
MdxBuilder:
µ → μ
μ → μ
=> equal

MdictUtils:
µ → µ
μ → μ
=> different

```

### `ς` vs `σ`

```plaintext
MdxBuilder:
ς → σ
σ → σ
=> equal

MdictUtils:
ς → ς
σ → σ
=> different

```

### `ß` vs `ss`

```plaintext
MdxBuilder:
ß → ß
ss → ss
=> different

MdictUtils:
ß → ß
ss → ss
=> different

```

两者都 **不是 CaseFold** 。

### `a-b` vs `ab`

```plaintext
MdxBuilder default:
a-b
ab
=> different

MdictUtils:
a-b → ab
ab → ab
=> equal

```

### `delegue` vs `délégue`

```plaintext
MdxBuilder:
e / é
=> different

MdictUtils:
e / é
=> different

```

两者都不会把 `é` 自动变成 `e`。

因此：

```plaintext
delegue...
délégue...

```

应该是两个连续 block。

之前看到的：

```plaintext
delegue
délégué
délégue
...

```

**不是 MdictUtils 的排序规则。** 这点已经通过真实 Python + 真实 MDX 双重验证。

* * *
