はじめに
macOS から貰ったファイル、名前は正しいのに読めない! (File Not Found になる)っていう経験ありませんか?
僕はあります!!
今回は、そのお話です。
Unicode の表現
Unicode には、文字そのものを どのような内部表現で扱うか を定めた複数の形式があります。
これは文字コードそのものではなく、「同じ文字をどのように構成して表すか」を決める仕組みで、Unicode normalization(正規化) と呼ばれます。
たとえば「ダンダダン」という語は、OS によって次のように内部表現が異なります。
ダンダダン
タ゛ンタ゛タ゛ン
Windows では「ダ」が 1 文字の合成済み文字(濁点込みの文字)として扱われます。
一方 macOS では、「タ」+「゛」のように 基底文字+結合文字(濁点) の 2 字で表現されます。
この 表記ゆれを同じ形式へ統一する処理 を、Unicode における 正規化(Normalization) と呼びます。
正規化の方法は以下の4つがあります。
| 名称 | 正式な名称 | 意味 | 例 |
|---|---|---|---|
| NFC | Normalization Form Canonical Composition | 合成文字を使う | パ |
| NFD | Normalization Form Canonical Decomposition | 結合文字を使う | ハ゜ |
| NFKC | Normalization Form Compatibility Composition | 互換文字を合成文字にする | ① → 1 |
| NFKD | Normalization Form Compatibility Decomposition | 互換文字を結合文字にする | ㍻ → 平成 |
この記事中では結合文字の表現を「文字+ ゛」または「文字+ ゜」として表します。
本来は別の文字です(U+3099, U+309A)。
記事中で本来の文字を使うと前の文字と結合してしまうためです。
なお、NFKC / NFKD については、今回は扱いません
問題点
Windows は NFC を採用し、macOS は NFD を採用しています。
この違いがあるため、macOS から受け取った日本語ファイル名を Windows でそのまま扱おうとすると、正常に認識できない場合があります。
たとえば、macOS では次のように保存されている文字列の場合
タ゛ンタ゛タ゛ン.png
// AFilename が "タ゛ンタ゛タ゛ン.png" の場合、判定に失敗する
if AFilename = 'ダンダダン.png' then
Writeln('OK')
else
Writeln('NG'); // こちらが表示される
Windows では「ダ」を合成文字として扱うため、そのままでは一致判定にも失敗し、ファイルが存在しない扱いになります。
正しく扱うためには、Windows の NFC 形式を macOS の NFD 形式に変換する必要があります。
ダンダダン.png
↓
タ゛ンタ゛タ゛ン.png
解決方法
各 OS には、文字列を NFC/NFD/NFKC/NFKD に変換する API が用意されています。
外部から受け取ったファイル名を、自分が実行しているプラットフォームの 標準的な正規化形式に揃えることで、正しくファイルを読み書きできるようになります。
Unicode 正規化に用いる API
| OS | API |
|---|---|
| Windows | NormalizeString |
| macOS & iOS | precomposedStringWithCanonicalMapping / decomposedStringWithCanonicalMapping |
| Android | Normalizer.normalize |
例えば、Windows だったら下記の様になります。
function ToNFC(const AText: String): String;
begin
var TextLen := Length(AText);
// バッファサイズを取得
var Len :=
NormalizeString(
NormalizationC,
PChar(AText),
TextLen,
nil,
0
);
if Len < 1 then
Exit;
// 変換
SetLength(Result, Len);
Len :=
NormalizeString(
NormalizationC,
PChar(AText),
TextLen,
PChar(Result),
Length(Result)
);
if Len > 0 then
SetLength(Result, Len);
end;
ライブラリ化
NFC / NFD の変換を一般化して、全てのプラットフォームに対応したソースコードがこちらです。
PK.Utils.UnicodeNormalizer.pas
(*
* Unicode 正規化ライブラリ
*
* PLATFORMS
* Windows / macOS / iOS / Android
*
* ENVIRONMENT
* Delphi 12.x
*
* LICENSE
* Copyright (c) 2025 HOSOKAWA Jun
* Released under the MIT license
* http://opensource.org/licenses/mit-license.php
*
* HISTORY
* 2025/09/20 Ver 1.0.0 First Release
*
* USES
* // Normalize
* var NFC := TUnicodeNormalizer.Normalize('タ゛', TUnicodeForm.NFC);
* var NFD := TUnicodeNormalizer.Normalize('ダ', TUnicodeForm.NFD);
*
* // Path Normalize
* var Path := TUnicodeNormalizer.GetEffectivePath('C:\temp\タ゛タ゛.png');
*
* Programmed by HOSOKAWA Jun
*)
unit PK.Utils.UnicodeNormalizer;
interface
type
TUnicodeForm = (NFC, NFD);
TUnicodeNormalizer = record
public
class function Normalize(
const AText: string;
const AForm: TUnicodeForm): string; static;
class function GetEffectivePath(const APath: string): string; static;
end;
implementation
uses
System.Classes
, System.IOUtils
, System.SysUtils
{$IFDEF MSWINDOWS}
, Winapi.Windows
{$ENDIF}
{$IFDEF OSX}
, Macapi.Foundation
, Macapi.Helpers
{$ENDIF}
{$IFDEF IOS}
, iOSapi.Foundation
, Macapi.Helpers
{$ENDIF}
{$IFDEF ANDROID}
, Androidapi.Helpers
, Androidapi.JNI.JavaTypes
, Androidapi.JNIBridge
{$ENDIF}
;
{$IFDEF ANDROID}
// Normalizer 定義
type
// java.text.Normalizer$Form
JNormalizer_Form = interface;
JNormalizer_FormClass = interface(JObjectClass)
['{D7431A01-F898-45EF-9A9C-A364F2459611}']
function _GetNFC: JNormalizer_Form; cdecl;
function _GetNFD: JNormalizer_Form; cdecl;
function _GetNFKC: JNormalizer_Form; cdecl;
function _GetNFKD: JNormalizer_Form; cdecl;
property NFC: JNormalizer_Form read _GetNFC;
property NFD: JNormalizer_Form read _GetNFD;
property NFKC: JNormalizer_Form read _GetNFKC;
property NFKD: JNormalizer_Form read _GetNFKD;
end;
[JavaSignature('java/text/Normalizer$Form')]
JNormalizer_Form = interface(JObject)
['{D554E99B-D023-432C-99F6-18949EC5378D}']
end;
TJNormalizer_Form = class(
TJavaGenericImport<JNormalizer_FormClass, JNormalizer_Form>)
end;
// java.text.Normalizer
JNormalizerClass = interface(JObjectClass)
['{9DAEA773-1D7A-4AA7-9EE8-4733E7BA958D}']
function normalize(
cs: JCharSequence;
form: JNormalizer_Form): JString; cdecl;
end;
[JavaSignature('java/text/Normalizer')]
JNormalizer = interface(JObject)
['{8BA22D1F-4177-4B2D-A429-32F941CE3FDC}']
end;
TJNormalizer = class(TJavaGenericImport<JNormalizerClass, JNormalizer>) end;
{$ENDIF ANDROID}
{ TUnicodeNormalizer }
class function TUnicodeNormalizer.Normalize(
const AText: string;
const AForm: TUnicodeForm): string;
begin
Result := AText;
if AText.IsEmpty then
Exit;
{$IFDEF MSWINDOWS}
var Form := NormalizationC;
if AForm = TUnicodeForm.NFD then
Form := NormalizationD;
var TextLen := Length(AText);
var Len := NormalizeString(Form, PChar(AText), TextLen, nil, 0);
if Len < 1 then
Exit;
SetLength(Result, Len);
Len :=
NormalizeString(
Form,
PChar(AText),
TextLen,
PChar(Result),
Length(Result)
);
if Len > 0 then
SetLength(Result, Len);
{$ENDIF}
{$IFDEF MACOS} // macOS & iOS
var NS := StrToNSStr(AText);
var Form: NSString;
if AForm = TUnicodeForm.NFC then
Form := NS.precomposedStringWithCanonicalMapping
else
Form := NS.decomposedStringWithCanonicalMapping;
Result := NSStrToStr(Form);
{$ENDIF}
{$IFDEF ANDROID}
begin
var Form := TJNormalizer_Form.JavaClass.NFC;
if AForm = TUnicodeForm.NFD then
Form := TJNormalizer_Form.JavaClass.NFD;
var Src := StrToJCharSequence(AText);
var Dest: JString := TJNormalizer.JavaClass.normalize(Src, Form);
Result := JStringToString(Dest);
end;
{$ENDIF}
{$IFDEF LINUX}
begin
// 何もしない
end;
{$ENDIF}
end;
class function TUnicodeNormalizer.GetEffectivePath(
const APath: string): string;
function TryExists(const APath: string): string;
begin
if TFile.Exists(APath) or TDirectory.Exists(APath) then
Result := APath
else
Result := '';
end;
begin
Result := '';
if APath.IsEmpty then
Exit;
// Platform Default
// Windows で製作したファイルを Windows で読む場合など
var NP := TryExists(APath);
if not NP.IsEmpty then
Exit(NP);
// NFC
NP := TryExists(Normalize(APath, TUnicodeForm.NFC));
if not NP.IsEmpty then
Exit(NP);
// NFD
NP := TryExists(Normalize(APath, TUnicodeForm.NFD));
if not NP.IsEmpty then
Exit(NP);
end;
end.
使い方
使い方は どの OS でも同じです。
TUnicodeNormalizer に文字列と正規化形式を渡すだけで変換できます。
// Normalize
var NFC := TUnicodeNormalizer.Normalize('タ゛', TUnicodeForm.NFC);
var NFD := TUnicodeNormalizer.Normalize('ダ', TUnicodeForm.NFD);
また、ファイル名向けに特化したメソッドも用意しています。
// NFC / NFD のどちらかに正規化し、ファイルが存在した方を返す
var Path := TUnicodeNormalizer.GetEffectivePath('C:\temp\タ゛タ゛.png');
このメソッドを通しておくことで Windows / macOS 混在環境でも扱えるパスへ変換できます。
最後に
macOS でゲームの素材やサウンドファイルなどを作る事は日常茶飯事です。
本当ならファイル名をアルファベットに統一してくれれば済む話ですが、どうしても日本語ファイル名を使わざるを得ないケースがあります。
そのときに問題となるのが、Windows(NFC)と macOS(NFD)という内部表現の違いです。
外見が同じでも OS によってファイル名が異なる扱いになるため、正規化を行わないままやり取りするとファイルが読めない、存在しない扱いになる、比較が一致しない――といった問題が発生します。
チーム内に Windows と macOS の両方が混在している場合は、ぜひこれらのメソッドを活用して、受け取った文字列を 適切な Unicode 正規化形式へ変換するようにしてみてください。