うならぼ

申し訳程度のアフィリエイトとか広告とか解析とかは/aboutを参照

ISO-2022-JPとSJISとEUCJP(とUTF-8)をざっくり判別するアルゴリズム

charsetの指定が適当なページがMobileSafariで文字化けするんで、XHR+FileReaderでエンコーディングを指定して読み込むブックマークレットを書いたんですが、自分で指定するのも面倒なので自動判定したいなと。当然既存のライブラリとかもあるんですが、どうせブックマークレットにするならシンプルなアルゴリズムを埋め込んでしまえないかなと。

そもそも日本語なのかもわからないものを厳密に判定しようとすると、結局各エンコーディングでデコード(のシミュレーション)をしてみるという方法をとるようですが、今回は日本語、それも多分UTF-8でないということまでわかっています。またISO-2022-JPはESC文字(0x1B)が特徴的ですが、この文字はSJIS・EUCJP・UTF-8のいずれにおいても使いませんから、これが現れたらISO-2022-JPとほぼ断言できます。

となると、あとはSJISとEUCJPの見分け方がわかれば解決しそうです。

SJISとEUCJPを見比べてみる

ざっくり塗り分けるとこんな感じ*1

f:id:unarist:20170224143223p:plain

0x81-0x9F

EUCJPしか考えられない領域はわずかですが、SJISしか考えられない領域は結構ありますね。特に1バイト目の0x81-0x9Fは非漢字(ひらがな・カタカナ・句読点・記号・罫線など)と第一水準漢字が完全に収まっているので、普通の文章ならここが使われるのはまず間違いないでしょう。

ただし0x8Eと0x8Fは別です。EUCJPでは0x8Eから始まる2バイトで半角カタカナを、0x8Fから始める3バイトで補助漢字(第三水準など)を表現します。とはいえ、2バイト目が0x40-0xA0の範囲であればEUCJPではないと言い切ることができます。

0xA1-0xDF

SJISではこの領域の1バイトで半角カナを表します。一方EUCJPでも非漢字と漢字の多くを含む、よく使う領域です。仮にEUCJPだとすると2バイト目もやはりSJISの半角カナ領域なので、EUCJP一文字なのかSJIS半角カナ2文字なのかを判別できません。

とはいえ、半角カナ(と第二水準漢字以降)だけで書かれた文章はまずありません。多分私は読みたくないです。ですからこの領域はSJISの判断条件には使わないことにします。むしろここは1バイト目2バイト目共にEUCJPでよく使われるはずですから、「EUCJPっぽさ」としてカウントしてもよいでしょう。

0xE0-0xFC

SJIS・EUCJP共に第二水準の途中からこの領域が使われます…が、適当なサイトで文字コード表を見るとわかりますが、どちらにしても日常的に使う漢字は少ないです。一応0xEB以降はSJISでも拡張や外字でしか使われていないのでほぼEUCJPと確定できますが、前述のとおりここをわざわざチェックする甲斐は薄いです。

実装してみる

SJISの2バイト目はASCII領域に入ってくるので、文字の区切りを間違えると領域の判定すら面倒です。一方EUCJPは全てのバイトが0x80-0xFFに収まっているので、実際にコードポイントを取得しないなら文字区切りは多少無視してもよいでしょう。

ということで、SJISに沿ってポインタを進めながら、主に1バイト目を見て判断していくことにします。0x00-0x7Fと0xA1-0xDFは1バイト、それ以外は2バイトですから簡単です。適当な文字数読み込んでもSJISらしいバイトが現れなければEUCJPとします。

なにで書いてもいいんですが、LINQPadで試行錯誤していたのでC#です。

Encoding DetectEncoding(byte[] bytes)
{
    var JIS = Encoding.GetEncoding("iso-2022-jp");
    var SJIS = Encoding.GetEncoding(932);
    var EUCJP = Encoding.GetEncoding("euc-jp");
    var nonAsciiCount = 0;
    
    // 2バイト目を読む余裕をもって終わる
    for (int i = 0; i < bytes.Length - 1; ++i)
    {
        var b1 = bytes[i];
        if (b1 == 0x1B) return JIS;
        if (b1 < 0x81) continue;

        if (b1 < 0xA0)
        {
            if ((b1 == 0x8E || b1 == 0x8f) && 0xA1 <= bytes[i + 1])
                ++i;
            else return SJIS;
        }
        else if (b1 >= 0xE0) ++i;

        if (50 <= ++nonAsciiCount) break;
    }

    return EUCJP;
}

