diff --git a/pdfplumber/__init__.py b/pdfplumber/__init__.py index 4bb0ba4..9fd5259 100644 --- a/pdfplumber/__init__.py +++ b/pdfplumber/__init__.py @@ -3,6 +3,7 @@ __all__ = [ "utils", "pdfminer", "open", + "repair", "set_debug", ] @@ -12,5 +13,6 @@ import pdfminer.pdftypes from . import utils from ._version import __version__ from .pdf import PDF +from .repair import repair open = PDF.open diff --git a/pdfplumber/pdf.py b/pdfplumber/pdf.py index d4e9576..ec80198 100644 --- a/pdfplumber/pdf.py +++ b/pdfplumber/pdf.py @@ -15,6 +15,7 @@ from pdfminer.psparser import PSException from ._typing import T_num, T_obj_list from .container import Container from .page import Page +from .repair import _repair from .utils import resolve_and_decode logger = logging.getLogger(__name__) @@ -64,12 +65,17 @@ class PDF(Container): path_or_fp: Union[str, pathlib.Path, BufferedReader, BytesIO], pages: Optional[Union[List[int], Tuple[int]]] = None, laparams: Optional[Dict[str, Any]] = None, - password: str = "", + password: Optional[str] = None, strict_metadata: bool = False, + repair: bool = False, ) -> "PDF": - if isinstance(path_or_fp, (str, pathlib.Path)): - stream: Union[BufferedReader, BytesIO] = open(path_or_fp, "rb") + stream: Union[str, pathlib.Path, BufferedReader, BytesIO] + if repair: + stream = _repair(path_or_fp, password=password) + stream_is_external = False + elif isinstance(path_or_fp, (str, pathlib.Path)): + stream = open(path_or_fp, "rb") stream_is_external = False else: stream = path_or_fp diff --git a/pdfplumber/repair.py b/pdfplumber/repair.py new file mode 100644 index 0000000..774733c --- /dev/null +++ b/pdfplumber/repair.py @@ -0,0 +1,62 @@ +import pathlib +import shutil +import subprocess +from io import BufferedReader, BytesIO +from typing import Optional, Union + + +def _repair( + path_or_fp: Union[str, pathlib.Path, BufferedReader, BytesIO], + password: Optional[str] = None, +) -> BytesIO: + + executable = shutil.which("gs") or shutil.which("gswin32c") + if executable is None: # pragma: nocover + raise Exception( + "Cannot find Ghostscript, which is required for repairs.\n" + "Visit https://www.ghostscript.com/ for installation instructions." + ) + + repair_args = [ + executable, + "-o", + "-", + "-sDEVICE=pdfwrite", + "-dPDFSETTINGS=/prepress", + ] + + if password: + repair_args += [f"-sPDFPassword={password}"] + + if isinstance(path_or_fp, (str, pathlib.Path)): + stdin = None + repair_args += [str(pathlib.Path(path_or_fp).absolute())] + else: + stdin = path_or_fp + repair_args += ["-"] + + stdout, stderr = subprocess.Popen( + repair_args, + stdin=subprocess.PIPE if stdin else None, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ).communicate(stdin.read() if stdin else None) + + if len(stderr): + raise Exception(f"{stderr.decode('utf-8')}") + + return BytesIO(stdout) + + +def repair( + path_or_fp: Union[str, pathlib.Path, BufferedReader, BytesIO], + outfile: Optional[Union[str, pathlib.Path]] = None, + password: Optional[str] = None, +) -> Optional[BytesIO]: + repaired = _repair(path_or_fp, password) + if outfile: + with open(outfile, "wb") as f: + f.write(repaired.read()) + return None + else: + return repaired diff --git a/tests/pdfs/malformed-from-issue-932.pdf b/tests/pdfs/malformed-from-issue-932.pdf new file mode 100644 index 0000000..151fc6c Binary files /dev/null and b/tests/pdfs/malformed-from-issue-932.pdf differ diff --git a/tests/test_repair.py b/tests/test_repair.py new file mode 100644 index 0000000..54adef9 --- /dev/null +++ b/tests/test_repair.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python +import os +import tempfile +import unittest + +import pytest + +import pdfplumber + +HERE = os.path.abspath(os.path.dirname(__file__)) + + +class Test(unittest.TestCase): + def test_from_issue_932(self): + path = os.path.join(HERE, "pdfs/malformed-from-issue-932.pdf") + with pdfplumber.open(path) as pdf: + page = pdf.pages[0] + char = page.chars[0] + assert char["bottom"] > page.height + + with pdfplumber.open(path, repair=True) as pdf: + page = pdf.pages[0] + char = page.chars[0] + assert char["bottom"] < page.height + + with pdfplumber.repair(path) as repaired: + with pdfplumber.open(repaired) as pdf: + page = pdf.pages[0] + char = page.chars[0] + assert char["bottom"] < page.height + + def test_other_repair_inputs(self): + path = os.path.join(HERE, "pdfs/malformed-from-issue-932.pdf") + with pdfplumber.open(open(path, "rb"), repair=True) as pdf: + page = pdf.pages[0] + char = page.chars[0] + assert char["bottom"] < page.height + + def test_bad_repair_path(self): + path = os.path.join(HERE, "pdfs/abc.xyz") + + with pytest.raises(Exception): + with pdfplumber.open(path, repair=True): + pass + + def test_repair_to_file(self): + path = os.path.join(HERE, "pdfs/malformed-from-issue-932.pdf") + with tempfile.NamedTemporaryFile("wb") as out: + pdfplumber.repair(path, outfile=out.name) + with pdfplumber.open(out.name) as pdf: + page = pdf.pages[0] + char = page.chars[0] + assert char["bottom"] < page.height + + def test_repair_password(self): + path = os.path.join(HERE, "pdfs/password-example.pdf") + with pdfplumber.open(path, repair=True, password="test") as pdf: + assert len(pdf.pages[0].chars)