kodning - Att använda Javascripts atob för att avkoda base64 avkodar inte korrekt utf-8-strängar


Jag använder Javascript-funktionen window.atob() för att avkoda en base64-kodad sträng (specifikt det base64-kodade innehållet från GitHub API). Problemet är att jag får tillbaka ASCII-kodade tecken (som ⢠istället för ™). Hur kan jag korrekt hantera den inkommande base64-kodade strömmen så att den avkodas som utf-8?

Svar #1

Unicode-problemet

Även om JavaScript (ECMAScript) har mognat, har sårbarheten hos Base64-, ASCII- och Unicode-kodning orsakat många huvudvärk (mycket av det finns i historiken för denna fråga).

Överväg följande exempel:

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

Varför stöter vi på detta?

Base64 förväntar sig enligt design binär data som sin input. När det gäller JavaScript-strängar innebär detta strängar där varje tecken upptar endast en byte. Så om du skickar en sträng till btoa() som innehåller tecken som upptar mer än en byte, kommer du att få ett fel, eftersom detta inte anses vara binär data.

Källa: MDN (2021)

Den ursprungliga MDN-artikeln täckte också den trasiga naturen hos window.btoa och .atob, som sedan dess har reparerats i modern ECMAScript. Den ursprungliga, nu döda MDN-artikeln förklarade:

Problemet med "Unicode" Eftersom DOMStrings är 16-bitarskodade strängar, kommer det i de flesta webbläsare att orsaka ett Character Out Of Range exception när man anropar window.btoa på en UTF-8-sträng om ett tecken överstiger intervallet för en 8-bitarsbyte (0x00~0xFF).


Lösning med binär interoperabilitet

Om du inte är säker på vilken lösning du vill ha, är detta förmodligen den du vill ha. Fortsätt bläddra för ASCII base64-lösningen och historiken för detta svar.


Du kan också vara intresserad av några av de svar som använder TextDecoder som https://stackoverflow.com/a/77383580/1214800

Källa: MDN (2021)

Lösningen som rekommenderas av MDN är att faktiskt koda till och från en binär strängrepresentation:

Kodning UTF-8 ⇢ binär

// convert a UTF-8 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=="

Avkodning av binär ⇢ UTF-8

function fromBinary(encoded) {
  const 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"

Där detta misslyckas lite är att du kommer märka att den kodade strängen EycgAOAAIABsAGEAIABtAG8AZABlAA== inte längre matchar den föregående lösningens sträng 4pyTIMOgIGxhIG1vZGU=. Detta beror på att det är en binärt kodad inbyggd JavaScript-sträng, inte en UTF8-kodad sträng. Om detta inte spelar någon roll för dig (dvs. om du inte konverterar strängar som representeras i Unicode från ett annat system eller om du är okej med JavaScripts inbyggda UTF-16LE-kodning) så är du redo att gå vidare. Om du däremot vill bevara UTF-8-funktionaliteten är det bättre att använda lösningen som beskrivs nedan.


Lösning med ASCII base64-interoperabilitet

Hela historien om denna fråga visar bara hur många olika sätt vi har varit tvungna att kringgå trasiga kodningssystem genom åren. Även om den ursprungliga MDN-artikeln inte längre finns, är denna lösning fortfarande sannolikt en bättre lösning, och gör ett utmärkt jobb med att lösa "Unicode-problemet" samtidigt som den behåller vanliga textbaserade base64-strängar som du kan dekoda på, säg, base64decode.org.

Det finns två möjliga metoder för att lösa detta problem:

  • Den första är att escape hela strängen (se encodeURIComponent) och sedan koda den;
  • det andra är att konvertera UTF-16 DOMString till en osignerad 8-bitars heltalsarray (Uint8Array) av tecken och sedan koda den.

En anteckning om tidigare lösningar: MDN-artikeln föreslog ursprungligen att använda unescape och escape för att lösa problemet med Character Out Of Range-undantaget, men de har sedan dess blivit föråldrade. Några andra svar här har föreslagit att kringgå detta med decodeURIComponent och encodeURIComponent, detta har visat sig vara opålitligt och oförutsägbart. Den senaste uppdateringen av detta svar använder moderna JavaScript-funktioner för att förbättra hastighet och modernisera koden.

Om du försöker spara lite tid kan du också överväga att använda ett bibliotek:

  • js-base64 (NPM, utmärkt för Node.js)
  • base64-js

Kodning UTF-8 ⇢ base64

function b64EncodeUnicode(str) {
    // first we use encodeURIComponent to get percent-encoded Unicode,
    // 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=="

Dekoda base64 ⇢ UTF-8

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"

(Varför behöver vi göra detta? ('00' + c.charCodeAt(0).toString(16)).slice(-2) lägger till en 0 framför enkla teckensträngar, till exempel när c == \n, returnerar c.charCodeAt(0).toString(16) a, vilket tvingar a att representeras som 0a).


TypeScript-stöd

Här är samma lösning med lite extra TypeScript-kompatibilitet (via @MA-Maddin):

// Encoding UTF-8 ⇢ 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 ⇢ UTF-8

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

Den första lösningen (föråldrad)

Detta använde escape och unescape (som nu är föråldrade, men detta fungerar fortfarande i alla moderna webbläsare):

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"

Och en sista sak: Jag stötte först på detta problem när jag använde GitHub API. För att få detta att fungera korrekt på (Mobil) Safari var jag faktiskt tvungen att ta bort alla mellanslag från base64-koden innan jag ens kunde avkoda koden. Om detta fortfarande är relevant 2021 vet jag inte:

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

Svar #2

Avkodning av base64 till UTF8-sträng

Nedan är det mest röstade svaret av @brandonscript

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(''));
}

