unoh.github.com

Mac OS X上のUnicode

2007-09-04 10:05:39 +0900

yamaokaです。

フォト蔵のアップロード画面では、 選択されたファイルのパスから拡張子を除いたファイル名だけを取得、 写真のタイトルの初期値に設定するということをしています(JavaScriptで)。

ある日、ユーザ様からお問い合わせがありました。 「サンバの季節.jpg」というファイルをアップロードしたところ、 タイトルが「サンハ」になってしまったとのこと。 Mac OS Xで、ブラウザはSafariをお使いでした。 同じ環境で試してみたところ、再現性を確認。 画面上ではタイトルに「サンバの季節」と設定されているにも関わらず、 実際に登録されるタイトルは「サンハ」になってしまいます。

問題が発生するのはタイトルをファイルのパスからJavaScriptで編集した場合だけで、 手で「サンバの季節」と直接入力した場合は、 何も問題なく登録処理を行うことができました。

Macでフォト蔵にアップロード
Macでフォト蔵にアップロード posted by (C)フォト蔵

Mac OS Xでは、ファイルシステムでUnicodeが用いられています。 いわゆるUTF-8なのですが、一般に言うところのUTF-8とは違います。 Macをお持ちの方は、「iconv -l」とTerminalからコマンドを実行してみてください。 「UTF-8-MAC」という表示が見つかるかと思います。

一般的なUTF-8では、文字はNormalization Form C(NFC)で 符号化正規化されています。 しかし、Mac OS Xのファイルシステムで用いられているUTF-8 (以下、便宜的にUTF-8-MACと呼びます)では、 文字はNormalization Form D(NFD)で符号化正規化されます。 日本語を扱う際にNFCとNFDでどこが違うかというと、 ひらがな・カタカナの濁点・半濁点の扱いです。 UTF-8では「が」は「U+304C」(HIRAGANA LETTER GA)ですが、 UTF-8-MACでは「U+304B U+3099」(HIRAGANA LETTER KA、COMBINING KATAKANA-HIRAGANA VOICED SOUND MARK) となります。Appleによる簡単な説明は以下のページにあります。

しかもややこしいことに、UTF-8で濁点をあらわすコードは「U+309B」(KATAKANA-HIRAGANA VOICED SOUND MARK)で、 UTF-8-MACから送信される「U+3099」(COMBINING KATAKANA-HIRAGANA VOICED SOUND MARK)とは別物です。 サーバ側できちんと変換できればよいのですが、 PHPのmbstringモジュールはUTF-8-MACをサポートしていません。 (Javaの場合、Java SE 6から導入された java.text.Normalizerクラス を使えば一発で変換できますね。)

また、Mac上で動作する他のブラウザの挙動を調査したところ (「サンバの季節」というファイルをタイトルを変更せずにアップロードした場合)、 それぞれの挙動が異なることがわかりました。

ブラウザフォーム上のタイトル表記登録されたタイトル
Safariサンバの季節サンハ
Firefoxサンバの季節サンバの季節
Operaサンハ゛の季節サンハ

Firefoxは内部的に変換処理を行うようになっているようです。 問題はSafariとOperaですね。 選択されたファイルのパスからJavaScriptで ファイル名を抜き出してタイトルに設定する部分で、 正しく扱えるような文字コードに変換することにしたいと思います。

基本的な流れとしては、UTF-8-MAC特有の「U+3099」(COMBINING KATAKANA-HIRAGANA VOICED SOUND MARK)、 「U+309A」(COMBINING KATAKANA-HIRAGANA SEMI-VOICED SOUND MARK)がファイル名に含まれている場合は、 その前の文字と結合して濁音・半濁音の文字にしてあげればいいでしょう (ひらがな・カタカナのみの暫定的な対処に過ぎませんが)。

変換用の文字テーブルを用意して、逐一変換していくかたちにしたいと思います。

というわけで、ライブラリを作りました。以下のように使用します。

var path = document.getElementById("yourfile").value();
if (Photozou.MacUnicode.isNFD()) {
  // MacのSafari、Operaの場合だけ変換
  path = Photozou.MacUnicode.normalizeToNFC(path);
}

以下、ライブラリのコードです。

if (typeof Photozou == "undefined") {
  Photozou = {};
}

Photozou.MacUnicode = {};

Photozou.MacUnicode.combinedKana = [
  0x304C,  // HIRAGANA LETTER GA
  0x304E,  // HIRAGANA LETTER GI
  0x3050,  // HIRAGANA LETTER GU
  0x3052,  // HIRAGANA LETTER GE
  0x3054,  // HIRAGANA LETTER GO
  0x3056,  // HIRAGANA LETTER ZA
  0x3058,  // HIRAGANA LETTER ZI
  0x305A,  // HIRAGANA LETTER ZU
  0x305C,  // HIRAGANA LETTER ZE
  0x305E,  // HIRAGANA LETTER ZO
  0x3060,  // HIRAGANA LETTER DA
  0x3062,  // HIRAGANA LETTER DI
  0x3065,  // HIRAGANA LETTER DU
  0x3067,  // HIRAGANA LETTER DE
  0x3069,  // HIRAGANA LETTER DO
  0x3070,  // HIRAGANA LETTER BA
  0x3073,  // HIRAGANA LETTER BI
  0x3076,  // HIRAGANA LETTER BU
  0x3079,  // HIRAGANA LETTER BE
  0x307C,  // HIRAGANA LETTER BO
  0x3071,  // HIRAGANA LETTER PA
  0x3074,  // HIRAGANA LETTER PI
  0x3077,  // HIRAGANA LETTER PU
  0x307A,  // HIRAGANA LETTER PE
  0X307D,  // HIRAGANA LETTER PO
  0x3094,  // HIRAGANA LETTER VU
  0x30AC,  // KATAKANA LETTER GA
  0x30AE,  // KATAKANA LETTER GI
  0x30B0,  // KATAKANA LETTER GU
  0x30B2,  // KATAKANA LETTER GE
  0x30B4,  // KATAKANA LETTER GO
  0x30B6,  // KATAKANA LETTER ZA
  0x30B8,  // KATAKANA LETTER ZI
  0x30BA,  // KATAKANA LETTER ZU
  0x30BC,  // KATAKANA LETTER ZE
  0x30BE,  // KATAKANA LETTER ZO
  0x30C0,  // KATAKANA LETTER DA
  0x30C2,  // KATAKANA LETTER DI
  0x30C5,  // KATAKANA LETTER DU
  0x30C7,  // KATAKANA LETTER DE
  0x30C9,  // KATAKANA LETTER DO
  0x30D0,  // KATAKANA LETTER BA
  0x30D3,  // KATAKANA LETTER BI
  0x30D6,  // KATAKANA LETTER BU
  0x30D9,  // KATAKANA LETTER BE
  0x30DC,  // KATAKANA LETTER BO
  0x30D1,  // KATAKANA LETTER PA
  0x30D4,  // KATAKANA LETTER PI
  0x30D7,  // KATAKANA LETTER PU
  0x30DA,  // KATAKANA LETTER PE
  0x30DD,  // KATAKANA LETTER PO
  0x30F4   // KATAKANA LETTER VU
];

