BUG: Make remove_from_tree() work on outline items - #3939
Conversation
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #3939 +/- ##
==========================================
- Coverage 97.88% 97.87% -0.02%
==========================================
Files 57 57
Lines 10813 10988 +175
Branches 2025 2057 +32
==========================================
+ Hits 10584 10754 +170
- Misses 127 130 +3
- Partials 102 104 +2 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
70eff85 to
bdbba51
Compare
stefan6419846
left a comment
There was a problem hiding this comment.
Thanks for the PR. I have left some inline comments.
bdbba51 to
10334ca
Compare
|
Thanks — that's a useful set of comments, and the third and fourth between them pointed at a On On Tests — moved to The The nested index — yes, Rechecked after the rework: the five tests fail on |
reader.outline and writer.outline do not yield the live outline nodes. Each item is a fresh Destination built by _build_outline_item from the node's fields, and a detached copy never has /Parent. Destination inherits remove_from_tree() from TreeObject, which checks self for that /Parent, so the call fails on every PDF and every outline item. Destination.node already points at the node in the document, but that node is a plain DictionaryObject, so removing through it is not possible either. There is currently no way to remove an outline item through the public API. Destination now overrides remove_from_tree() and acts on node, the dictionary in the document, so TreeObject is left unaware of the subclass. Outline parents read from a document are plain DictionaryObjects unless PdfWriter.get_outline_root() has upgraded them, so remove_child is bound explicitly rather than reached through the parent, and the single internal call it makes to _remove_node_from_tree is bound the same way. Closes py-pdf#3878
10334ca to
b64c81d
Compare
| """ | ||
| Remove the outline item this destination was built from. | ||
|
|
||
| ``reader.outline`` and ``writer.outline`` yield detached copies rather than |
There was a problem hiding this comment.
Please use single backticks.
| the nodes themselves, so the copy never has a ``/Parent`` and removing it | ||
| would be a no-op. ``node`` is the dictionary in the document. | ||
| """ | ||
| if self.node is None: |
There was a problem hiding this comment.
This condition and the following condition are untested. Please ensure to test them for proper coverage.
What this fixes
#3878 reports that
remove_from_tree()fails on outline items whose colour is[0.0, 0.0, 0.0]. The colour is not the trigger.Destination.colorreturns black as its default when/Cis absent, so on the reporter's PDF — where no bookmark carries a colour at all — that filter matched every item in the document.The actual problem is that
reader.outlineandwriter.outlinenever hand back the live outline nodes._build_outline_itemconstructs a freshDestinationfrom each node's fields and does not copy/Parent, because a detached copy is not in any tree.DestinationsubclassesTreeObjectand so inheritsremove_from_tree(), which checksselffor the/Parentthat copy can never have.This fails on every PDF and every outline item.
Destination.nodedoes point at the real node, but that node is a plainDictionaryObject, soitem.node.remove_from_tree()raisesAttributeError. As far as I can tell there is currently no way to remove an outline item through the public API.The change
remove_from_tree()acts on the node the item was built from, via a_tree_node()hook thatDestinationoverrides to returnself.node. The plumbing already existed and was documented on the class; nothing used it.The parent is also upgraded to a
TreeObjectwhere needed. Outline parents read from a document arrive as plainDictionaryObjects unlessPdfWriter.get_outline_root()has upgraded them, which it only does for the root, so the existingcast()had no effect at runtime.TreeObjectadds methods and no state, so the dictionary, its identity and its indirect reference are unchanged.Verification
Five tests added to
tests/test_generic.py, next to the existingremove_from_treetests. All five fail onmainand pass with this change. They cover removing the first, middle and last item of a cloned writer, a writer that built its own outline, and a nested child. The parametrised cases also assert the result surviveswrite()and a reload, which is what proves the mutation reached the document rather than a copy.tests/test_generic.pygoes from 111 to 116 passing with no change to the pre-existing failures.-k "outline or bookmark or tree"acrosstests/is identical before and after.mypyandruff checkare clean on the changed file. Also checked against theinput.pdfattached to the issue: the reporter's own loop now removes the items and the reloaded file agrees.One thing I would like your opinion on
parent.__class__ = TreeObjectupgrades in place becauseTreeObject(parent)copies the dictionary, which would send the mutation to the copy.get_outline_root()solves the same problem by copying and then calling_replace_object, but that needs writer access this code does not have. Happy to reshape it if you would rather it were done another way.Closes #3878