10
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

[Delphi] Unicode の正規化について

Last updated at Posted at 2025-11-30

はじめに

macOS から貰ったファイル、名前は正しいのに読めない! (File Not Found になる)っていう経験ありませんか?
僕はあります!!

今回は、そのお話です。

Unicode の表現

Unicode には、文字そのものを どのような内部表現で扱うか を定めた複数の形式があります。
これは文字コードそのものではなく、「同じ文字をどのように構成して表すか」を決める仕組みで、Unicode normalization(正規化) と呼ばれます。

たとえば「ダンダダン」という語は、OS によって次のように内部表現が異なります。

windows
ダンダダン
macOS
タ゛ンタ゛タ゛ン

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 では次のように保存されている文字列の場合

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 正規化形式へ変換するようにしてみてください。

10
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
10
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?