BUG: Fix outline merge ordering when inserting at specific position - #3965
BUG: Fix outline merge ordering when inserting at specific position#3965adityamoolya wants to merge 6 commits into
Conversation
When merging a source PDF into a target at a given page position, bookmarks were unconditionally appended to the end of the outline tree instead of being inserted at the corresponding position. This was caused by three distinct bugs: 1. TreeObject.insert_child() traversal bug (_data_structures.py): The linked-list walk started from /Last and followed /Next. Since the tail node has no /Next, the loop terminated immediately and always fell back to appending. Fixed by traversing from /First via /Next, with correct handling for insertion at the head, middle, or tail. 2. Page identity corruption (_writer.py): _get_page_number_by_indirect() relied on PageObject.page_number which used list.index(). Because PageObject inherits from dict, identical blank pages compared equal under dict.__eq__, causing page number resolution to falsely return 0. Fixed by comparing indirect_reference .idnum directly, bypassing content-based equality. 3. Position mutation in merge() (_writer.py): merge() incremented the position variable in the page-insertion loop, so by the time outline processing ran, position pointed past the inserted pages. Fixed by capturing initial_position before the loop and using it for outline insertion point calculation. Added _find_outline_item_before_page() helper to locate the correct insertion point in the existing outline tree, and wired it into _insert_filtered_outline() via the 'before' parameter. Added test_merge_outline_ordering_position() verifying that merging Doc B at position 1 of Doc A produces outline order [A1, B1, B2, A2, A3].
107c65f to
c3b4099
Compare
- Fix ruff S112 (try-except-continue) in _find_outline_item_before_page by logging the exception via logger_warning before continuing. - Fix test_tree_object__insert_child__cycle: the cycle must be reachable from /First (not /Last) since insert_child now traverses forward.
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #3965 +/- ##
==========================================
+ Coverage 97.98% 98.04% +0.06%
==========================================
Files 57 57
Lines 11106 11153 +47
Branches 2080 2098 +18
==========================================
+ Hits 10882 10935 +53
+ Misses 125 122 -3
+ Partials 99 96 -3 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
stefan6419846
left a comment
There was a problem hiding this comment.
Thanks for the PR, although I have not fully reviewed it due to its complexity.
At the moment, coverage is quite bad. Could you please review it and make sure that all relevant lines are covered correctly.
456f87b to
1bccccb
Compare
6ba9138 to
bd49ffa
Compare
- Restructure insert_child to use early-return style per review feedback - Add test coverage for insert_child append-at-end branch - Add tests for merge outline ordering at start, end, and with /Dest arrays - Update stale comment referencing old /Last traversal
bd49ffa to
586ecb8
Compare
stefan6419846
left a comment
There was a problem hiding this comment.
Please check the updated remarks and the merge conflict.
57f31de to
7e011b6
Compare
|
Addressed all review comments , moved _find_outline_item_before_page to pypdf/generic/_outline.py and added a proper docstring, and resolved the merge conflict. CI is passing as well . |
| if page_ref is None: | ||
| continue | ||
| try: | ||
| pn = writer._get_page_number_by_indirect(page_ref) |
There was a problem hiding this comment.
Please avoid such abbreviations.
| indirect_reference = IndirectObject(indirect_reference, 0, self) | ||
| target_id = getattr(indirect_reference, "idnum", None) | ||
| if target_id is not None: | ||
| for idx, page in enumerate(self.pages): |
There was a problem hiding this comment.
This will quickly become a bottleneck for files with lots of outlines. I guess we need some sort of cache object numbers to page indizes.
| target_id = getattr(indirect_reference, "idnum", None) | ||
| if target_id is not None: | ||
| for idx, page in enumerate(self.pages): | ||
| if page.indirect_reference is not None and page.indirect_reference.idnum == target_id: |
There was a problem hiding this comment.
Isn't this behavior incorrect if we are dealing with multiple generations?
| if isinstance(obj, PageObject): | ||
| if obj.indirect_reference is not None: | ||
| target_id = obj.indirect_reference.idnum | ||
| for idx, page in enumerate(self.pages): |
There was a problem hiding this comment.
This is the same as above. Can we avoid the repetition?
| exc=exc, | ||
| ) | ||
| continue | ||
| if pn is not None and pn >= page_number: |
There was a problem hiding this comment.
Doesn't this assume that outline items are ordered by destination page number, which we cannot assume?
| The child's ``IndirectObject`` (suitable for the *before* parameter of | ||
| ``TreeObject.insert_child``), or ``None`` if no such child exists. | ||
| """ | ||
| for child in parent.children(): |
There was a problem hiding this comment.
Why can we omit nested outlines?
| continue | ||
| try: | ||
| pn = writer._get_page_number_by_indirect(page_ref) | ||
| except Exception as exc: |
There was a problem hiding this comment.
Which exceptions do we actually expect here?
| assert titles == ["B1", "A_Dest"] | ||
|
|
||
|
|
||
| def test_merge_outline_ordering_coverage_edge_cases(): |
There was a problem hiding this comment.
Please use focused tests and consider moving them to tests/generic/test_outline.py. The current test setup is rather complex and very implementation-driven.
Closes TODO at pypdf/_writer.py line 2792.
The TODO was about using the before parameter when inserting outlines during merge(). Previously, when pages from another PDF were merged at a specific position, their bookmarks were always added to the end of the outline instead of being inserted at the correct position.
For example, merging Doc B into Doc A at position=1 should result in [A1, B1, B2, A2, A3], but previously produced [A1, A2, A3, B1, B2].
The main issue was in TreeObject.insert_child() in _data_structures.py. The traversal started from /Last and followed /Next. Since /Last has no /Next, the traversal stopped immediately and the code always fell back to appending the new outline items.
The fix changes the traversal to start from /First and follow /Next, allowing the correct insertion point to be found. The before parameter is also passed through to _insert_filtered_outline() so the bookmarks are inserted at the same position as the merged pages.
I used Claude Opus 4.6 to write tests for the same