Skip to content

BUG: Map suffixed and ligature glyph names in font encodings - #4310

Open
michaelkillgta wants to merge 2 commits into
py-pdf:mainfrom
michaelkillgta:fix/agl-glyph-name-components
Open

michaelkillgta wants to merge 2 commits into
py-pdf:mainfrom
michaelkillgta:fix/agl-glyph-name-components

Conversation

@michaelkillgta

@michaelkillgta michaelkillgta commented Oct 29, 2026 •

Copy link
Copy Markdown

Closes #4309.

_glyph_name_to_unicode now follows the Adobe Glyph List mapping rules.

Specification references

  • ISO 32000-2:2020, 9.10.2 "Mapping character codes to Unicode values" (same section number in ISO 32000-1:2008): for a simple font whose /Differences array uses standard character names, map the code to a character name and then "look up the character name in the Adobe Glyph List" to get the Unicode value.
  • Adobe Glyph List Specification, version 2.9, section 2 "The mapping":
    1. Drop all characters from the first period onwards (a.sc -> a, one.oldstyle -> 1).
    2. Split the rest into components at underscores (f_i -> f, i).
    3. Map each component and concatenate the results. A component is a glyph list name, or uni followed by groups of four hex digits, each in 0000-D7FF or E000-FFFF (uni00660069 -> fi), or u followed by four to six hex digits, in 0000-D7FF or E000-10FFFF. This is why /u1 no longer turns into "\x01" and surrogates are rejected.
  • Adobe Glyph List Specification, section 3 "Examples": Lcommaaccent_uni20AC0308_u1040C.alternate and uniD801DC0C are used as test cases.

Deviations from the specification

Each of these keeps pypdf's current behavior:

  • An exact match in pypdf's glyph table is checked first, before and after dropping the suffix. The table also lists some names that contain a period, for example /two.superior, and f_f_i.alt still maps to ffi.
  • Lowercase hex digits are accepted. The specification only allows uppercase, but main already decodes names like /uni00e9.
  • The specification maps a component that cannot be mapped to an empty string. Here the whole name returns None instead, so callers keep the raw glyph name as before (/f_unknownglyph, /.sc).

The same helper is used for CFF and Type 1 font program encodings, so those get the same handling.

Tests

  • New parametrized test in tests/test_cmap.py. It fails on main and passes here.
  • Every new line and branch in _glyph_name_to_unicode and _glyph_component_code_points is covered: pytest tests/test_cmap.py --cov=pypdf._cmap --cov-branch.
  • The full test suite, ruff check pypdf tests and mypy pypdf pass locally.

Signed-off-by: michaelkillgta <269775689+michaelkillgta@users.noreply.github.com>
@codecov

codecov Bot commented Oct 29, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 98.63%. Comparing base (819f5ed) to head (78d7de0).
⚠️ Report is 8 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #4310      +/-   ##
==========================================
+ Coverage   98.55%   98.63%   +0.08%     
==========================================
  Files          59       59              
  Lines       11992    11976      -16     
  Branches     2268     2275       +7     
==========================================
- Hits        11819    11813       -6     
+ Misses         93       86       -7     
+ Partials       80       77       -3     

☔ 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 check the coverage and provide exact references to the official specification.

Signed-off-by: michaelkillgta <269775689+michaelkillgta@users.noreply.github.com>
Comment thread pypdf/_cmap.py
code_point = glyph_name[2:]
else:
# Adobe Glyph List Specification, section 2, step 1.
base_name = glyph_name.lstrip("/").split(".", 1)[0]

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.

Suggested change
base_name = glyph_name.lstrip("/").split(".", 1)[0]
base_name = glyph_name.removeprefix("/").split(".", maxsplit=1)[0]

Comment thread pypdf/_cmap.py
base_name = glyph_name.lstrip("/").split(".", 1)[0]
if not base_name:
return None
if f"/{base_name}" in adobe_glyphs:

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.

Consider using the Walrus operator with a dedicated variable to avoid repeated conversion.

Comment thread pypdf/_cmap.py
return None
if f"/{base_name}" in adobe_glyphs:
return adobe_glyphs[f"/{base_name}"]
result = ""

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.

Prefer a list and "".join over repeated string concatenation.

Comment thread pypdf/_cmap.py
See the Adobe Glyph List Specification, version 2.9, section 2, step 3.
Surrogates (D800-DFFF) and values above 10FFFF are rejected.
"""
if component.startswith("uni") and len(component) > 3 and len(component) % 4 == 3:

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.

You are calculating len(component) quite often. Consider using a dedicated variable.

Comment thread pypdf/_cmap.py
"""
Map a ``uniXXXX[XXXX...]`` or ``uXXXX[XX]`` glyph name component.

See the Adobe Glyph List Specification, version 2.9, section 2, step 3.

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.

Do you have a link to the specification?

Comment thread pypdf/_cmap.py
code_points = [component[1:]]
else:
return None
result = ""

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.

See above for concatenating.

Comment thread pypdf/_cmap.py
return None
result = ""
for code_point in code_points:
if not all(c in "0123456789ABCDEFabcdef" for c in code_point):

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.

Suggested change
if not all(c in "0123456789ABCDEFabcdef" for c in code_point):
if not all(c in string.hexdigits for c in code_point):

Comment thread pypdf/_cmap.py
if not all(c in "0123456789ABCDEFabcdef" for c in code_point):
return None
value = int(code_point, 16)
if 0xD800 <= value <= 0xDFFF or value > 0x10FFFF:

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 define these range limits as named constants for better readability/documentation.

Comment thread pypdf/_cmap.py
return result


def _glyph_component_code_points(component: str) -> str | None:

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 tests/test_cmap.py

@pytest.mark.parametrize(
("glyph_name", "expected"),
[

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.

Are you able to provide meaningful parameter IDs to document what each line is trying to ensure?

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.

extract_text() outputs raw glyph names like /f_i and /a.sc from font /Differences

2 participants