Read this word:

অিভজ্ঞতা

If you read Bangla, your eyes stumbled for a moment. The vowel sign ি is on the wrong side of ভ. It should say অভিজ্ঞতা: experience.

You recovered in half a second. Software doesn't. To a program, অিভজ্ঞতা isn't a misspelling of "experience". It's a word that doesn't exist.

That word came out of a real CV, uploaded to the ATS checker we built at PromptCV, the CV maker I build for job seekers in Bangladesh. The CV clearly had an Experience section. Our checker told the person it didn't, because every Bangla heading had come out of the PDF scrambled like this.

So we fixed the checker. Then, to measure the fix, we also ran our own exported PDFs through it.

They failed too.

The culprit turned out to be Chrome. In the PDFs it printed for us, PDF readers were losing up to 77% of the Bangla words. This post is about three different ways Bangla text breaks in PDFs, how to detect each one in JavaScript, and how we repaired Chrome's output without changing a single pixel.

TL;DR

  • "Bangla is broken in my PDF" is really three bugs: legacy Bijoy fonts, text stored in visual order, and the way Chrome writes complex scripts into PDFs.
  • Two of them can be detected with near-certainty using one regex and a character-category trick (code below).
  • Chrome/Puppeteer PDFs look perfect and still extract badly. Across 9 PDF readers, the words recovered dropped as low as 23%. We rebuilt the text layer from data Chrome already embeds, and now all 9 get 100%.
  • Check your own PDF in one line: pdftotext your.pdf - | grep -nP '(^|\s)[িেৈ]'

Three bugs that look like one

Copy Bangla out of a PDF and you'll usually get one of these:

Avwg GKRb †mvdUIq¨vi BwÄwbqvi| ← should be: আমি একজন সফটওয়্যার ইঞ্জিনিয়ার। িহসাবরক্ষক অিভজ্ঞতা ← should be: হিসাবরক্ষক অভিজ্ঞতা বর্ত মান রা কি ব ← words split, letters missing 

They look like the same problem. They aren't:

What you see Cause Where it comes from
Latin junk like Avwg GKRb Legacy (non-Unicode) fonts: Bijoy, SutonnyMJ Documents typed in Bijoy
Vowel signs before the consonant: িহসাব Text stored in visual order Many PDF producers
Split words, missing letters, invisible NUL characters How Chrome writes complex scripts into PDFs Anything printed from Chrome or Puppeteer

Let's take them one at a time, from the most familiar to the most surprising.

Bug 1: The ghost of Bijoy

If you've grown up in Bangladesh, you've seen Avwg GKRb before. It's Bangla typed with the Bijoy keyboard in a font like SutonnyMJ, and it was everywhere before Unicode Bangla took over.

The trick behind it is a costume. These fonts don't store Bangla at all. They store plain Latin letters and draw Bangla shapes on top: A + v is drawn as আ, w as ি, g as ম. So Avwg looks like আমি on screen, and to every program that reads the text, it's just Avwg.

When such a PDF is extracted, the higher bytes come back through the Windows-1252 table, which is why the junk is sprinkled with †, ¨, ‡ and ¦.

Catching it (without catching Björk)

The obvious detector, "lots of characters above 0x80", fails on day one. Accented names are full of them: Café Zürich, São Paulo, Björk Guðmundsdóttir. We also tried measuring how tightly the odd characters cluster. Short accented words in a sentence cluster just as tightly as real mojibake, at every window size we tested.

The difference turned out to be the kind of character. Real accented characters are letters (é, ü, ð). Bijoy junk lands mostly on Windows-1252 symbols (†, ‡, ¦, ¨, ‰), which almost never appear in real writing. So the detector asks three questions, and all three must agree:

// Symbols that Bijoy mojibake produces but real prose almost never does. // Letters (é, ü, ð...) and normal typography (— “ ” • € ©) are NOT in here. const SUSPICIOUS = /[‚„†‡‰‹˜›¦¨¬¯±²³´·¸¹¼½¾]/g const BANGLA = /[\u0980-\u09FF]/ // In production this is ~300 common English words plus CV vocabulary. const COMMON_WORDS = new Set([ 'the', 'and', 'for', 'with', 'from', 'have', 'this', 'that', 'experience', 'education', 'skills', 'project', 'team', 'university', // ... ]) export function looksLikeBijoy(text) { // 1. Any real Unicode Bangla means this isn't a legacy-font document if (BANGLA.test(text)) return false // 2. Are the odd characters symbols, not letters? const suspiciousRatio = (text.match(SUSPICIOUS)?.length ?? 0) / Math.max(text.length, 1) // 3. Does it read like English at all? (Skip words under 3 letters: // Bijoy junk is full of fragments like "we" and "bw" that match by chance.) const words = text.toLowerCase().match(/[a-z]{3,}/g) ?? [] const englishHitRate = words.length ? words.filter((w) => COMMON_WORDS.has(w)).length / words.length : 0 return suspiciousRatio >= 0.03 && englishHitRate < 0.03 } 

