DEV Community

Gu
Gu

Posted on

The same TextDecoder call gives different characters in Node and Chrome

Five days ago, at the end of a debugging session, I wrote this in the notes file for qbofile, a set of browser-based converters I maintain for the file formats accounting software uses:

latin1 is a WHATWG alias for windows-1252, not ISO-8859-1. So the browser shows 2014 while the Node test — Buffer.toString('latin1'), true ISO-8859-1 — shows 97. Same bytes, two decoders, two results.

Every fact in that note is correct. The explanation at the end is not, and it is the reason I stopped looking.

Two different APIs giving two different answers is a tidy story. It also implies a fix: if you want the browser's answer in Node, use the browser's API.

So this week I used the browser's API in Node.

const d = new TextDecoder('latin1');
d.encoding;                                  // 'windows-1252'
d.decode(Uint8Array.from([0x92]));           // ?
Enter fullscreen mode Exit fullscreen mode
                       Chrome 148        Node v22.18.0
.encoding              windows-1252      windows-1252
decode 0x92            U+2019   ’        U+0092
decode 0x97            U+2014   —        U+0097
decode 0x85            U+2026   …        U+0085
Enter fullscreen mode Exit fullscreen mode

Identical call, identical reported encoding, different characters. The browser applies the Windows-1252 index for 0x80–0x9F. Node hands back the raw code point, which is ISO-8859-1 behaviour under a Windows-1252 label.

The first row is the problem

When output looks wrong, the instinct is to check which decoder you actually got. .encoding is how you check, and it says windows-1252 in both places.

That reads like confirmation. It is not one. There is no warning, no error, and no assertion you can put on .encoding that catches this.

Why I was in that range at all

Those converters run entirely in the browser — nobody wants to upload a bank statement to a server. And plenty of banks still emit Windows-1252. A payee with an apostrophe in its name arrives as byte 0x92, not as UTF-8. Curly quotes, dashes, the bullet, the euro sign: twenty-seven characters that Windows-1252 has and Latin-1 does not, all of them sitting in 0x80–0x9F.

That is the whole range this bug lives in. It is not an edge case for anyone reading bank files.

It is not a broken ICU build

That was my first guess — a small-icu Node without the full encoding tables. It is not:

new TextDecoder('shift_jis').decode(Uint8Array.from([0x82,0xA0]));  // 'あ'  correct
new TextDecoder('koi8-r').decode(Uint8Array.from([0xC1]));          // 'а'  correct
Enter fullscreen mode Exit fullscreen mode

Other legacy encodings decode correctly. It is specifically 0x80–0x9F under windows-1252 — precisely the range that distinguishes it from ISO-8859-1.

Where it would bite

It needs one arrangement, which is a common one: your assertions run in Node and your code runs in a browser.

Write a test that decodes a Windows-1252 fixture and checks the result, and for every byte in that range the test is describing Node rather than your code. Green means your expected value matches Node's decoder. Red means it does not. Neither outcome tells you what your users get.

I have 476 tests, they all pass, and none of them decode bytes — the fixtures are code points, so the decoder never enters the picture. That was not foresight. My encoder takes a string and returns bytes, so a string was the obvious thing to hand it.

What I changed

Not the code. The note:

TextDecoder('latin1').encoding === 'windows-1252'   both runtimes
decode 0x80–0x9F  → windows-1252 index    Chrome 148
                  → raw code point        Node v22.18.0
Enter fullscreen mode Exit fullscreen mode

The original was not wrong. It was written in the same voice I use for things I have actually run, and half of it I had not. It now says which runtime each line was observed in, which makes it uglier and means I no longer have to remember which half to trust.

Top comments (0)