1. ホーム
  2. javascript

[解決済み] Javascriptのatobを使ってbase64をデコードすると、utf-8の文字列が正しくデコードされない

2022-04-22 21:23:16

質問

私はJavascriptを使用しています。 window.atob() 関数を使って base64 エンコードした文字列 (具体的には GitHub API の内容を base64 でエンコードしたもの) をデコードします。問題は、ASCII エンコードされた文字が戻ってくることです (たとえば ⢠ではなく ). どうすれば、受信したbase64エンコードされたストリームを適切に処理し、utf-8としてデコードできるようになりますか?

どのように解決するのですか?

ユニコード問題

JavaScript (ECMAScript) は成熟してきましたが、Base64、ASCII、そして Unicode エンコーディングのもろさは、多くの頭痛の種でした(その多くはこの質問の歴史にあります)。

次のような例を考えてみましょう。

const ok = "a";
console.log(ok.codePointAt(0).toString(16)); //   61: occupies < 1 byte

const notOK = "✓"
console.log(notOK.codePointAt(0).toString(16)); // 2713: occupies > 1 byte

console.log(btoa(ok));    // YQ==
console.log(btoa(notOK)); // error

なぜ、このようなことになるのでしょうか。

<ブロッククオート

Base64は、設計上、バイナリデータを入力として想定しています。JavaScriptの文字列で言えば、これは各文字が1バイトしか占めない文字列を意味します。したがって、1バイト以上を占める文字を含む文字列をbtoa()に渡すと、バイナリデータとはみなされないため、エラーが発生します。

出典 MDN (2021)

MDN の元記事では、壊れた window.btoa.atob が、現代のECMAScriptでは修正されている。オリジナルの、今は亡き MDN の記事で説明されています。

<ブロッククオート

ユニコード問題。 以来 DOMString は16ビットでエンコードされた文字列であり、ほとんどのブラウザで window.btoa ユニコード文字列に対して Character Out Of Range exception が8ビットバイトの範囲(0x00~0xFF)を超える場合。


バイナリ互換による解決策

(ASCII base64による解決策はスクロールしてください。)

出典 MDN (2021)

MDNが推奨する解決策は、バイナリ文字列表現との間で実際にエンコードすることです。

エンコーディング UTF8 ⇢ バイナリ

// convert a Unicode string to a string in which
// each 16-bit unit occupies only one byte
function toBinary(string) {
  const codeUnits = new Uint16Array(string.length);
  for (let i = 0; i < codeUnits.length; i++) {
    codeUnits[i] = string.charCodeAt(i);
  }
  return btoa(String.fromCharCode(...new Uint8Array(codeUnits.buffer)));
}

// a string that contains characters occupying > 1 byte
let encoded = toBinary("✓ à la mode") // "EycgAOAAIABsAGEAIABtAG8AZABlAA=="

バイナリ ⇢ UTF-8 のデコード

function fromBinary(encoded) {
  binary = atob(encoded)
  const bytes = new Uint8Array(binary.length);
  for (let i = 0; i < bytes.length; i++) {
    bytes[i] = binary.charCodeAt(i);
  }
  return String.fromCharCode(...new Uint16Array(bytes.buffer));
}

// our previous Base64-encoded string
let decoded = fromBinary(encoded) // "✓ à la mode"

ここで少し失敗するのは、エンコードされた文字列である EycgAOAAIABsAGEAIABtAG8AZABlAA== という文字列は、もはや前の解決策の文字列 4pyTIMOgIGxhIG1vZGU= . これは、UTF-8でエンコードされた文字列ではなく、バイナリでエンコードされた文字列であるためです。もしこれがあなたにとって問題でなければ(つまり、他のシステムからUTF-8で表現された文字列を変換するのでなければ)、そのままで大丈夫でしょう。しかし、UTF-8の機能を維持したいのであれば、以下で説明する解決策を使用したほうがよいでしょう。


ASCII base64の相互運用性を考慮した解決策