The thresholds came from measurement, not guesswork. Real Bijoy text scores about 8% on the symbol ratio. Every accented-Latin case we tried, including whole CVs full of foreign names and cities, scores 0%. The meanest case we could invent (a wall of foreign proper nouns plus dagger footnote marks, with no ordinary words at all) hit 4%, and the English-word check still caught it.

The fix for this one belongs to the author: convert the text to Unicode Bangla. You can't reliably repair it from the PDF.

Bug 2: The vowel that jumped the queue

Back to অিভজ্ঞতা.

In Unicode, the vowel sign ি in হি is stored after its consonant (হ, then ি), even though you see it drawn before. That's called logical order. It's what makes search, sorting and spell-check work.

A PDF, though, records glyphs in the order they're painted on the page, left to right. So many PDF producers write ি first. A naive extractor hands you exactly what was painted: িহ instead of হি, and অিভজ্ঞতা instead of অভিজ্ঞতা. Every Bangla heading stops matching anything.

Catching it with proof, not probability

This detector is my favourite, because it doesn't have to guess. A pre-base vowel sign (ি, ে, ৈ) can never legally start a word in Bangla. So if you find one at the start of a word, that's not a statistical signal. It's proof:

const PRE_BASE_AT_WORD_START = /(?:^|\s)[িেৈ]/gu const BANGLA_LETTER = /[\u0980-\u09FF]/g const LATIN_LETTER = /[a-zA-Z]/g function banglaShare(text) { const bn = text.match(BANGLA_LETTER)?.length ?? 0 const en = text.match(LATIN_LETTER)?.length ?? 0 return bn + en === 0 ? 0 : bn / (bn + en) } export function looksVisualOrder(text) { // Only judge documents that are meaningfully Bangla (see below) if (banglaShare(text) <= 0.15) return false const hits = text.match(PRE_BASE_AT_WORD_START)?.length ?? 0 return hits >= 2 // one stray glyph can be a copy-paste accident } 

The two numbers in there each have a story:

  • Why hits >= 2? We measured instead of picking. Scrambled PDFs scored 5, 5 and 13 hits. Correctly ordered ones scored 0. A floor of 2 sits comfortably between.
  • Why the 15% Bangla floor? Our first version had no such check, and it had a nasty failure. An English CV with a Bangla address (মিরপুর, সিলেট, তেজগাঁও) needed just two scrambled words out of six hundred to be declared "unreadable", and that verdict wiped out an otherwise perfectly useful result. A false alarm that destroys a correct answer is worse than a missed problem, so now the detector refuses to judge documents that are mostly not Bangla.

Both of these detectors run on every upload in our free ATS CV checker, inside the browser, so the file never leaves the visitor's device.

Bug 3: Plot twist, it was Chrome

Here's where it got uncomfortable.

PromptCV generates CV PDFs with Puppeteer, which uses Chrome's own print-to-PDF. We'd assumed our own PDFs were fine. When we ran exported Bangla CVs through the visual-order detector, it flagged them.

The PDFs looked flawless on screen. Every conjunct, every vowel sign, exactly right. But to anything reading the text, they were scrambled. So we stopped guessing and set up a proper test:

  • Four test CVs: a normal Bangla CV, a stress test packed with conjuncts, reph, ya-phala, khanda-ta and a ZWJ word like র‍্যাংকিং, a two-page CV, and an English control.
  • Nine PDF readers, because you never know which one an employer uses:
    • pdf.js (most web apps)
    • Poppler and Xpdf pdftotext
    • PyMuPDF, pdfminer.six and pypdf (the Python crowd)
    • Apache PDFBox and Tika (the base of many ATS pipelines)
    • PDFium (what a recruiter gets when they copy text out of Chrome or Edge)

Here's how many of the Bangla words each one got back:

PDF reader Normal CV Stress-test CV After our fix
pdf.js 35% 23% 100%
pdfminer.six 32% 27% 100%
pypdf 35% 27% 100%
PDFium (Chrome/Edge copy-paste) 40% 26% 100%
PDFBox / Tika 84% 58% 100%
Poppler 84% 71% 100%
Xpdf 84% 64% 100%
PyMuPDF 88% 84% 100%

The same Bangla CV line as it looks on the page, as pdf.js extracted it before the fix, and after

pdf.js, the library most web apps use, recovered about a third of the words, plus 78 invisible NUL characters on a one-page CV. And the "good" readers still lost about one word in six.

Why Chrome does this

We opened the PDFs up and found five things going on:

  1. Every glyph is a Type3 glyph, even the English ones. The fonts were variable fonts (which is how Google Fonts serves them), and Skia, Chrome's PDF engine, can't embed those as normal TrueType. So it writes every letter as a tiny drawing.
  2. Chrome actually writes the correct text, too. Around every cluster whose glyphs don't map neatly onto characters, it adds an /ActualText tag holding the right logical text. A one-page CV had 147 of them.
  3. But pdf.js, pdfminer and pypdf ignore /ActualText. They read the drawings' own labels, which are in painted (visual) order.
  4. Glyphs Skia can't label get U+0000. That's where the invisible NULs come from.
  5. The readers that do honour /ActualText still split words like বর্ত মান, because of how the neighbouring glyphs are positioned.

Everything we tried that didn't work

  • Adding /ActualText. Chrome already does. Half the readers ignore it.
  • A different font. Hind Siliguri, Noto Serif Bengali, Tiro Bangla: all still scrambled.
  • Chrome print flags. Ligatures off, kerning off, text-rendering tweaks, tagged and untagged PDF, outlined text. Identical every time.
  • An invisible text layer laid over outlined glyphs. It could work, but it changes how every glyph is rendered and drags in a substitute font, for no better result than what we did instead.

What worked: make every glyph tell the truth

Chrome had already done the hard part. It shaped the Bangla correctly and wrote down the correct text in /ActualText. The text and the drawings just disagreed. So rather than bring in a second shaping engine, we post-process the PDF and make the drawings carry the right labels:

  1. Group glyphs into clusters: each /ActualText span, plus anything that visually overlaps it.
  2. Leave correct clusters alone. If the glyphs already spell the right text, just drop the /ActualText wrapper. (Leaving it on makes PDFium put a space inside every word.) About a third of the clusters on a typical CV were already fine.
  3. Rebuild each scrambled cluster as two glyphs in a new Type3 font:
    • Glyph A replays Chrome's original drawings in their original positions, so the page looks identical. Its label is the cluster's text up to the first combining mark.
    • Glyph B is invisible, 0.001 em wide, and carries the rest of the text.

Why two? Because pdf.js treats any glyph whose label contains a combining mark as zero-width, which wrecks word spacing. Moving the marks onto a nearly-invisible sidekick sidesteps that.

  1. Re-write the affected text with explicit positions, since Skia places some glyphs relative to the previous one.

Then came a run of reader-specific quirks, each one found only because we were measuring:

  • Poppler guesses a Type3 font's size from glyph code 0, so every new font gets an empty code 0 with a sensible width.
  • PDFium's word-break threshold scales with glyph width, so word-final clusters are capped at 1 em.
  • PDFium silently drops a glyph whose code matches the glyph drawn just before it (it's filtering out fake-bold duplicates). We saw ঢাকা come out as ঢা. Glyph codes are now assigned to dodge that.

The result, and what it costs

All nine readers now get 100% of the Bangla words on every CV template we measured, on Windows and on a Linux build of our production Docker image. (One exception: PDFium reads one single-column contact block one glyph per line, and it does exactly the same without our fix.) On screen, PDFium renders the pages pixel-identical. MuPDF and Poppler differ only by one-pixel anti-aliasing slivers you can't see at normal zoom. English-only PDFs never go through the repair, so they come back byte-for-byte untouched.

Nothing is free, though:

  • Bigger files: 58–118% larger (a one-page CV went from 232 KB to 366 KB).
  • CPU: about 100 ms per page.
  • Fragile by nature: it depends on Chrome keeping its current way of writing PDFs. If an upgrade changes that, the repair rewrites nothing and the scrambling silently returns. So we log a warning whenever a Bangla PDF comes out with zero rewritten clusters, and there's a kill switch.
  • Not every font: static (non-variable) Bangla fonts are embedded as TrueType, and the repair leaves them alone.

Check your own PDFs in 10 seconds

If your app produces Bangla PDFs (or Hindi, Tamil, or any script with vowel signs that sit before their consonant), try this right now:

pdftotext your.pdf - | grep -nP '(^|\s)[িেৈ]' 

Any output at all means visual-order text. For a fuller check in Node with pdf.js:

import * as pdfjs from 'pdfjs-dist/legacy/build/pdf.mjs' import { readFile } from 'node:fs/promises' const data = new Uint8Array(await readFile('your.pdf')) const doc = await pdfjs.getDocument({ data }).promise let text = '' for (let p = 1; p <= doc.numPages; p++) { const page = await doc.getPage(p) const { items } = await page.getTextContent() text += items.map((item) => item.str ?? '').join(' ') + '\n' } console.log({ bijoy: looksLikeBijoy(text), visualOrder: looksVisualOrder(text), nulCount: (text.match(/\u0000/g) ?? []).length, }) 

A non-zero NUL count on a Chrome-made PDF is a strong hint you're hitting Bug 3.

What I'd tell my past self

  • Name the bug before you fix it. "Bangla is broken in my PDF" is three bugs with three different fixes.
  • Look for evidence that can't be coincidence. A vowel sign that can't start a word. Symbols that don't appear in prose. Then add a floor so a few stray words can't condemn a whole document.
  • Test more than one reader. pdf.js, Poppler and PDFium disagree constantly, and you don't get to choose which one reads your user's document.
  • Pretty is not readable. A PDF can look perfect to a human and be a third readable to a machine. If machines read your documents (search engines, AI tools, applicant tracking systems), measure what they actually get back.

If you're job hunting in Bangladesh, you can see exactly what software reads from your CV, in English or Bangla, with PromptCV's free ATS checker.

And I'm curious: have you hit this in Hindi, Tamil, Sinhala or another script? What did your PDFs do? Tell me in the comments.