Skip to content

BUG: Rank the CFF built-in encoding below the font dictionary's /Encoding - #4345

Open
Ak4shh wants to merge 1 commit into
py-pdf:mainfrom
Ak4shh:fix/cff-font-encoding-precedence
Open

Ak4shh wants to merge 1 commit into
py-pdf:mainfrom
Ak4shh:fix/cff-font-encoding-precedence

Conversation

@Ak4shh

@Ak4shh Ak4shh commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #4343.

The bug

When a Type1 font embeds a CFF program (/FontFile3 with /Subtype /Type1C),
the encoding read from the program with fontTools was treated like a
/ToUnicode CMap: every code the program defines became chr(code) and was
then mapped through the program's glyph names. The font dictionary's own
/Encoding lost, whatever it contained.

ISO 32000-2:2020, 9.6.6.1 ranks it the other way round. The built-in encoding
of a font program is only the default base encoding:

font dictionary /Encoding base encoding
absent the font program's built-in encoding
a name, or a dictionary with /BaseEncoding that base encoding
a dictionary without /BaseEncoding the font program's built-in encoding

/Differences override the listed codes in every case.

The fix

_rank_font_program_encoding() applies those rules to the map derived from the
font program, before get_encoding() sees it:

  • a named /Encoding or a /BaseEncoding replaces the built-in encoding, so
    the derived entries are dropped;
  • with a /Differences-only /Encoding, the derived entries for the codes the
    /Differences list are dropped, so they fall back to the font dictionary;
  • without /Encoding, nothing is dropped.

The map is cached per font program stream (#4156), so the filtering runs on the
copy returned for each font dictionary and leaves the cache intact.

Tests

test_get_encoding__cff_type1_font_program_is_ranked_below_font_encoding builds
a CFF program that maps the codes 0x41/0x42 to Z/Y, puts (AB) in the
content stream, and covers every row of the table plus a null /Encoding and an
/Encoding dictionary without /Differences:

case                          main  fixed
no /Encoding                   ZY     ZY
null /Encoding                 ZY     ZY
empty /Encoding dictionary     ZY     ZY
named /Encoding                ZY     AB
/BaseEncoding + /Differences   ZY     AB
/Differences only              ZY     AY

Full suite

python -m pytest tests -q on Windows / Python 3.13: 2403 passed, 11 skipped,
10 xfailed, 1 failed, 99 errors — the same failure and errors as unmodified
main (2397 passed). Both are environmental: the sandbox temp directory is not
writable (PermissionError / OSError) and the sample-files submodule is
empty. passed rises by exactly the 6 new cases.

Scope

Two files: pypdf/_cmap.py, tests/test_cmap.py.

AI assistance

Written and reviewed together with DeepSeek Harness (deepseek-flash): it
drafted the patch and the tests, and I reviewed and verified them.

@codecov

codecov Bot commented Nov 3, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 98.81%. Comparing base (f5b3f07) to head (7c86078).

Additional details and impacted files
@@           Coverage Diff           @@
##             main    #4345   +/-   ##
=======================================
  Coverage   98.81%   98.81%           
=======================================
  Files          59       59           
  Lines       12139    12161   +22     
  Branches     2310     2316    +6     
=======================================
+ Hits        11995    12017   +22     
  Misses         70       70           
  Partials       74       74           

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@stefan6419846 stefan6419846 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please review the inline comments and the coverage.

Comment thread pypdf/_cmap.py Outdated
return None


def _differences_codes(encoding: DictionaryObject) -> set[int]:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Function names should start with an action.

Comment thread pypdf/_cmap.py
return codes
code = 0
for entry in differences:
if isinstance(entry, int):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please explain this logic.

@PJBrs

PJBrs commented Nov 4, 2026

Copy link
Copy Markdown
Contributor

I'm currently unable to test or contribute. That being said, I contributed the CFF font program parser, in a way analogous to the already existing code for conventional Type1 font programs. At the time, I was already wondering why we parsed the embedded font to add a character_map. With the code here, I'm starting to wonder even more whether parsing the embedded font's encoding should be part of the _parse_to_unicode() method (the current case) or the _parse_encoding() method instead. I'm wondering, @Ak4shh , whether you considered this. This is not to suggest that you should change this PR, but it might be something to keep in mind.

I was somewhat out of my depth in this regard when I added the CFF font program parser, which is why I only added the clearly missing functionality and did not reconsider the existing implementation for parsing the Type1 font. In fact, I was working locally on the final missing piece here for simple font encodings, that is, parsing an embedded TTF program, and while I'm pretty sure that my local code does what it should, it produced gibberish for one of the files in our test suite... Might be the same issue.

…ding

For a Type1 font that embeds a CFF program, ISO 32000-2:2020, 9.6.6.1 makes
the program's built-in encoding only the default base encoding: a named
/Encoding or a /BaseEncoding replaces it, and /Differences override the codes
they list.

The map derived with fontTools was handed to get_encoding() as if it came from
a /ToUnicode CMap. Every code the program defines became chr(code) and was
then mapped through the program's glyph names, so the font dictionary's own
/Encoding was ignored and documents relying on /BaseEncoding + /Differences
were extracted as garbage.

_rank_font_program_encoding() now drops the derived entries that the font
dictionary overrides, before the identity rule in get_encoding() applies.

Fixes py-pdf#4343.

Signed-off-by: Ak4shh <akashposa40@gmail.com>
@Ak4shh
Ak4shh force-pushed the fix/cff-font-encoding-precedence branch from 420b856 to 7c86078 Compare November 5, 2026 23:47

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

BUG: embedded CFF built-in encoding overrides /Encoding with /BaseEncoding + /Differences when fontTools is installed

3 participants