Photozou.MacUnicode.uncombinedKana = [
  0x304B,  // HIRAGANA LETTER KA
  0x304D,  // HIRAGANA LETTER KI
  0x304F,  // HIRAGANA LETTER KU
  0x3051,  // HIRAGANA LETTER KE
  0x3053,  // HIRAGANA LETTER KO
  0x3055,  // HIRAGANA LETTER SA
  0x3057,  // HIRAGANA LETTER SI
  0x3059,  // HIRAGANA LETTER SU
  0x305B,  // HIRAGANA LETTER SE
  0x305D,  // HIRAGANA LETTER SO
  0x305F,  // HIRAGANA LETTER TA
  0x3061,  // HIRAGANA LETTER TI
  0x3064,  // HIRAGANA LETTER TU
  0x3066,  // HIRAGANA LETTER TE
  0x3068,  // HIRAGANA LETTER TO
  0x306F,  // HIRAGANA LETTER HA
  0x3072,  // HIRAGANA LETTER HI
  0x3075,  // HIRAGANA LETTER HU
  0x3078,  // HIRAGANA LETTER HE
  0x307B,  // HIRAGANA LETTER HO
  0x306F,  // HIRAGANA LETTER HA
  0x3072,  // HIRAGANA LETTER HI
  0x3075,  // HIRAGANA LETTER HU
  0x3078,  // HIRAGANA LETTER HE
  0x307B,  // HIRAGANA LETTER HO
  0x3046,  // HIRAGANA LETTER U
  0x30AB,  // KATAKANA LETTER KA
  0x30AD,  // KATAKANA LETTER KI
  0x30AF,  // KATAKANA LETTER KU
  0x30B1,  // KATAKANA LETTER KE
  0x30B3,  // KATAKANA LETTER KO
  0x30B5,  // KATAKANA LETTER SA
  0x30B7,  // KATAKANA LETTER SI
  0x30B9,  // KATAKANA LETTER SU
  0x30BB,  // KATAKANA LETTER SE
  0x30BD,  // KATAKANA LETTER SO
  0x30BF,  // KATAKANA LETTER TA
  0x30C1,  // KATAKANA LETTER TI
  0x30C4,  // KATAKANA LETTER TU
  0x30C6,  // KATAKANA LETTER TE
  0x30C8,  // KATAKANA LETTER TO
  0x30CF,  // KATAKANA LETTER HA
  0x30D2,  // KATAKANA LETTER HI
  0x30D5,  // KATAKANA LETTER HU
  0x30D8,  // KATAKANA LETTER HE
  0x30DB,  // KATAKANA LETTER HO
  0x30CF,  // KATAKANA LETTER HA
  0x30D2,  // KATAKANA LETTER HI
  0x30D5,  // KATAKANA LETTER HU
  0x30D8,  // KATAKANA LETTER HE
  0x30DB,  // KATAKANA LETTER HO
  0x30A6   // KATAKANA LETTER U
];

Photozou.MacUnicode.isNFD = function() {
  var ua = navigator.userAgent;
  if (ua.indexOf("Mac") > -1) {
    // Mac
    if (ua.indexOf("Safari") > -1 || window.opera) {
      // Safari or Opera
      return true;
    }
  }
  return false;
};

Photozou.MacUnicode.normalizeToNFC = function(path) {
  var result = "";
  var pathLength = path.length;
  for (var i = 0; i < pathLength; i++) {
    var c = path.charCodeAt(i);
    if (c == 0x3099 || c == 0x309A) {
      // a voiced sound mark or a semi-voiced sound mark
      if (i == 0) {
        continue;
      }
      var prev = path.charCodeAt(i - 1);
      for (var k in Photozou.MacUnicode.uncombinedKana) {
        if (prev == Photozou.MacUnicode.uncombinedKana[k]) {
          if (result.length > 0) {
            result = result.substr(0, result.length - 1);
          }
          result += String.fromCharCode(Photozou.MacUnicode.combinedKana[k]);
          break;
        }
      }
    } else {
      result += path.charAt(i);
    }
  }
  return result;
};

変換用の文字テーブルがほとんどを占めているため、 実際の処理内容としては大したことをしていません。 一文字ずつ見ていって、必要なら変換しているだけです。 サーバ側で変換する場合も、同様の変換処理をしてあげればよさそうですね。

追記

http://d.hatena.ne.jp/odz/20070904/1188884960
記事に関して上記のようなご指摘をいただきました。 私自身Unicodeに関して詳しいわけではないので、こうしたご指摘はありがたいです。 一部、符号化という語を誤った意味で使っていたので修正しました。 NFC、NFDはUnicodeの正規化形式の種類です。

以下、さらなる補足です。

PHPのmbstringの設定で、 mbstring.http_inputとmbstring.internal_encodingの値が異なり、 mbstring.encoding_translationの値がonになっている場合、 サーバ側で入力パラメータのエンコーディングが自動変換されます。 その場合、NFDで正規化された合成文字は正しく変換されずに 途中で切れてしまう場合があります (そもそもPHPのmbstringモジュールがNFDに対応していないため)。

この問題に対処するためには、 エンコーディングの自動変換をしないようにしてサーバ側で変換処理を行うか、 送信前にJavaScriptで変換してから送信するしかないようです。 ICUを使った正規化ライブラリが存在するようなので、 ICUベースでUnicode対応する予定のPHP6が待ち遠しいところです。