ようこそゲストさん

CPA-LABテクニカル

2008/07/01(火) mb_encode_mimeheaderの都市伝説を検証する

はてブ情報 はてブに登録 はてブ数 php5spok

mb_encode_mimeheader都市伝説

mb_encode_mimeheaderは、なんか使いにくい。文字化けする。とあまり良い評判を聞きません。私も、「使うのはちょっと怖いな」と思っていました。しかし、mb_encode_mimeheaderは正しく設定すれば、きちんと働いてくれます。それを検証したいと思います。

間違ったバッドノウハウ

mb_encode_mimeheaderの有名なバッドノウハウに
PHPmb_encode_mimeheaderは事前にmb_internal_encodingが必要-akiyan.com
というのがあります。その中身は
PHPのmb_encode_mimeheader関数で文字列をエンコードするときは、直前にmb_internal_encoding関数で変換したい文字列のエンコーディングをセットしてから呼ばないとうまく動作しないもよう。エンコードした文字列の後ろのほうが化け化けになってしまう。
というものです。
そして、
// 元のエンコーディングを保存
$orgEncoding = mb_internal_encoding();
// 変換したい文字列のエンコーディングをセット
mb_internal_encoding($targetEncoding); 
// エンコーディング実行
$encodedString  = mb_encode_mimeheader($targetString, $targetEncoding, 'B', "\n");
// 保存しておいたエンコーディングに戻す
mb_internal_encoding($orgEncoding); 
というコードが掲載されています。
それを検証しようとした、
mb_internal_encoding()は必須か?」に対しての疑問と検証-よくきたblog
というのもあります。

しかしながら、このバッドノウハウは間違っており、検証した方も、残念ながら、検証になっていません。

正しいバッドノウハウ

そこでPHP5.2.4のソースコードを読みつつ、動作検証をしてみました。
その結果、mb_encode_mimeheaderを使う時の正しいバッドノウハウは、
mb_encode_mimeheaderに渡す文字列の文字コードとmb_internal_encodingは一致させておかなければならない。
というものです。
必ずしも、最終のターゲットエンコーディングとmb_internal_encodingを一致させる必要はありません。
ん、最初のノウハウと何が違うのかって?
次章でよ~く考えてみましょう。

結論から

文字コードが3つでてきますので、整理しましょう。
  1. mb_internal_encodingの文字コード
  2. mb_encode_mimeheaderに渡す文字列の文字コード
  3. mime変換する最終文字コード
この中で、1と2が一致している必要があるだけであって、3は独立していてOKです。
2→3の文字コード変換は、mb_encode_mimeheaderがきちんとやってくれます。
正しくいえば、mb_encode_mimeheaderは「2の文字コードは1と同じであるとして、1→3のパラメータでもって、2の文字列を変換」します。つまり、 

mb_convert_encoding($word,$target_encoding,mb_internal_encoding());

という動作が、mb_encode_mimeheaderには含まれています。
(特殊な条件の時のみ、無変換となる)
間違っているバッドノウハウは、1と3を一致させようというもので、2についての言及がありません。偶然に2についても同じ文字コードであれば文字化けすることはないのですが、2が(1,3)と違うコードの場合、正しくエンコードされません。
1=2となるように、文字コードを設定しましょう。
(さらに、mb_languageもjaに設定しておけば間違いがありません。)

本来は、mb_encode_mimeheader関数にて 2の「渡す文字列の文字コード」を指定できるような仕組みがあればいいのですが、残念ながらそうなっていません。
1のmb_internal_encodingを指定することで、2の「渡す文字列の文字コード」を間接的に指定することになります。
この結論は、下記テストコードと、PHP5.2.4本体のmb関係のソースを読んでのことですが、C言語のソースは私も理解できない部分もあり、結論が間違っている可能性もあるかも知れません。その際はご指摘いただければ幸いです。

テストコード

わかりやすくするために、1文字だけMIMEエンコードしてみましょう。後で、長文もテストしてみます。
ソースコードはutf-8で保存しています。
code.1
$internal = 'utf-8';
$input    = 'utf-8';
$target   = 'iso-2022-jp';

mb_language('ja');
mb_internal_encoding($internal);
$word = "あ";
$word = mb_convert_encoding($word,$input,mb_detect_encoding($word));
echo $a=mb_encode_mimeheader($word,$target);
その結果
 =?ISO-2022-JP?B?GyRCJCIbKEI=?=
ただしく変換されています。
code.2
$internal = 'euc-jp';
$input    = 'utf-8';
$target   = 'iso-2022-jp';
以下、省略
 =?ISO-2022-JP?B?Pz8=?=
失敗しています。
code.3
$internal = 'iso-2022-jp';
$input    = 'iso-2022-jp';
$target   = 'iso-2022-jp';
以下、省略
 =?ISO-2022-JP?B?GyRCJCIbKEI=?=