ついでにUTF-8

0xA1-0xDFをEUCJPっぽさとしてカウントするという話をしましたが、じゃあSJISSJISっぽさとしてカウントしたら、どちらでもないものとしてUTF-8も判定できないでしょうか。

Encoding DetectEncoding(byte[] bytes)
{
    var JIS = Encoding.GetEncoding("iso-2022-jp");
    var SJIS = Encoding.GetEncoding(932);
    var EUCJP = Encoding.GetEncoding("euc-jp");
    var eucjpScore = 0;
    var sjisScore = 0;
    var nonAsciiCount = 0;
    for (int i = 0; i < bytes.Length-1; ++i)
    {
        var b1 = bytes[i];
        if (b1 == 0x1B) return JIS;
        if (b1 < 0x81) continue;

        nonAsciiCount++;
        if (b1 < 0xA0)
        {
            // SJISの非漢字~第二水準漢字の一部。
            // 0x8EはEUC-JPの半角カナ、0x8Fは補助漢字の可能性もあるので、その場合は保留。
            // 補助漢字だと3バイトなのでずれるが、EUC-JPは1バイト目も2バイト目も範囲が
            // ほとんど変わらないので割とどうでもいい。たとえASCII文字を巻き込んだとしても。
            // ...といっても、出現頻度は少ないだろうから、気にせずSJISに加算してもいい気はする。
            // (スコア制にせず即returnするなら駄目だけども)
            if (!(b1 == 0x8E || b1 == 0x8f) || bytes[i + 1] < 0xA1)
                ++sjisScore;
            
            ++i;
        }
        else if (b1 < 0xE0)
        {
            // SJISの半角カナかEUC-JPの非漢字~第二水準漢字の一部。見分けがつかない。
            // とはいえまともな文章(?)なら非漢字~第一水準漢字(<0xA0)がそのうち出るはずだし、
            // 逆にEUC-JPならここは頻出するはずなので、この範囲が多ければEUC-JPと判断する。
            ++eucjpScore;
        }
        else ++i; //2バイト目次第ではSJISと確定できる可能性もあるが、どうせ他でわかるので無視。

        // 0xEB以降はSJISの拡張や外字でしか使われてないのでEUC-JPと判断してもいいかもしれないが、
        // EUC-JPで割り当てられてる文字もあまり使わない文字ばかりなので。

        if (50 <= nonAsciiCount) break;
    }

    Console.Write($"sjis:{sjisScore} euc:{eucjpScore} nonasc:{nonAsciiCount} ");
    
    // sjisScoreの方がUTF-8食わせたときも割合が上がりやすい。ちょうどいい場所なのかも。
    // (例えば"けもの"は E3 81 "91 E3" "82 82" E3 81 AE で4文字中2文字見つかる)
    if (sjisScore > nonAsciiCount * 0.7)
        return SJIS;
    if (eucjpScore > nonAsciiCount * 0.7)
        return EUCJP;
    else 
        return Encoding.UTF8;
}

細かく検証したわけではありませんが、ぼちぼちいけそうです。

メモ: 元の用途としては問題ないはずだが、BOMのことを忘れていた

追記:やっぱり既出でした

Escape Codec Library: ecl.js

このライブラリの GetEscapeCodeType が似たようなアプローチですね。私のより細かく見ていたり、UTF8もちゃんと調べてたりします。

ちなみに私のJS版というか、冒頭で触れたBookmarkletがこちら。XHRからArrayBufferで取得してるので、そのあたりは簡単になってます。

現在のドキュメントを文字コード自動判定で読み直す - Hatena::Let

参考サイト

塗り分け表の作成にあたっては以下のサイトが参考になりました。

またWebAPIとして TextEncoder/TextDecoder を実装する Encoding Standard というものもあります。Safariがサポートしていないので今回は使いませんでしたが、著名なエンコーディングのエンコーダ・デコーダの具体的な実装が載っていて参考になります。

*1:Shift_JISに第三・第四水準漢字や丸数字などを足したShift_JIS-2004では0x85~0x86にも割り当てがあります。Encoding Standardには記載がなく、おそらくFileReaderでも使えませんが…。

