[UNICODE] UTF-8とUnicodeのためのBase64
btoa('héllo') は例外を投げます。btoa('日本語') も例外を投げます。これはバグではありません — 1990年代のAPIと2020年代のテキストの衝突です。Unicodeを正しくBase64エンコードする方法を、バイト単位で解説します。
// 罠
ブラウザの btoa() は無害に見えます。文字列を渡せば、Base64が返ってきます。しかしある日、ユーザーが名前を提出します — Renée — するとコードが InvalidCharacterError: The string to be encoded contains characters outside of the Latin1 range でクラッシュします。
これは変わった挙動でもなく、JavaScript単独のせいでもありません。btoa() は、U+00FF以下に留まる限り「文字列」と「バイト」が同じものとして扱われていた時代に仕様化されました。Unicodeが世界を食い尽くしましたが、btoa() の契約はLatin-1に凍結されたままです。西欧文字を超えるものをBase64エンコードするには、まずUTF-8を経由する必要があります。
// 30秒で分かる修正
// Browser — encode any Unicode string to Base64
function encodeUtf8Base64(str) {
const bytes = new TextEncoder().encode(str); // UTF-8 bytes
let bin = '';
for (const b of bytes) bin += String.fromCharCode(b); // Latin-1 wrapper
return btoa(bin); // now btoa can handle it
}
// Browser — decode Base64 back to a Unicode string
function decodeUtf8Base64(b64) {
const bin = atob(b64);
const bytes = Uint8Array.from(bin, c => c.charCodeAt(0));
return new TextDecoder().decode(bytes); // UTF-8 → string
}
// Usage
encodeUtf8Base64('Renée'); // 'UmVuw6ll'
encodeUtf8Base64('日本語'); // '5pel5pys6Kqe'
encodeUtf8Base64('👋 hello'); // '8J+RiyBoZWxsbw=='
decodeUtf8Base64('5pel5pys6Kqe'); // '日本語'
// なぜこれが動くのか — UTF-8への簡単な寄り道
JavaScriptの文字列はUTF-16コードユニットのシーケンスです。文字 日 はU+65E5であり、これは単一の16ビットコードユニットです。絵文字 👋(U+1F44B)は基本多言語面の外にあり、2つの16ビットコードユニットの サロゲートペア で表現されます。
btoa() はコードユニットではなく バイト を必要とします。しかも、どんなバイトでもよいわけではなく、0〜255の範囲のLatin-1バイトを求めます。UTF-16コードユニットは日常的に255を超えます(例:日 = 0x65E5 = 26085)ので、btoa() は例外を投げます。
UTF-8はUnicode文字集合全体の8ビットクリーンなエンコーディングです。すべてのUnicode文字が1〜4バイトになり、どのバイトも0〜255の範囲に収まることが保証されます。まず文字列をUTF-8バイト列に変換し、それから btoa() を騙してそれらのバイトをLatin-1文字列(1バイト1文字)として見せれば、すべてが整います:btoa() は扱えるバイトを見て、受信側は同じバイト列をUTF-8に戻してデコードします。
// TEXTENCODERは内部で何をしているか
TextEncoder は、JavaScriptの文字列をUTF-8バイト列に変換する標準ベースの方法です。2017年ごろからあらゆるブラウザに搭載され、Node.js 11+ではグローバルとして利用できます。
生成されるバイト列を覗いてみましょう:
new TextEncoder().encode('A');
// Uint8Array [ 0x41 ] (1 byte)
new TextEncoder().encode('é');
// Uint8Array [ 0xC3, 0xA9 ] (2 bytes)
new TextEncoder().encode('日');
// Uint8Array [ 0xE6, 0x97, 0xA5 ] (3 bytes)
new TextEncoder().encode('👋');
// Uint8Array [ 0xF0, 0x9F, 0x91, 0x8B ] (4 bytes)
new TextEncoder().encode('Renée');
// Uint8Array [ 0x52, 0x65, 0x6E, 0xC3, 0xA9, 0x65 ] (6 bytes for 5 chars)
// NODE.JS: BUFFERはデフォルトでUTF-8対応
Nodeの Buffer クラスは、UTF-8変換を内部で行います。TextEncoder はまったく必要ありません。Buffer.from() に文字列を渡すと、別のエンコーディングを明示的に指定しない限り、UTF-8としてエンコードされます。そのため、Nodeでのサーバーサイド Base64 は1行で済みます:
// Encode
Buffer.from('日本語').toString('base64');
// '5pel5pys6Kqe'
// Decode
Buffer.from('5pel5pys6Kqe', 'base64').toString();
// '日本語'
// Explicit UTF-8 (the default, but clearer)
Buffer.from('Renée', 'utf8').toString('base64');
// 'UmVuw6ll'
// 各言語でのUNICODE BASE64
// Python
import base64
base64.b64encode('日本語'.encode('utf-8'))
# b'5pel5pys6Kqe'
base64.b64decode('5pel5pys6Kqe').decode('utf-8')
# '日本語'
// PHP
base64_encode('日本語'); // '5pel5pys6Kqe'
base64_decode('5pel5pys6Kqe'); // '日本語'
// (PHP strings are byte strings, so UTF-8 is handled implicitly
// if your source file is saved as UTF-8.)
// Java
import java.util.Base64;
import java.nio.charset.StandardCharsets;
Base64.getEncoder().encodeToString("日本語".getBytes(StandardCharsets.UTF_8));
// '5pel5pys6Kqe'
new String(Base64.getDecoder().decode("5pel5pys6Kqe"), StandardCharsets.UTF_8);
// '日本語'
// Go
import "encoding/base64"
base64.StdEncoding.EncodeToString([]byte("日本語"))
// '5pel5pys6Kqe'
b, _ := base64.StdEncoding.DecodeString("5pel5pys6Kqe")
string(b)
// '日本語'
// Ruby (source file must be UTF-8)
require 'base64'
Base64.strict_encode64('日本語') # '5pel5pys6Kqe'
Base64.decode64('5pel5pys6Kqe') # '日本語'
// Rust
use base64::{Engine, engine::general_purpose::STANDARD};
STANDARD.encode("日本語"); // "5pel5pys6Kqe"
STANDARD.decode("5pel5pys6Kqe")
.map(|b| String::from_utf8(b).unwrap())?; // "日本語"
// Shell (bash/zsh with GNU coreutils)
echo -n '日本語' | base64 # '5pel5pys6Kqe'
echo -n '5pel5pys6Kqe' | base64 -d # '日本語'
// UNICODEのよくある失敗
-
>
デコード時の文字化け —
atob()で直接デコードしたため、受信側に文字化けが表示される。修正:TextDecoderでラップする。 -
>
サロゲートペアエラー — コードが文字数で文字列を分割し、👋 のサロゲートペアの中間に当たってしまう。修正:文字ごとではなく、文字列全体に
TextEncoderを使う。 -
>
BOMの問題 — ソーステキストがUTF-8 BOM(
EF BB BF)で始まる。それも一緒にエンコードされ、受信側に迷子の\uFEFFが現れる。エンコード前にBOMを削除する。 -
>
誤った文字セットメタデータ — UTF-8バイトのBase64を、
charset=iso-8859-1とラベル付けされたフィールドに入れる。デコーダーはラベルを使ってバイトを解釈します。メタデータを実際のエンコーディングと常に揃えましょう。 -
>
URLセーフBase64の混乱 — 保管用にbase64urlを使ったが、デコード前に標準アルファベットに戻すのを忘れた。修正:
replace(/-/g, '+').replace(/_/g, '/')で正規化し、4の倍数にパディングする。 -
>
ロケール依存のエンコーディング — Python 2では
'日本語'.encode()がデフォルトでASCIIになりクラッシュしました。現代のランタイムでは常にutf-8を明示的に指定しましょう。
// ラウンドトリップテスト — 決定的な証明
Base64パイプラインがUnicodeを正しく扱うかを検証する最良の方法は、ラウンドトリップテストです。既知の良好な文字列をエンコードし、デコードして、元とバイト単位で比較します。
// Vitest / Jest / any test runner
const samples = [
'hello',
'héllo',
'日本語',
'한국어',
'👋 Renée — 日本語 — مرحبا',
'\n\t\0 edge cases',
'𐀀 ancient greek (U+10000)',
];
for (const s of samples) {
const encoded = encodeUtf8Base64(s);
const decoded = decodeUtf8Base64(encoded);
expect(decoded).toBe(s);
expect([...new TextEncoder().encode(s)])
.toEqual([...new TextEncoder().encode(decoded)]);
}
// バイナリのBASE64 vs テキストのBASE64
細かいが重要な区別です。PNGファイルをBase64化するとき、入力はすでにバイト列です — Unicodeの問いに答える必要はありません。そのバイトをそのままエンコーダーに流すだけです。
文字列をBase64化するときは、入力はUnicodeコードポイントのシーケンスであって、バイトではありません。エンコーダーが何かを見る前に、バイトエンコーディング(2026年では事実上常にUTF-8)を選ぶ必要があります。生成されるBase64文字列は、受信側が同じバイトエンコーディングで戻してくれる場合にのみ有効です。
これが「なぜ自分のBase64が間違っているのか」というチケットの大半の原因です:2つのシステムが、異なる基礎バイトエンコーディング(片方がUTF-8、もう片方がUTF-16やLatin-1)を使って同じ論理的な文字列をBase64エンコードし、受信側のデコーダーは誤ったバイトを嬉々として再現してしまうのです。
// 常にUTF-8を使うべきか?
はい、別のものを指定するレガシーシステム内で作業している場合を除いて。UTF-8は次のような特徴を持ちます:
• ウェブで支配的なエンコーディング(2026年には97%以上のウェブサイト)
• 現代のプログラミング言語すべての標準ライブラリでのデフォルト
• 先頭128コードポイントでASCII互換なため、英語を決して壊さない
• すべてのUnicode文字について明確に定義され、ラウンドトリップ可能
• 新しいプロトコルについてIETFが推奨する文字セット(RFC 2277)
古い代替品(UTF-16、UTF-32、Latin-1、GBK、Shift-JIS)は依然としてレガシーデータベース、Windows API、一部の通信プロトコルに現れます。そのようなシステムからデータが来た場合は、境界でUTF-8に変換し、UTF-8をBase64化し、エンコーディングの決定をペイロード形式で文書化してください。
// TEXTENCODERなしのフォールバック(レガシーブラウザ)
TextEncoder のない非常に古いブラウザ(IE11以前)をサポートする必要がある場合、古典的なトリックは encodeURIComponent をUTF-8エンコーダーとして使い、unescapeでバイト文字列に戻す方法です。このパターンは、2008年から2020年までのBase64とUnicodeに関するあらゆるStackOverflowの回答に登場しました。
// Legacy browser fallback (pre-2017)
function encodeUtf8Base64Legacy(str) {
return btoa(unescape(encodeURIComponent(str)));
}
function decodeUtf8Base64Legacy(b64) {
return decodeURIComponent(escape(atob(b64)));
}
encodeUtf8Base64Legacy('日本語'); // '5pel5pys6Kqe'
decodeUtf8Base64Legacy('5pel5pys6Kqe'); // '日本語'
// Note: unescape() and escape() are deprecated.
// Only use this pattern if TextEncoder is genuinely unavailable.
// It also mishandles some edge-case surrogates.
// 覚えておくべきこと
- > Base64は文字ではなくバイトをエンコードする。まずバイトエンコーディングを選ぶ — ほぼ必ずUTF-8。
-
>
ブラウザでは:
TextEncoder→String.fromCharCode→btoa→ 片方向。 -
>
ブラウザでは:
atob→Uint8Array.from→TextDecoder→ もう片方向。 -
>
Node.jsでは:
Buffer.from(str).toString('base64')とBuffer.from(b64, 'base64').toString()— 両方ともデフォルトでUTF-8。 -
>
Python / Go / Java では:
.encode()/.getBytes()に常に明示的な'utf-8'を渡す。 - > 出荷前に、必ず実際のUnicode(絵文字、CJK、アラビア語、結合文字)でラウンドトリップテストする。
// 関連
• Base64とは何か、どのように動作するのか? — ここでのすべてを支える6ビットグルーピング。
• JavaScript(ブラウザ + Node)でのBase64 — フルなクロスランタイムヘルパー。
• URLセーフ vs 標準Base64 — 直交した、これも判断が必要な選択。
• ライブのBase64エンコーダーを試す — 同じUnicode対応パイプラインを、あなたのブラウザで。