文字化けを TextDecoder だけで戻す逆引き表と windows-1252 の罠

文字化ã や 譁・ュ怜喧縺 のような文字列は、壊れたデータではなく「正しいバイト列を、間違った文字コードで表示した結果」であることが多いです。表示に使われた文字コードへいったん書き戻してバイト列を取り出し、正しい文字コードで読み直せば元の文章が出てきます。

hashitosystem の 文字化け復元 は、これをブラウザ内だけで行うツールです。この記事では、ツールの中身(逆引き表の作り方と候補の絞り方)を説明し、同じ処理を Node.js で動かしたときに踏む windows-1252 の落とし穴 を実測で確かめます。

化け方は「どの符号で書いて、どの符号で読んだか」の組み合わせ

UTF-8 で保存された「文字化けを直す」を、別の文字コードとして読むと次のようになります(Chrome 152 と Node.js v24.11.1 で確認)。

読み方 画面に出る文字列
windows-1252(Latin-1 と呼ばれがち) 文字化ã‘ã‚’ç›´ã™(間に見えない制御文字を含む)
Shift_JIS 譁�ュ怜喧縺代r逶エ縺�

戻すときは逆向きに、「表示に使われた符号(via)」でバイト列へエンコードし、「本来の符号(to)」でデコードします。ツールは via に windows-1252 / Shift_JIS / EUC-JP、to に UTF-8 / Shift_JIS / EUC-JP / ISO-2022-JP を持ち、組み合わせを総当たりします。

TextDecoder はデコードしかできないので、逆引き表を作る

ブラウザ標準の TextEncoder は UTF-8 しか出力できません。TextDecoder は Shift_JIS や EUC-JP を読めますが、書き出す API はありません。

そこでツールは、ありうるバイト列を全部 TextDecoder に通して「文字 → バイト列」の表を作る 方法をとっています。

  • 1バイト: 0x00〜0xFF の256通り
  • Shift_JIS の2バイト: 先頭 0x81〜0x9F と 0xE0〜0xFC、2バイト目 0x40〜0xFC(0x7F を除く)
  • デコード結果に置換文字 U+FFFD が含まれる並びは表に入れない
  • 同じ文字に複数のバイト列が当たるときは、先に入った(短い)ほうを残す

Shift_JIS の表は Chrome 152 で9,398項目になり、windows-1252 と合わせて作るのに約14ミリ秒でした。一度作ればキャッシュできるので、入力のたびに作り直す必要はありません。

候補の並べ方と、戻らない部分

総当たりすると候補が複数出るので、ツールは「読める文章らしさ」で並べます。ASCII の印字可能文字・ひらがな・カタカナ・句読点を加点し、置換文字・制御文字・私用領域・半角カナを減点する単純な採点です。同点なら UTF-8 を本来の符号とする候補を先に出します。UTF-8 のバイト列を別の符号で表示した、というのがいちばん多い化け方だからです。

ただし採点は並べ替えのためだけで、正解の断定ではありません。たとえば Café naïve のように、化けた側の文字列がラテン文字として高い点になることがあるため、ツールは「入力より高い点の候補だけ残す」という条件を付けていません。

もう1つの限界は 置換文字 U+FFFD です。Shift_JIS で読んだ時点で解釈できなかったバイトは U+FFFD に置き換わり、元のバイト値は残っていません。上の表の 譁�ュ怜喧... を戻すと、Node.js と Chrome のどちらでも次のようになりました。

1
断�化けを直�(置換文字 2 個は戻らない)

欠けた位置は戻らず、欠けたバイトに隣接する文字(ここでは先頭の「文」)が別の漢字になることもあります。ツールはこの場合「部分復元」と明記します。

windows-1252 の罠その1: Latin-1 ではない

「Latin-1 で化けた」とよく言いますが、ブラウザの TextDecoder("latin1") は Latin-1(ISO-8859-1)ではありません。WHATWG Encoding Standard では latin1・iso-8859-1・ascii・us-ascii はすべて windows-1252 のラベルで、実際に new TextDecoder("latin1").encoding は "windows-1252" を返します。

違いは 0x80〜0x9F です。windows-1252 の索引 では 0x80 が €(U+20AC)、0x92 が ’(U+2019)、0x96 が –(U+2013)に割り当てられています。「文」の UTF-8 は E6 96 87 なので、化けた文字列には U+2013 が入ります。

このため「1文字=1バイト」と見て charCodeAt() をそのままバイト値にする素朴な戻し方は、– が 0xFF を超えるので失敗します。ツールのコードにも、この形の文字列が戻せなかったという実測のコメントが残っています。

windows-1252 の罠その2: Node.js と Chrome で結果が違う

同じ TextDecoder("windows-1252") を Node.js v24.11.1 で試すと、0x80〜0x9F が Latin-1 のまま(0x80 → U+0080)返ってきました。Chrome 152 では仕様どおり U+20AC です。

1
2
// Chrome 152: "€"   Node.js v24.11.1: "\u0080"
new TextDecoder("windows-1252").decode(Uint8Array.of(0x80));

これは Node.js の既知の不具合で、issue #56542(v23.6.0 と v22.13.0 で報告、0x92 が U+2019 にならない)として確認済みになっています。修正の PR #60893 は2025年12月4日にマージされました。お使いの Node.js が該当するかは、上の1行で確かめられます。

影響は次のとおりです。ブラウザやエディタで化けた文字列(– を含む)を Node.js 側の逆引き表で戻そうとすると、表に – が無いので失敗します。Node.js v24.11.1 で実行すると、"–"(U+2013)の位置で経路不成立になりました。

windows-1252 の罠その3: 見えない制御文字がコピーで落ちる

