30歳からのプログラミング

30歳無職から独学でプログラミングを開始した人間の記録。

JavaScript で Base64

この記事では Base64 やbtoa、そしてbtoaの挙動を理解するために必要な Latin1 について説明していく。

この記事に出てくるコードの動作確認は以下の環境で行った。

  • Deno 1.28.3
  • TypeScript 4.8.3

概要

Base64 はデータのエンコード方式の一種。
全てのデータをaz(26 文字)、AZ(26 文字)、09(10 文字)、そして+/を合わせた計 64 文字、さらにそこに=を組み合わせたテキストで表現する。

そうすることで、扱えるデータに制限のある環境において、その制限を超えたデータを扱えるようになる。
例えば電子メールではテキストデータしか扱えないが、バイナリデータを Base64 にエンコードしてしまうことで、問題なくバイナリデータを送信できるようになる。あとは受信側で Base64 をデコードすればよい。
他にも、 Data URL でバイナリデータを扱う際にも Base64 が使われている。

btoa と atob

JavaScript には、文字列を Base64 でエンコード・デコードするための関数が予め用意されている。

エンコードにはbtoaを使い、デコードにはatobを使う。

console.log(btoa("a")); // YQ==
console.log(atob("YQ==")); // a

だがbtoaは、あらゆる文字をエンコードできるわけではない。むしろエンコードできない文字のほうが多い。
例えば日本語を渡すとエラーになってしまう。

// error: Uncaught InvalidCharacterError: The string to be encoded contains characters outside of the Latin1 range.
console.log(btoa("あ"));

Latin1 とは何か

エラーメッセージはThe string to be encoded contains characters outside of the Latin1 range.。「エンコードする文字列に、Latin1 の範囲外の文字が含まれている」とのこと。
これにより、btoaには Latin1 範囲内の文字しか渡せないことが分かる。

では Latin1 とは何か。

ISO/IEC 8859 という文字コードのパート 1 の通称が、 Latin1 。
ISO/IEC 8859 は 8 ビット 256 文字の文字コードであり、複数の「パート」がある。256 文字のうち前半 128 文字は全パート共通で、その部分は ASCII 文字コードと同一になっている。そして後半 128 文字は、パートによって内容が異なる。
目的に応じてパートを選択して使用するようになっているのだが、そのうちのパート 1 が Latin1 である。

Latin1 で使える文字は以下で見れる。
https://ja.wikipedia.org/wiki/ISO/IEC_8859-1#%E7%AC%A6%E5%8F%B7%E8%A1%A8

そして Unicode の最初の 256 個の Code Point(U+0000..U+00FF)は Latin1 と同じ内容になっている(Unicode や Code Point についてはこの記事で説明している)。
JavaScript は Unicode を採用しているため、 JavaScript の文脈においては「Latin1」と言ったときは、その範囲の文字を指すと考えてよい。

U+0100以降の文字をbtoaに渡してみると、確かに失敗する。

let str = String.fromCodePoint(0x61);
console.log(str); // a
console.log(btoa(str)); // YQ==

str = String.fromCodePoint(0xff);
console.log(str); // ÿ
console.log(btoa(str)); // /w==

str = String.fromCodePoint(0x100);
console.log(str); // Ā
console.log(btoa(str)); // error: Uncaught InvalidCharacterError: The string to be encoded contains characters outside of the Latin1 range.

このため、 Latin1 範囲外の文字をbtoaでエンコードするためには、その文字を何らかの方法で Latin1 範囲内の文字に変換する必要がある。そしてそれは、元の文字に戻せる方法でなければならない。そうしないとデコードできない。

具体的な処理の流れは以下のようになる。

  1. 文字を Latin1 範囲内の文字に変換する
  2. 変換後の文字をbtoaに渡す
  3. Base64 でエンコードされた文字が手に入る

デコードして元の文字を手に入れるときは、これと逆のことをやればよい。

  1. エンコードされた文字をatobに渡す
  2. atobから渡された文字に対して、「文字を Latin1 範囲内の文字に変換する」で行ったのと逆の処理を行う
  3. 元の文字が手に入る

encodeURIComponent

Latin1 の文字列を手に入れるための手段のひとつとして、encodeURIComponentがある。

この関数は以下の文字以外の全ての文字をエスケープする。

A-Z a-z 0-9 - _ . ! ~ * ' ( )