Node.jsがC:hoge.txt形式の相対パスをサポートしてたけど扱いが難しいって話

C:hoge.txt絶対パスでしょうか。答えはNOです。

これはCドライブのカレントディレクトリに対する相対パスを表します。絶対パスならドライブ文字、コロンに続いてバックスラッシュを付けなければなりません。

Naming Files, Paths, and Namespaces (Windows)

大抵はドライブごとのカレントディレクトリなんて保持していないでしょうから、これが真面目に活躍するのはコマンドプロンプトぐらいのものです。と言いつつ、実はWinAPIのGetFullPathName関数やそれを使った.NETのPath.GetFullPathメソッドがサポートしています。

Node.js の path.resolve() はそれに加えて相対パスの基準となるパスを複数指定できるので、

という仕様です。ソースコードはこの辺

で、本題に入ります。

ルート直下に入れた gulp を直接起動できない

つまりこういう package.jsonR:\ 直下に置いてあって、npm start で gulp を起動しようとしたのです。PATHの通ったコマンド増やさなくて済むので私は好きな方法です。

{
  "name": "hoge",
  "version": "1.0.0",
  "description": "",
  "dependencies": {
    "gulp": "^3.9.1",
  },
  "devDependencies": {},
  "scripts": {
    "start": "gulp"
  },
  "author": "",
  "license": "ISC"
}

そしたらこんなエラー。

R:\>npm start

> hoge@1.0.0 start R:\
> gulp

[22:41:55] Using gulpfile R:\gulpfile.js
module.js:472
    throw err;
    ^

Error: Cannot find module 'R:node_modules\gulp\index.js'
    at Function.Module._resolveFilename (module.js:470:15)
    at Function.Module._load (module.js:418:25)
    at Module.require (module.js:498:17)
    at require (internal/module.js:20:19)
    at Liftoff.handleArguments (R:\node_modules\gulp\bin\gulp.js:121:18)
    at Liftoff.<anonymous> (R:\node_modules\liftoff\index.js:199:16)
    at module.exports (R:\node_modules\flagged-respawn\index.js:17:3)
    at Liftoff.<anonymous> (R:\node_modules\liftoff\index.js:191:9)
    at R:\node_modules\liftoff\index.js:165:9
    at R:\node_modules\v8flags\index.js:108:14

なんのこっちゃ。でも R:node_modules\gulp\index.js が怪しいですね。

不思議なことに、グローバルに入れた gulp コマンドをこのディレクトリで叩けば問題なく動きました。

「何故この形式のパスが生まれたのか」「これはrequireできるのか」を探ります。

R:node_modules\gulp\index.js ができるまで

gulpが使っている Liftoff というライブラリは、Node.js本体のモジュール検索処理を真似た resolve というライブラリを使っています。

ディレクトリをさかのぼって検索パスを構築する処理に問題があるのですが、まあまずはコードを見てみましょう。

https://github.com/substack/node-resolve/blob/v1.2.0/lib/node-modules-paths.js

var splitRe = process.platform === 'win32' ? /[\/\\]/ : /\/+/;

var parts = start.split(splitRe);

var dirs = [];
for (var i = parts.length - 1; i >= 0; i--) {
    if (modules.indexOf(parts[i]) !== -1) continue;
    dirs = dirs.concat(modules.map(function(module_dir) {
        return prefix + path.join(
            path.join.apply(path, parts.slice(0, i + 1)),
            module_dir
        );
    }));
}
if (process.platform === 'win32'){
    dirs[dirs.length-1] = dirs[dirs.length-1].replace(":", ":\\");
}

区切り文字 \ or / で分割しておき、そのうちn個を連結して node_modules を連結する、というのを繰り返しています。R:\hoge を分割すると R:hoge になるわけですが、path.join() を使うことで区切り文字を挟んでくれるので安心ですね。

ところで、次の二つの結果は同じでしょうか?

  • path.join(path.join('R:', 'hoge'), 'node_modules')
  • path.join('R:', 'hoge', 'node_modules')

どちらも R:\hoge\node_modules になります。では次は?

  • path.join(path.join('R:'), 'node_modules')
  • path.join('R:', 'node_modules')