もちろん正しく変換されています。
code.4
$internal = 'iso-2022-jp';
$input    = 'iso-2022-jp';
$target   = 'utf-8';
以下、省略
 =?UTF-8?B?44GC?=
これも正しく動作しています。

すべての組み合わせまでは試していませんので、皆様も試していただければ幸いです。

長文でテストしてみる。

次はインプットする文章を長文にして2行に渡るMIMEエンコードがうまくいくかどうか試してみましょう。
以下のテストコードの文字コードはutf-8を前提にしています。ソースコード自体を他の文字コードで保存する場合は、$charset_sorce='utf-8';の部分を該当の文字コードに置き換えて下さい。
なお、下記コードの中で「おaa」などという文章を試しているのは、MIME変換処理において、マルチバイト文字の途中でぶったぎるかどうか、という点も合わせて検証しているからです*1
<?php

$Mime = new MimeTest();
$Mime->language='ja';
$charset_sorce='utf-8';

$charset[]=array(
    'internal'=>'utf-8',
    'input'=>'utf-8',
    'target'=>'iso-2022-jp',
);
$charset[]=array(
    'internal'=>'euc-jp',
    'input'=>'euc-jp',
    'target'=>'iso-2022-jp',
);
$charset[]=array(
    'internal'=>'euc-jp',
    'input'=>'euc-jp',
    'target'=>'utf-8',
);
$charset[]=array(
    'internal'=>'iso-2022-jp',
    'input'=>'iso-2022-jp',
    'target'=>'utf-8',
);

$charset[]=array(
    'internal'=>'euc-jp',
    'input'=>'iso-2022-jp',
    'target'=>'iso-2022-jp',
);
$charset[]=array(
    'internal'=>'utf-8',
    'input'=>'iso-2022-jp',
    'target'=>'iso-2022-jp',
);
$charset[]=array(
    'internal'=>'utf-8',
    'input'=>'euc-jp',
    'target'=>'iso-2022-jp',
);
$charset[]=array(
    'internal'=>'iso-2022-jp',
    'input'=>'euc-jp',
    'target'=>'iso-2022-jp',
);

$word[] = "お問い合わせありがとうございます【○●サイト】";
$word[] = "おa問い合わせありがとうございます【○●サイト】";
$word[] = "おaa問い合わせありがとうございます【○●サイト】";

foreach($charset as $chars){
    foreach($word as $wd){
        echo "<pre>";
        print_r($chars);
        print_r($Mime->run($chars,$wd,$charset_sorce));
        echo "</pre>";
    }
}

class MimeTest{

    var $language = 'ja';
    var $charset_internal = null;
    var $charset_input    = null;
    var $charset_target   = null;

    function run($charset,$word,$charset_sorce){

        $this->charset_internal = $charset['internal'];
        $this->charset_input = $charset['input'];
        $this->charset_target = $charset['target'];
        mb_language($this->language);
        mb_internal_encoding($this->charset_internal);
        $word = mb_convert_encoding($word,$this->charset_input,$charset_sorce);
        $ret_mime = mb_encode_mimeheader($word,$this->charset_target);

        $ret_mime_array = preg_split('/\r?\n/is',$ret_mime);
            foreach($ret_mime_array as $ret_mime_line){
                $enc = strtoupper($this->charset_target);
                $ret_strip = preg_replace(array('/=\?'.$enc.'\?B\?/','/={0,3}\?=$/'),'',trim($ret_mime_line));
                $ret_base64[]=$ret_strip;
                $ret_base64_len[]=strlen($ret_strip);
                $ret_decode[] = base64_decode($ret_strip);
            }
        return array(
            $ret_mime,
            mb_convert_encoding(mb_decode_mimeheader($ret_mime),$charset_sorce,$this->charset_internal),
            implode(' - ',$ret_base64),
            implode(' - ',$ret_base64_len),
            mb_convert_encoding(implode('',$ret_decode),$charset_sorce,$this->charset_target),
        );
    }
}
結論の出力は長くなるので省略しますが、次のような結論になります。
1 mb_internal_encodingの文字コード
2 mb_encode_mimeheaderに渡す文字列の文字コード
3 mime変換する最終文字コード

とした場合。

1=2 であれば正常稼働。
1≠2であれば異常(文字化け)

となり、3は1と2から独立して指定できる。
ということがわかります。
また、マルチバイトの途中でMIME変換をぶった切るということもないようですので、「1=2」というバッドノウハウさえ守れば、mb_encode_mimeheaderは、安心して使用できるかと思います。

Qdmailでは、mb_encode_mimeheaderが安心して使えるかどうかわからなかった時期に制作しましたので、mb_encode_mimeheaderについては同等のメソッドを自作しています。

*1 : 結論としては、問題ありませんでした。