この質問の歴史全体から、長年にわたって壊れたエンコーディングシステムを回避するために、いかに多くの異なる方法が取られてきたかがわかります。オリジナルのMDN記事はもう存在しませんが、この解決策は間違いなくより良いもので、例えば、デコードできるプレーンテキストのbase64文字列を維持しながら、"The Unicode Problem" を解決する素晴らしい仕事をしています。 base64decode.org .

この問題を解決する方法として、2つの方法が考えられます。

  • 1つ目は、文字列全体をエスケープする方法です(UTF-8の場合。 encodeURIComponent を使用し、エンコードします。
  • 2つ目は、UTF-16を変換して DOMString をUTF-8の文字配列に変換し、エンコードします。

以前の解決策に関するメモ:MDN の記事では、もともと unescapeescape を解決するために Character Out Of Range 例外の問題がありましたが、その後、非推奨となりました。他の回答では、この問題を回避するために decodeURIComponentencodeURIComponent しかし、これは信頼性が低く、予測不可能であることが証明されています。この回答に対する最新の更新では、最新のJavaScript関数を使用して、速度を向上させ、コードを近代化しました。

時間を節約したいのであれば、ライブラリの利用を検討するのも手です。

UTF8 ⇢ base64のエンコーディング

    function b64EncodeUnicode(str) {
        // first we use encodeURIComponent to get percent-encoded UTF-8,
        // then we convert the percent encodings into raw bytes which
        // can be fed into btoa.
        return btoa(encodeURIComponent(str).replace(/%([0-9A-F]{2})/g,
            function toSolidBytes(match, p1) {
                return String.fromCharCode('0x' + p1);
        }));
    }
    
    b64EncodeUnicode('✓ à la mode'); // "4pyTIMOgIGxhIG1vZGU="
    b64EncodeUnicode('\n'); // "Cg=="

base64 ⇢ UTF8 をデコードする。

    function b64DecodeUnicode(str) {
        // Going backwards: from bytestream, to percent-encoding, to original string.
        return decodeURIComponent(atob(str).split('').map(function(c) {
            return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
        }).join(''));
    }
    
    b64DecodeUnicode('4pyTIMOgIGxhIG1vZGU='); // "✓ à la mode"
    b64DecodeUnicode('Cg=='); // "\n"

(なぜ、こんなことをする必要があるのでしょうか? ('00' + c.charCodeAt(0).toString(16)).slice(-2) は1文字の文字列に0を付加します。 c == \n は、その c.charCodeAt(0).toString(16) を返します。 a 強制的に a として表現されます。 0a ).


TypeScript対応

同じソリューションにTypeScriptの互換性を追加したものがこちらです(@MA-Maddin経由)。

// Encoding UTF8 ⇢ base64

function b64EncodeUnicode(str) {
    return btoa(encodeURIComponent(str).replace(/%([0-9A-F]{2})/g, function(match, p1) {
        return String.fromCharCode(parseInt(p1, 16))
    }))
}

// Decoding base64 ⇢ UTF8

function b64DecodeUnicode(str) {
    return decodeURIComponent(Array.prototype.map.call(atob(str), function(c) {
        return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2)
    }).join(''))
}


最初の解決策(非推奨)

これは、使用した escapeunescape (これは現在では非推奨ですが、すべてのモダンブラウザで動作します)。

function utf8_to_b64( str ) {
    return window.btoa(unescape(encodeURIComponent( str )));
}

function b64_to_utf8( str ) {
    return decodeURIComponent(escape(window.atob( str )));
}

// Usage:
utf8_to_b64('✓ à la mode'); // "4pyTIMOgIGxhIG1vZGU="
b64_to_utf8('4pyTIMOgIGxhIG1vZGU='); // "✓ à la mode"


最後にもうひとつ。私がこの問題に最初に遭遇したのは、GitHub APIを呼び出したときでした。モバイルの)Safari で正しく動作させるためには、base64 のソースからすべての空白文字を削除する必要がありました。 前に ソースをデコードすることもできました。これが2021年にも当てはまるかどうかは分かりませんが。

function b64_to_utf8( str ) {
    str = str.replace(/\s/g, '');    
    return decodeURIComponent(escape(window.atob( str )));
}