しばやん雑記

Azure とメイドさんが大好きなフリーランスのプログラマーのブログ

P/Invoke と Win32 API の両方で文字コードの罠にはまった話

最近真面目に作っている WinQuickLook は Win32 API と COM の塊なので、P/Invoke で割とはまりました。

特に文字コード周りが致命的だったので、簡単に内容をまとめておきます。

DllImport のデフォルト文字コードは ANSI

DllImport をデフォルトのまま使うと文字コードが ANSI になるので、Unicode がデフォルトの NT 系 Windows では Unicode <-> ANSI 変換が必要になるので無駄な処理となります。なので、最低でも CharSet.Auto を明示的に指定しましょう。

例としてシェルからファイルの情報を取得する SHGetFileInfo API の P/Invoke 定義を書きます。

SHGetFileInfoA function (shellapi.h) - Win32 apps | Microsoft Learn

必要な部分だけ MSDN から持ってきました。単純な 1 つの構造体と 1 つの API ですが、NT 系の Windows では ANSI と Unicode 版の両方が実装されています。

typedef struct _SHFILEINFO {
  HICON hIcon;
  int   iIcon;
  DWORD dwAttributes;
  TCHAR szDisplayName[MAX_PATH];
  TCHAR szTypeName[80];
} SHFILEINFO;

DWORD_PTR SHGetFileInfo(
  _In_    LPCTSTR    pszPath,
          DWORD      dwFileAttributes,
  _Inout_ SHFILEINFO *psfi,
          UINT       cbFileInfo,
          UINT       uFlags
);

ANSI として使う場合は何も考えずに string にマッピングすれば良いですが、ANSI と Unicode の両方で使えるようにするためには、多少 CharSet などの設定を追加する必要があります。

C++ の定義から C# の P/Invoke 定義を作成すると以下のようになります。

[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Auto)]
public struct SHFILEINFO
{
    public IntPtr hIcon;
    public int iIcon;
    public uint dwAttributes;

    [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 260)]
    public string szDisplayName;

    [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 80)]
    public string szTypeName;
}

[DllImport("shell32.dll", CharSet = CharSet.Auto)]
public static extern IntPtr SHGetFileInfo(
    [In, MarshalAs(UnmanagedType.LPTStr)] string pszPath,
    uint dwFileAttributes,
    [In, Out] ref SHFILEINFO psfi,
    uint cbFileInfo,
    uint uFlags
);

TCHAR の配列は MarshalAs 属性で UnmanagedType.ByValTStr と SizeConst を明示的に指定します。

そして重要なのが StructLayout での CharSet 指定です。当然ながら TCHAR 配列の場合はポインタと異なり、ANSI と Unicode でバイト数が変わってくるので、明示的に Auto を付けます。

STRRET を string に変換すると文字化けする時がある

COM を使って IShellFolder.GetDisplayNameOf メソッドを呼び出して取得した STRRET を StrRetToBuf で string に変換すると、Unicode の合成文字っぽい部分が文字化けしてしまいました。

STRRET は union を使って Unicode と ANSI の両方を返せるようになっているのですが、何故か COM のくせに GetDisplayNameOf で ANSI を返すことがあるみたいです。

STRRET (shtypes.h) - Win32 apps | Microsoft Learn

更に StrRetToBuf 関数を DllImport のデフォルトで定義すると、先程のように ANSI 版が使われるようなので文字化けが発生することがあるようです。

typedef struct _STRRET {
  UINT  uType;
  union {
    LPWSTR pOleStr;
    UINT   uOffset;
    CHAR   cStr[MAX_PATH];
  };
} STRRET, *LPSTRRET;

ちなみに OLECHAR は Unicode です。COM では基本的に全て Unicode が使われています。

この STRRET 構造体と StrRetToBuf 関数を C# で定義すると以下のようになります。

[StructLayout(LayoutKind.Explicit, Size = 264)]
public struct STRRET
{
    [FieldOffset(0)]
    public uint uType;

    [FieldOffset(4)]
    public IntPtr pOleStr;

    [FieldOffset(4)]
    public uint uOffset;

    [FieldOffset(4)]
    public IntPtr cStr;
}

[DllImport("shlwapi.dll", CharSet = CharSet.Auto)]
public static extern void StrRetToBuf(
    [In, Out] ref STRRET pstr,
    [In] IntPtr pidl,
    [Out, MarshalAs(UnmanagedType.LPTStr)] StringBuilder pszBuf,
    uint cchBuf
);

[DllImport("shlwapi.dll")]
public static extern void StrRetToBSTR(
    [In, Out] ref STRRET pstr,
    [In] IntPtr pidl,
    [Out, MarshalAs(UnmanagedType.BStr)] out string pbstr
);

STRRET から文字列に変換する関数として、他にも BSTR に変換してくれる StrRetToBSTR 関数があるので、こっちを使うという手もあります。BSTR も Unicode なので MarshalAs で指定するだけです。