PHPの本体コードを読んでみる。

mbstring.cのPHP_FUNCTION(mb_encode_mimeheader)
の初めの方に次のような記述があります。
  string.no_encoding = MBSTRG(current_internal_encoding);
ここで、「インプットされる文字列の文字コード=インターナル文字コード」という処理を行っているわけですね。
ここで、もしphp.ini等において mb_internal_encoding が設定されておらず、かつ、mb_language が'ja'(日本語)に設定されている場合は、euc-jpがセットされます。だからこそ、この記事の前に、PHPの日本語デフォルトはEUC-JPの記事を書いていたのです~。

WEB上の間違った情報(主なもの)

以下は、googleで、mb_encode_mimeheader のキーワードで検索した時に、比較的上位に出てくるページや、他に多く引用されているページにつき、私の見解を書いています。決して、けなしているわけではありません。ただ、私の得た結論が正しいとするならば、間違った情報をそのままにしておくと、また私のような無駄な時間を費やしてしまう人がでてくるであろう、という予測のもとに、「この情報は私が正しいと思う結論に対して、間違っている」ということを指摘するものです。
PHPのバージョン違いによるものもあるかも知れません。私が検証したのはPHP5.2.4ですので、それより前のPHPでは挙動が違うのかも知れません。(一応、PHP4.4.7でもテストコードを動かしてみて、結論は変わらないという心証はえているのですが、4.4.7についてはPHPソースまでは読んでいません。)
またPHP5.2.4であっても私の見解が間違っている可能性もあります。その際は、ご指摘いただければ幸いです。

この記事の冒頭で紹介したページです。1=3という間違った結論です。多くのWEBサイトがこのページを引用しています。この記事のバージョン5.1.2ではこうだったのでしょうか?
私と同様の疑問を持ち、akiyan.comさんの記事を検証しています。しかし、環境が、internal='euc-jp',input='utf-8',target='utf-8'では文字化け、internal='utf-8',input='utf-8',target='utf-8'で文字化けなし、ということを検証しているのみで、十分ではありません。
1=2=3と設定しないと文字化けする、という間違った情報です。丁寧に「mb_encode_mimeheader() ですが、文字コードの変換まではしてくれない為」と書かれていますが、これも間違いです。上のほうに書いたように、むしろ原則として、文字コードの変換をします。

非常に有益なテストコードです。この記事の次の記事の方が
しかも、これらの結果が異なるんですね。
mb_encode_mimeheader($str, 'ISO-2022-JP');
mb_encode_mimeheader(mb_convert_encoding($str,'JIS','EUC'),'ISO-2022-JP');
と述べていらっしゃいますが、mb_internal_encoding('EUC-JP');なので、スクリプトをEUC-JPで保存していれば、上段は1=2となり正常稼働しますが、下段は1≠2なので当然文字化けします。もし、このスクリプトをUTF-8で保存して実行すれば、上段も文字化けするでしょう。
また、同じスレッドで、
mb_encode_mimeheader 自体は、文字コードの変換はしてくれないと思っていたんですが。
という発言もありますが、このように思っている人は多いのではないでしょうか。
繰り返しになりますが、原則として文字コードの変換がなされます。

こちらは逆に、「mb_encode_mimeheader()に渡す文字列はISO-2022-JPに変換するな。」という結論を導いていらっしゃいますが、間違いです。最初のスクリプトが、1≠2になっているだけであり、2=iso-2022-jpとしているからではありません。しかも、次のスクリプトでは、1=2=iso-2022-jp と設定し、結果も正しいのに、「mb_encode_mimeheader()に渡す文字列はISO-2022-JPに変換するな。」というのは、論理的に正しくありません。mb_encode_mimeheader()に渡す文字列はISO-2022-JPに変換しても、1=2を守る限り大丈夫です。

正しい考察
PHPのmb_encode_mimeheaderは事前に-ryuzi_kambe の?D
こちらは、私と同じ結論です。そちらにも書いてある通り、mb_encode_mimeheaderに”from-encoding”を指定するパラメータがないのが、混乱のもとになっていると思います。

混乱する考察
mb_encode_mimeheaderについては、そのソースコードのエンコードを書いていないスクリプトの場合、情報が不足するため混乱の元となります。mb_encode_mimeheaderに言及する多くのWEBサイトでは、ソースコードの文字コードに触れおらず、暗黙のうちにEUC-JPやShift_Jisが前提だったりします。
おまけに、inputされる文字列の文字コードさえも記載のないページもあります。
そのようなページはあまりに多いので、ここでの引用は省略します。

開発者さま

ぜひ、このようなバッドノウハウをなくすために、mb_encode_mimeheaderにて、引数に渡す文字列のエンコードであるfrom-encodingを指定できるようにお願いいたします*2
たったこれだけで、この大混乱を収束することができます。。。


