How to check for garbled characters from the wrong encoding

Run node encoding-report.mjs damaged.txt: it maps every character back to its windows-1252 byte and decodes those bytes as UTF-8. A report of cp1252 passes 1 means the text is mojibake and the original is recoverable. U+FFFD 2 means two bytes were destroyed and no repair exists.

Why check this

Two different accidents produce text a tester describes the same way, and only one of them can be fixed after the fact. A UTF-8 payload read by a component that assumed windows-1252 turns é into é: every byte survives, so the original text is still in the data. A windows-1252 file read as UTF-8 turns é into U+FFFD: the byte is gone, and every later export carries the hole.

Run this when an import, an export or a third-party feed changes, on the file as it arrives and again after your own pipeline has touched it. The failure it catches is an overnight job that writes repaired-looking rows into production while the source rows were already unrecoverable.

Prerequisites

node -e "require('fs').writeFileSync('good.txt','José’s café costs 5 €','utf8')"
node -e "require('fs').writeFileSync('legacy.csv',Buffer.from('name,city\nJosé,Genève\n','latin1'))"
node -e "require('fs').writeFileSync('ru.txt','Ёлка','utf8')"

damage.mjs reproduces the fault on purpose: it reads UTF-8 bytes as windows-1252 and writes the result back out as UTF-8.

import { readFileSync, writeFileSync } from 'node:fs';
const text = new TextDecoder('windows-1252').decode(readFileSync(process.argv[2]));
writeFileSync(process.argv[3], text, 'utf8');
console.log(text);

encoding-report.mjs is the check. It builds the reverse of the windows-1252 table out of the decoder itself, so all 256 byte values are covered, and it repeats the undo until the undo stops working.

import { readFileSync } from 'node:fs';

const text = new TextDecoder('utf-8').decode(readFileSync(process.argv[2]));
const dec = new TextDecoder('windows-1252');
const rev = new Map();
for (let b = 0; b < 256; b += 1) rev.set(dec.decode(Uint8Array.of(b)), b);

function undo(s) {
  if ([...s].some((c) => !rev.has(c))) return null;
  try {
    return new TextDecoder('utf-8', { fatal: true })
      .decode(Uint8Array.from([...s], (c) => rev.get(c)));
  } catch { return null; }
}

let passes = 0;
let current = text;
for (let next = undo(current); next !== null; next = undo(current)) {
  current = next;
  passes += 1;
}

console.log('characters   ', [...text].length);
console.log('U+FFFD       ', [...text].filter((c) => c === '�').length);
console.log('cp1252 passes', passes);
console.log('recovered    ', passes > 0 ? current : '(nothing to undo)');

