概述
macOS (APFS/HFS+) 文件系统强制使用 NFD(Normalization Form D,分解形式)存储文件名,而 Windows (NTFS) 使用 NFC(Normalization Form C,合成形式)。当同一批文件从 Mac 拷贝到 Windows,之后又从 Windows 源再次导入时,就会产生"看起来一模一样、实际上字节不同"的重复文件。
| 编码 | 示例 | 平台 |
|---|---|---|
| NFD (分解) | ガ = U+30AB + U+3099 | macOS |
| NFC (合成) | ガ = U+30AC | Windows |
Unicode 归一化原理
Unicode 有多种等价表示方式。以日文假名 ガ 为例:
- NFC (合成):
ガ= 单个码点 U+30AC - NFD (分解):
ガ= 基字カ(U+30AB) + 浊点゙(U+3099)
这两个字符串在视觉上完全一致,但在字节层面是不同的。默认字符串比较在某些上下文中会将它们视为相等(如 PowerShell 的 -eq 操作符),但系统调用和序数比较(Ordinal Comparison)会区分它们。
Linux 平台的情况
Linux 主流文件系统(ext4、XFS、Btrfs)不强制任何归一化形式,文件名以原始字节序列存储。这意味着在 Linux 上你可以创建 NFD 和 NFC 文件名的任意组合。但由于大部分 Linux 工具和桌面环境偏向 NFC,混用仍可能导致用户困惑。
Git 的 NFD/NFC 处理
macOS 上的 Git 在默认配置下会对文件系统返回的文件名进行 NFC 归一化,通过 core.precomposeunicode 配置控制:
# macOS 默认已启用(Git 2.18+)
git config --global core.precomposeunicode true
提示:当
core.precomposeunicode = true时,Git 在 macOS 上会把从文件系统读取的 NFD 文件名转换为 NFC,再进行比较和存储。这避免了 Git 仓库中出现 NFD/NFC 重复问题,但也意味着同一个仓库在 macOS(开启 compose)和 Linux(直接存储)上的 .git 对象可能不一致。建议跨平台团队统一为true。
常见 Git 场景的 NFD/NFC 陷阱:
git status显示未修改的文件为 "modified"(实际是 NFD/NFC 差异)git mv重命名文件时,新旧文件名看似相同但编码不同- CI/CD 中拉取 macOS 推送的仓库时文件校验和(如 SHA256)不匹配
检测方法
核心判断逻辑
一个文件名可以通过对其执行 NFD 和 NFC 归一化并与原字符串进行序数比较(Ordinal)来判断编码形式:
$Ordinal = [System.StringComparison]::Ordinal
function Test-IsNfd([string]$Name) {
$nfc = $Name.Normalize([System.Text.NormalizationForm]::FormC)
$nfd = $Name.Normalize([System.Text.NormalizationForm]::FormD)
$eqNfd = [System.String]::Equals($Name, $nfd, $Ordinal)
$eqNfc = [System.String]::Equals($Name, $nfc, $Ordinal)
return $eqNfd -and -not $eqNfc
}
function Test-IsNfc([string]$Name) {
$nfc = $Name.Normalize([System.Text.NormalizationForm]::FormC)
$nfd = $Name.Normalize([System.Text.NormalizationForm]::FormD)
$eqNfd = [System.String]::Equals($Name, $nfd, $Ordinal)
$eqNfc = [System.String]::Equals($Name, $nfc, $Ordinal)
return $eqNfc -and -not $eqNfd
}
关键:必须使用
Ordinal比较。PowerShell 默认的-eq操作符在比较字符串时也进行归一化,会将 NFD 和 NFC 视为相等,导致检测失败。
JavaScript 版本
function isNfd(filename) {
return filename === filename.normalize('NFD') && filename !== filename.normalize('NFC');
}
function isNfc(filename) {
return filename === filename.normalize('NFC') && filename !== filename.normalize('NFD');
}
Python 版本
跨平台的 Python 版本,适合集成到自动化脚本中:
import unicodedata
import os
def is_nfd(filename: str) -> bool:
"""判断文件名是否为 NFD 编码"""
nfc = unicodedata.normalize('NFC', filename)
nfd = unicodedata.normalize('NFD', filename)
return filename == nfd and filename != nfc
def is_nfc(filename: str) -> bool:
"""判断文件名是否为 NFC 编码"""
nfc = unicodedata.normalize('NFC', filename)
nfd = unicodedata.normalize('NFD', filename)
return filename == nfc and filename != nfd
def scan_directory(path: str):
"""扫描目录,按 NFD/NFC/Normal 分类文件"""
result = {"nfd": [], "nfc": [], "normal": []}
for f in os.listdir(path):
if is_nfd(f):
result["nfd"].append(f)
elif is_nfc(f):
result["nfc"].append(f)
else:
result["normal"].append(f)
return result
调试过程
在 G:\telegram\30\audio\se 目录中发现了大量因 NFD/NFC 差异导致的重复文件(主要为日文音频文件,含片假名+浊点/半浊点字符)。调试过程经历了多个阶段。
debug-nfd.ps1 — 初步验证
筛选含日文字符的文件,逐一检查 NFD/NFC 状态,输出前 2 个字符的 Unicode 码点 HEX。
debug-nfd2.ps1 — 全量扫描
扫描目录下所有文件,记录所有 NFD/NFC 差异的文件,发现大量差异。
debug-nfd3.ps1 — 绕过 PowerShell 归一化
怀疑 PowerShell 的 Get-ChildItem 可能对文件名做了归一化处理,改用 .NET 底层 API:
$nativeNames = [System.IO.Directory]::EnumerateFileSystemEntries($dir)
debug-nfd4.ps1 — cmd /b 绕过
进一步使用 cmd /c "dir <path> /b" 获取原始文件名,彻底绕过 PowerShell 可能对文件名做的任何预处理。
比较行为测试 (test-normalize.ps1, test-ordinal.ps1)
确认了关键发现:PowerShell -eq 操作符执行语义相等比较(会归一化后再比较),而 Ordinal 比较才反映字节级差异。
--- Default comparison (-eq) ---
NFD == NFC? True ← 语义相等,但字节不同
NFD == NFD? True
--- Ordinal comparison ---
NFD == NFC? False ← 字节不同,正确区分
NFD == NFD? True
NFD == NFD? True
文件系统检测 (test-fs-ordinal.ps1)
在目标目录上使用 Ordinal 比较进行全量统计,确认了 25 对 NFD/NFC 重复文件。
清理工具
PowerShell 脚本 (normalize-filenames.ps1)
功能完整的去重工具:
- 扫描目录,按 NFD/NFC/normal 分类文件
- 通过跨平台归一化配对重复文件
- 支持 dry-run 预览
- 默认保留 NFC(Windows 兼容性更好),可选
-KeepNfd - 纯 PowerShell,无运行时依赖
# 预览(不删除)
.\normalize-filenames.ps1 "D:\downloads"
# 执行清理(保留 NFC)
.\normalize-filenames.ps1 "D:\downloads" -Delete
# 或保留 NFD
.\normalize-filenames.ps1 "D:\downloads" -Delete -KeepNfd
Node.js 脚本 (normalize-filenames.js)
等价的 Node.js 版本,适用于需要跨平台运行或嵌入其他工具链的场景:
node normalize-filenames.js "G:\telegram\30\audio\se" # 仅预览
node normalize-filenames.js "G:\telegram\30\audio\se" --delete # 执行清理
node normalize-filenames.js "G:\telegram\30\audio\se" --keep-nfd --delete
Python 版本
跨平台的 Python 版本,适合集成到自动化脚本中:
import unicodedata
import os
import argparse
from collections import defaultdict
def normalize_classify(filename: str) -> str:
"""返回文件名归一化类型: nfd / nfc / normal"""
nfc = unicodedata.normalize('NFC', filename)
nfd = unicodedata.normalize('NFD', filename)
is_nfc = (filename == nfc) and (filename != nfd)
is_nfd = (filename == nfd) and (filename != nfc)
return "nfd" if is_nfd else "nfc" if is_nfc else "normal"
def find_duplicates(files: list) -> list:
"""找到 NFD/NFC 配对重复文件"""
nfc_map = {}
nfd_files = []
for f in files:
nfc_normalized = unicodedata.normalize('NFC', f)
cls = normalize_classify(f)
if cls == "nfc":
nfc_map[nfc_normalized] = f
elif cls == "nfd":
nfd_files.append((nfc_normalized, f))
pairs = []
for norm_name, nfd_name in nfd_files:
if norm_name in nfc_map:
pairs.append((nfd_name, nfc_map[norm_name]))
return pairs
def main():
parser = argparse.ArgumentParser(description="NFD/NFC 重复文件清理工具")
parser.add_argument("directory", help="目标目录")
parser.add_argument("--delete", action="store_true", help="执行删除(默认仅预览)")
parser.add_argument("--keep-nfd", action="store_true", help="保留 NFD 版本(默认保留 NFC)")
args = parser.parse_args()
files = os.listdir(args.directory)
classified = defaultdict(list)
for f in files:
classified[normalize_classify(f)].append(f)
print(f"Total: {len(files)}")
print(f"NFD (macOS): {len(classified['nfd'])}")
print(f"NFC (Win): {len(classified['nfc'])}")
print(f"Normal: {len(classified['normal'])}")
pairs = find_duplicates(files)
print(f"Duplicates: {len(pairs)} pairs")
keep = "nfd" if args.keep_nfd else "nfc"
for nfd_name, nfc_name in pairs:
to_delete, to_keep = (nfc_name, nfd_name) if keep == "nfd" else (nfd_name, nfc_name)
if args.delete:
os.remove(os.path.join(args.directory, to_delete))
print(f"DELETED: {to_delete}")
else:
print(f"DELETE: {to_delete}")
print(f"KEEP: {to_keep}")
if __name__ == "__main__":
main()
实际清理结果
在 G:\telegram\30\audio\se 目录中的统计:
Total: 188
NFD (macOS): 25
NFC (Win): 25
Normal: 138
Pairs: 25
To free: 2069.1 KB
示例文件对:
DELETE: スタジアムファンファーレ13.ogg (NFD: ジ = U+30B7 + U+3099)
KEEP: スタジアムファンファーレ13.ogg (NFC: ジ = U+30B8)
预防措施
要从源头上减少 NFD/NFC 重复文件问题,可以:
- 跨平台文件交换使用压缩包:ZIP 或 tar 归档会保留原始文件名编码,避免文件系统干预
- 统一 Git 配置:在 macOS 开发机上确保
core.precomposeunicode = true - 使用同步工具时留意:rsync、Syncthing 等工具可以配置文件名编码转换
- 命名规范:在跨团队项目中约定仅使用 ASCII 范围内的字符命名文件(A-Z、a-z、0-9、-、_),完全避开此问题
- 定期扫描:使用本文的脚本定期扫描共享目录,及早发现重复文件
经验总结
-
PowerShell 的
-eq做语义比较:默认字符串比较会在比较前归一化,这对用户友好但对检测文件编码差异是陷阱。必须用[System.StringComparison]::Ordinal。 -
文件系统 API 的选择:
Get-ChildItem在 PowerShell 中可能对返回的文件名做处理。如需读取原始文件名,应使用:[System.IO.Directory]::EnumerateFileSystemEntries()cmd /c "dir <path> /b"
-
NFD/NFC 不仅限于日文:任何带组合字符的 Unicode 文本都可能受影响,包括拉丁字母加重音符号(如
é可以是 NFC 的 U+00E9,或 NFD 的eU+0065 +´U+0301)、韩文、阿拉伯文等。 -
保留 NFC 的理由:Windows 生态系统广泛使用 NFC,保留 NFC 副本在 Windows 上兼容性更好。但如果是给 Mac 用户发文件,保留 NFD 可能更合适。
-
应用场景:Telegram 下载文件、跨平台文件同步(如从 Mac 下载再 Windows 导出)、NAS 共享目录等场景最容易出现 NFD/NFC 重复文件。
参考链接
- Unicode Normalization Forms (Unicode 标准)
- Git
core.precomposeunicode文档 - macOS File System Programming Guide — Unicode
- PowerShell
System.StringComparison枚举 - Python
unicodedata.normalize()文档
相关文件清单
| 文件 | 说明 |
|---|---|
normalize-filenames.ps1 | 生产级 PowerShell 去重工具 |
normalize-filenames.js | 等价 Node.js 版本 |
normalize-filenames.py | Python 版本 |
debug-nfd.ps1 | 初步验证(筛选日文字符) |
debug-nfd2.ps1 | 全量扫描所有文件 |
debug-nfd3.ps1 | 使用 .NET 底层 API 绕过归一化 |
debug-nfd4.ps1 | 使用 cmd /b 获取原始文件名 |
test-normalize.ps1 | .NET 归一化检测行为测试 |
test-ordinal.ps1 | 默认比较 vs Ordinal 比较测试 |
test-fs-ordinal.ps1 | 文件系统 + Ordinal 比较全量统计 |
normalize-filenames-repo/README.md | 项目说明文档 |