windows-1252 でも 0x81・0x8D・0x8F・0x90・0x9D の5つは文字が割り当てられておらず、WHATWG の索引では同じ値の C1 制御文字(U+0081 など)になります。「け」の UTF-8 は E3 81 91 なので、化けた文字列には U+0081 が混ざります。

Chrome で「文字化けを直す」を windows-1252 で読むと21文字になり、そのうち2文字が U+0081 でした。これは画面に見えないため、目で見て打ち直したりコピーの途中で落ちたりすると19文字になります。19文字版を戻すと、Chrome でも 文字化�を直� のように「け」と「す」が欠けました。元の21文字をそのまま渡せば、完全に 文字化けを直す に戻ります。

化けた文字列を扱うときは、画面からの目視コピーではなく、ファイルやクリップボードの値をそのまま渡すのが安全です。

実際に試す

前提: Node.js 18 以降(TextDecoder の Shift_JIS 対応に full ICU が必要。公式配布のバイナリは full ICU 入り)。以下を mojibake.mjs として保存します。windows-1252 の 0x80〜0x9F は、Node.js の上記不具合を避けるため WHATWG の索引の値を自前で持たせています。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
// mojibake.mjs — 化けた文字列を「表示に使われた符号」のバイト列へ戻し、別の符号で読み直す
// 使い方: node mojibake.mjs "<化けた文字列>"

// WHATWG Encoding Standard の windows-1252 で 0x80〜0x9F が指す符号位置
const W1252_80_9F = [
0x20ac, 0x81, 0x201a, 0x192, 0x201e, 0x2026, 0x2020, 0x2021,
0x2c6, 0x2030, 0x160, 0x2039, 0x152, 0x8d, 0x17d, 0x8f,
0x90, 0x2018, 0x2019, 0x201c, 0x201d, 0x2022, 0x2013, 0x2014,
0x2dc, 0x2122, 0x161, 0x203a, 0x153, 0x9d, 0x17e, 0x178,
];

function buildReverseTable(encoding) {
const dec = new TextDecoder(encoding); // fatal: false(未定義は U+FFFD)
const map = new Map();
const put = (bytes) => {
const s = dec.decode(new Uint8Array(bytes));
if (s.length === 0 || s.includes("�") || map.has(s)) return;
map.set(s, bytes);
};
if (encoding === "windows-1252") {
// Node の一部バージョンは 0x80〜0x9F を Latin-1 のまま返すので、表を自前で持つ
for (let b = 0; b <= 0xff; b++) {
const cp = b >= 0x80 && b <= 0x9f ? W1252_80_9F[b - 0x80] : b;
map.set(String.fromCodePoint(cp), [b]);
}
return map;
}
for (let b = 0; b <= 0xff; b++) put([b]);
if (encoding === "shift_jis") {
for (let lead = 0x81; lead <= 0xfc; lead++) {
if (lead > 0x9f && lead < 0xe0) continue; // 0xA0〜0xDF は1バイト(半角カナ)
for (let t = 0x40; t <= 0xfc; t++) if (t !== 0x7f) put([lead, t]);
}
}
return map;
}

function toBytes(text, map) {
const out = [];
let dropped = 0;
for (const ch of text) {
if (ch === "�") { dropped++; continue; } // 保存時点で失われたバイト。どの経路でも戻らない
const bytes = map.get(ch);
if (!bytes) return null; // この経路では化けていない
out.push(...bytes);
}
return { bytes: Uint8Array.from(out), dropped };
}

const input = process.argv[2] ?? "";
for (const via of ["windows-1252", "shift_jis"]) {
const r = toBytes(input, buildReverseTable(via));
if (!r) { console.log(`${via}: この経路ではない`); continue; }
const text = new TextDecoder("utf-8").decode(r.bytes);
console.log(`UTF-8 を ${via} で表示していた場合: ${text}` + (r.dropped ? `(置換文字 ${r.dropped} 個は戻らない)` : ""));
}

Node.js v24.11.1 での実行結果です。

1
2
3
$ node mojibake.mjs "Café naïve"
UTF-8 を windows-1252 で表示していた場合: Café naïve
shift_jis: この経路ではない

Shift_JIS 経由で化けた文字列は、生成して渡すと部分復元になります(コピーで置換文字以外の文字が落ちないよう、生成した値をそのまま渡しています)。

1
2
3
4
$ node -e "process.stdout.write(new TextDecoder('shift_jis').decode(new TextEncoder().encode('文字化けを直す')))" > g.txt
$ node mojibake.mjs "$(cat g.txt)"
windows-1252: この経路ではない
UTF-8 を shift_jis で表示していた場合: 断�化けを直�(置換文字 2 個は戻らない)

ブラウザで手早く試すなら、文字化け復元 に化けた文字列を貼ると、via と to の組み合わせごとの候補が読める順に並びます。CSV ファイルそのものの文字コードを変換したい場合は CSV 文字コード変換 が使えます。

まとめ

  • 文字化けは「表示に使われた符号へ戻して、正しい符号で読み直す」と戻せることが多い
  • TextDecoder しか無い環境では、全バイト列をデコードして逆引き表を作れば書き戻しができる
  • ブラウザの latin1 は windows-1252。0x80〜0x9F が記号になるので charCodeAt() の素朴な逆算は失敗する
  • Node.js の一部バージョンは windows-1252 の 0x80〜0x9F を仕様どおりに返さない。表を自前で持つか、1行で確かめてから使う
  • 置換文字 U+FFFD と、コピーで落ちた見えない制御文字の位置は戻らない

文字化けを TextDecoder だけで戻す逆引き表と windows-1252 の罠
https://blog.hashito.biz/2026/09/30/mojibake-repair-textdecoder-reverse-table-windows-1252/
著者
hashito
作成日
2026年9月30日
著作権