上は R:node_modules、下は R:\node_modules になります。

path.join('R:') の結果は R:. という見慣れないものになり、これに何かを連結すると R:node_modules といった形になります。だったら path.join('R:', 'hoge') もそうすべきだと思うんですが、こっちは利便性を優先したんですかねえ。

最後の要素だけ修正するコードが書かれていますが、今回は ["R:node_modules", "R:node_modules"] と要素が重複してるので解決しません。重複してることが問題か。

そうして R:node_modules という検索パスが生まれました。このあと検索パスから gulp/index.js を探すわけですが、今回の場合カレントディレクトリが R:\ なので、fs.stat()fs.readFile() に渡しても R:\node_modules\gulp\index.js と解釈され、特に問題は起きません。

あとは対象モジュールのpackage.jsonにmainが書かれていれば path.resolve() が呼ばれて(この場で)絶対パスになるようですが、gulpのpackage.jsonには書いてないので、resolve 結果は相対パスのままです。

R:node_modules\gulp\index.js はrequireできるのか

冒頭に書いた通りこれは相対パスなので、./../ で始まっていない相対パスを渡すと node_modules 内で検索されるという話と基本的には同じです。場合によってはカレントディレクトリやルートディレクトリになるという点を除いて。

モジュールの検索にあたっては、現在のモジュールが置かれているディレクトリから順にさかのぼっていき*1、それぞれの node_modules ディレクトリ(が存在する場合に限る)を基準に path.resolve() が行われます。ちなみに node_modules\node_modules は省略されます。

例えば R:\node_modules\gulp\bin\gulp.js なら、

  • R:\node_modules\gulp\bin\node_modules 存在しない
  • R:\node_modules\gulp\node_modules 存在しない
  • R:\node_modules\node_modules node_modulesの入れ子は省略
  • R:\node_modules

とまあ候補は最後のひとつしかないんですが、同じドライブなので相対パス扱いになり、R:\node_modules + R:node_modules\gulp\index.js = R:\node_modules\node_modules\gulp\index.js は存在しないので失敗します。

では、グローバルに入れた gulp コマンド経由では成功したのは何故でしょうか。

gulp コマンドを使った場合は、gulp コマンドの場所が起点になります。

  • C:\Users\unarist\AppData\Roaming\npm\node_modules\gulp\bin\node_modules
  • C:\Users\unarist\AppData\Roaming\npm\node_modules\gulp\node_modules
  • C:\Users\unarist\AppData\Roaming\npm\node_modules\node_modules
  • C:\Users\unarist\AppData\Roaming\npm\node_modules
  • C:\Users\unarist\AppData\Roaming\node_modules
  • C:\Users\unarist\AppData\node_modules
  • C:\Users\unarist\node_modules
  • C:\Users\node_modules
  • C:\node_modules

候補が2つ残りましたが、上から行きましょう。というかどっちでもいいんです。

パスの解決を行いますが、C:\... は別ドライブなので相対パスの基点として連結することはできません。次に process.cwd() を確認すると R:\ なので、これを使って R:\ + R:node_modules\gulp\index.js = R:\node_modules\gulp\index.js となります。

おっと、正解のパスにたどり着いてしまいました。

まとめ

require("R:node_modules\\...") と書いても、

のいずれかを満たせば、R:\node_modules\... からロードすることが可能です。

この手の相対パスは意図せずして .\\ から読み込まれる可能性があると言った方がいいかもしれない。例のresolveライブラリにちょろっと入っていた修正もそれが原因っぽいし*2。ちなみに今回のresolveライブラリのバグは先月末に修正がマージされているのでそのうちリリースされるはず。

おまけ

  • Module._load
    • Module._resolveFilename: ロード・キャッシュに使うファイルパスを確定する
    • キャッシュの確認
    • new Module()
    • Module.prototype.load: モジュールの読み込み
      • Module._nodeModulePaths: そのモジュールを起点とした検索パスを用意しておく
      • Module._extensions[ext]: 拡張子ごとのロード処理

*1:実際にはモジュール読み込み時に列挙しておいた module.paths を使う

*2:あれの場合はpackage.jsonにmain書かれてればその場でresolveしちゃう=検索パスの条件がなくなるし、仮にrequireできなくても正しくない結果が返りうることには違いない