NFD / NFC Unicode 归一化编码记录

作者:Administrator 发布时间: 2026-05-17 阅读量:14 评论数:0

概述

macOS (APFS/HFS+) 文件系统强制使用 NFD(Normalization Form D,分解形式)存储文件名,而 Windows (NTFS) 使用 NFC(Normalization Form C,合成形式)。当同一批文件从 Mac 拷贝到 Windows,之后又从 Windows 源再次导入时,就会产生"看起来一模一样、实际上字节不同"的重复文件。

编码示例平台
NFD (分解)ガ = U+30AB + U+3099macOS
NFC (合成) = U+30ACWindows

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 重复文件问题,可以:

  1. 跨平台文件交换使用压缩包:ZIP 或 tar 归档会保留原始文件名编码,避免文件系统干预
  2. 统一 Git 配置:在 macOS 开发机上确保 core.precomposeunicode = true
  3. 使用同步工具时留意:rsync、Syncthing 等工具可以配置文件名编码转换
  4. 命名规范:在跨团队项目中约定仅使用 ASCII 范围内的字符命名文件(A-Z、a-z、0-9、-、_),完全避开此问题
  5. 定期扫描:使用本文的脚本定期扫描共享目录,及早发现重复文件

经验总结

  1. PowerShell 的 -eq 做语义比较:默认字符串比较会在比较前归一化,这对用户友好但对检测文件编码差异是陷阱。必须用 [System.StringComparison]::Ordinal

  2. 文件系统 API 的选择Get-ChildItem 在 PowerShell 中可能对返回的文件名做处理。如需读取原始文件名,应使用:

    • [System.IO.Directory]::EnumerateFileSystemEntries()
    • cmd /c "dir <path> /b"
  3. NFD/NFC 不仅限于日文:任何带组合字符的 Unicode 文本都可能受影响,包括拉丁字母加重音符号(如 é 可以是 NFC 的 U+00E9,或 NFD 的 e U+0065 + ´ U+0301)、韩文、阿拉伯文等。

  4. 保留 NFC 的理由:Windows 生态系统广泛使用 NFC,保留 NFC 副本在 Windows 上兼容性更好。但如果是给 Mac 用户发文件,保留 NFD 可能更合适。

  5. 应用场景:Telegram 下载文件、跨平台文件同步(如从 Mac 下载再 Windows 导出)、NAS 共享目录等场景最容易出现 NFD/NFC 重复文件。

参考链接

相关文件清单

文件说明
normalize-filenames.ps1生产级 PowerShell 去重工具
normalize-filenames.js等价 Node.js 版本
normalize-filenames.pyPython 版本
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项目说明文档

评论