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
Summary
For a simple font (
/Type1,/TrueType,/MMType1) character codes are alwaysone byte. If such a font carries a
/ToUnicodeCMap whosebfchar/bfrangeentries 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_dictis keyed by the decoded string ratherthan by the source bytes:
<0044>and<44>both collapse to the key"D".The result is silently mangled text — no warning, no exception.
Environment
mainas of 2026-08-31)Code + PDF
Two minimal PDFs are attached, both generated by
make_repro.py(also attached).Each draws the text
pypdf-cmapusing standard-14/Helveticawith/WinAnsiEncoding, plus a/ToUnicodeCMap that maps every used code toX.The only difference is the source-code length in the CMap.
/ToUnicodesource codessimple_font_2byte_tounicode.pdf<0070> <0058>(2 bytes)simple_font_1byte_tounicode.pdf<70> <0058>(1 byte)Actual
Expected
Apache PDFBox 3.0.8 produces exactly the expected output for both files:
The second file is the important control: it must keep working, so "ignore
/ToUnicodewhen the text looks wrong" is not an acceptable fix.Root cause
pypdf/_cmap.py,parse_bfchar(and analogouslyparse_bfrange):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 samekey, 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 asimple 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):and looks up with an explicit length for simple fonts
(
pdfbox/.../font/PDFont.java):A two-byte entry therefore never answers a one-byte query.
PDSimpleFont.toUnicodethen falls back to
/Encoding→ glyph name → Adobe Glyph List, which is the samepath 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,/TrueTypeor/MMType1, only honour/ToUnicodeentrieswhose source code length is 1, and otherwise fall through to the
/Encodinghandling 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