From c2a741e968da1b4d7195a7924ba62a9445f825e4 Mon Sep 17 00:00:00 2001 From: pubpub-zz <4083478+pubpub-zz@users.noreply.github.com> Date: Tue, 18 Jul 2023 18:05:19 +0200 Subject: [PATCH 01/10] BUG: Process CMYK in deflate images (#1977) Closes #1954 --- pypdf/filters.py | 18 +++++++++++++----- tests/test_filters.py | 20 ++++++++++++++++++++ 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/pypdf/filters.py b/pypdf/filters.py index e4aa6ea4c6..461411d311 100644 --- a/pypdf/filters.py +++ b/pypdf/filters.py @@ -743,6 +743,7 @@ def bits2byte(data: bytes, size: Tuple[int, int], bits: int) -> bytes: return bytes(nbuff) extension = ".png" # mime_type = "image/png" + image_format = "PNG" lookup: Any base: Any hival: Any @@ -794,10 +795,14 @@ def bits2byte(data: bytes, size: Tuple[int, int], bits: int) -> bytes: elif not isinstance(color_space, NullObject) and color_space[0] == "/ICCBased": # see Table 66 - Additional Entries Specific to an ICC Profile # Stream Dictionary - mode = _get_imagemode(color_space, colors, mode) - extension = ".png" - img = Image.frombytes(mode, size, data) # reloaded as mode may have change - image_format = "PNG" + mode2 = _get_imagemode(color_space, colors, mode) + if mode != mode2: + img = Image.frombytes( + mode2, size, data + ) # reloaded as mode may have change + if mode == "CMYK": + extension = ".tif" + image_format = "TIFF" return img, image_format, extension def _handle_jpx( @@ -907,7 +912,10 @@ def _handle_jpx( # CMYK image without decode requires reverting scale (cf p243,2ยง last sentence) decode = x_object_obj.get( - IA.DECODE, ([1.0, 0.0] * 4) if img.mode == "CMYK" else None + IA.DECODE, + ([1.0, 0.0] * 4) + if img.mode == "CMYK" and lfilters in (FT.DCT_DECODE, FT.JPX_DECODE) + else None, ) if ( isinstance(color_space, ArrayObject) diff --git a/tests/test_filters.py b/tests/test_filters.py index 2eb8b58c0e..7353a9bdfe 100644 --- a/tests/test_filters.py +++ b/tests/test_filters.py @@ -388,6 +388,7 @@ def test_rgba(): @pytest.mark.enable_socket() def test_cmyk(): """Decode cmyk""" + # JPEG compression try: from Crypto.Cipher import AES # noqa: F401 except ImportError: @@ -401,11 +402,30 @@ def test_cmyk(): BytesIO(get_pdf_from_url(url_png, name=name_png)) ) # not a pdf but it works data = reader.pages[1].images[0] + assert data.image.mode == "CMYK" + assert ".jpg" in data.name diff = ImageChops.difference(data.image, refimg) d = sqrt( sum([(a * a + b * b + c * c + d * d) for a, b, c, d in diff.getdata()]) ) / (diff.size[0] * diff.size[1]) assert d < 0.01 + # deflate + url = "https://github.com/py-pdf/pypdf/files/12078533/cmyk2.pdf" + name = "cmyk_deflate.pdf" + reader = PdfReader(BytesIO(get_pdf_from_url(url, name=name))) + url_png = "https://github.com/py-pdf/pypdf/files/12078556/cmyk.tif.txt" + name_png = "cmyk_deflate.tif" + refimg = Image.open( + BytesIO(get_pdf_from_url(url_png, name=name_png)) + ) # not a pdf but it works + data = reader.pages[0].images[0] + assert data.image.mode == "CMYK" + assert ".tif" in data.name + diff = ImageChops.difference(data.image, refimg) + d = sqrt( + sum([(a * a + b * b + c * c + d * d) for a, b, c, d in diff.getdata()]) + ) / (diff.size[0] * diff.size[1]) + assert d < 0.001 # lossless compression expected @pytest.mark.enable_socket() From 85ca63e87ca94a550957bd66fe58844cf084e118 Mon Sep 17 00:00:00 2001 From: pubpub-zz <4083478+pubpub-zz@users.noreply.github.com> Date: Thu, 20 Jul 2023 21:46:33 +0200 Subject: [PATCH 02/10] BUG: Decode Black only/CMYK deviceN images (#1984) Closes #1979 --- pypdf/constants.py | 1 + pypdf/filters.py | 38 +++++++++++++++++++++++--------------- tests/test_filters.py | 21 +++++++++++++++++++++ 3 files changed, 45 insertions(+), 15 deletions(-) diff --git a/pypdf/constants.py b/pypdf/constants.py index 3d6c6d90f1..438f57194f 100644 --- a/pypdf/constants.py +++ b/pypdf/constants.py @@ -224,6 +224,7 @@ class ImageAttributes: BITS_PER_COMPONENT = "/BitsPerComponent" # integer, required COLOR_SPACE = "/ColorSpace" # name, required DECODE = "/Decode" # array, optional + INTENT = "/Intent" # string, optional INTERPOLATE = "/Interpolate" # boolean, optional IMAGE_MASK = "/ImageMask" # boolean, optional MASK = "/Mask" # 1-bit image mask stream diff --git a/pypdf/filters.py b/pypdf/filters.py index 461411d311..a6bcb2d64e 100644 --- a/pypdf/filters.py +++ b/pypdf/filters.py @@ -647,7 +647,9 @@ def decodeStreamData(stream: Any) -> Union[str, bytes]: # deprecated def _get_imagemode( - color_space: Union[str, List[Any]], color_components: int, prev_mode: mode_str_type + color_space: Union[str, List[Any], Any], + color_components: int, + prev_mode: mode_str_type, ) -> mode_str_type: """Returns the image mode not taking into account mask(transparency)""" if isinstance(color_space, str): @@ -663,26 +665,29 @@ def _get_imagemode( color_components = cast(int, icc_profile["/N"]) color_space = icc_profile.get("/Alternate", "") elif color_space[0] == "/Indexed": - color_space = color_space[1].get_object() - if isinstance(color_space, list): - color_space = color_space[1].get_object().get("/Alternate", "") - color_components = 1 if "Gray" in color_space else 2 - if not (isinstance(color_space, str) and "Gray" in color_space): - color_space = "palette" + color_space = color_space[1] + if isinstance(color_space, IndirectObject): + color_space = color_space.get_object() + mode2 = _get_imagemode(color_space, color_components, prev_mode) + if mode2 in ("RGB", "CMYK"): + mode2 = "P" + return mode2 elif color_space[0] == "/Separation": color_space = color_space[2] elif color_space[0] == "/DeviceN": - color_space = color_space[2] color_components = len(color_space[1]) + color_space = color_space[2] + if isinstance(color_space, IndirectObject): # pragma: no cover + color_space = color_space.get_object() mode_map = { - "1bit": "1", # 0 will be used for 1 bit + "1bit": "1", # pos [0] will be used for 1 bit + "/DeviceGray": "L", # must be in pos [1] + "palette": "P", # must be in pos [2] for color_components align. + "/DeviceRGB": "RGB", # must be in pos [3] + "/DeviceCMYK": "CMYK", # must be in pos [4] "2bit": "2bits", # 2 bits images "4bit": "4bits", # 4 bits - "/DeviceGray": "L", - "palette": "P", # reserved for color_components alignment - "/DeviceRGB": "RGB", - "/DeviceCMYK": "CMYK", } mode: mode_str_type = ( mode_map.get(color_space) # type: ignore @@ -913,8 +918,11 @@ def _handle_jpx( # CMYK image without decode requires reverting scale (cf p243,2ยง last sentence) decode = x_object_obj.get( IA.DECODE, - ([1.0, 0.0] * 4) - if img.mode == "CMYK" and lfilters in (FT.DCT_DECODE, FT.JPX_DECODE) + ([1.0, 0.0] * len(img.getbands())) + if ( + (img.mode == "CMYK" or (mode == "CMYK" and img.mode == "L")) + and lfilters in (FT.DCT_DECODE, FT.JPX_DECODE) + ) else None, ) if ( diff --git a/tests/test_filters.py b/tests/test_filters.py index 7353a9bdfe..c70b5192cd 100644 --- a/tests/test_filters.py +++ b/tests/test_filters.py @@ -485,3 +485,24 @@ def test_2bits_image(): sum([(a * a + b * b + c * c + d * d) for a, b, c, d in diff.getdata()]) ) / (diff.size[0] * diff.size[1]) assert d < 0.01 + + +@pytest.mark.enable_socket() +def test_gray_devicen_cmyk(): + """ + Cf #1979 + Gray Image in CMYK : requiring reverse + """ + url = "https://github.com/py-pdf/pypdf/files/12080338/example_121.pdf" + name = "gray_cmyk.pdf" + reader = PdfReader(BytesIO(get_pdf_from_url(url, name=name))) + url_png = "https://user-images.githubusercontent.com/4083478/254545494-42df4949-1557-4f2d-acca-6be6e8de1122.png" + name_png = "velo.png" + refimg = Image.open( + BytesIO(get_pdf_from_url(url_png, name=name_png)) + ) # not a pdf but it works + data = reader.pages[0].images[0] + assert data.image.mode == "L" + diff = ImageChops.difference(data.image, refimg) + d = sqrt(sum([(a * a) for a in diff.getdata()])) / (diff.size[0] * diff.size[1]) + assert d < 0.001 From 6df64af3ec22eebe52590f975b2a9bba6e497176 Mon Sep 17 00:00:00 2001 From: Matthew Peveler Date: Thu, 20 Jul 2023 14:53:10 -0700 Subject: [PATCH 03/10] DEV: Add body to created GitHub release (#1985) PR fixes that new GitHub releases were lacking a body, where this was due to the fact that we were not outputting `tag_body` to `$GITHUB_ENV` so that it wasn't available in follow-up steps. However, because the body is a multiline string, we've got to wrap it in special syntax to get it to work (see [docs](https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#example-of-a-multiline-string) for example). See https://github.com/py-pdf/pypdf/actions/runs/5601580443/jobs/10245662760?pr=1985 as an example test run that shows it working in a test workflow. Closes #1971 --- .github/workflows/release.yaml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index a006a01b65..ac6db4c098 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -46,7 +46,10 @@ jobs: latest_tag=$(git describe --tags --abbrev=0) echo "latest_tag=$(git describe --tags --abbrev=0)" >> "$GITHUB_ENV" echo "date=$(date +'%Y-%m-%d')" >> "$GITHUB_ENV" - tag_body=$(git tag -l "${latest_tag}" --format='%(contents:body)') + EOF=$(dd if=/dev/urandom bs=15 count=1 status=none | base64) + echo "tag_body<<$EOF" >> "$GITHUB_ENV" + git tag -l "${latest_tag}" --format='%(contents:body)' >> "$GITHUB_ENV" + echo "$EOF" >> "$GITHUB_ENV" - name: Create GitHub Release ๐Ÿš€ uses: actions/create-release@v1 env: @@ -56,4 +59,4 @@ jobs: release_name: Version ${{ env.latest_tag }}, ${{ env.date }} draft: false prerelease: false - body: Body is ${{ env.tag_body }} + body: ${{ env.tag_body }} From 8c1095a9416429ed23beeed50d71348e3d73f931 Mon Sep 17 00:00:00 2001 From: pubpub-zz <4083478+pubpub-zz@users.noreply.github.com> Date: Sun, 23 Jul 2023 18:29:56 +0200 Subject: [PATCH 04/10] ENH: Add is_open in outlines in PdfReader and PdfWriter (#1960) Closes #1922 --- pypdf/_reader.py | 9 ++++ pypdf/_writer.py | 34 +++++++++++--- pypdf/generic/_data_structures.py | 73 +++++++++++++++++++++++-------- tests/test_writer.py | 42 ++++++++++++++++-- 4 files changed, 131 insertions(+), 27 deletions(-) diff --git a/pypdf/_reader.py b/pypdf/_reader.py index eb570096e7..815e9a872d 100644 --- a/pypdf/_reader.py +++ b/pypdf/_reader.py @@ -86,6 +86,7 @@ ) from .generic import ( ArrayObject, + BooleanObject, ContentStream, DecodedStreamObject, Destination, @@ -1083,7 +1084,15 @@ def _build_outline_item(self, node: DictionaryObject) -> Optional[Destination]: # absolute value = num. visible children # with positive = open/unfolded, negative = closed/folded outline_item[NameObject("/Count")] = node["/Count"] + # if count is 0 we will consider it as open ( in order to have always an is_open to simplify + outline_item[NameObject("/%is_open%")] = BooleanObject( + node.get("/Count", 0) >= 0 + ) outline_item.node = node + try: + outline_item.indirect_reference = node.indirect_reference + except AttributeError: + pass return outline_item @property diff --git a/pypdf/_writer.py b/pypdf/_writer.py index 1abb615668..fe87b27e9c 100644 --- a/pypdf/_writer.py +++ b/pypdf/_writer.py @@ -1633,6 +1633,7 @@ def add_outline_item_destination( page_destination: Union[None, PageObject, TreeObject] = None, parent: Union[None, TreeObject, IndirectObject] = None, before: Union[None, TreeObject, IndirectObject] = None, + is_open: bool = True, dest: Union[None, PageObject, TreeObject] = None, # deprecated ) -> IndirectObject: if page_destination is not None and dest is not None: # deprecated @@ -1655,14 +1656,33 @@ def add_outline_item_destination( # argument is only Optional due to deprecated argument. raise ValueError("page_destination may not be None") + if isinstance(page_destination, PageObject): + return self.add_outline_item_destination( + Destination( + f"page #{page_destination.page_number}", + cast(IndirectObject, page_destination.indirect_reference), + Fit.fit(), + ) + ) + if parent is None: parent = self.get_outline_root() + page_destination[NameObject("/%is_open%")] = BooleanObject(is_open) parent = cast(TreeObject, parent.get_object()) page_destination_ref = self._add_object(page_destination) if before is not None: before = before.indirect_reference - parent.insert_child(page_destination_ref, before, self) + parent.insert_child( + page_destination_ref, + before, + self, + page_destination.inc_parent_counter_outline + if is_open + else (lambda x, y: 0), + ) + if "/Count" not in page_destination: + page_destination[NameObject("/Count")] = NumberObject(0) return page_destination_ref @@ -1700,10 +1720,9 @@ def add_outline_item_dict( outline_item: OutlineItemType, parent: Union[None, TreeObject, IndirectObject] = None, before: Union[None, TreeObject, IndirectObject] = None, + is_open: bool = True, ) -> IndirectObject: outline_item_object = TreeObject() - for k, v in list(outline_item.items()): - outline_item_object[NameObject(str(k))] = v outline_item_object.update(outline_item) if "/A" in outline_item: @@ -1714,7 +1733,9 @@ def add_outline_item_dict( action_ref = self._add_object(action) outline_item_object[NameObject("/A")] = action_ref - return self.add_outline_item_destination(outline_item_object, parent, before) + return self.add_outline_item_destination( + outline_item_object, parent, before, is_open + ) @deprecation_bookmark(bookmark="outline_item") def add_bookmark_dict( @@ -1754,6 +1775,7 @@ def add_outline_item( bold: bool = False, italic: bool = False, fit: Fit = PAGE_FIT, + is_open: bool = True, pagenum: Optional[int] = None, # deprecated ) -> IndirectObject: """ @@ -1779,7 +1801,7 @@ def add_outline_item( if fit is not None and page_number is None: page_number = fit # type: ignore return self.add_outline_item( - title, page_number, parent, None, before, color, bold, italic # type: ignore + title, page_number, parent, None, before, color, bold, italic, is_open=is_open # type: ignore ) if page_number is not None and pagenum is not None: raise ValueError( @@ -1822,7 +1844,7 @@ def add_outline_item( if parent is None: parent = self.get_outline_root() - return self.add_outline_item_destination(outline_item, parent, before) + return self.add_outline_item_destination(outline_item, parent, before, is_open) def add_bookmark( self, diff --git a/pypdf/generic/_data_structures.py b/pypdf/generic/_data_structures.py index 72e239ec78..d88416ae2f 100644 --- a/pypdf/generic/_data_structures.py +++ b/pypdf/generic/_data_structures.py @@ -32,7 +32,18 @@ import logging import re from io import BytesIO -from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple, Union, cast +from typing import ( + Any, + Callable, + Dict, + Iterable, + List, + Optional, + Sequence, + Tuple, + Union, + cast, +) from .._protocols import PdfReaderProtocol, PdfWriterProtocol from .._utils import ( @@ -364,7 +375,9 @@ def write_to_stream( ) stream.write(b"<<\n") for key, value in list(self.items()): - key.write_to_stream(stream) + if len(key) > 2 and key[1] == "%" and key[-1] == "%": + continue + key.write_to_stream(stream, encryption_key) stream.write(b" ") value.write_to_stream(stream) stream.write(b"\n") @@ -568,19 +581,43 @@ def addChild(self, child: Any, pdf: Any) -> None: # deprecated def add_child(self, child: Any, pdf: PdfWriterProtocol) -> None: self.insert_child(child, None, pdf) - def insert_child(self, child: Any, before: Any, pdf: PdfWriterProtocol) -> None: - def inc_parent_counter( - parent: Union[None, IndirectObject, TreeObject], n: int - ) -> None: - if parent is None: - return - parent = cast("TreeObject", parent.get_object()) - if "/Count" in parent: - parent[NameObject("/Count")] = NumberObject( - cast(int, parent[NameObject("/Count")]) + n - ) - inc_parent_counter(parent.get("/Parent", None), n) + def inc_parent_counter_default( + self, parent: Union[None, IndirectObject, "TreeObject"], n: int + ) -> None: + if parent is None: + return + parent = cast("TreeObject", parent.get_object()) + if "/Count" in parent: + parent[NameObject("/Count")] = NumberObject( + max(0, cast(int, parent[NameObject("/Count")]) + n) + ) + self.inc_parent_counter_default(parent.get("/Parent", None), n) + def inc_parent_counter_outline( + self, parent: Union[None, IndirectObject, "TreeObject"], n: int + ) -> None: + if parent is None: + return + parent = cast("TreeObject", parent.get_object()) + # BooleanObject requires comparison with == not is + opn = parent.get("/%is_open%", True) == True # noqa + c = cast(int, parent.get("/Count", 0)) + if c < 0: + c = abs(c) + parent[NameObject("/Count")] = NumberObject((c + n) * (1 if opn else -1)) + if not opn: + return + self.inc_parent_counter_outline(parent.get("/Parent", None), n) + + def insert_child( + self, + child: Any, + before: Any, + pdf: PdfWriterProtocol, + inc_parent_counter: Optional[Callable] = None, + ) -> IndirectObject: + if inc_parent_counter is None: + inc_parent_counter = self.inc_parent_counter_default child_obj = child.get_object() child = child.indirect_reference # get_reference(child_obj) @@ -595,7 +632,7 @@ def inc_parent_counter( del child_obj["/Next"] if "/Prev" in child_obj: del child_obj["/Prev"] - return + return child else: prev = cast("DictionaryObject", self["/Last"]) @@ -610,7 +647,7 @@ def inc_parent_counter( del child_obj["/Next"] self[NameObject("/Last")] = child inc_parent_counter(self, child_obj.get("/Count", 1)) - return + return child try: # insert as first or in the middle assert isinstance(prev["/Prev"], DictionaryObject) prev["/Prev"][NameObject("/Next")] = child @@ -621,6 +658,7 @@ def inc_parent_counter( prev[NameObject("/Prev")] = child child_obj[NameObject("/Parent")] = self.indirect_reference inc_parent_counter(self, child_obj.get("/Count", 1)) + return child def removeChild(self, child: Any) -> None: # deprecated deprecation_with_replacement("removeChild", "remove_child", "3.0.0") @@ -651,8 +689,7 @@ def _remove_node_from_tree( else: # Removing only tree node - assert self[NameObject("/Count")] == 1 - del self[NameObject("/Count")] + self[NameObject("/Count")] = NumberObject(0) del self[NameObject("/First")] if NameObject("/Last") in self: del self[NameObject("/Last")] diff --git a/tests/test_writer.py b/tests/test_writer.py index 75842896d4..c03ed48d15 100644 --- a/tests/test_writer.py +++ b/tests/test_writer.py @@ -553,13 +553,49 @@ def test_add_outline_item(pdf_file_path): writer.add_page(page) outline_item = writer.add_outline_item( - "An outline item", 1, None, (255, 0, 15), True, True, Fit.fit() + "An outline item", + 1, + None, + (255, 0, 15), + True, + True, + Fit.fit(), + is_open=False, + ) + _o2a = writer.add_outline_item( + "Another", 2, outline_item, None, False, False, Fit.fit() + ) + _o2b = writer.add_outline_item( + "Another bis", 2, outline_item, None, False, False, Fit.fit() + ) + outline_item2 = writer.add_outline_item( + "An outline item 2", + 1, + None, + (255, 0, 15), + True, + True, + Fit.fit(), + is_open=True, + ) + _o3a = writer.add_outline_item( + "Another 2", 2, outline_item2, None, False, False, Fit.fit() + ) + _o3b = writer.add_outline_item( + "Another 2bis", 2, outline_item2, None, False, False, Fit.fit() ) - writer.add_outline_item("Another", 2, outline_item, None, False, False, Fit.fit()) # write "output" to pypdf-output.pdf - with open(pdf_file_path, "wb") as output_stream: + with open(pdf_file_path, "w+b") as output_stream: writer.write(output_stream) + output_stream.seek(0) + reader = PdfReader(output_stream) + assert reader.trailer["/Root"]["/Outlines"]["/Count"] == 3 + assert reader.outline[0]["/Count"] == -2 + assert reader.outline[0]["/%is_open%"] == False # noqa + assert reader.outline[2]["/Count"] == 2 + assert reader.outline[2]["/%is_open%"] == True # noqa + assert reader.outline[1][0]["/Count"] == 0 def test_add_named_destination(pdf_file_path): From b0cf83006dfab5b059b7f13d6c58c00c79786084 Mon Sep 17 00:00:00 2001 From: Martin Thoma Date: Sun, 23 Jul 2023 18:31:40 +0200 Subject: [PATCH 05/10] DEV: Use softprops/action-gh-release@v1 (#1991) Get rid of deprecation warnings --- .github/workflows/release.yaml | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index ac6db4c098..f3ff2a8519 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -11,8 +11,6 @@ on: jobs: build_and_publish: - # this doesn't make sense if you don't have the PyPI secret - if: github.repository == 'py-pdf/pypdf' name: Publish a new version of pypdf runs-on: ubuntu-latest @@ -48,15 +46,13 @@ jobs: echo "date=$(date +'%Y-%m-%d')" >> "$GITHUB_ENV" EOF=$(dd if=/dev/urandom bs=15 count=1 status=none | base64) echo "tag_body<<$EOF" >> "$GITHUB_ENV" - git tag -l "${latest_tag}" --format='%(contents:body)' >> "$GITHUB_ENV" + git --no-pager tag -l "${latest_tag}" --format='%(contents:body)' >> "$GITHUB_ENV" echo "$EOF" >> "$GITHUB_ENV" - name: Create GitHub Release ๐Ÿš€ - uses: actions/create-release@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + uses: softprops/action-gh-release@v1 with: - tag_name: ${{ github.ref }} - release_name: Version ${{ env.latest_tag }}, ${{ env.date }} + tag_name: ${{ env.latest_tag }} + name: Version ${{ env.latest_tag }}, ${{ env.date }} draft: false prerelease: false body: ${{ env.tag_body }} From 965b98e6251f918e1516feebdae05b5858057a9f Mon Sep 17 00:00:00 2001 From: pubpub-zz <4083478+pubpub-zz@users.noreply.github.com> Date: Sun, 23 Jul 2023 18:39:48 +0200 Subject: [PATCH 06/10] BUG: Cope with different ISO date length (#1999) Ensure compatibility with all optional date field formats as specified in the PDF 1.7 specification. closes #1972 closes #1996 --- pypdf/_reader.py | 15 +++------------ pypdf/_utils.py | 33 +++++++++++++++++++++++++++++++++ tests/test_utils.py | 31 +++++++++++++++++++++++++++++++ 3 files changed, 67 insertions(+), 12 deletions(-) diff --git a/pypdf/_reader.py b/pypdf/_reader.py index 815e9a872d..721bcdb7ce 100644 --- a/pypdf/_reader.py +++ b/pypdf/_reader.py @@ -59,6 +59,7 @@ deprecation_no_replacement, deprecation_with_replacement, logger_warning, + parse_iso8824_date, read_non_whitespace, read_previous_line, read_until_whitespace, @@ -240,12 +241,7 @@ def producer_raw(self) -> Optional[str]: @property def creation_date(self) -> Optional[datetime]: """Read-only property accessing the document's creation date.""" - text = self._get_text(DI.CREATION_DATE) - if text is None: - return None - return datetime.strptime( - text.replace("Z", "+").replace("'", ""), "D:%Y%m%d%H%M%S%z" - ) + return parse_iso8824_date(self._get_text(DI.CREATION_DATE)) @property def creation_date_raw(self) -> Optional[str]: @@ -264,12 +260,7 @@ def modification_date(self) -> Optional[datetime]: The date and time the document was most recently modified. """ - text = self._get_text(DI.MOD_DATE) - if text is None: - return None - return datetime.strptime( - text.replace("Z", "+").replace("'", ""), "D:%Y%m%d%H%M%S%z" - ) + return parse_iso8824_date(self._get_text(DI.MOD_DATE)) @property def modification_date_raw(self) -> Optional[str]: diff --git a/pypdf/_utils.py b/pypdf/_utils.py index 2f1d56f09e..da121ac55b 100644 --- a/pypdf/_utils.py +++ b/pypdf/_utils.py @@ -33,6 +33,7 @@ import logging import warnings from dataclasses import dataclass +from datetime import datetime, timezone from io import DEFAULT_BUFFER_SIZE, BytesIO from os import SEEK_CUR from typing import ( @@ -76,6 +77,38 @@ DEPR_MSG_HAPPENED = "{} is deprecated and was removed in pypdf {}. Use {} instead." +def parse_iso8824_date(text: Optional[str]) -> Optional[datetime]: + orgtext = text + if text is None: + return None + if text[0].isdigit(): + text = "D:" + text + if text.endswith(("Z", "z")): + text += "0000" + text = text.replace("z", "+").replace("Z", "+").replace("'", "") + i = max(text.find("+"), text.find("-")) + if i > 0 and i != len(text) - 5: + text += "00" + for f in ( + "D:%Y", + "D:%Y%m", + "D:%Y%m%d", + "D:%Y%m%d%H", + "D:%Y%m%d%H%M", + "D:%Y%m%d%H%M%S", + "D:%Y%m%d%H%M%S%z", + ): + try: + d = datetime.strptime(text, f) # noqa: DTZ007 + except ValueError: + continue + else: + if text[-5:] == "+0000": + d = d.replace(tzinfo=timezone.utc) + return d + raise ValueError(f"Can not convert date: {orgtext}") + + def _get_max_pdf_version_header(header1: bytes, header2: bytes) -> bytes: versions = ( b"%PDF-1.3", diff --git a/tests/test_utils.py b/tests/test_utils.py index a3e7e09746..ca954a4936 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -15,6 +15,7 @@ deprecation_no_replacement, mark_location, matrix_multiply, + parse_iso8824_date, read_block_backwards, read_previous_line, read_until_regex, @@ -337,3 +338,33 @@ def test_file_class(): f = File(name="image.png", data=b"") assert str(f) == "File(name=image.png, data: 0 Byte)" assert repr(f) == "File(name=image.png, data: 0 Byte, hash: 0)" + + +@pytest.mark.parametrize( + ("text", "expected"), + [ + ("D:20210318000756", "2021-03-18T00:07:56"), + ("20210318000756", "2021-03-18T00:07:56"), + ("D:2021", "2021-01-01T00:00:00"), + ("D:202103", "2021-03-01T00:00:00"), + ("D:20210304", "2021-03-04T00:00:00"), + ("D:2021030402", "2021-03-04T02:00:00"), + ("D:20210408054711", "2021-04-08T05:47:11"), + ("D:20210408054711Z", "2021-04-08T05:47:11+00:00"), + ("D:20210408054711Z00", "2021-04-08T05:47:11+00:00"), + ("D:20210408054711Z0000", "2021-04-08T05:47:11+00:00"), + ("D:20210408075331+02'00'", "2021-04-08T07:53:31+02:00"), + ("D:20210408075331-03'00'", "2021-04-08T07:53:31-03:00"), + ], +) +def test_parse_datetime(text, expected): + date = parse_iso8824_date(text) + date_str = (date.isoformat() + date.strftime("%z"))[: len(expected)] + assert date_str == expected + + +def test_parse_datetime_err(): + with pytest.raises(ValueError) as ex: + parse_iso8824_date("D:20210408T054711Z") + assert ex.value.args[0] == "Can not convert date: D:20210408T054711Z" + assert parse_iso8824_date("D:20210408054711").tzinfo is None From 524ddf905bb4569eb3383b82bbc4a7523198cb1d Mon Sep 17 00:00:00 2001 From: pubpub-zz <4083478+pubpub-zz@users.noreply.github.com> Date: Sun, 23 Jul 2023 18:43:09 +0200 Subject: [PATCH 07/10] DEV: Add mypy to pre-commit (#2001) --- .pre-commit-config.yaml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 022dcf68f7..4a8dba216d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -47,3 +47,10 @@ repos: hooks: - id: flake8 args: ["--ignore", "E,W,F"] + +- repo: https://github.com/pre-commit/mirrors-mypy + rev: 'v1.4.1' + hooks: + - id: mypy + files: ^pypdf/.* + args: [--ignore-missing-imports] From dcf997a028e993b215457c5629cb4e78186e11c0 Mon Sep 17 00:00:00 2001 From: pubpub-zz <4083478+pubpub-zz@users.noreply.github.com> Date: Sun, 23 Jul 2023 18:45:41 +0200 Subject: [PATCH 08/10] BUG: Search /DA in hierarchy fields (#2002) Closes #1997 --- pypdf/_writer.py | 32 +++++++++++++++++++++---- tests/test_writer.py | 57 ++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 80 insertions(+), 9 deletions(-) diff --git a/pypdf/_writer.py b/pypdf/_writer.py index fe87b27e9c..baf6df870e 100644 --- a/pypdf/_writer.py +++ b/pypdf/_writer.py @@ -87,6 +87,7 @@ from .constants import PageAttributes as PG from .constants import PagesAttributes as PA from .constants import TrailerKeys as TK +from .errors import PyPdfError from .generic import ( PAGE_FIT, AnnotationBuilder, @@ -836,11 +837,14 @@ def _update_text_field(self, field: DictionaryObject) -> None: rct = RectangleObject((0, 0, _rct[2] - _rct[0], _rct[3] - _rct[1])) # Extract font information - font_properties: Any = ( - cast(str, field[AA.DA]).replace("\n", " ").replace("\r", " ").split(" ") - ) + da = cast(str, field[AA.DA]) + font_properties = da.replace("\n", " ").replace("\r", " ").split(" ") font_name = font_properties[font_properties.index("Tf") - 2] font_height = float(font_properties[font_properties.index("Tf") - 1]) + if font_height == 0: + font_height = rct.height - 2 + font_properties[font_properties.index("Tf") - 1] = str(font_height) + da = " ".join(font_properties) y_offset = rct.height - 1 - font_height # Retrieve field text and selected values @@ -855,7 +859,7 @@ def _update_text_field(self, field: DictionaryObject) -> None: sel = [] # Generate appearance stream - ap_stream = f"q\n/Tx BMC \nq\n1 1 {rct.width - 1} {rct.height - 1} re\nW\nBT\n{field[AA.DA]}\n".encode() + ap_stream = f"q\n/Tx BMC \nq\n1 1 {rct.width - 1} {rct.height - 1} re\nW\nBT\n{da}\n".encode() for line_number, line in enumerate(txt.replace("\n", "\r").split("\r")): if line in sel: # may be improved but can not find how get fill working => replaced with lined box @@ -938,12 +942,21 @@ def update_page_form_field_values( auto_regenerate: set/unset the need_appearances flag ; the flag is unchanged if auto_regenerate is None """ + if CatalogDictionary.ACRO_FORM not in self._root_object: + raise PyPdfError("No /AcroForm dictionary in PdfWriter Object") + af = cast(DictionaryObject, self._root_object[CatalogDictionary.ACRO_FORM]) + if InteractiveFormDictEntries.Fields not in af: + raise PyPdfError("No /Fields dictionary in Pdf in PdfWriter Object") if isinstance(auto_regenerate, bool): self.set_need_appearances_writer(auto_regenerate) # Iterate through pages, update field values if PG.ANNOTS not in page: logger_warning("No fields to update on this page", __name__) return + # /Helvetica is just in case of but this is normally insufficient as we miss the font ressource + default_da = af.get( + InteractiveFormDictEntries.DA, TextStringObject("/Helvetica 0 Tf 0 g") + ) for writer_annot in page[PG.ANNOTS]: # type: ignore writer_annot = cast(DictionaryObject, writer_annot.get_object()) # retrieve parent field values, if present @@ -968,6 +981,17 @@ def update_page_form_field_values( or writer_annot.get(FA.FT) == "/Ch" ): # textbox + if AA.DA not in writer_annot: + f = writer_annot + da = default_da + while AA.DA not in f: + f = f.get("/Parent") + if f is None: + break + f = f.get_object() + if AA.DA in f: + da = f[AA.DA] + writer_annot[NameObject(AA.DA)] = da self._update_text_field(writer_annot) elif writer_annot.get(FA.FT) == "/Sig": # signature diff --git a/tests/test_writer.py b/tests/test_writer.py index c03ed48d15..706bef1b19 100644 --- a/tests/test_writer.py +++ b/tests/test_writer.py @@ -13,7 +13,7 @@ PdfWriter, Transformation, ) -from pypdf.errors import DeprecationError, PageSizeNotDefinedError +from pypdf.errors import DeprecationError, PageSizeNotDefinedError, PyPdfError from pypdf.generic import ( ArrayObject, ContentStream, @@ -437,10 +437,8 @@ def test_fill_form(pdf_file_path): reader = PdfReader(RESOURCE_ROOT / "form.pdf") writer = PdfWriter() - page = reader.pages[0] - - writer.add_page(page) - writer.add_page(PdfReader(RESOURCE_ROOT / "crazyones.pdf").pages[0]) + writer.append(reader, [0]) + writer.append(RESOURCE_ROOT / "crazyones.pdf", [0]) writer.update_page_form_field_values( writer.pages[0], {"foo": "some filled in text"}, flags=1 @@ -1535,3 +1533,52 @@ def test_watermark(): b = BytesIO() writer.write(b) assert len(b.getvalue()) < 2.1 * 1024 * 1024 + + +@pytest.mark.enable_socket() +def test_da_missing_in_annot(): + url = "https://github.com/py-pdf/pypdf/files/12136285/Building.Division.Permit.Application.pdf" + name = "BuildingDivisionPermitApplication.pdf" + reader = PdfReader(BytesIO(get_pdf_from_url(url, name=name))) + writer = PdfWriter(clone_from=reader) + writer.update_page_form_field_values( + writer.pages[0], {"PCN-1": "0"}, auto_regenerate=False + ) + b = BytesIO() + writer.write(b) + reader = PdfReader(BytesIO(b.getvalue())) + ff = reader.get_fields() + # check for autosize processing + assert ( + b"0 Tf" + not in ff["PCN-1"].indirect_reference.get_object()["/AP"]["/N"].get_data() + ) + f2 = writer.get_object(ff["PCN-2"].indirect_reference.idnum) + f2[NameObject("/Parent")] = writer.get_object( + ff["PCN-1"].indirect_reference.idnum + ).indirect_reference + writer.update_page_form_field_values( + writer.pages[0], {"PCN-2": "1"}, auto_regenerate=False + ) + + +def test_missing_fields(pdf_file_path): + reader = PdfReader(RESOURCE_ROOT / "form.pdf") + + writer = PdfWriter() + writer.add_page(reader.pages[0]) + + with pytest.raises(PyPdfError) as exc: + writer.update_page_form_field_values( + writer.pages[0], {"foo": "some filled in text"}, flags=1 + ) + assert exc.value.args[0] == "No /AcroForm dictionary in PdfWriter Object" + + writer = PdfWriter() + writer.append(reader, [0]) + del writer._root_object["/AcroForm"]["/Fields"] + with pytest.raises(PyPdfError) as exc: + writer.update_page_form_field_values( + writer.pages[0], {"foo": "some filled in text"}, flags=1 + ) + assert exc.value.args[0] == "No /Fields dictionary in Pdf in PdfWriter Object" From 271945bda6ecae27f71144ca0baca0d85e1e7b04 Mon Sep 17 00:00:00 2001 From: Martin Thoma Date: Sun, 23 Jul 2023 22:17:32 +0200 Subject: [PATCH 09/10] MAINT: Update packages + ruff (#2006) --- .pre-commit-config.yaml | 2 +- pypdf/_writer.py | 4 ++-- pypdf/generic/_data_structures.py | 10 +++++----- pyproject.toml | 1 + requirements/ci-3.11.txt | 2 +- requirements/ci.txt | 6 +++--- requirements/dev.txt | 31 ++++++++++++++++--------------- requirements/docs.txt | 12 ++++++------ sample-files | 2 +- 9 files changed, 36 insertions(+), 34 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4a8dba216d..dd3f32cb5e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -33,7 +33,7 @@ repos: additional_dependencies: [black==22.1.0] exclude: "docs/user/robustness.md" - repo: https://github.com/charliermarsh/ruff-pre-commit - rev: 'v0.0.278' + rev: 'v0.0.280' hooks: - id: ruff args: ['--fix'] diff --git a/pypdf/_writer.py b/pypdf/_writer.py index baf6df870e..6d70756c3a 100644 --- a/pypdf/_writer.py +++ b/pypdf/_writer.py @@ -2905,7 +2905,7 @@ def merge( ) # need for the outline processing below for dest in reader._namedDests.values(): arr = dest.dest_array - if "/Names" in self._root_object and dest["/Title"] in cast( # noqa: SIM114 + if "/Names" in self._root_object and dest["/Title"] in cast( list, cast( DictionaryObject, @@ -2940,7 +2940,7 @@ def merge( "TreeObject", self.add_outline_item( TextStringObject(outline_item), - list(srcpages.values())[0].indirect_reference, + next(iter(srcpages.values())).indirect_reference, fit=PAGE_FIT, ).get_object(), ) diff --git a/pypdf/generic/_data_structures.py b/pypdf/generic/_data_structures.py index d88416ae2f..8577b7be20 100644 --- a/pypdf/generic/_data_structures.py +++ b/pypdf/generic/_data_structures.py @@ -259,10 +259,10 @@ def _clone( == cast(DictionaryObject, src[k]).get("/Type", None) ) ): - cur_obj: Optional["DictionaryObject"] = cast( + cur_obj: Optional[DictionaryObject] = cast( "DictionaryObject", src[k] ) - prev_obj: Optional["DictionaryObject"] = self + prev_obj: Optional[DictionaryObject] = self while cur_obj is not None: clon = cast( "DictionaryObject", @@ -786,7 +786,7 @@ def _reset_node_tree_relationship(child_obj: Any) -> None: class StreamObject(DictionaryObject): def __init__(self) -> None: self.__data: Optional[str] = None - self.decoded_self: Optional["DecodedStreamObject"] = None + self.decoded_self: Optional[DecodedStreamObject] = None def _clone( self, @@ -865,7 +865,7 @@ def initializeFromDictionary( def initialize_from_dictionary( data: Dict[str, Any] ) -> Union["EncodedStreamObject", "DecodedStreamObject"]: - retval: Union["EncodedStreamObject", "DecodedStreamObject"] + retval: Union[EncodedStreamObject, DecodedStreamObject] if SA.FILTER in data: retval = EncodedStreamObject() else: @@ -931,7 +931,7 @@ def setData(self, data: Any) -> None: # deprecated class EncodedStreamObject(StreamObject): def __init__(self) -> None: - self.decoded_self: Optional["DecodedStreamObject"] = None + self.decoded_self: Optional[DecodedStreamObject] = None @property def decodedSelf(self) -> Optional["DecodedStreamObject"]: # deprecated diff --git a/pyproject.toml b/pyproject.toml index 4066dd0617..2bec042371 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -120,6 +120,7 @@ ignore = [ "DTZ001", # The use of `datetime.datetime()` without `tzinfo` is necessary "PLC", # Personal preference + "PLR1714", # Consider merging multiple comparisons "FA100", # Missing `from __future__ import annotations`, but uses `typing.Dict` "TD002", # Authors of TODOs can be found via git "FIX002", # TODOs should typically not be in the code, but sometimes are ok diff --git a/requirements/ci-3.11.txt b/requirements/ci-3.11.txt index 169f930497..5c9e3607f3 100644 --- a/requirements/ci-3.11.txt +++ b/requirements/ci-3.11.txt @@ -61,7 +61,7 @@ pytest-socket==0.6.0 # via -r requirements/ci.in pytest-timeout==2.1.0 # via -r requirements/ci.in -ruff==0.0.278 +ruff==0.0.280 # via -r requirements/ci.in typeguard==3.0.2 # via -r requirements/ci.in diff --git a/requirements/ci.txt b/requirements/ci.txt index 708904bfa9..2596f2ae1c 100644 --- a/requirements/ci.txt +++ b/requirements/ci.txt @@ -59,7 +59,7 @@ pycryptodome==3.18.0 # via -r requirements/ci.in pyflakes==2.5.0 # via flake8 -pyparsing==3.0.9 +pyparsing==3.1.0 # via packaging pytest==7.0.1 # via @@ -79,13 +79,13 @@ tomli==1.2.3 # via # mypy # pytest -typed-ast==1.5.4 +typed-ast==1.5.5 # via mypy typeguard==2.13.3 # via -r requirements/ci.in types-dataclasses==0.6.6 # via -r requirements/ci.in -types-pillow==9.5.0.4 +types-pillow==10.0.0.2 # via -r requirements/ci.in typing-extensions==4.1.1 # via diff --git a/requirements/dev.txt b/requirements/dev.txt index eb3686e0e4..e64ee70108 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -8,25 +8,25 @@ black==23.3.0 # via -r requirements/dev.in build==0.10.0 # via pip-tools -certifi==2023.5.7 +certifi==2023.7.22 # via requests cfgv==3.3.1 # via pre-commit -charset-normalizer==3.1.0 +charset-normalizer==3.2.0 # via requests -click==8.1.3 +click==8.1.6 # via # black # pip-tools coverage[toml]==7.2.7 # via pytest-cov -distlib==0.3.6 +distlib==0.3.7 # via virtualenv docutils==0.20.1 # via flit -exceptiongroup==1.1.1 +exceptiongroup==1.1.2 # via pytest -filelock==3.12.1 +filelock==3.12.2 # via virtualenv flit==3.9.0 # via -r requirements/dev.in @@ -51,23 +51,23 @@ pathspec==0.11.1 # via black pillow==9.5.0 # via -r requirements/dev.in -pip-tools==6.13.0 +pip-tools==6.14.0 # via -r requirements/dev.in -platformdirs==3.5.3 +platformdirs==3.9.1 # via # black # virtualenv -pluggy==1.0.0 +pluggy==1.2.0 # via pytest pre-commit==2.17.0 # via -r requirements/dev.in pyproject-hooks==1.0.0 # via build -pytest==7.3.2 +pytest==7.4.0 # via pytest-cov pytest-cov==4.1.0 # via -r requirements/dev.in -pyyaml==6.0 +pyyaml==6.0.1 # via pre-commit requests==2.31.0 # via flit @@ -78,16 +78,17 @@ tomli==2.0.1 # black # build # coverage + # pip-tools # pytest tomli-w==1.0.0 # via flit -typing-extensions==4.6.3 +typing-extensions==4.7.1 # via black -urllib3==2.0.3 +urllib3==2.0.4 # via requests -virtualenv==20.23.0 +virtualenv==20.24.1 # via pre-commit -wheel==0.40.0 +wheel==0.41.0 # via # -r requirements/dev.in # pip-tools diff --git a/requirements/docs.txt b/requirements/docs.txt index b4f2e245f8..18eea3d30c 100644 --- a/requirements/docs.txt +++ b/requirements/docs.txt @@ -10,9 +10,9 @@ attrs==23.1.0 # via -r requirements/docs.in babel==2.12.1 # via sphinx -certifi==2023.5.7 +certifi==2023.7.22 # via requests -charset-normalizer==3.1.0 +charset-normalizer==3.2.0 # via requests docutils==0.17.1 # via @@ -23,7 +23,7 @@ idna==3.4 # via requests imagesize==1.4.1 # via sphinx -importlib-metadata==6.6.0 +importlib-metadata==6.7.0 # via # attrs # sphinx @@ -49,7 +49,7 @@ pygments==2.15.1 # via sphinx pytz==2023.3 # via babel -pyyaml==6.0 +pyyaml==6.0.1 # via myst-parser requests==2.31.0 # via sphinx @@ -77,11 +77,11 @@ sphinxcontrib-qthelp==1.0.3 # via sphinx sphinxcontrib-serializinghtml==1.1.5 # via sphinx -typing-extensions==4.6.3 +typing-extensions==4.7.1 # via # importlib-metadata # markdown-it-py -urllib3==2.0.3 +urllib3==2.0.4 # via requests zipp==3.15.0 # via importlib-metadata diff --git a/sample-files b/sample-files index 2cf1e75af7..5b5ee6b0e9 160000 --- a/sample-files +++ b/sample-files @@ -1 +1 @@ -Subproject commit 2cf1e75af7bcb9c097deae6fb112c715d4721744 +Subproject commit 5b5ee6b0e9bf4b683490351b1e15e01d6553da7b From 74f81752093788dbe9933ac56c924858a3eed048 Mon Sep 17 00:00:00 2001 From: Martin Thoma Date: Sun, 23 Jul 2023 22:23:12 +0200 Subject: [PATCH 10/10] REL: 3.13.0 New Features (ENH): - Add is_open in outlines in PdfReader and PdfWriter (#1960) Bug Fixes (BUG): - Search /DA in hierarchy fields (#2002) - Cope with different ISO date length (#1999) - Decode Black only/CMYK deviceN images (#1984) - Process CMYK in deflate images (#1977) Developer Experience (DEV): - Add mypy to pre-commit (#2001) - Release automation (#1991, #1985) [Full Changelog](https://github.com/py-pdf/pypdf/compare/3.12.2...3.13.0) --- CHANGELOG.md | 17 +++++++++++++++++ pypdf/_version.py | 2 +- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 352989905e..5a539a93b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # CHANGELOG +## Version 3.13.0, 2023-07-23 + +### New Features (ENH) +- Add is_open in outlines in PdfReader and PdfWriter (#1960) + +### Bug Fixes (BUG) +- Search /DA in hierarchy fields (#2002) +- Cope with different ISO date length (#1999) +- Decode Black only/CMYK deviceN images (#1984) +- Process CMYK in deflate images (#1977) + +### Developer Experience (DEV) +- Add mypy to pre-commit (#2001) +- Release automation (#1991, #1985) + +[Full Changelog](https://github.com/py-pdf/pypdf/compare/3.12.2...3.13.0) + ## Version 3.12.2, 2023-07-16 ### Bug Fixes (BUG) diff --git a/pypdf/_version.py b/pypdf/_version.py index e2257b3203..62ee17b836 100644 --- a/pypdf/_version.py +++ b/pypdf/_version.py @@ -1 +1 @@ -__version__ = "3.12.2" +__version__ = "3.13.0"