Skip to content

PI: Read every page label number tree once for all pages - #4321

Open
kholdrex wants to merge 4 commits into
py-pdf:mainfrom
kholdrex:page-labels-all-trees
Open

kholdrex wants to merge 4 commits into
py-pdf:mainfrom
kholdrex:page-labels-all-trees

Conversation

@kholdrex

Copy link
Copy Markdown
Contributor

#4251 only sped up one case: a flat /Nums array with sorted integer keys and direct values. Everything else still calls index2label() for each page, and that walks the tree from the start every time. So /Nums with indirect values or unsorted keys, and any /Kids tree, are still pages × label ranges.

Now a /Nums array is read once for all pages. The pair get_label_from_nums() stops at only moves forward as the page index grows, so the keys don't need to be sorted. For /Kids, each array's kids are read in order and only as far as needed (same as index2label()), and I keep track of which pages each kid covers. Each /Nums array is then read once.

Timings for 8,000 pages (1.0 to 1.4 MB), loading the page tree included:

main this PR
/Nums, indirect values 8.6 s 0.33 s
/Nums, unsorted keys 4.1 s 0.30 s
/Kids, one kid 4.0 s 0.31 s
/Kids, many kids 9.8 s 0.42 s

Labels and exceptions are the same as calling index2label() page by page, and the new tests check that for several /Kids layouts and for indirect and shared /Nums. Two small differences: a warning about a broken kid now shows up once per /Kids array, not once per page, and a kid whose /Limits aren't numbers gets skipped with the existing "malformed /Limits" warning.

AI assistance: Claude Code (Claude Opus 5.5) helped write and measure this, and Codex (GPT-6 Astra) reviewed it. I reviewed the change and ran the test suite.

@codecov

codecov Bot commented Oct 31, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 98.66%. Comparing base (140204c) to head (b32fa96).

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #4321      +/-   ##
==========================================
+ Coverage   98.65%   98.66%   +0.01%     
==========================================
  Files          59       59              
  Lines       11964    12090     +126     
  Branches     2271     2296      +25     
==========================================
+ Hits        11803    11929     +126     
  Misses         84       84              
  Partials       77       77              

☔ 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.

Comment thread pypdf/_page_labels.py Outdated


def _get_label(value: Any, start_index: Any, index: int) -> str:
m: dict[str | None, Callable[[int], str]] = {

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.

When we are refactoring this anyway, we could probably make this a constant outside of the function.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done, moved it to _NUMBERING_STYLES.

Comment thread pypdf/_page_labels.py Outdated
prefix=prefix,
exception=exception,
)
return str(index + 1) # Fallback

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.

This can probably moved one level up:

Suggested change
return str(index + 1) # Fallback
return str(index + 1) # Fallback

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done.

Comment thread pypdf/_page_labels.py Outdated
def _get_page_range(limits: list[Any], page_count: int) -> tuple[int, int] | None:
"""Get the first and last page index between the two ``/Limits`` values, if any."""
low, high = limits[0], limits[1]
if any(isinstance(limit, float) and math.isnan(limit) for limit in (low, high)):

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.

In which cases will limit be NaN?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

It can't come from a file, only from objects built by hand, so I removed the check.

Comment thread pypdf/_page_labels.py Outdated
low, high = limits[0], limits[1]
if any(isinstance(limit, float) and math.isnan(limit) for limit in (low, high)):
return None
if low > page_count - 1 or high < 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.

We can eliminate the subtraction?

Suggested change
if low > page_count - 1 or high < 0:
if low >= page_count or high < 0:

Alternatively, as we use page_count - 1 later on, consider using a dedicated variable.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done, thanks.

Comment thread pypdf/_page_labels.py Outdated
position = bisect_right(self._starts, index) - 1
if position >= 0 and index <= self._ends[position]:
return self._first_kids[position]
if self._next_kid >= len(self._kids):

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.

As self._kids is a fixed parameter passed on __init__, I guess we can calculate len(self._kids) once in __init__ as well?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done.

Comment thread pypdf/_page_labels.py Outdated
indexed once and each ``/Nums`` array is read once for all of its pages. Warnings
about malformed kids are logged once per array instead of once per page.
"""
finders: dict[int, tuple[ArrayObject, _FirstKidFinder]] = {}

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 making the finder values a dedicated container for readability.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The finder keeps its own /Kids array now, so the dict just maps to the finder.

Comment thread pypdf/_page_labels.py Outdated
about malformed kids are logged once per array instead of once per page.
"""
finders: dict[int, tuple[ArrayObject, _FirstKidFinder]] = {}
leaves: dict[int, tuple[ArrayObject | None, DictionaryObject, list[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.

Consider making the leaf values a dedicated container for readability.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done.

Comment thread pypdf/_page_labels.py Outdated
if id(kids) not in finders:
finders[id(kids)] = (kids, _FirstKidFinder(kids, page_count))
finder = finders[id(kids)][1]
groups: dict[int, tuple[DictionaryObject, list[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.

Consider making the group values a dedicated container for readability.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done.

Comment thread pypdf/_page_labels.py Outdated
groups.setdefault(id(kid), (kid, []))[1].append(index)
for kid, kid_indexes in groups.values():
if not is_null_or_none(kid.get("/Kids", None)):
if level + 1 == 100: # pragma: no cover

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.

Did we have this limit before? If not, where is it coming from?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yes, index2label() raises the same NotImplementedError at level 100. Added a comment saying so.

Comment thread pypdf/_page_labels.py
leaf_labels = (
_get_labels_from_nums(leaf_nums, leaf_indexes)
if leaf_nums is not None
else [get_label_from_nums(leaf, index) for index in leaf_indexes]

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.

I find it somehow confusing to have _get_labels_from_nums and get_labels_from_nums.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Renamed to _get_labels_for_page_indexes.

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.

2 participants