From 6f3a50ebdbe3a572a736a9e73b9c02152d059041 Mon Sep 17 00:00:00 2001 From: pubpub-zz <4083478+pubpub-zz@users.noreply.github.com> Date: Sun, 3 Sep 2023 18:07:06 +0200 Subject: [PATCH 1/6] ENH : ease access to ViewerPreferences closes #2105 still doc and test to be fixed/added --- pypdf/_reader.py | 14 ++++ pypdf/_writer.py | 22 +++++ pypdf/generic/__init__.py | 2 + pypdf/generic/_viewerpref.py | 150 +++++++++++++++++++++++++++++++++++ 4 files changed, 188 insertions(+) create mode 100644 pypdf/generic/_viewerpref.py diff --git a/pypdf/_reader.py b/pypdf/_reader.py index a4a422de8..7215bc78f 100644 --- a/pypdf/_reader.py +++ b/pypdf/_reader.py @@ -103,6 +103,7 @@ PdfObject, TextStringObject, TreeObject, + ViewerPreferences, read_object, ) from .types import OutlineType, PagemodeType @@ -293,6 +294,19 @@ class PdfReader: Defaults to ``None`` """ + @property + def viewer_preferences(self) -> Optional[ViewerPreferences]: + """Returns the existing ViewerPreferences as a overloaded dictionniary.""" + o = cast(DictionaryObject, self.trailer["/Root"]).get( + CD.VIEWER_PREFERENCES, None + ) + if o is None: + return None + o = o.get_object() + if not isinstance(o, ViewerPreferences): + o = ViewerPreferences(o) + return o + def __init__( self, stream: Union[StrByteType, Path], diff --git a/pypdf/_writer.py b/pypdf/_writer.py index ea1441aa6..053b0ce08 100644 --- a/pypdf/_writer.py +++ b/pypdf/_writer.py @@ -82,6 +82,7 @@ TypFitArguments, UserAccessPermissions, ) +from .constants import CatalogDictionary as CD from .constants import Core as CO from .constants import ( FieldDictionaryAttributes as FA, @@ -110,6 +111,7 @@ StreamObject, TextStringObject, TreeObject, + ViewerPreferences, create_string_object, hex_to_rgb, ) @@ -367,6 +369,26 @@ def set_need_appearances_writer(self, state: bool = True) -> None: f"set_need_appearances_writer({state}) catch : {exc}", __name__ ) + @property + def viewer_preferences(self) -> Optional[ViewerPreferences]: + """Returns the existing ViewerPreferences as a overloaded dictionniary.""" + o = cast(DictionaryObject, self._root_object).get(CD.VIEWER_PREFERENCES, None) + if o is None: + return None + o = o.get_object() + if not isinstance(o, ViewerPreferences): + o = ViewerPreferences(o) + if hasattr(o, "indirect_reference"): + self._replace_object(o.indirect_reference, o) + else: + self._root_object[NameObject(CD.VIEWER_PREFERENCES)] = o + return o + + def create_viewer_preference(self) -> ViewerPreferences: + o = ViewerPreferences() + self._root_object[CD.VIEWER_PREFERENCES] = self._add_object(o) + return o + def add_page( self, page: PageObject, diff --git a/pypdf/generic/__init__.py b/pypdf/generic/__init__.py index 915e9b6fd..778a9339e 100644 --- a/pypdf/generic/__init__.py +++ b/pypdf/generic/__init__.py @@ -67,6 +67,7 @@ read_hex_string_from_stream, read_string_from_stream, ) +from ._viewerpref import ViewerPreferences def readHexStringFromStream( @@ -443,6 +444,7 @@ def link( "RectangleObject", "Field", "Destination", + "ViewerPreferences", # --- More specific stuff # Outline "OutlineItem", diff --git a/pypdf/generic/_viewerpref.py b/pypdf/generic/_viewerpref.py new file mode 100644 index 000000000..f5e631e18 --- /dev/null +++ b/pypdf/generic/_viewerpref.py @@ -0,0 +1,150 @@ +# Copyright (c) 2023, Pubpub-ZZ +# +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# * The name of the author may not be used to endorse or promote products +# derived from this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. + +from typing import ( + Any, + List, + Optional, +) + +from ._base import BooleanObject, NameObject, NumberObject +from ._data_structures import ArrayObject, DictionaryObject + +f_obj = BooleanObject(False) + + +class ViewerPreferences(DictionaryObject): + def _get_bool(self, key: str, deft: Optional[BooleanObject]) -> BooleanObject: + return self.get(key, deft) + + def _set_bool(self, key: str, v: bool) -> None: + self[NameObject(key)] = BooleanObject(v is True) + + def _get_name(self, key: str, deft: Optional[NameObject]) -> Optional[NameObject]: + return self.get(key, deft) + + def _set_name(self, key: str, lst: List[str], v: NameObject) -> None: + if v[0] != "/": + raise ValueError(f"{v} is not starting with '/'") + if lst != [] and key not in lst: + raise ValueError(f"{v} is not par of acceptable values") + self[NameObject(key)] = NameObject(v) + + def _get_arr(self, key: str) -> NumberObject: + return self.get(key, ArrayObject()) + + def _set_arr(self, key: str, v: Optional[ArrayObject]) -> None: + if not isinstance(v, ArrayObject): + raise ValueError("ArrayObject is expected") + self[NameObject(key)] = v + + def _get_int(self, key: str, deft: Optional[NumberObject]) -> NumberObject: + return self.get(key, deft) + + def _set_int(self, key: str, v: int) -> None: + self[NameObject(key)] = NumberObject(v) + + def __new__(cls: Any, value: Any = None): + def _add_prop_bool(key: str, deft: Optional[BooleanObject]) -> property: + return property( + lambda self: self._get_bool(key, deft), + lambda self, v: self._set_bool(key, v), + None, + f""" + Returns/Modify the status of {key}, Returns {deft} if not defined + """, + ) + + def _add_prop_name( + key: str, lst: List[str], deft: Optional[NameObject] + ) -> property: + return property( + lambda self: self._get_name(key, deft), + lambda self, v: self._set_name(key, lst, v), + None, + f""" + Returns/Modify the status of {key}, Returns {deft} if not defined. + Acceptable values: {lst} + """, + ) + + def _add_prop_arr(key: str, deft: Optional[ArrayObject]) -> property: + return property( + lambda self: self._get_arr(key), + lambda self, v: self._set_arr(key, v), + None, + f""" + Returns/Modify the status of {key}, Returns {deft} if not defined + """, + ) + + def _add_prop_int(key: str, deft: Optional[int]) -> property: + return property( + lambda self: self._get_int(key, deft), + lambda self, v: self._set_int(key, v), + None, + f""" + Returns/Modify the status of {key}, Returns {deft} if not defined + """, + ) + + cls.hide_toolbar = _add_prop_bool("/HideToolbar", f_obj) + cls.hide_menubar = _add_prop_bool("/HideMenubar", f_obj) + cls.hide_windowui = _add_prop_bool("/HideWindowUI", f_obj) + cls.fit_window = _add_prop_bool("/FitWindow", f_obj) + cls.center_window = _add_prop_bool("/CenterWindow", f_obj) + cls.display_doctitle = _add_prop_bool("/DisplayDocTitle", f_obj) + + cls.non_fullscreen_pagemode = _add_prop_name( + "/NonFullScreenPageMode", + ["/UseNone", "/UseOutlines", "/UseThumbs", "/UseOC"], + NameObject("/UseNone"), + ) + cls.direction = _add_prop_name( + "/Direction", ["/L2R", "/R2L"], NameObject("/L2R") + ) + cls.view_area = _add_prop_name("/ViewArea", [], None) + cls.view_clip = _add_prop_name("/ViewClip", [], None) + cls.print_area = _add_prop_name("/PrintArea", [], None) + cls.print_clip = _add_prop_name("/PrintClip", [], None) + cls.print_scaling = _add_prop_name("/PrintScaling", [], None) + cls.duplex = _add_prop_name( + "/Duplex", ["/Simplex", "/DuplexFlipShortEdge", "/DuplexFlipLongEdge"], None + ) + cls.pick_tray_by_pdfsize = _add_prop_bool("/PickTrayByPDFSize", None) + cls.print_pagerange = _add_prop_arr("/PrintPageRange", None) + cls.num_copies = _add_prop_int("/NumCopies", None) + + # still to be done /PrintPageRange and /NumCopies + + return DictionaryObject.__new__(cls) + + def __init__(self, obj: Optional[DictionaryObject] = None) -> None: + super().__init__(self) + if obj is not None: + self.update(obj.items()) From 3675ba8149217d191ccd69a2eeb6431c9bd15d83 Mon Sep 17 00:00:00 2001 From: pubpub-zz <4083478+pubpub-zz@users.noreply.github.com> Date: Sun, 3 Sep 2023 19:06:41 +0200 Subject: [PATCH 2/6] add Test + fixes --- pypdf/_writer.py | 2 +- pypdf/generic/_viewerpref.py | 4 ++-- tests/test_writer.py | 30 ++++++++++++++++++++++++++++++ 3 files changed, 33 insertions(+), 3 deletions(-) diff --git a/pypdf/_writer.py b/pypdf/_writer.py index 053b0ce08..958f85794 100644 --- a/pypdf/_writer.py +++ b/pypdf/_writer.py @@ -386,7 +386,7 @@ def viewer_preferences(self) -> Optional[ViewerPreferences]: def create_viewer_preference(self) -> ViewerPreferences: o = ViewerPreferences() - self._root_object[CD.VIEWER_PREFERENCES] = self._add_object(o) + self._root_object[NameObject(CD.VIEWER_PREFERENCES)] = self._add_object(o) return o def add_page( diff --git a/pypdf/generic/_viewerpref.py b/pypdf/generic/_viewerpref.py index f5e631e18..5895ef4f9 100644 --- a/pypdf/generic/_viewerpref.py +++ b/pypdf/generic/_viewerpref.py @@ -51,7 +51,7 @@ def _get_name(self, key: str, deft: Optional[NameObject]) -> Optional[NameObject def _set_name(self, key: str, lst: List[str], v: NameObject) -> None: if v[0] != "/": raise ValueError(f"{v} is not starting with '/'") - if lst != [] and key not in lst: + if lst != [] and v not in lst: raise ValueError(f"{v} is not par of acceptable values") self[NameObject(key)] = NameObject(v) @@ -69,7 +69,7 @@ def _get_int(self, key: str, deft: Optional[NumberObject]) -> NumberObject: def _set_int(self, key: str, v: int) -> None: self[NameObject(key)] = NumberObject(v) - def __new__(cls: Any, value: Any = None): + def __new__(cls: Any, value: Any = None) -> "ViewerPreferences": def _add_prop_bool(key: str, deft: Optional[BooleanObject]) -> property: return property( lambda self: self._get_bool(key, deft), diff --git a/tests/test_writer.py b/tests/test_writer.py index 195cddba6..8460e9eac 100644 --- a/tests/test_writer.py +++ b/tests/test_writer.py @@ -1675,3 +1675,33 @@ def test_damaged_pdf_length_returning_none(): reader = PdfReader(BytesIO(get_data_from_url(url, name=name))) writer = PdfWriter() writer.append(reader) + + +@pytest.mark.enable_socket() +def test_viewerpreferences(): + """ + Add Tests for ViewerPreferences + https://github.com/py-pdf/pypdf/issues/140#issuecomment-1685380549 + """ + url = "https://github.com/py-pdf/pypdf/files/9175966/2015._pb_decode_pg0.pdf" + name = "2015._pb_decode_pg0.pdf" + reader = PdfReader(BytesIO(get_data_from_url(url, name=name))) + v = reader.viewer_preferences + assert bool(v.center_window) is True + writer = PdfWriter(clone_from=reader) + v = writer.viewer_preferences + assert bool(v.center_window) is True + v.center_window = False + assert bool(writer._root_object["/ViewerPreferences"]["/CenterWindow"]) is False + assert v.print_area == "/CropBox" + with pytest.raises(ValueError): + v.non_fullscreen_pagemode = "toto" + with pytest.raises(ValueError): + v.non_fullscreen_pagemode = "/toto" + v.non_fullscreen_pagemode = "/UseOutlines" + assert ( + writer._root_object["/ViewerPreferences"]["/NonFullScreenPageMode"] + == "/UseOutlines" + ) + writer.create_viewer_preference() + assert len(writer._root_object["/ViewerPreferences"]) == 0 From 2ed8da33512097f2a8c8f36fd42bce212fd86491 Mon Sep 17 00:00:00 2001 From: pubpub-zz <4083478+pubpub-zz@users.noreply.github.com> Date: Sun, 3 Sep 2023 19:26:43 +0200 Subject: [PATCH 3/6] fix issue with wrong substitution "== True" is different from "is True" --- tests/test_writer.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/test_writer.py b/tests/test_writer.py index 8460e9eac..3d17cfaec 100644 --- a/tests/test_writer.py +++ b/tests/test_writer.py @@ -1687,12 +1687,15 @@ def test_viewerpreferences(): name = "2015._pb_decode_pg0.pdf" reader = PdfReader(BytesIO(get_data_from_url(url, name=name))) v = reader.viewer_preferences - assert bool(v.center_window) is True + assert v.center_window == True # noqa: E712 writer = PdfWriter(clone_from=reader) v = writer.viewer_preferences - assert bool(v.center_window) is True + assert v.center_window == True # noqa: E712 v.center_window = False - assert bool(writer._root_object["/ViewerPreferences"]["/CenterWindow"]) is False + assert ( + writer._root_object["/ViewerPreferences"]["/CenterWindow"] + == False # noqa: E712 + ) assert v.print_area == "/CropBox" with pytest.raises(ValueError): v.non_fullscreen_pagemode = "toto" From bb770c00380e425f7530e9e96698f04af99c7118 Mon Sep 17 00:00:00 2001 From: pubpub-zz <4083478+pubpub-zz@users.noreply.github.com> Date: Sun, 3 Sep 2023 19:26:43 +0200 Subject: [PATCH 4/6] fix issue with wrong substitution "== True" is different from "is True" --- pypdf/generic/_viewerpref.py | 4 ++++ tests/test_writer.py | 32 +++++++++++++++++++++++++++++--- 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/pypdf/generic/_viewerpref.py b/pypdf/generic/_viewerpref.py index 5895ef4f9..e945d6a5e 100644 --- a/pypdf/generic/_viewerpref.py +++ b/pypdf/generic/_viewerpref.py @@ -148,3 +148,7 @@ def __init__(self, obj: Optional[DictionaryObject] = None) -> None: super().__init__(self) if obj is not None: self.update(obj.items()) + try: + self.indirect_reference = obj.indirect_reference # type: ignore + except AttributeError: + pass diff --git a/tests/test_writer.py b/tests/test_writer.py index 8460e9eac..d70a6136f 100644 --- a/tests/test_writer.py +++ b/tests/test_writer.py @@ -1687,12 +1687,15 @@ def test_viewerpreferences(): name = "2015._pb_decode_pg0.pdf" reader = PdfReader(BytesIO(get_data_from_url(url, name=name))) v = reader.viewer_preferences - assert bool(v.center_window) is True + assert v.center_window == True # noqa: E712 writer = PdfWriter(clone_from=reader) v = writer.viewer_preferences - assert bool(v.center_window) is True + assert v.center_window == True # noqa: E712 v.center_window = False - assert bool(writer._root_object["/ViewerPreferences"]["/CenterWindow"]) is False + assert ( + writer._root_object["/ViewerPreferences"]["/CenterWindow"] + == False # noqa: E712 + ) assert v.print_area == "/CropBox" with pytest.raises(ValueError): v.non_fullscreen_pagemode = "toto" @@ -1703,5 +1706,28 @@ def test_viewerpreferences(): writer._root_object["/ViewerPreferences"]["/NonFullScreenPageMode"] == "/UseOutlines" ) + writer = PdfWriter(clone_from=reader) + v = writer.viewer_preferences + assert v.center_window == True # noqa: E712 + v.center_window = False + assert ( + writer._root_object["/ViewerPreferences"]["/CenterWindow"] + == False # noqa: E712 + ) + + writer = PdfWriter(clone_from=reader) + writer._root_object[NameObject("/ViewerPreferences")] = writer._add_object( + writer._root_object["/ViewerPreferences"] + ) + v = writer.viewer_preferences + v.center_window = False + assert ( + writer._root_object["/ViewerPreferences"]["/CenterWindow"] + == False # noqa: E712 + ) + writer.create_viewer_preference() assert len(writer._root_object["/ViewerPreferences"]) == 0 + + del reader.trailer["/Root"]["/ViewerPreferences"] + assert reader.viewer_preferences is None From 7e3251c61fae445168196d5c3e5f3d4664edf6fd Mon Sep 17 00:00:00 2001 From: pubpub-zz <4083478+pubpub-zz@users.noreply.github.com> Date: Sun, 3 Sep 2023 20:49:22 +0200 Subject: [PATCH 5/6] coverage+fix --- pypdf/generic/_viewerpref.py | 6 +++--- tests/test_writer.py | 5 +++++ 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/pypdf/generic/_viewerpref.py b/pypdf/generic/_viewerpref.py index e945d6a5e..763f4d166 100644 --- a/pypdf/generic/_viewerpref.py +++ b/pypdf/generic/_viewerpref.py @@ -55,8 +55,8 @@ def _set_name(self, key: str, lst: List[str], v: NameObject) -> None: raise ValueError(f"{v} is not par of acceptable values") self[NameObject(key)] = NameObject(v) - def _get_arr(self, key: str) -> NumberObject: - return self.get(key, ArrayObject()) + def _get_arr(self, key: str, deft: Optional[List[Any]]) -> NumberObject: + return self.get(key, None if deft is None else ArrayObject(deft)) def _set_arr(self, key: str, v: Optional[ArrayObject]) -> None: if not isinstance(v, ArrayObject): @@ -95,7 +95,7 @@ def _add_prop_name( def _add_prop_arr(key: str, deft: Optional[ArrayObject]) -> property: return property( - lambda self: self._get_arr(key), + lambda self: self._get_arr(key, deft), lambda self, v: self._set_arr(key, v), None, f""" diff --git a/tests/test_writer.py b/tests/test_writer.py index d70a6136f..5c3786b03 100644 --- a/tests/test_writer.py +++ b/tests/test_writer.py @@ -1725,6 +1725,11 @@ def test_viewerpreferences(): writer._root_object["/ViewerPreferences"]["/CenterWindow"] == False # noqa: E712 ) + v.num_copies = 1 + assert v.num_copies == 1 + assert v.print_pagerange is None + v.print_pagerange = ArrayObject() + assert len(v.print_pagerange) == 0 writer.create_viewer_preference() assert len(writer._root_object["/ViewerPreferences"]) == 0 From 2d487b19878f940ce650c6b90b7e28ee4fb42b03 Mon Sep 17 00:00:00 2001 From: pubpub-zz <4083478+pubpub-zz@users.noreply.github.com> Date: Sun, 3 Sep 2023 21:06:19 +0200 Subject: [PATCH 6/6] coverage --- tests/test_writer.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/test_writer.py b/tests/test_writer.py index 5c3786b03..485e4b97a 100644 --- a/tests/test_writer.py +++ b/tests/test_writer.py @@ -1728,6 +1728,8 @@ def test_viewerpreferences(): v.num_copies = 1 assert v.num_copies == 1 assert v.print_pagerange is None + with pytest.raises(ValueError): + v.print_pagerange = "toto" v.print_pagerange = ArrayObject() assert len(v.print_pagerange) == 0 @@ -1736,3 +1738,5 @@ def test_viewerpreferences(): del reader.trailer["/Root"]["/ViewerPreferences"] assert reader.viewer_preferences is None + writer = PdfWriter(clone_from=reader) + assert writer.viewer_preferences is None