Skip to content
getnextpdf.com

Make CJK and Arabic PDFs copy-paste correct

Create a Portable Document Format (PDF) file with Chinese, Japanese, Korean (CJK), and Arabic text that copies and pastes as the original logical characters. The engine maps every glyph to Unicode through a /ToUnicode CMap, canonicalizes the result with Normalization Form Compatibility Composition (NFKC), and wraps shaped or letter-spaced runs in a /Span carrying /ActualText. Register the fonts and write the content; extraction stays correct. Verify extraction with pdftotext/Poppler, not PyMuPDF; veraPDF validates PDF/Universal Accessibility 2 (PDF/UA-2), not extraction.

Terminal window
composer require nextpdf/core

Register a CJK font, such as Noto Sans CJK, and an Arabic-capable font whose character map covers the Arabic Presentation Forms-B block, such as Noto Naskh Arabic. Embed only fonts that you are licensed to embed.

Glyph codes in a content stream are not Unicode. A /ToUnicode CMap maps each code back to Unicode so a reader can extract text (ISO 32000-2 §9.10). The engine canonicalizes those values with Normalization Form Compatibility Composition (NFKC), as defined by Unicode UAX #15. A CJK Compatibility Ideograph and an Arabic presentation form map to their base character, so search and copy return canonical text instead of a compatibility code point.

Two cases need more than /ToUnicode: letter-spaced Latin and shaped right-to-left Arabic. The engine draws them as spaced or reordered glyphs, so glyph order alone cannot recover the logical string. It wraps each run in a /Span marked-content sequence carrying /ActualText, an exact replacement for the enclosed content (ISO 32000-2 §14.9). Extractors that honor /ActualText return the logical string.

SymbolLocationRole
FontRegistry::register(string $fontFile, string $alias = ''): FontInfoNextPDF\Typography\FontRegistryRegister the CJK and Arabic faces.
DocumentFactory::create(): DocumentNextPDF\Core\DocumentFactoryBuild a document that uses your registry.
Document::writeHtml(string $html): staticNextPDF\Core\Concerns\HasTextOutputRender multilingual content.
<?php
declare(strict_types=1);
require_once __DIR__ . '/vendor/autoload.php';
use NextPDF\Core\DocumentFactory;
use NextPDF\Graphics\ImageRegistry;
use NextPDF\Typography\FontRegistry;
$fonts = new FontRegistry();
$fonts->register(__DIR__ . '/NotoSansCJK-Regular.ttf', alias: 'CJK');
$fonts->register(__DIR__ . '/NotoNaskhArabic-Regular.ttf', alias: 'Arabic');
$doc = (new DocumentFactory($fonts, new ImageRegistry(maxCacheBytes: 0)))->create();
$doc->addPage();
$doc->writeHtml(
'<p style="font-family: \'CJK\';">PDF 2.0 引擎 — 量子</p>'
. '<p style="direction: rtl; font-family: \'Arabic\';">فاتورة</p>'
);
$doc->save(__DIR__ . '/multilingual.pdf');
Terminal window
pdftotext multilingual.pdf - | head
# Extracts the logical text: "PDF 2.0 引擎 — 量子" and the logical Arabic "فاتورة",
# not compatibility code points or reversed presentation forms.

This self-contained example tags the document, adds a letter-spaced heading, and writes to the harness path.

<?php
declare(strict_types=1);
require_once __DIR__ . '/vendor/autoload.php';
use NextPDF\Core\DocumentFactory;
use NextPDF\Graphics\ImageRegistry;
use NextPDF\Typography\FontRegistry;
$fonts = new FontRegistry();
$fonts->register(__DIR__ . '/NotoSansCJK-Regular.ttf', alias: 'CJK');
$fonts->register(__DIR__ . '/NotoNaskhArabic-Regular.ttf', alias: 'Arabic');
$doc = (new DocumentFactory($fonts, new ImageRegistry(maxCacheBytes: 0)))->create();
$doc->setTitle('Multilingual extraction');
$doc->enableTaggedPdf('en');
$doc->addPage();
$html = <<<'HTML'
<h1 style="font-family: 'CJK'; letter-spacing: 3px;">CODE WORD</h1>
<p style="font-family: 'CJK';">中文 · 日本語 · 한국어 · 量子 (compatibility ideograph)</p>
<p style="direction: rtl; font-family: 'Arabic';">المبلغ الإجمالي 380.00</p>
HTML;
$doc->writeHtml($html);
$out = getenv('NEXTPDF_OUT');
$doc->save($out !== false ? $out : __DIR__ . '/multilingual-copy-paste-extraction.pdf');
echo "Wrote the multilingual PDF\n";

Run pdftotext on the output. The letter-spaced heading extracts as CODE WORD with no inserted spaces, the CJK line extracts as its base characters, and the Arabic line extracts as the logical string.

  • Verify extraction with pdftotext, not PyMuPDF. PyMuPDF’s raw text mode ignores inline /ActualText and returns visual glyphs, so it can under-report correctness. Poppler (pdftotext) honors /ActualText; veraPDF validates PDF/UA-2, not extraction.
  • Extraction needs /ToUnicode. Register and embed fonts so the writer emits the /ToUnicode CMap. A non-embedded, non-standard font cannot guarantee a Unicode mapping.
  • /ActualText covers shaped and letter-spaced runs. For plain, unshaped, unspaced text, /ToUnicode alone extracts correctly; the /Span wrapper preserves spaced or reordered runs.
  • Tagged HTML tables pass PDF/UA-2 checks. Extraction is correct, and a tagged HTML <table> now passes veraPDF --flavour ua2 with zero failures — see Accessibility.

Building the /ToUnicode CMap and /Span wrappers scales linearly with glyph count. This recipe budgets wall_ms: 1500, peak_mb: 96.

Validate the length of user-supplied multilingual strings so output size stays bounded. The /ToUnicode builder rejects surrogate halves and out-of-codespace codes, so a malformed map cannot create a corrupt extraction resource. The engine runs no scripts and fetches no remote resources for local fonts.

StatementSpecClause
A /ToUnicode CMap maps character codes to Unicode for extraction.ISO 32000-2§9.10
/ActualText is an exact replacement for the enclosed content.ISO 32000-2§14.9
NFKC is compatibility decomposition followed by canonical composition.Unicode UAX #15§1.2

Not applicable.