Skip to content

Commit 8c1095a

Browse files
authored
ENH: Add is_open in outlines in PdfReader and PdfWriter (#1960)
Closes #1922
1 parent 6df64af commit 8c1095a

File tree

4 files changed

+131
-27
lines changed

4 files changed

+131
-27
lines changed

pypdf/_reader.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@
8686
)
8787
from .generic import (
8888
ArrayObject,
89+
BooleanObject,
8990
ContentStream,
9091
DecodedStreamObject,
9192
Destination,
@@ -1083,7 +1084,15 @@ def _build_outline_item(self, node: DictionaryObject) -> Optional[Destination]:
10831084
# absolute value = num. visible children
10841085
# with positive = open/unfolded, negative = closed/folded
10851086
outline_item[NameObject("/Count")] = node["/Count"]
1087+
# if count is 0 we will consider it as open ( in order to have always an is_open to simplify
1088+
outline_item[NameObject("/%is_open%")] = BooleanObject(
1089+
node.get("/Count", 0) >= 0
1090+
)
10861091
outline_item.node = node
1092+
try:
1093+
outline_item.indirect_reference = node.indirect_reference
1094+
except AttributeError:
1095+
pass
10871096
return outline_item
10881097

10891098
@property

pypdf/_writer.py

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1633,6 +1633,7 @@ def add_outline_item_destination(
16331633
page_destination: Union[None, PageObject, TreeObject] = None,
16341634
parent: Union[None, TreeObject, IndirectObject] = None,
16351635
before: Union[None, TreeObject, IndirectObject] = None,
1636+
is_open: bool = True,
16361637
dest: Union[None, PageObject, TreeObject] = None, # deprecated
16371638
) -> IndirectObject:
16381639
if page_destination is not None and dest is not None: # deprecated
@@ -1655,14 +1656,33 @@ def add_outline_item_destination(
16551656
# argument is only Optional due to deprecated argument.
16561657
raise ValueError("page_destination may not be None")
16571658

1659+
if isinstance(page_destination, PageObject):
1660+
return self.add_outline_item_destination(
1661+
Destination(
1662+
f"page #{page_destination.page_number}",
1663+
cast(IndirectObject, page_destination.indirect_reference),
1664+
Fit.fit(),
1665+
)
1666+
)
1667+
16581668
if parent is None:
16591669
parent = self.get_outline_root()
16601670

1671+
page_destination[NameObject("/%is_open%")] = BooleanObject(is_open)
16611672
parent = cast(TreeObject, parent.get_object())
16621673
page_destination_ref = self._add_object(page_destination)
16631674
if before is not None:
16641675
before = before.indirect_reference
1665-
parent.insert_child(page_destination_ref, before, self)
1676+
parent.insert_child(
1677+
page_destination_ref,
1678+
before,
1679+
self,
1680+
page_destination.inc_parent_counter_outline
1681+
if is_open
1682+
else (lambda x, y: 0),
1683+
)
1684+
if "/Count" not in page_destination:
1685+
page_destination[NameObject("/Count")] = NumberObject(0)
16661686

16671687
return page_destination_ref
16681688

@@ -1700,10 +1720,9 @@ def add_outline_item_dict(
17001720
outline_item: OutlineItemType,
17011721
parent: Union[None, TreeObject, IndirectObject] = None,
17021722
before: Union[None, TreeObject, IndirectObject] = None,
1723+
is_open: bool = True,
17031724
) -> IndirectObject:
17041725
outline_item_object = TreeObject()
1705-
for k, v in list(outline_item.items()):
1706-
outline_item_object[NameObject(str(k))] = v
17071726
outline_item_object.update(outline_item)
17081727

17091728
if "/A" in outline_item:
@@ -1714,7 +1733,9 @@ def add_outline_item_dict(
17141733
action_ref = self._add_object(action)
17151734
outline_item_object[NameObject("/A")] = action_ref
17161735

1717-
return self.add_outline_item_destination(outline_item_object, parent, before)
1736+
return self.add_outline_item_destination(
1737+
outline_item_object, parent, before, is_open
1738+
)
17181739

17191740
@deprecation_bookmark(bookmark="outline_item")
17201741
def add_bookmark_dict(
@@ -1754,6 +1775,7 @@ def add_outline_item(
17541775
bold: bool = False,
17551776
italic: bool = False,
17561777
fit: Fit = PAGE_FIT,
1778+
is_open: bool = True,
17571779
pagenum: Optional[int] = None, # deprecated
17581780
) -> IndirectObject:
17591781
"""
@@ -1779,7 +1801,7 @@ def add_outline_item(
17791801
if fit is not None and page_number is None:
17801802
page_number = fit # type: ignore
17811803
return self.add_outline_item(
1782-
title, page_number, parent, None, before, color, bold, italic # type: ignore
1804+
title, page_number, parent, None, before, color, bold, italic, is_open=is_open # type: ignore
17831805
)
17841806
if page_number is not None and pagenum is not None:
17851807
raise ValueError(
@@ -1822,7 +1844,7 @@ def add_outline_item(
18221844

18231845
if parent is None:
18241846
parent = self.get_outline_root()
1825-
return self.add_outline_item_destination(outline_item, parent, before)
1847+
return self.add_outline_item_destination(outline_item, parent, before, is_open)
18261848

18271849
def add_bookmark(
18281850
self,

pypdf/generic/_data_structures.py

Lines changed: 55 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,18 @@
3232
import logging
3333
import re
3434
from io import BytesIO
35-
from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple, Union, cast
35+
from typing import (
36+
Any,
37+
Callable,
38+
Dict,
39+
Iterable,
40+
List,
41+
Optional,
42+
Sequence,
43+
Tuple,
44+
Union,
45+
cast,
46+
)
3647

3748
from .._protocols import PdfReaderProtocol, PdfWriterProtocol
3849
from .._utils import (
@@ -364,7 +375,9 @@ def write_to_stream(
364375
)
365376
stream.write(b"<<\n")
366377
for key, value in list(self.items()):
367-
key.write_to_stream(stream)
378+
if len(key) > 2 and key[1] == "%" and key[-1] == "%":
379+
continue
380+
key.write_to_stream(stream, encryption_key)
368381
stream.write(b" ")
369382
value.write_to_stream(stream)
370383
stream.write(b"\n")
@@ -568,19 +581,43 @@ def addChild(self, child: Any, pdf: Any) -> None: # deprecated
568581
def add_child(self, child: Any, pdf: PdfWriterProtocol) -> None:
569582
self.insert_child(child, None, pdf)
570583

571-
def insert_child(self, child: Any, before: Any, pdf: PdfWriterProtocol) -> None:
572-
def inc_parent_counter(
573-
parent: Union[None, IndirectObject, TreeObject], n: int
574-
) -> None:
575-
if parent is None:
576-
return
577-
parent = cast("TreeObject", parent.get_object())
578-
if "/Count" in parent:
579-
parent[NameObject("/Count")] = NumberObject(
580-
cast(int, parent[NameObject("/Count")]) + n
581-
)
582-
inc_parent_counter(parent.get("/Parent", None), n)
584+
def inc_parent_counter_default(
585+
self, parent: Union[None, IndirectObject, "TreeObject"], n: int
586+
) -> None:
587+
if parent is None:
588+
return
589+
parent = cast("TreeObject", parent.get_object())
590+
if "/Count" in parent:
591+
parent[NameObject("/Count")] = NumberObject(
592+
max(0, cast(int, parent[NameObject("/Count")]) + n)
593+
)
594+
self.inc_parent_counter_default(parent.get("/Parent", None), n)
583595

596+
def inc_parent_counter_outline(
597+
self, parent: Union[None, IndirectObject, "TreeObject"], n: int
598+
) -> None:
599+
if parent is None:
600+
return
601+
parent = cast("TreeObject", parent.get_object())
602+
# BooleanObject requires comparison with == not is
603+
opn = parent.get("/%is_open%", True) == True # noqa
604+
c = cast(int, parent.get("/Count", 0))
605+
if c < 0:
606+
c = abs(c)
607+
parent[NameObject("/Count")] = NumberObject((c + n) * (1 if opn else -1))
608+
if not opn:
609+
return
610+
self.inc_parent_counter_outline(parent.get("/Parent", None), n)
611+
612+
def insert_child(
613+
self,
614+
child: Any,
615+
before: Any,
616+
pdf: PdfWriterProtocol,
617+
inc_parent_counter: Optional[Callable] = None,
618+
) -> IndirectObject:
619+
if inc_parent_counter is None:
620+
inc_parent_counter = self.inc_parent_counter_default
584621
child_obj = child.get_object()
585622
child = child.indirect_reference # get_reference(child_obj)
586623

@@ -595,7 +632,7 @@ def inc_parent_counter(
595632
del child_obj["/Next"]
596633
if "/Prev" in child_obj:
597634
del child_obj["/Prev"]
598-
return
635+
return child
599636
else:
600637
prev = cast("DictionaryObject", self["/Last"])
601638

@@ -610,7 +647,7 @@ def inc_parent_counter(
610647
del child_obj["/Next"]
611648
self[NameObject("/Last")] = child
612649
inc_parent_counter(self, child_obj.get("/Count", 1))
613-
return
650+
return child
614651
try: # insert as first or in the middle
615652
assert isinstance(prev["/Prev"], DictionaryObject)
616653
prev["/Prev"][NameObject("/Next")] = child
@@ -621,6 +658,7 @@ def inc_parent_counter(
621658
prev[NameObject("/Prev")] = child
622659
child_obj[NameObject("/Parent")] = self.indirect_reference
623660
inc_parent_counter(self, child_obj.get("/Count", 1))
661+
return child
624662

625663
def removeChild(self, child: Any) -> None: # deprecated
626664
deprecation_with_replacement("removeChild", "remove_child", "3.0.0")
@@ -651,8 +689,7 @@ def _remove_node_from_tree(
651689

652690
else:
653691
# Removing only tree node
654-
assert self[NameObject("/Count")] == 1
655-
del self[NameObject("/Count")]
692+
self[NameObject("/Count")] = NumberObject(0)
656693
del self[NameObject("/First")]
657694
if NameObject("/Last") in self:
658695
del self[NameObject("/Last")]

tests/test_writer.py

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -553,13 +553,49 @@ def test_add_outline_item(pdf_file_path):
553553
writer.add_page(page)
554554

555555
outline_item = writer.add_outline_item(
556-
"An outline item", 1, None, (255, 0, 15), True, True, Fit.fit()
556+
"An outline item",
557+
1,
558+
None,
559+
(255, 0, 15),
560+
True,
561+
True,
562+
Fit.fit(),
563+
is_open=False,
564+
)
565+
_o2a = writer.add_outline_item(
566+
"Another", 2, outline_item, None, False, False, Fit.fit()
567+
)
568+
_o2b = writer.add_outline_item(
569+
"Another bis", 2, outline_item, None, False, False, Fit.fit()
570+
)
571+
outline_item2 = writer.add_outline_item(
572+
"An outline item 2",
573+
1,
574+
None,
575+
(255, 0, 15),
576+
True,
577+
True,
578+
Fit.fit(),
579+
is_open=True,
580+
)
581+
_o3a = writer.add_outline_item(
582+
"Another 2", 2, outline_item2, None, False, False, Fit.fit()
583+
)
584+
_o3b = writer.add_outline_item(
585+
"Another 2bis", 2, outline_item2, None, False, False, Fit.fit()
557586
)
558-
writer.add_outline_item("Another", 2, outline_item, None, False, False, Fit.fit())
559587

560588
# write "output" to pypdf-output.pdf
561-
with open(pdf_file_path, "wb") as output_stream:
589+
with open(pdf_file_path, "w+b") as output_stream:
562590
writer.write(output_stream)
591+
output_stream.seek(0)
592+
reader = PdfReader(output_stream)
593+
assert reader.trailer["/Root"]["/Outlines"]["/Count"] == 3
594+
assert reader.outline[0]["/Count"] == -2
595+
assert reader.outline[0]["/%is_open%"] == False # noqa
596+
assert reader.outline[2]["/Count"] == 2
597+
assert reader.outline[2]["/%is_open%"] == True # noqa
598+
assert reader.outline[1][0]["/Count"] == 0
563599

564600

565601
def test_add_named_destination(pdf_file_path):

0 commit comments

Comments
 (0)