When a document needs to look polished, I tend to build it as HTML plus CSS and render it to PDF with headless Chrome. Every typographic decision stays under my control, and revisions can be driven from a script.
Then one day I opened a freshly rendered PDF and found garbage sitting in the middle of the text.
The symptom
The middle dot separator that used to read · now read ·. Bullets that used to be • had become •. The same pattern repeated across every non-ASCII character: one character replaced by two or three strange ones, always led by an accented letter.
What made it hard to spot: I never retyped any of those characters. The file had been correct all along. All I had done was run a small PowerShell script to swap a few strings inside the HTML.
My first suspect was Chrome. Maybe a font issue, maybe a charset problem at render time. But opening the HTML directly in a browser showed the same garbage. So the damage was not happening during rendering. It was already baked into the file.
The fastest way to confirm that is to look at bytes instead of glyphs:
Format-Hex -Path .\cv.html | Select-Object -First 20A · should be stored as the two UTF-8 bytes C2 B7. What I found was four bytes, C3 82 C2 B7. That is not a wrong character. That is the right character, encoded twice.
The root cause: Get-Content and its default encoding
My script was this simple:
(Get-Content $path -Raw) -replace 'Placeholder', 'New Value' |
Set-Content $path -Encoding utf8It looks harmless. I even felt safe about it, because the write side names utf8. The trouble is in the first line, the only one where I never named an encoding at all.
In Windows PowerShell, Get-Content without an -Encoding parameter does not read the file as UTF-8. It reads it using the system ANSI code page, which on most machines means Windows-1252. And Windows-1252 is a single byte per character encoding, so it has no concept of multibyte sequences at all.
Here is the chain. The bytes C2 B7 in the file get read as two separate characters: C2 becomes Â, B7 becomes ·. PowerShell now holds the string "·" and sees nothing wrong with it. When that string is written back to disk as real UTF-8, which is exactly what the write side was told to do, both characters get encoded again, and you end up with C3 82 C2 B7.
Higher codepoints break worse because they take three bytes. A bullet • is E2 80 A2 in UTF-8, read one byte at a time as â, €, and ¢, then written back out as seven bytes, C3 A2 E2 82 AC C2 A2. That is where all the • in my document came from.
So nothing was lost. Every character was still there, just wrapped in an extra layer.
The fix: read and write with an explicit encoding
Stop relying on defaults. Go through the .NET APIs directly and name the encoding on both sides:
$utf8 = [System.Text.UTF8Encoding]::new($false)
$text = [System.IO.File]::ReadAllText($path, $utf8)
$text = $text -replace 'Placeholder', 'New Value'
[System.IO.File]::WriteAllText($path, $text, $utf8)The $false argument to UTF8Encoding matters. It means UTF-8 without a BOM. Let the BOM through and those three marker bytes sit at the very front of the HTML file, ahead of <!DOCTYPE html>, where they can surface as a stray character or upset a parser further down the pipeline. That is another reason not to fall back on Set-Content -Encoding utf8 in Windows PowerShell: it writes the BOM for you, and does not offer a way out.
One detail people skip: fixing only the read side is not enough. If ReadAllText is correct but you still write through the default Set-Content or Out-File, you have simply moved the problem to the other end. Both ends need the same encoding object.
Reversing files that are already corrupted
Because no information was lost, this mojibake can be undone. You replay the broken path in reverse: take the wrong string, encode it as Windows-1252 bytes, then decode those bytes as UTF-8.
$utf8 = [System.Text.UTF8Encoding]::new($false)
$win1252 = [System.Text.Encoding]::GetEncoding(1252)
$broken = [System.IO.File]::ReadAllText($path, $utf8)
$bytes = $win1252.GetBytes($broken)
$fixed = $utf8.GetString($bytes)
[System.IO.File]::WriteAllText($path, $fixed, $utf8)The GetBytes line is doing the real work. It forces  back down to the byte C2 and · back down to B7, restoring the original byte sequence. GetString then reads that sequence as proper UTF-8 and hands you a clean · again.
Run this exactly once, and copy the file before you start. The operation is not safe to repeat: applying it to an already clean file will corrupt that file in the opposite direction.
What I took away
- When a Unicode character turns into two or three odd characters, it is not a font problem and not a rendering problem. It is double encoding, and the proof lives at the byte level.
- In Windows PowerShell,
Get-Contentwithout-Encodingfollows the system ANSI code page, not UTF-8. The damage comes from the mismatch: an ANSI read side meeting a UTF-8 write side. A pipeline as small as read, replace, write is enough to wreck a healthy file. - Name the encoding at both ends, read and write, and choose UTF-8 without a BOM for web files.
- This class of mojibake is reversible as long as nothing collapsed into question marks. While the bytes are intact, you can just run the conversion backwards.