Skip to content

/ToUnicode CMap with 2-byte source codes is wrongly applied to simple fonts (1-byte codes) #4035

Description

@Affenlocke

Summary

For a simple font (/Type1, /TrueType, /MMType1) character codes are always
one byte. If such a font carries a /ToUnicode CMap whose bfchar/bfrange
entries are registered with two-byte source codes, that CMap does not apply to
the font's codes and should be ignored.

pypdf applies it anyway, because map_dict is keyed by the decoded string rather
than by the source bytes: <0044> and <44> both collapse to the key "D".
The result is silently mangled text — no warning, no exception.

Environment

  • pypdf 6.10.2 (also present in main as of 2026-08-31)
  • Python 3.13.12
  • Windows 11

Code + PDF

Two minimal PDFs are attached, both generated by make_repro.py (also attached).
Each draws the text pypdf-cmap using standard-14 /Helvetica with
/WinAnsiEncoding, plus a /ToUnicode CMap that maps every used code to X.
The only difference is the source-code length in the CMap.

File /ToUnicode source codes CMap applicable?
simple_font_2byte_tounicode.pdf <0070> <0058> (2 bytes) no — font uses 1-byte codes
simple_font_1byte_tounicode.pdf <70> <0058> (1 byte) yes
from pypdf import PdfReader

for name in ("simple_font_2byte_tounicode.pdf", "simple_font_1byte_tounicode.pdf"):
    print(name, repr(PdfReader(name).pages[0].extract_text()))

Actual

simple_font_2byte_tounicode.pdf 'XXXXXXXXXX'
simple_font_1byte_tounicode.pdf 'XXXXXXXXXX'

Expected

simple_font_2byte_tounicode.pdf 'pypdf-cmap'     <-- CMap does not apply, fall back to /Encoding
simple_font_1byte_tounicode.pdf 'XXXXXXXXXX'     <-- CMap applies, unchanged

Apache PDFBox 3.0.8 produces exactly the expected output for both files:

$ java -jar pdfbox-app-3.0.8.jar export:text -i simple_font_2byte_tounicode.pdf -o out.txt
$ cat out.txt
pypdf-cmap
$ java -jar pdfbox-app-3.0.8.jar export:text -i simple_font_1byte_tounicode.pdf -o out.txt
$ cat out.txt
XXXXXXXXXX

The second file is the important control: it must keep working, so "ignore
/ToUnicode when the text looks wrong" is not an acceptable fix.

Root cause

pypdf/_cmap.py, parse_bfchar (and analogously parse_bfrange):

map_dict[-1] = len(lst[0]) // 2          # source code length is computed ...
...
map_dict[
    unhexlify(lst[0]).decode(
        "charmap" if map_dict[-1] == 1 else "utf-16-be", "surrogatepass"
    )
] = map_to                                # ... but the key is the decoded string

unhexlify(b"0044").decode("utf-16-be") yields "D", and the one-byte code
<44> decoded via "charmap" yields "D" as well. Both end up under the same
key, so the byte length stops being part of the lookup. It is retained in
map_dict[-1], but that value is never consulted when the map is applied to a
simple font's one-byte codes.

How PDFBox handles it

PDFBox keeps the mappings in three separate maps keyed by source length
(fontbox/.../cmap/CMap.java):

private final Map<Integer, String> charToUnicodeOneByte  = new HashMap<>();
private final Map<Integer, String> charToUnicodeTwoBytes = new HashMap<>();
private final Map<Integer, String> charToUnicodeMoreBytes = new HashMap<>();

and looks up with an explicit length for simple fonts
(pdfbox/.../font/PDFont.java):

if (code < 256 && !(this instanceof PDType0Font))
{
    COSName encoding = dict.getCOSName(COSName.ENCODING);
    if (encoding != null && !encoding.getName().startsWith("Identity"))
    {
        return toUnicodeCMap.toUnicode(code, 1);
    }
}
return toUnicodeCMap.toUnicode(code);

A two-byte entry therefore never answers a one-byte query. PDSimpleFont.toUnicode
then falls back to /Encoding → glyph name → Adobe Glyph List, which is the same
path the renderer uses to draw the glyph — hence the correct result.

Suggested fix

Make the source-code length part of the lookup. A minimal variant: when the font
subtype is /Type1, /TrueType or /MMType1, only honour /ToUnicode entries
whose source code length is 1, and otherwise fall through to the /Encoding
handling that already exists. The required length is already available as
map_dict[-1].

I am happy to open a PR if you agree with the direction.

make_repro.py
simple_font_1byte_tounicode.pdf
simple_font_2byte_tounicode.pdf

Metadata

Metadata

Assignees

No one assigned

    Labels

    needs-discussionThe PR/issue needs more discussion before we can continueworkflow-text-extractionFrom a users perspective, text extraction is the affected feature/workflow

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions