Skip to content

Commit 8c532a0

Browse files
authored
MAINT: Consistent terminology for outline items (#1156)
This PR makes sure PyPDF2 uses a consistent nomenclature for the outline: * **Outline**: A document has exactly one outline (also called "table of contents", in short toc). That outline might be empty. * **Outline Item**: An element within an outline. This is also called a "bookmark" by some PDF viewers. This means that some names will be deprecated to ensure consistency: ## PdfReader * `outlines` ➔ `outline` * `_build_outline()` ➔ `_build_outline_item()` ## PdfWriter * Keep `get_outline_root()` * `add_bookmark_dict()` ➔ `add_outline()` * `add_bookmark()` ➔ `add_outline_item()` ## PdfMerger * `find_bookmark()` ➔ `find_outline_item()` * `_write_bookmarks()` ➔ `_write_outline()` * `_write_bookmark_on_page()` ➔ `_write_outline_item_on_page()` * `_associate_bookmarks_to_pages()` ➔ `_associate_outline_items_to_pages()` * Keep `_trim_outline()` ## generic.py * `Bookmark` ➔ `OutlineItem` Closes #1048 Closes #1098
1 parent 2d48068 commit 8c532a0

File tree

13 files changed

+519
-297
lines changed

13 files changed

+519
-297
lines changed

PyPDF2/_merger.py

Lines changed: 151 additions & 104 deletions
Large diffs are not rendered by default.

PyPDF2/_reader.py

Lines changed: 57 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@
8989
TreeObject,
9090
read_object,
9191
)
92-
from .types import OutlinesType, PagemodeType
92+
from .types import OutlineType, PagemodeType
9393
from .xmp import XmpInformation
9494

9595

