Skip to content

BUG: Fix cloning of objects that require constructor arguments - #4262

Open
Ak4shh wants to merge 3 commits into
py-pdf:mainfrom
Ak4shh:fix/clone-annotation-dictionaries
Open

Ak4shh wants to merge 3 commits into
py-pdf:mainfrom
Ak4shh:fix/clone-annotation-dictionaries

Conversation

@Ak4shh

@Ak4shh Ak4shh commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #3467.

The bug

PageObject.merge_page() clones the annotations of the page passed to it.
DictionaryObject.clone() built the clone target with self.__class__(),
which is impossible for every annotation class, because their constructors
require arguments:

TypeError: Polygon.__init__() missing 1 required positional argument: 'vertices'

The same self.__class__() call was in DictionaryObject.replicate(), so this
affected any clone/replicate of an annotation dictionary, not just
merge_page.

The fix

DictionaryObject._new_for_clone() creates the empty target. When the class
cannot be instantiated without arguments, it instantiates it without calling
__init__; the clone is populated from the source object immediately
afterwards, so the constructor arguments are not needed. Annotation
constructors only store their arguments in the dictionary anyway
(self["/Vertices"] = ...), which _clone then copies from the source.

The check is signature-based (and cached per class) rather than
try/except TypeError, because Link() raises ValueError when neither
url nor target_page_index is given.

Classes that can be built without arguments (e.g. PageObject, which sets
pdf in __init__) keep taking the normal construction path.

Tests

  • tests/test_page.py::test_merge_page_with_annotations_of_merged_page — the
    issue's reproducer: merge a page that carries a Polygon annotation, then
    assert the annotation survives a write/re-read round trip.
  • tests/test_annotations.py::test_clone_and_replicate_annotation —
    parametrized over all ten annotation classes (Ellipse, FreeText,
    Highlight, Line, Link, Polygon, PolyLine, Popup, Rectangle,
    Text), asserting that clone() and replicate() both return the same
    class with the same keys and that the copy does not share entries with the
    source.

Both tests fail on main and pass with this change:

# without the fix
TypeError: Ellipse.__init__() missing 1 required positional argument: 'rect'
TypeError: FreeText.__init__() missing 2 required keyword-only arguments: ...
TypeError: Highlight.__init__() missing 2 required keyword-only arguments: ...
TypeError: Line.__init__() missing 3 required positional arguments: ...

Full suite

python -m pytest tests -q --basetemp=... on Windows / Python 3.13:
31 failed, 2158 passed, 34 skipped, 95 errors — identical failures and errors
to the unmodified tree (all pre-existing and environmental: empty
sample-files/ submodule, missing fontTools, pytest-cache/tmp_path
permission issues). passed rises by exactly the 11 new test cases.

Scope

Three files: pypdf/generic/_data_structures.py, tests/test_annotations.py,
tests/test_page.py.

@Ak4shh
Ak4shh force-pushed the fix/clone-annotation-dictionaries branch from c0712a9 to ac5cef6 Compare October 27, 2026 01:15
@codecov

codecov Bot commented Oct 27, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 98.67%. Comparing base (9c4a693) to head (7d3deae).
⚠️ Report is 15 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #4262      +/-   ##
==========================================
+ Coverage   98.40%   98.67%   +0.26%     
==========================================
  Files          59       59              
  Lines       11992    11967      -25     
  Branches     2267     2268       +1     
==========================================
+ Hits        11801    11808       +7     
+ Misses        104       84      -20     
+ Partials       87       75      -12     

☔ 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/generic/_data_structures.py Resolved
Comment thread tests/test_annotations.py Outdated
for copy in (cloned, replicated):
assert type(copy) is type(annotation)
assert copy is not annotation
assert copy.keys() == annotation.keys()

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.

Why not .items()?

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.

Removed it - inspect.signature only raises for C-level builtins like type, never a Python init, so it was dead code.Updated to compare .items() so the assertion checks both keys and values.

Comment thread tests/test_page.py Outdated
def test_merge_page_with_annotations_of_merged_page():
"""Annotations of the page passed to merge_page are cloned as well.

See issue #3467: cloning such a page raised ``TypeError`` because the

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.

The old behavior is irrelevant in the test docstrings.

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.

Removed the historical behavior from both test docstrings. They now describe the behavior being tested.

DictionaryObject.clone() and DictionaryObject.replicate() created the target
object via self.__class__(). That fails for every annotation class (Text,
FreeText, Link, Polygon, ...), because their constructors require arguments,
so PageObject.merge_page() raised TypeError whenever the merged page carried a
markup annotation.

Create the empty target through a helper that skips __init__ when the class
cannot be instantiated without arguments. The clone is populated from the
source object right afterwards, so the constructor arguments are not needed;
the annotation constructors only store their arguments in the dictionary
anyway.

Fixes py-pdf#3467.

Signed-off-by: Ak4shh <akashposa40@gmail.com>
@Ak4shh
Ak4shh force-pushed the fix/clone-annotation-dictionaries branch from ac5cef6 to 9bc7dd5 Compare October 29, 2026 01:37
Ak4shh and others added 2 commits October 30, 2026 20:22
Linked entries such as /Next, /Prev, /N and /V were still instantiated
via `__class__()`, failing for classes like OutlineItem.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Ak4shh <akashposa40@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Ak4shh <akashposa40@gmail.com>
@Ak4shh

Ak4shh commented Oct 31, 2026

Copy link
Copy Markdown
Contributor Author

Removed the try/except around inspect.signature(). A Python-level init never triggers it, so it was dead code.
The annotation test now compares .items(), so values are checked as well as keys.
The test docstrings now describe only the behavior under test, not the old behavior.

@Ak4shh

Ak4shh commented Oct 31, 2026

Copy link
Copy Markdown
Contributor Author

Addressed all three points, and also fixed cloning of linked /Next//Prev//N//V objects (with a test). Could you take another look?

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.

merge_page with passed page having markup annotation fails

2 participants