しかしながら、マルチバイト関係のPHP本体のソースをみると、すごく複雑です。c言語で、マルチバイトを自在に処理するのは至難の業ですね。mb_convet_encodingをおっかけると、あの組み合わせの多さに、それだけで萎えてしまいます。それに果敢に挑戦された開発者の方はすごいです。mb*関係に係わった関係者の方々に感謝と尊敬の意を表します。


*2 : こういうのってどこに要望ださばいいのだろう

覚書

この記事を書くための覚書です。これから下はあまり意味はありません。

mb_*関数群では、mbfl_string構造体でもって、マルチバイトの値を管理している。
mbfl_string構造体は次のようなデータを持つ。
  language // 言語情報 
  encoding // エンコーディング情報
  文字列ポインタ // 文字列のデータ
  len // 長さ
また、mime_header_encoder_data構造体は次のような構造となっている。(注釈はスポックによる)
mbfl_convert_filter構造体には、from文字コード→to文字コードの変換メソッド名(の一部)が入る。
struct mime_header_encoder_data {
	mbfl_convert_filter *conv1_filter; // input文字コード→wcharのコンバート
	mbfl_convert_filter *block_filter; // MIMEの=?iso-2022-jp部分(encoded-block)実際はコンバートしない
	mbfl_convert_filter *conv2_filter; // wchar→outcodeへのコンバート
	mbfl_convert_filter *conv2_filter_backup;
	mbfl_convert_filter *encod_filter; // outcode→transfer_enc(BorQP)へのコンバート 
	mbfl_convert_filter *encod_filter_backup;
	mbfl_memory_device outdev;
	mbfl_memory_device tmpdev;
	int status1;
	int status2;
	int prevpos;
	int linehead;
	int firstindent;
	int encnamelen;
	int lwsplen;
	char encname[128];
	char lwsp[16];
};

PHP_FUNCTION(mb_encode_mimeheader)string.no_encodingにカレントエンコーディングセット。

mbfl_mime_header_encode

mime_header_encoder_newtransfer block(Base64orQP),Output,encoded block,Input code blockの4つの変換の下準備

mbfl_mime_header_encodelinefeed,indent前処理

mime_header_encoder_newincode → outcode → transfer_encoding のメソッド名セット

戻り:mbfl_mime_header_encode順番にincode → outcode → transfer_encoding
解析用ソース抜き出し置き場
PHP_FUNCTIONmb_encode_mimeheader.ZIP

1: ELF 2008年07月15日(火) 午後5時01分

よくきたblogな人ですが,私の件の記事は網羅性は高くないです.
もっと細かいことを書いたものがありまして,「超・極める! PHP」読んでみてください.
ちなみに広い意味ではmb_mimeencoder()だけだと不十分です.

2: spok 2008年07月17日(木) 深夜0時22分

ELFさん
はじめまして。プロの方からコメントをいただけて光栄です。
私の記事が間違っていなかったようで安心しております。
さっそく本を拝読しました。
大変勉強になりました。envelope-fromはQdmailでもノーケアなので、今後のバージョンアップに生かしたいと思います。最後の'e'が必要ですよね?

日本語メールの問題は、これだけメールがメジャーになっても、なかなか理論通りにいかない頭の痛い問題ですね。
ヘッダー含めた76文字問題と改行コード固定問題は、確かにその通りかと思います。ダブルクオーテーションで囲まれた部分が、RFCに反してmimeエンコードされるという慣例実装も頭が痛いですね。

P217右側12行目の「「変換元文字エンコード」...(中略)...この指定は、mb_encode_mimeheader()関数でも使用することができます。」とありますが、たぶんmb_encode_mimeheader()関数では、「変換元」は直接指定できないかと思います。mb_internal_encoding()での間接指定かと思います。

P236-237の検証は、大変勉強になりました。
ただし、そもそも(変換元の)文字エンコードの引数を指定できないためmb_internal_encoding()の指定が(事実上)必須であるmb_encode_mimeheader()を説明する前段で、『mb_internal_encoding()の設定値ですが、これはほとんどの場合、「文字エンコードの引数を省略した時の値」という意味以上のものがありません』という前振りは、かえって読者が混乱するのではないかと思います。
また、3つの「必要ない」が記載されておりますが、これは、『mb_language,mb_internal_encodingが正しく設定されていること&mimeのターゲットエンコードをiso-2022-jpに変換すること('ja'でのメールデフォルトcharsetはiso-2022-jpであること)、が大前提』ということを強調しておかないと、誤解を招くような気がします。
もっとも、アマチュアたる私の読解力の問題カモ知れません。。。。
リスト7は確かに冗長なスクリプトとは思います。

私なぞが偉そうに言えることではないのですけれども。。。。


名前:  非公開コメント   

  • TB-URL  http://www.cpa-lab.com/tech/0153/tb/