@@ -677,19 +677,30 @@ def getNamedDestinations(
677677
return self._get_named_destinations(tree, retval)
678678

679679
@property
680-
def outlines(self) -> OutlinesType:
680+
def outline(self) -> OutlineType:
681681
"""
682-
Read-only property for outlines present in the document.
682+
Read-only property for the outline (i.e., a collection of 'outline items'
683+
which are also known as 'bookmarks') present in the document.
683684
684685
:return: a nested list of :class:`Destinations<PyPDF2.generic.Destination>`.
685686
"""
686-
return self._get_outlines()
687+
return self._get_outline()
687688

688-
def _get_outlines(
689-
self, node: Optional[DictionaryObject] = None, outlines: Optional[Any] = None
690-
) -> OutlinesType:
691-
if outlines is None:
692-
outlines = []
689+
@property
690+
def outlines(self) -> OutlineType:
691+
"""
692+
.. deprecated:: 2.9.0
693+
694+
Use :py:attr:`outline` instead.
695+
"""
696+
deprecate_with_replacement("outlines", "outline")
697+
return self.outline
698+
699+
def _get_outline(
700+
self, node: Optional[DictionaryObject] = None, outline: Optional[Any] = None
701+
) -> OutlineType:
702+
if outline is None:
703+
outline = []
693704
catalog = cast(DictionaryObject, self.trailer[TK.ROOT])
694705

695706
# get the outline dictionary and named destinations
@@ -699,49 +710,49 @@ def _get_outlines(
699710
except PdfReadError:
700711
# this occurs if the /Outlines object reference is incorrect
701712
# for an example of such a file, see https://unglueit-files.s3.amazonaws.com/ebf/7552c42e9280b4476e59e77acc0bc812.pdf
702-
# so continue to load the file without the Bookmarks
703-
return outlines
713+
# so continue to load the file without the Outlines
714+
return outline
704715

705716
if isinstance(lines, NullObject):
706-
return outlines
717+
return outline
707718

708719
# TABLE 8.3 Entries in the outline dictionary
709720
if lines is not None and "/First" in lines:
710721
node = cast(DictionaryObject, lines["/First"])
711722
self._namedDests = self._get_named_destinations()
712723

713724
if node is None:
714-
return outlines
725+
return outline
715726

716-
# see if there are any more outlines
727+
# see if there are any more outline items
717728
while True:
718-
outline = self._build_outline(node)
719-
if outline:
720-
outlines.append(outline)
729+
outline_obj = self._build_outline_item(node)
730+
if outline_obj:
731+
outline.append(outline_obj)
721732

722-
# check for sub-outlines
733+
# check for sub-outline
723734
if "/First" in node:
724-
sub_outlines: List[Any] = []
725-
self._get_outlines(cast(DictionaryObject, node["/First"]), sub_outlines)
726-
if sub_outlines:
727-
outlines.append(sub_outlines)
735+
sub_outline: List[Any] = []
736+
self._get_outline(cast(DictionaryObject, node["/First"]), sub_outline)
737+
if sub_outline:
738+
outline.append(sub_outline)
728739

729740
if "/Next" not in node:
730741
break
731742
node = cast(DictionaryObject, node["/Next"])
732743

733-
return outlines
744+
return outline
734745

735746
def getOutlines(
736-
self, node: Optional[DictionaryObject] = None, outlines: Optional[Any] = None
737-
) -> OutlinesType: # pragma: no cover
747+
self, node: Optional[DictionaryObject] = None, outline: Optional[Any] = None
748+
) -> OutlineType: # pragma: no cover
738749
"""
739750
.. deprecated:: 1.28.0
740751
741-
Use :py:attr:`outlines` instead.
752+
Use :py:attr:`outline` instead.
742753
"""
743-
deprecate_with_replacement("getOutlines", "outlines")
744-
return self._get_outlines(node, outlines)
754+
deprecate_with_replacement("getOutlines", "outline")
755+
return self._get_outline(node, outline)
745756

746757
def _get_page_number_by_indirect(
747758
self, indirect_ref: Union[None, int, NullObject, IndirectObject]
@@ -809,7 +820,7 @@ def _build_destination(
809820
array: List[Union[NumberObject, IndirectObject, NullObject, DictionaryObject]],
810821
) -> Destination:
811822
page, typ = None, None
812-
# handle outlines with missing or invalid destination
823+
# handle outline items with missing or invalid destination
813824
if (
814825
isinstance(array, (type(None), NullObject))
815826
or (isinstance(array, ArrayObject) and len(array) == 0)
@@ -835,8 +846,8 @@ def _build_destination(
835846
title, indirect_ref, TextStringObject("/Fit") # type: ignore
836847
)
837848

838-
def _build_outline(self, node: DictionaryObject) -> Optional[Destination]:
839-
dest, title, outline = None, None, None
849+
def _build_outline_item(self, node: DictionaryObject) -> Optional[Destination]:
850+
dest, title, outline_item = None, None, None
840851

841852
# title required for valid outline
842853
# PDF Reference 1.7: TABLE 8.4 Entries in an outline item dictionary
@@ -861,40 +872,40 @@ def _build_outline(self, node: DictionaryObject) -> Optional[Destination]:
861872
dest = dest["/D"]
862873

863874
if isinstance(dest, ArrayObject):
864-
outline = self._build_destination(title, dest) # type: ignore
875+
outline_item = self._build_destination(title, dest) # type: ignore
865876
elif isinstance(dest, str):
866877
# named destination, addresses NameObject Issue #193
867878
try:
868-
outline = self._build_destination(
879+
outline_item = self._build_destination(
869880
title, self._namedDests[dest].dest_array
870881
)
871882
except KeyError:
872883
# named destination not found in Name Dict
873-
outline = self._build_destination(title, None)
884+
outline_item = self._build_destination(title, None)
874885
elif isinstance(dest, type(None)):
875-
# outline not required to have destination or action
886+
# outline item not required to have destination or action
876887
# PDFv1.7 Table 153
877-
outline = self._build_destination(title, dest) # type: ignore
888+
outline_item = self._build_destination(title, dest) # type: ignore
878889
else:
879890
if self.strict:
880891
raise PdfReadError(f"Unexpected destination {dest!r}")
881-
outline = self._build_destination(title, None) # type: ignore
892+
outline_item = self._build_destination(title, None) # type: ignore
882893

883-
# if outline created, add color, format, and child count if present
884-
if outline:
894+
# if outline item created, add color, format, and child count if present
895+
if outline_item:
885896
if "/C" in node:
886-
# Color of outline in (R, G, B) with values ranging 0.0-1.0
887-
outline[NameObject("/C")] = ArrayObject(FloatObject(c) for c in node["/C"]) # type: ignore
897+
# Color of outline item font in (R, G, B) with values ranging 0.0-1.0
898+
outline_item[NameObject("/C")] = ArrayObject(FloatObject(c) for c in node["/C"]) # type: ignore
888899
if "/F" in node:
889900
# specifies style characteristics bold and/or italic
890901
# 1=italic, 2=bold, 3=both
891-
outline[NameObject("/F")] = node["/F"]
902+
outline_item[NameObject("/F")] = node["/F"]
892903
if "/Count" in node:
893904
# absolute value = num. visible children
894905
# positive = open/unfolded, negative = closed/folded
895-
outline[NameObject("/Count")] = node["/Count"]
906+
outline_item[NameObject("/Count")] = node["/Count"]
896907

897-
return outline
908+
return outline_item
898909

899910
@property
900911
def pages(self) -> _VirtualList:
@@ -961,9 +972,9 @@ def page_mode(self) -> Optional[PagemodeType]:
961972
:widths: 50 200
962973
963974
* - /UseNone
964-
- Do not show outlines or thumbnails panels
975+
- Do not show outline or thumbnails panels
965976
* - /UseOutlines
966-
- Show outlines (aka bookmarks) panel
977+
- Show outline (aka bookmarks) panel
967978
* - /UseThumbs
968979
- Show page thumbnails panel
969980
* - /FullScreen

PyPDF2/_utils.py

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
__author__ = "Mathieu Fenniak"
3030
__author_email__ = "[email protected]"
3131

32+
import functools
3233
import logging
3334
import warnings
3435
from codecs import getencoder
@@ -40,7 +41,7 @@
4041
FileIO,
4142
)
4243
from os import SEEK_CUR
43-
from typing import Dict, Optional, Pattern, Tuple, Union, overload
44+
from typing import Any, Callable, Dict, Optional, Pattern, Tuple, Union, overload
4445

4546
try:
4647
# Python 3.10+: https://www.python.org/dev/peps/pep-0484/
@@ -362,3 +363,44 @@ def logger_warning(msg: str, src: str) -> None:
362363
to strict=False mode.
363364
"""
364365
logging.getLogger(src).warning(msg)
366+
367+
368+
def deprecate_bookmark(**aliases: str) -> Callable:
369+
"""
370+
Decorator for deprecated term "bookmark"
371+
To be used for methods and function arguments
372+
outline_item = a bookmark
373+
outline = a collection of outline items
374+
"""
375+
376+
def decoration(func: Callable): # type: ignore
377+
@functools.wraps(func)
378+
def wrapper(*args, **kwargs): # type: ignore
379+
rename_kwargs(func.__name__, kwargs, aliases)
380+
return func(*args, **kwargs)
381+
382+
return wrapper
383+
384+
return decoration
385+
386+
387+
def rename_kwargs( # type: ignore
388+
func_name: str, kwargs: Dict[str, Any], aliases: Dict[str, str]
389+
):
390+
"""
391+
Helper function to deprecate arguments.
392+
"""
393+
394+
for old_term, new_term in aliases.items():
395+
if old_term in kwargs:
396+
if new_term in kwargs:
397+
raise TypeError(
398+
f"{func_name} received both {old_term} and {new_term} as an argument."
399+
f"{old_term} is deprecated. Use {new_term} instead."
400+
)
401+
kwargs[new_term] = kwargs.pop(old_term)
402+
warnings.warn(
403+
message=(
404+
f"{old_term} is deprecated as an argument. Use {new_term} instead"
405+
)
406+
)

0 commit comments

Comments
 (0)