Unicode NFD 规范与俄日韩等文字命名文件的字素分解现象

Unicode NFD 规范与俄日韩等文字命名文件的字素分解现象

版权声明:原创文章,未经授权,请勿转载

Windows 接收来自 macOSiOS 以俄语、日语、韩语、瑞典语等字符命名的文件时,可能会出现一种名为 "字素分解" 的现象:

nfd_filename_in_windows.png

看起来就像是: 韩文被拆分成一个个符号或“字母”,并且将这个被拆解的字符串粘贴到 Chrome、Edge 浏览器以后还可以得到正确的显示!

出现这个问题,主要是 macOSiOSWindows 文件系统所采用的 Unicode 归一化(Normalization)规范不同所导致。

关于 Normalization 的翻译

大多数文档中为 正规化,但私以为归一化翻译更优

简单的说,就是在 Unicode 中会存在 同一个字符(视觉上的) 可能具有不同的代码点的现象,这就导致了两个看起来一模一样的文件路径,按照字符串逐字节的方式对比,会出现不匹配的情况。

例如:

  • 字符 Å,可以被编码为: U+00C5(标准名:LATIN CAPITAL LETTER A WITH RING ABOVE),也可以被编码为: U+212B(标准名:ANGSTROM SIGN)。
  • 日文 パ 可以被编码为: U+30d1(パ),也可以被编码为 U+30CF,U+309A (パ)
  • 韩文 신 可以被编码为: U+C2E0(신),也可以被编码为 U+C2E0,U+1175,U+11AB (신)

为了解决这个问题,Unicode 提出了 等价性归一化 概念,即 Unicode 部分代码点之间,实际上是完全等价的。而对不同代码点表示的等价字符,能够在匹配时得到一致的结果,就需要通过归一化,将代码点转换统一的标准形式 [1]

正规形式有两种,一种叫完全组合(Fully Composed),即尽可能将多个码点替换为单个码点;另一种叫完全分解(Fully Decomposed),即尽可能将单个码点分解为多个码点。

其中最常见的两个就是:

  • NFC(Normalization Form Canonical Composition)
    • NFC 规范,将字符组合为标准的组合形式。
    • NFC 规范,也是 大多数平台的文件系统采用的归一化规范,如:Windows 和大多数 Linux 发行版。
    • 示例: 신(신)
  • NFD(Normalization Form Canonical Decomposition)
    • NFD 规范,它将字符分解为基本字符和组合字符。
    • NFD 规范,是 macOSiOSHFS+ 文件系统采用的归一化规范[2] [3]
    • 示例: 신(신)

而文章开头描述的问题,是因为 macOSiOSHFS+ 文件系统采用了 NFD 规范来存储的文件名,与 Windows 采用的规范 NFC 不同,因此导致的问题。

解决方案也挺简单,只需要将文件名的字符串转换为 NFC 规范即可,各位看官也可以自行在 Unicode Normalization Test 工具上测试。

NFC & NFD 规范转换

Python 示例

import unicodedata as ud
a = b'\xc2\xe0'.decode('utf-16-be')
b = b'\x11\x09\x11\x75\x11\xab'.decode('utf-16-be')
print(f'a:{a}, b:{b}, a==b:{a==b}')                      # 输出: a:신, b:신, a==b:False
print(f'a:{a}, b:{b}, a==b:{a==ud.normalize("NFC", b)}') # 输出: a:신, b:신, a==b:True
print(f'a:{a}, b:{b}, a==b:{b==ud.normalize("NFD", b)}') # 输出: a:신, b:신, a==b:True

Win32 C++ 示例

#include <format>
#include <iostream>
#include "windows.h"

inline std::wstring NormalizeWString(const std::wstring& string, std::string form = "NFC")
{
    // Convert the form string to uppercase
    for (char& c : form)
        c = std::toupper(c);

    // Determine the normalization form
    NORM_FORM normForm = (form == "NFD") ? NormalizationD : NormalizationC;

    // Get the required buffer size for the normalized string
    int bufferSize = NormalizeString(normForm, string.c_str(), -1, nullptr, 0);

    // Allocate buffer
    std::wstring normalizedString;
    normalizedString.resize(bufferSize);

    // Normalize the string
    NormalizeString(normForm, string.c_str(), -1, &normalizedString[0], bufferSize);

    // Remove the null-terminator
    return normalizedString.c_str();
}

int main(int argc, char* argv[])
{
    // 两个字符串在Web浏览器中看起来可能没有区别,但实际上是完全不同的字符串,
    // 这点可以复制到记事本中查看。
    std::wstring nfc = L"신민아  조정석-나의 사랑 나의 신부 - 커플송";
    std::wstring nfd = L"신민아  조정석-나의 사랑 나의 신부 - 커플송";

    bool result1 = NormalizeWString(nfd, "NFC") == nfc;
    bool result2 = NormalizeWString(nfc, "NFD") == nfd;

    std::cout <<
        std::format("nfc-len: {}, nfd-len: {}, result1: {}, result2: {}",
            nfc.length(), nfd.length(), result1, result2)
        << std::endl;

    return 0;
}

// 需要c++20, 并在编译选项中加入 "/utf-8" 否则会出现: 字符不能在当前代码页表示的错误
// 输出如下:
// nfc-len: 26, nfd-len: 51, result1: true, result2: true

参考资料