Ovanstående kod kan fungera, men den är väldigt långsam. Om din input är en mycket stor base64-sträng, till exempel 30 000 tecken för ett base64 HTML-dokument. Det kommer att kräva mycket beräkning.

Här är mitt svar, använd inbyggda TextDecoder, nästan 10 gånger snabbare än koden ovan för stor input.

function decodeBase64(base64) {
    const text = atob(base64);
    const length = text.length;
    const bytes = new Uint8Array(length);
    for (let i = 0; i < length; i++) {
        bytes[i] = text.charCodeAt(i);
    }
    const decoder = new TextDecoder(); // default is utf-8
    return decoder.decode(bytes);
}

Svar #3

Saker förändras. Metoderna escape/unescape har blivit föråldrade.

Du kan URI-koda strängen innan du Base64-kodar den. Observera att detta inte producerar Base64-kodat UTF8, utan snarare Base64-kodat URL-kodat data. Båda parter måste vara överens om samma kodning.

Se fungerande exempel här: http://codepen.io/anon/pen/PZgbPW

// encode string
var base64 = window.btoa(encodeURIComponent('€ 你好 æøåÆØÅ'));
// decode string
var str = decodeURIComponent(window.atob(tmp));
// str is now === '€ 你好 æøåÆØÅ'

För OP:s problem bör ett tredjepartsbibliotek som js-base64 lösa problemet.

Svar #4

Den kompletta artikeln som fungerar för mig: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Base64_encoding_and_decoding

Delen där vi kodar från Unicode/UTF-8 är

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"

Detta är en av de mest använda metoderna nuförtiden.

Svar #5

Om du föredrar att behandla strängar som bytes kan du använda följande funktioner

function u_atob(ascii) {
    return Uint8Array.from(atob(ascii), c => c.charCodeAt(0));
}

function u_btoa(buffer) {
    var binary = [];
    var bytes = new Uint8Array(buffer);
    for (var i = 0, il = bytes.byteLength; i < il; i++) {
        binary.push(String.fromCharCode(bytes[i]));
    }
    return btoa(binary.join(''));
}


// example, it works also with astral plane characters such as '𝒞'
var encodedString = new TextEncoder().encode('✓');
var base64String = u_btoa(encodedString);
console.log('✓' === new TextDecoder().decode(u_atob(base64String)))

Svar #6

TL;DR enradslösning:

const base64Decode = base64EncodedString =>
  new TextDecoder().decode(Uint8Array.from(atob(base64EncodedString), m => m.charCodeAt(0)));

const decodedString = base64Decode('R8O8bnRlciBNw7hsbGVyIFPDoW5jaGV6IFBlw7FhIPCfjokK');
console.log(decodedString); // Günter Møller Sánchez Peña 🎉

Relaterade artiklar