Skip to content

Commit 05f2a65

Browse files
authored
ENH: Ease access to ViewerPreferences (#2144)
Closes #2105
1 parent 455c773 commit 05f2a65

File tree

5 files changed

+257
-0
lines changed

5 files changed

+257
-0
lines changed

pypdf/_reader.py

+14
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@
103103
PdfObject,
104104
TextStringObject,
105105
TreeObject,
106+
ViewerPreferences,
106107
read_object,
107108
)
108109
from .types import OutlineType, PagemodeType
@@ -293,6 +294,19 @@ class PdfReader:
293294
Defaults to ``None``
294295
"""
295296

297+
@property
298+
def viewer_preferences(self) -> Optional[ViewerPreferences]:
299+
"""Returns the existing ViewerPreferences as a overloaded dictionniary."""
300+
o = cast(DictionaryObject, self.trailer["/Root"]).get(
301+
CD.VIEWER_PREFERENCES, None
302+
)
303+
if o is None:
304+
return None
305+
o = o.get_object()
306+
if not isinstance(o, ViewerPreferences):
307+
o = ViewerPreferences(o)
308+
return o
309+
296310
def __init__(
297311
self,
298312
stream: Union[StrByteType, Path],

pypdf/_writer.py

+22
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@
8282
TypFitArguments,
8383
UserAccessPermissions,
8484
)
85+
from .constants import CatalogDictionary as CD
8586
from .constants import Core as CO
8687
from .constants import (
8788
FieldDictionaryAttributes as FA,
@@ -110,6 +111,7 @@
110111
StreamObject,
111112
TextStringObject,
112113
TreeObject,
114+
ViewerPreferences,
113115
create_string_object,
114116
hex_to_rgb,
115117
)
@@ -367,6 +369,26 @@ def set_need_appearances_writer(self, state: bool = True) -> None:
367369
f"set_need_appearances_writer({state}) catch : {exc}", __name__
368370
)
369371

372+
@property
373+
def viewer_preferences(self) -> Optional[ViewerPreferences]:
374+
"""Returns the existing ViewerPreferences as a overloaded dictionniary."""
375+
o = cast(DictionaryObject, self._root_object).get(CD.VIEWER_PREFERENCES, None)
376+
if o is None:
377+
return None
378+
o = o.get_object()
379+
if not isinstance(o, ViewerPreferences):
380+
o = ViewerPreferences(o)
381+
if hasattr(o, "indirect_reference"):
382+
self._replace_object(o.indirect_reference, o)
383+
else:
384+
self._root_object[NameObject(CD.VIEWER_PREFERENCES)] = o
385+
return o
386+
387+
def create_viewer_preference(self) -> ViewerPreferences:
388+
o = ViewerPreferences()
389+
self._root_object[NameObject(CD.VIEWER_PREFERENCES)] = self._add_object(o)
390+
return o
391+
370392
def add_page(
371393
self,
372394
page: PageObject,

pypdf/generic/__init__.py

+2
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@
6767
read_hex_string_from_stream,
6868
read_string_from_stream,
6969
)
70+
from ._viewerpref import ViewerPreferences
7071

7172

7273
def readHexStringFromStream(
@@ -443,6 +444,7 @@ def link(
443444
"RectangleObject",
444445
"Field",
445446
"Destination",
447+
"ViewerPreferences",
446448
# --- More specific stuff
447449
# Outline
448450
"OutlineItem",

pypdf/generic/_viewerpref.py

+154
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
# Copyright (c) 2023, Pubpub-ZZ
2+
#
3+
# All rights reserved.
4+
#
5+
# Redistribution and use in source and binary forms, with or without
6+
# modification, are permitted provided that the following conditions are
7+
# met:
8+
#
9+
# * Redistributions of source code must retain the above copyright notice,
10+
# this list of conditions and the following disclaimer.
11+
# * Redistributions in binary form must reproduce the above copyright notice,
12+
# this list of conditions and the following disclaimer in the documentation
13+
# and/or other materials provided with the distribution.
14+
# * The name of the author may not be used to endorse or promote products
15+
# derived from this software without specific prior written permission.
16+
#
17+
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
18+
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
19+
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
20+
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
21+
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
22+
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
23+
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
24+
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
25+
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
26+
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
27+
# POSSIBILITY OF SUCH DAMAGE.
28+
29+
from typing import (
30+
Any,
31+
List,
32+
Optional,
33+
)
34+
35+
from ._base import BooleanObject, NameObject, NumberObject
36+
from ._data_structures import ArrayObject, DictionaryObject
37+
38+
f_obj = BooleanObject(False)
39+
40+
41+
class ViewerPreferences(DictionaryObject):
42+
def _get_bool(self, key: str, deft: Optional[BooleanObject]) -> BooleanObject:
43+
return self.get(key, deft)
44+
45+
def _set_bool(self, key: str, v: bool) -> None:
46+
self[NameObject(key)] = BooleanObject(v is True)
47+
48+
def _get_name(self, key: str, deft: Optional[NameObject]) -> Optional[NameObject]:
49+
return self.get(key, deft)
50+
51+
def _set_name(self, key: str, lst: List[str], v: NameObject) -> None:
52+
if v[0] != "/":
53+
raise ValueError(f"{v} is not starting with '/'")
54+
if lst != [] and v not in lst:
55+
raise ValueError(f"{v} is not par of acceptable values")
56+
self[NameObject(key)] = NameObject(v)
57+
58+
def _get_arr(self, key: str, deft: Optional[List[Any]]) -> NumberObject:
59+
return self.get(key, None if deft is None else ArrayObject(deft))
60+
61+
def _set_arr(self, key: str, v: Optional[ArrayObject]) -> None:
62+
if not isinstance(v, ArrayObject):
63+
raise ValueError("ArrayObject is expected")
64+
self[NameObject(key)] = v
65+
66+
def _get_int(self, key: str, deft: Optional[NumberObject]) -> NumberObject:
67+
return self.get(key, deft)
68+
69+
def _set_int(self, key: str, v: int) -> None:
70+
self[NameObject(key)] = NumberObject(v)
71+
72+
def __new__(cls: Any, value: Any = None) -> "ViewerPreferences":
73+
def _add_prop_bool(key: str, deft: Optional[BooleanObject]) -> property:
74+
return property(
75+
lambda self: self._get_bool(key, deft),
76+
lambda self, v: self._set_bool(key, v),
77+
None,
78+
f"""
79+
Returns/Modify the status of {key}, Returns {deft} if not defined
80+
""",
81+
)
82+
83+
def _add_prop_name(
84+
key: str, lst: List[str], deft: Optional[NameObject]
85+
) -> property:
86+
return property(
87+
lambda self: self._get_name(key, deft),
88+
lambda self, v: self._set_name(key, lst, v),
89+
None,
90+
f"""
91+
Returns/Modify the status of {key}, Returns {deft} if not defined.
92+
Acceptable values: {lst}
93+
""",
94+
)
95+
96+
def _add_prop_arr(key: str, deft: Optional[ArrayObject]) -> property:
97+
return property(
98+
lambda self: self._get_arr(key, deft),
99+
lambda self, v: self._set_arr(key, v),
100+
None,
101+
f"""
102+
Returns/Modify the status of {key}, Returns {deft} if not defined
103+
""",
104+
)
105+
106+
def _add_prop_int(key: str, deft: Optional[int]) -> property:
107+
return property(
108+
lambda self: self._get_int(key, deft),
109+
lambda self, v: self._set_int(key, v),
110+
None,
111+
f"""
112+
Returns/Modify the status of {key}, Returns {deft} if not defined
113+
""",
114+
)
115+
116+
cls.hide_toolbar = _add_prop_bool("/HideToolbar", f_obj)
117+
cls.hide_menubar = _add_prop_bool("/HideMenubar", f_obj)
118+
cls.hide_windowui = _add_prop_bool("/HideWindowUI", f_obj)
119+
cls.fit_window = _add_prop_bool("/FitWindow", f_obj)
120+
cls.center_window = _add_prop_bool("/CenterWindow", f_obj)
121+
cls.display_doctitle = _add_prop_bool("/DisplayDocTitle", f_obj)
122+
123+
cls.non_fullscreen_pagemode = _add_prop_name(
124+
"/NonFullScreenPageMode",
125+
["/UseNone", "/UseOutlines", "/UseThumbs", "/UseOC"],
126+
NameObject("/UseNone"),
127+
)
128+
cls.direction = _add_prop_name(
129+
"/Direction", ["/L2R", "/R2L"], NameObject("/L2R")
130+
)
131+
cls.view_area = _add_prop_name("/ViewArea", [], None)
132+
cls.view_clip = _add_prop_name("/ViewClip", [], None)
133+
cls.print_area = _add_prop_name("/PrintArea", [], None)
134+
cls.print_clip = _add_prop_name("/PrintClip", [], None)
135+
cls.print_scaling = _add_prop_name("/PrintScaling", [], None)
136+
cls.duplex = _add_prop_name(
137+
"/Duplex", ["/Simplex", "/DuplexFlipShortEdge", "/DuplexFlipLongEdge"], None
138+
)
139+
cls.pick_tray_by_pdfsize = _add_prop_bool("/PickTrayByPDFSize", None)
140+
cls.print_pagerange = _add_prop_arr("/PrintPageRange", None)
141+
cls.num_copies = _add_prop_int("/NumCopies", None)
142+
143+
# still to be done /PrintPageRange and /NumCopies
144+
145+
return DictionaryObject.__new__(cls)
146+
147+
def __init__(self, obj: Optional[DictionaryObject] = None) -> None:
148+
super().__init__(self)
149+
if obj is not None:
150+
self.update(obj.items())
151+
try:
152+
self.indirect_reference = obj.indirect_reference # type: ignore
153+
except AttributeError:
154+
pass

tests/test_writer.py

+65
Original file line numberDiff line numberDiff line change
@@ -1718,3 +1718,68 @@ def test_damaged_pdf_length_returning_none():
17181718
reader = PdfReader(BytesIO(get_data_from_url(url, name=name)))
17191719
writer = PdfWriter()
17201720
writer.append(reader)
1721+
1722+
1723+
@pytest.mark.enable_socket()
1724+
def test_viewerpreferences():
1725+
"""
1726+
Add Tests for ViewerPreferences
1727+
https://github.com/py-pdf/pypdf/issues/140#issuecomment-1685380549
1728+
"""
1729+
url = "https://github.com/py-pdf/pypdf/files/9175966/2015._pb_decode_pg0.pdf"
1730+
name = "2015._pb_decode_pg0.pdf"
1731+
reader = PdfReader(BytesIO(get_data_from_url(url, name=name)))
1732+
v = reader.viewer_preferences
1733+
assert v.center_window == True # noqa: E712
1734+
writer = PdfWriter(clone_from=reader)
1735+
v = writer.viewer_preferences
1736+
assert v.center_window == True # noqa: E712
1737+
v.center_window = False
1738+
assert (
1739+
writer._root_object["/ViewerPreferences"]["/CenterWindow"]
1740+
== False # noqa: E712
1741+
)
1742+
assert v.print_area == "/CropBox"
1743+
with pytest.raises(ValueError):
1744+
v.non_fullscreen_pagemode = "toto"
1745+
with pytest.raises(ValueError):
1746+
v.non_fullscreen_pagemode = "/toto"
1747+
v.non_fullscreen_pagemode = "/UseOutlines"
1748+
assert (
1749+
writer._root_object["/ViewerPreferences"]["/NonFullScreenPageMode"]
1750+
== "/UseOutlines"
1751+
)
1752+
writer = PdfWriter(clone_from=reader)
1753+
v = writer.viewer_preferences
1754+
assert v.center_window == True # noqa: E712
1755+
v.center_window = False
1756+
assert (
1757+
writer._root_object["/ViewerPreferences"]["/CenterWindow"]
1758+
== False # noqa: E712
1759+
)
1760+
1761+
writer = PdfWriter(clone_from=reader)
1762+
writer._root_object[NameObject("/ViewerPreferences")] = writer._add_object(
1763+
writer._root_object["/ViewerPreferences"]
1764+
)
1765+
v = writer.viewer_preferences
1766+
v.center_window = False
1767+
assert (
1768+
writer._root_object["/ViewerPreferences"]["/CenterWindow"]
1769+
== False # noqa: E712
1770+
)
1771+
v.num_copies = 1
1772+
assert v.num_copies == 1
1773+
assert v.print_pagerange is None
1774+
with pytest.raises(ValueError):
1775+
v.print_pagerange = "toto"
1776+
v.print_pagerange = ArrayObject()
1777+
assert len(v.print_pagerange) == 0
1778+
1779+
writer.create_viewer_preference()
1780+
assert len(writer._root_object["/ViewerPreferences"]) == 0
1781+
1782+
del reader.trailer["/Root"]["/ViewerPreferences"]
1783+
assert reader.viewer_preferences is None
1784+
writer = PdfWriter(clone_from=reader)
1785+
assert writer.viewer_preferences is None

0 commit comments

Comments
 (0)