3行まとめ
- ブラウザ標準の
btoa("日本語")は例外を投げる。btoaは各文字を0〜255の1バイトとみなすバイナリ文字列専用で、UTF-16のマルチバイト文字を渡せないため - 正しくは「文字列 →(TextEncoder)→ UTF-8バイト列 →(fromCharCode)→ バイナリ文字列 →
btoa」の一段挟む。デコードはその逆をたどる - URLセーフBase64は
+→-/→_・末尾=除去。デコード時は逆変換してlength % 4からパディングを復元する。日本語・絵文字対応のBase64ツールとして実装した
Base64は開発でしょっちゅう使う。データURIを作る、APIのレスポンスに埋まったBase64を覗く、JWTのペイロードをデコードして中身を確認する。ところがブラウザ標準の btoa に日本語を渡すと、いきなりこう言われる。
Uncaught DOMException: Failed to execute 'btoa' on 'Window':
The string to be encoded contains characters outside of the Latin1 range.
(投げられるのは name が InvalidCharacterError の DOMException。ブラウザによって表示ラベルは異なる。)
「なんで日本語がBase64にできないの?」——これはBase64の問題ではなく、btoa という関数の設計が古いことに由来する。ぱんだツールズのBase64変換は、この落とし穴を踏まずに日本語・絵文字も正しく扱えるようにしたブラウザ完結ツール。
この記事では、なぜ btoa で日本語が壊れるのか、そしてTextEncoderを挟んでどう正しく直すかを、実装ベースで書く。
なぜ btoa で日本語が壊れるのか
btoa の名前は "binary to ASCII" の略。ここでいう "binary" は、**1文字が1バイト(0〜255)に収まる「バイナリ文字列」**を指す。JavaScriptに専用のバイト型が無かった時代、バイナリを文字列で表現していた名残のAPIだ。
だから btoa は、渡された文字列の各文字のコードを見て「0〜255(Latin1の範囲)に収まっているか」を確認する。"A"(65)はOK、"é"(U+00E9 = 233)もぎりぎりOK。だが "日" はUTF-16のコード単位が 0x65E5(26085)で、255をはるかに超える。コード単位が255を超える文字が来た時点で例外(InvalidCharacterError)を投げる、という仕組み。境界は「マルチバイトか」ではなく「Latin1の範囲を超えるか」なので、Ā(U+0100 = 256)以上はすべて弾かれる。
つまり btoa は「文字列」ではなく「バイト列を文字で表したもの」を食う関数。文字列をBase64にしたければ、先に自分でバイト列(=UTF-8エンコード)に変換して、それを1バイト1文字のバイナリ文字列にしてから渡す必要がある。
TextEncoderを挟んで正しくエンコードする
やることは3段階。文字列をUTF-8のバイト列にして、そのバイト列を1バイト=1文字のバイナリ文字列に組み直し、それを btoa に渡す。
function encodeBase64(text: string, urlSafe: boolean): string {
const bytes = new TextEncoder().encode(text) // ① UTF-8バイト列(Uint8Array)
const binaryString = String.fromCharCode(...bytes) // ② 各バイトを0〜255の文字に
let encoded = btoa(binaryString) // ③ ここでようやくBase64
if (urlSafe) {
encoded = encoded.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
return encoded
}
-
①
TextEncoder().encode(text)… 文字列をUTF-8のバイト列(Uint8Array)にする。"日"は[0xE6, 0x97, 0xA5]の3バイトになる。ここで「文字」から「バイト」への変換が確定する -
②
String.fromCharCode(...bytes)… そのバイト列を、1バイト=1文字のバイナリ文字列に組み直す。各要素は0〜255なので、btoaが受け付けられる形になる -
③
btoa(binaryString)… 255以下の文字しか含まないので、今度こそ例外を出さずにBase64化できる
要は「btoa が求めるバイナリ文字列を、UTF-8で自前で用意してあげる」だけ。この一段を挟むことで、日本語でも絵文字でも中国語でも、どんな文字でもBase64にできる。
デコードは逆順にたどる
デコードは完全に逆の手順。atob でバイナリ文字列に戻し、そこから1文字ずつバイトを取り出して Uint8Array に詰め直し、TextDecoderでUTF-8として解釈する。
function decodeBase64(b64: string, urlSafe: boolean): string {
let normalized = b64
if (urlSafe) {
normalized = normalized.replace(/-/g, '+').replace(/_/g, '/')
const pad = normalized.length % 4
if (pad === 2) normalized += '=='
else if (pad === 3) normalized += '='
}
const binaryString = atob(normalized) // ① バイナリ文字列に戻す
const bytes = new Uint8Array(binaryString.length)
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i) // ② 1文字ずつバイトを取り出す
}
return new TextDecoder().decode(bytes) // ③ UTF-8として復号
}
atob が返すのもバイナリ文字列(1文字=1バイト)なので、charCodeAt で各バイトの数値を取り出して Uint8Array に詰める。あとは TextDecoder がUTF-8として文字列に戻す。エンコードで挟んだTextEncoderの対称になっている。
URLセーフBase64とパディングの復元
通常のBase64は A-Z a-z 0-9 + / の64文字と、長さを4の倍数に揃えるパディング = を使う。だが + / = はURLの中で意味を持つ文字なので、クエリパラメータやJWTに埋め込むと壊れる。そこでURLセーフBase64では +→-、/→_ に置換し、末尾の = を落とす。
エンコード側は上のコードの .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') の一行。問題はデコード側で、落とした = を復元しないと atob が正しく解釈できない。ここで使うのが length % 4。
Base64は4文字ごとに3バイトを表すので、パディングを除いた長さを4で割った余りから、必要な = の数が逆算できる。
- 余り
2… 本来==が付いていた →==を足す - 余り
3… 本来=が付いていた →=を足す - 余り
0… パディング無しでちょうど4の倍数 → 何もしない
(余り 1 はBase64として不正な長さ)。- _ を + / に戻し、パディングを復元してから atob に渡す。JWTのペイロードがURLセーフBase64なので、JWTデコーダでも同じパディング復元をやっている。
学び:fromCharCode(...bytes) は巨大入力で落ちる
この実装、テキスト変換としては十分だが、String.fromCharCode(...bytes) のスプレッド展開には落とし穴がある。スプレッドはバイト列を関数の「引数」に展開するので、入力が非常に大きい(数十万バイト規模の)とき、引数の数が処理系の上限を超えて RangeError: Maximum call stack size exceeded で落ちることがある。
テキストのBase64変換ならまず問題にならない範囲だが、もし大きなバイナリ(画像ファイルなど)をBase64化する用途なら、fromCharCode をチャンクに分けて回すか、そもそも FileReader.readAsDataURL() にデータURI化を任せるのが安全。「文字列→バイト列→バイナリ文字列」という橋渡しの都合が、規模が大きくなると顔を出す、という一例。
なお、こうした煩わしさを解消する Uint8Array.prototype.toBase64 / Uint8Array.fromBase64 がJavaScriptの標準に入りつつある。バイト列とBase64を直接変換できるので、将来的には btoa/atob の橋渡しは不要になっていく方向。ただし対応環境が揃うまでは、上のTextEncoder方式が確実に動く。
最後に念のため——Base64はエンコードであって暗号化ではない。3バイトを4文字で表すのでデータ量は約33%増えるが、誰でも一瞬でデコードできる。機密情報を隠す用途には使えない。中身を守りたいならテキスト暗号化(AES-256)のような本物の暗号を使う。
まとめ
-
btoaは "binary to ASCII"。各文字を0〜255の1バイトとみなすバイナリ文字列専用なので、UTF-16のマルチバイト文字を直接渡すとInvalidCharacterErrorになる - 正しくは「文字列 →(
TextEncoder)→ UTF-8バイト列 →(String.fromCharCode)→ バイナリ文字列 →btoa」。デコードはatob→charCodeAtでバイト取り出し →TextDecoderの逆順 - URLセーフBase64は
+→-/→_・末尾=除去。デコードでは逆変換し、length % 4の余り(2→==, 3→=)からパディングを復元する -
fromCharCode(...bytes)のスプレッドは巨大入力でスタック上限に当たる。大きなバイナリは分割かreadAsDataURLを使う。標準のUint8Array.toBase64が普及すればこの橋渡し自体が不要になる
日本語・絵文字入りのBase64を確認したいときにどうぞ。入力はブラウザの外に出ない。
ぱんだツールズ では他にも JWTデコーダ・テキスト暗号化・URLエンコード・ハッシュ生成など、開発者向けのブラウザ完結ツールを多数公開中。全部無料・登録不要・入力はサーバーに送られない。
https://sakutto-panda.com
この記事は Zenn にも同じ内容を投稿しています。