Steps

  1. Step 1.

    Produce the damage yourself, so the shape of it is familiar before it arrives in a bug report.

    node damage.mjs good.txt damaged.txt
    
    José’s café costs 5 €

    é became é and ’ became ’. Those two pairs are the signature of UTF-8 read as windows-1252.

  2. Step 2.

    Run the report on the damaged file.

    node encoding-report.mjs damaged.txt
    
    characters    27
    U+FFFD        0
    cp1252 passes 1
    recovered     José’s café costs 5 €

    21 characters became 27, because each accented letter turned into two. One undo pass recovers the original, so nothing was lost.

  3. Step 3.

    Run the same report on the file that went the other way, decoded as UTF-8 when its bytes were windows-1252.

    node encoding-report.mjs legacy.csv
    
    characters    22
    U+FFFD        2
    cp1252 passes 0
    recovered     (nothing to undo)

    Bytes 0xE9 and 0xE8 were replaced by U+FFFD on decode. No undo exists, because those bytes are no longer in the string. This copy of the data is finished.

  4. Step 4.

    Test for double encoding by damaging the damaged file a second time.

    node damage.mjs damaged.txt double.txt && node encoding-report.mjs double.txt
    
    José’s café costs 5 €
    characters    40
    U+FFFD        0
    cp1252 passes 2
    recovered     José’s café costs 5 €

    cp1252 passes 2 is the count of times the text went through the wrong decoder. A single-pass repair on this file leaves José’s behind and looks like a partial fix.

  5. Step 5.

    Read code points rather than glyphs, on text whose bytes land in the gap of the windows-1252 table.

    node -e "const s=new TextDecoder('windows-1252').decode(require('fs').readFileSync(process.argv[1]));console.log('visible glyphs',[...s].filter(c=>c.codePointAt(0)>0x9f).length);console.log('code points   ',[...s].map(c=>'U+'+c.codePointAt(0).toString(16).toUpperCase().padStart(4,'0')).join(' '))" ru.txt
    
    visible glyphs 7
    code points    U+00D0 U+0081 U+00D0 U+00BB U+00D0 U+00BA U+00D0 U+00B0

    ru.txt holds the Russian word for fir tree. Its first byte pair is D0 81, and 0x81 has no character in windows-1252. Node maps it to U+0081, an invisible control. The damaged string is 8 characters wide and shows 7 glyphs, so a visual comparison undercounts it.

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | cp1252 passes 1 | UTF-8 was read as windows-1252 once | Repair from the report, and fix the reader rather than the data. | | cp1252 passes 2 or more | The same text went through the wrong decoder twice | Repair the same number of times, and find both readers. | | U+FFFD above 0 | Bytes were replaced on decode | Go back to the source file. This copy cannot be repaired. | | Both counts 0 | No windows-1252 damage in this file | The complaint is about a font or a locale, not an encoding. | | More characters than the text should hold | Invisible C1 controls are in the string | Read the code points, as in step 5. |

Common mistakes

Sign: Node reports recoverable mojibake and Python raises UnicodeDecodeError on the same bytes.Cause: Python's cp1252 codec has five undefined byte values: 0x81, 0x8D, 0x8F, 0x90 and 0x9D. The WHATWG table Node follows maps each one to the matching C1 control instead. On the bytes D0 81, Python answers charmap codec can't decode byte 0x81 in position 1, while Node returns a string. Neither is broken, and a repair script has to name the table it used.
Sign: TextDecoder('iso-8859-1') is used to reproduce latin-1 damage and produces windows-1252 damage.Cause: The Encoding Standard lists iso-8859-1 as a label for windows-1252, so that decoder reports its encoding as windows-1252. True latin-1 leaves bytes 0x80 to 0x9F as invisible controls, which is why latin-1 damage carries no visible ’ pair. For real latin-1 in Node, use Buffer.toString('latin1').
Sign: Text that was already correct comes back changed after a repair script runs.Cause: Any string whose characters all map into windows-1252, and whose resulting bytes form valid UTF-8, gets undone whether it was damaged or not. Run the report first and act on its verdict. Never repair a whole column unconditionally.
Sign: The database is repaired and the same rows are garbled again a week later.Cause: Repairing rows leaves the component that misread them in place. Find the reader that declared the wrong charset, fix it, then repair the stored data once.

What to check next

FAQ

How to check if text is double encoded utf-8?

Undo the windows-1252 step repeatedly and count how many passes succeed. The report in step 4 prints cp1252 passes 2 for text that went through the wrong decoder twice. Text damaged once prints 1, clean text prints 0.

Is mojibake always reversible?

When every byte survived, yes. When the decoder replaced bytes with U+FFFD, no. The two cases read alike in a bug report, and the report separates them in one run.

Why do accented letters show as two characters and Cyrillic as one plus a gap?

Both expand to two characters. Cyrillic bytes often land in the 0x80 to 0x9F range, which windows-1252 either leaves undefined or maps to an invisible control, so only one character of the pair is visible.

Can I find this without writing a script?

Not reliably. A search for à finds the common Latin cases and misses every string whose second byte is a C1 control. The undo test covers both, and it also tells you how many passes were involved.

Verified

Verified by Maks Vernynode 22.23.2python 3.13.1

Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.

intermediate8 minpublished updated Maks Verny