Arabic text layer corrupted (wrong Unicode codepoints) even without mixing Latin text
Describe the bug
Rendering Arabic text produces a PDF whose visible glyphs look mostly correct, but the underlying text layer (what pdfjs-dist's getTextContent(), copy/paste, browser search, or an ATS resume parser reads) is corrupted: specific Arabic letters are silently swapped for a different letter that shares the same base glyph shape but a different dot count/placement (e.g. ت↔ث, ح↔ج, ب↔ن, ي↔ى). Word spacing is also frequently dropped or a stray & is inserted.
This reproduces with pure Arabic text alone — no Latin text needs to be mixed in. Mixing in Latin runs (company names, tool names) makes the visual layout additionally worse (missing space between a Latin run and the following Arabic word), but the letter-substitution corruption in the extracted text happens either way.
Because the glyphs still look approximately right at a glance, this is easy to miss visually — it only shows up when the text layer is read programmatically (copy/paste, Ctrl+F, screen readers, ATS parsers). For a resume/CV generator this is a serious, silent-corruption bug: a PDF can look fine and still fail every downstream text-based use.
To Reproduce
Two standalone repros below (no app code involved, just @react-pdf/renderer + a registered Arabic font — tested with Noto Naskh Arabic, a static Google-Fonts style TTF).
import { Document, Page, Text, Font, renderToBuffer } from '@react-pdf/renderer';
import fs from 'node:fs';
Font.register({
family: 'NotoNaskhArabic',
fonts: [{ src: 'NotoNaskhArabic-Regular.ttf', fontWeight: 400 }],
});
const doc = (
<Document>
<Page size="A4" style={{ padding: 32, fontFamily: 'NotoNaskhArabic', fontSize: 12, direction: 'rtl' }}>
<Text>
عملت كمهندسة برمجيات لمدة ثلاث سنوات. حاصلة على درجة البكالوريوس في علوم
الحاسوب. أخصائية توظيف تقني. استخدمت أدوات مثل الطلب يوميًا.
</Text>
</Page>
</Document>
);
const buffer = await renderToBuffer(doc);
fs.writeFileSync('out.pdf', buffer);Extracting the text layer with pdfjs-dist (getTextContent()) gives:
عملت كمهندسة برمجي ات لمدة ث&لات & سنوات. حاصلة على درحة النكالورثوس فى علوم الجاسوت. احصابية ثوظي ف ثقنى. اسيجدمت|.دوات مي&ل الطلت ث ومي ًاCompare a few words directly (input → extracted):
| Input | Extracted | What changed |
|---|---|---|
ثلاث (three) |
ث&لات |
stray & inserted, trailing ث → ت |
درجة (degree) |
درحة |
ج → ح |
البكالوريوس (bachelor's) |
النكالورثوس |
ب→ن, ي→ث |
الحاسوب (computer) |
الجاسوت |
ح→ج, ب→ت |
أخصائية (specialist) |
احصابية |
خ→ح, ئ→ب |
توظيف (recruitment) |
ثوظيف |
leading ت → ث |
استخدمت (used, v.) |
اسيجدمت |
ت→ي, خ→ج |
Every substitution pairs letters that share the same base contour and differ only by dot count/placement (ب ت ث are one shape with 1 dot below / 2 dots above / 3 dots above; ج ح خ are one shape with a dot below / none / a dot above). That pattern strongly suggests the code that maps a shaped glyph back to a Unicode codepoint (for the PDF's ToUnicode CMap / text layer) is picking the wrong sibling glyph from the font's glyph table — not a bidi-ordering problem, since the visual glyphs render close to correctly.
import { Document, Page, Text, View, Font, renderToBuffer } from '@react-pdf/renderer';
import fs from 'node:fs';
Font.register({ family: 'NotoNaskhArabic', fonts: [{ src: 'NotoNaskhArabic-Regular.ttf', fontWeight: 400 }] });
Font.register({ family: 'Roboto', fonts: [{ src: 'Roboto-Regular.ttf', fontWeight: 400 }] });
const doc = (
<Document>
<Page size="A4" style={{ padding: 32, fontFamily: 'NotoNaskhArabic', fontSize: 12, direction: 'rtl' }}>
<View style={{ marginBottom: 10 }}>
<Text>
عملت كمهندسة برمجيات في <Text style={{ fontFamily: 'Roboto' }}>Meridian Fintech</Text> لمدة ثلاث سنوات.
</Text>
</View>
<View style={{ marginBottom: 10 }}>
<Text>
استخدمت أدوات مثل <Text style={{ fontFamily: 'Roboto' }}>GitHub</Text> و
<Text style={{ fontFamily: 'Roboto' }}>LinkedIn</Text> و
<Text style={{ fontFamily: 'Roboto' }}>Notion</Text> يوميًا.
</Text>
</View>
</Page>
</Document>
);
const buffer = await renderToBuffer(doc);
fs.writeFileSync('out.pdf', buffer);Rendered PDF (visual, screenshot at 2x scale) — glyphs look roughly right, but notice there is no space between each Latin run and the following Arabic و ("and"): it reads GitHubوLinkedInوNotion glued together instead of GitHub و LinkedIn و Notion.
Extracted text layer for this one:
عملت كمهندسة ب رمج يات في|Meridian Fintech| |.لمدة ثلات سنوات||حاصلة علي درحة الن كالورثوس من| |University of Texas at Austin| |.في علوم الجاسوت||-حصاب-ية ثوظيف ثقني|اسيجدمت ا-دوات ميل| |GitHub| |و|LinkedIn| |و|Notion| |.ثوميًاSame class of letter-substitution corruption as Repro 1, plus the missing-space layout issue around the Latin runs.
Expected behavior
The extracted text layer should exactly match the input string (same letters, same spacing), and there should be a space between a Latin run and an adjacent Arabic word, matching what's visually laid out.
Environment
@react-pdf/renderer: 4.8.1 (latest on npm at time of filing)@react-pdf/textkit: 7.0.1 (latest, published 2 days before filing — includes the run-level bidi rewrite from #2900/aeaa7a76b)- Font tested: Noto Naskh Arabic (static TTF). A teammate on the same project also tried Amiri (different foundry, not Noto-derived) and the letter-substitution corruption did not go away — it just moved to a different text run on the same page, which is why we suspect this is a stateful/accumulated bug (possibly in glyph subsetting or the
ToUnicodeCMap building — this repo recently switched from a forkedpdfkitto the upstreampdfkitpackage in #3509, also ~2 days before filing) rather than something specific to one font file. - Node: v24.14.0, Windows
Additional context
We're building CV/resume PDFs in multiple languages including Arabic. This is blocking that launch since a resume that silently loses letters in its text layer would fail ATS parsing and copy/paste for real job seekers — worse than an outright crash, since nothing visibly indicates the document is broken. Happy to help narrow this down further (e.g. bisecting recent textkit/pdfkit changes) if it's useful — just let me know where to look.
Source: diegomura/react-pdf