アルファベットや数字はもちろん、記号も全て Latin1 の範囲内に収まっている。
つまり Latin1 範囲外の文字は全てエスケープ対象となる。

// 2d
// 5f
// 2e
// 21
// 7e
// 2a
// 27
// 28
// 29
Array.from("-_.!~*'()").forEach((item) => {
  console.log(item.codePointAt(0)?.toString(16));
});

そしてencodeURIComponentの返り値は%XXXXは 16 進数)という形式の文字列になる。%も Latin1 なので(Code Point U+0025)、返り値は必ず Latin1 範囲内の文字列になることが保証される。

そのためencodeURIComponentを使えば、 Latin1 範囲外の文字を含む文字列を、 Latin1 範囲内に収まる形に変換できる。あとはそれをbtoaに渡せばよい。

const original = "あ";

const latin1 = encodeURIComponent(original);
console.log(latin1); // %E3%81%82

const base64 = btoa(latin1);
console.log(base64); // JUUzJTgxJTgy

// 逆の処理を行うと元の文字列が手に入る

const decoded = atob(base64);
console.log(decoded); // %E3%81%82

const restore = decodeURIComponent(decoded);
console.log(restore); // あ

ArrayBuffer を活用する

ArrayBuffer を使うことでも、 Latin1 範囲内の文字列に変換することができる(ArrayBuffer そのものについてはこの記事で説明している)。

JavaScript では文字を、符号なし 16 ビット整数を使って表現している(UTF-16)。
これを、符号なし 8 ビット整数による表現に変えてしまう。そうするとひとつひとつの要素は0..255の範囲内に収まるので、それを Code Point として利用すればそれは必ず Latin1 の範囲内に文字になる。

const str = "あ";

// 符号なし 16 ビット整数を入れていくための「箱」として Uint16Array を用意する
const ta16 = new Uint16Array(str.length);

// JavaScript の Code Unit は符号なし 16 ビット整数なので、そのまま Uint16Array に格納できる
const codeUnit = str.charCodeAt(0);
console.log(codeUnit); // 12354
ta16[0] = codeUnit;
console.log(ta16); // Uint16Array(1) [ 12354 ]

// Uint8Array による表現を手に入れる
const ta8 = new Uint8Array(ta16.buffer);
console.log(ta8); // Uint8Array(2) [ 66, 48 ]

// Uint8Array の各要素は 0..255 の範囲内になるので、それを Code Point として利用すれば必ず Latin1 の範囲内の文字列になる
const latin1 = String.fromCodePoint(...ta8);
console.log(latin1); // B0

// 問題なく btoa を使える
const encoded = btoa(latin1);
console.log(encoded); // QjA=

元の文字列を得るには逆の処理を行う。

const decoded = atob("QjA=");
console.log(decoded); // B0

const ta8 = new Uint8Array(decoded.length);
ta8[0] = decoded.charCodeAt(0);
ta8[1] = decoded.charCodeAt(1);
console.log(ta8); // Uint8Array(2) [ 66, 48 ]

const ta16 = new Uint16Array(ta8.buffer);
console.log(ta16); // Uint16Array(1) [ 12354 ]

const original = String.fromCodePoint(...ta16);
console.log(original); // あ

上記の内容を任意の文字列に対して行えるように関数化したものが、以下になる。

const toBase64 = (str: string): string => {
  const ta16 = new Uint16Array(str.length);
  for (let i = 0; i < ta16.length; i += 1) {
    ta16[i] = str.charCodeAt(i);
  }
  const latin1 = String.fromCodePoint(...new Uint8Array(ta16.buffer));
  return btoa(latin1);
};

const fromBase64 = (encoded: string): string => {
  const decoded = atob(encoded);
  const ta8 = new Uint8Array(decoded.length);
  for (let i = 0; i < ta8.length; i += 1) {
    ta8[i] = decoded.charCodeAt(i);
  }
  return String.fromCodePoint(...new Uint16Array(ta8.buffer));
};

console.log(toBase64("あ")); // QjA=
console.log(fromBase64("QjA=")); // あ

console.log(toBase64("abcあいうえお🐶")); // YQBiAGMAQjBEMEYwSDBKMD3YNtw=
console.log(fromBase64("YQBiAGMAQjBEMEYwSDBKMD3YNtw=")); // abcあいうえお🐶

参考資料