Skip to content

Arabic text layer corrupted (wrong Unicode codepoints) even without mixing Latin text #3533

Description

@NahuelGiudizi

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).

Repro 1 — pure Arabic, no Latin mixed in
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.

Repro 2 — Arabic mixed with Latin runs (closer to a real-world use case: names of companies/tools inside Arabic prose)
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 Bidi issue with RTL languages (Hebrew, Arabic) #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 ToUnicode CMap building — this repo recently switched from a forked pdfkit to the upstream pdfkit package in refactor: replace @react-pdf/pdfkit fork with upstream pdfkit #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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions