+
+
+
+ YOOOxXYOO0O
+ pixels
+ at
+ GOO
+ DPI
+
+
+ oO]
+ megapixels
+
+
+
+
+
+
diff --git a/tests/cache/multipage/__-l__eng__000002_ocr.png__000002_ocr_hocr__hocr__txt/stderr.bin b/tests/cache/multipage/__-l__eng__000002_ocr.png__000002_ocr_hocr__hocr__txt/stderr.bin
new file mode 100644
index 00000000..16b617e5
--- /dev/null
+++ b/tests/cache/multipage/__-l__eng__000002_ocr.png__000002_ocr_hocr__hocr__txt/stderr.bin
@@ -0,0 +1 @@
+Tesseract Open Source OCR Engine v4.1.1 with Leptonica
diff --git a/tests/cache/multipage/__-l__eng__000002_ocr.png__000002_ocr_hocr__hocr__txt/stdout.bin b/tests/cache/multipage/__-l__eng__000002_ocr.png__000002_ocr_hocr__hocr__txt/stdout.bin
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/cache/multipage/__-l__eng__000002_ocr.png__000002_ocr_hocr__hocr__txt/txt.bin b/tests/cache/multipage/__-l__eng__000002_ocr.png__000002_ocr_hocr__hocr__txt/txt.bin
new file mode 100644
index 00000000..21e1e995
--- /dev/null
+++ b/tests/cache/multipage/__-l__eng__000002_ocr.png__000002_ocr_hocr__hocr__txt/txt.bin
@@ -0,0 +1,3 @@
+YOOOxXYOO0O pixels at GOO DPI
+oO] megapixels
+
\ No newline at end of file
diff --git a/tests/conftest.py b/tests/conftest.py
index 8724ead5..adcd1354 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -24,7 +24,9 @@ from subprocess import PIPE, run
import pytest
-from ocrmypdf import api, cli
+from ocrmypdf import api, cli, pdfinfo
+from ocrmypdf._exec import unpaper
+from ocrmypdf._plugin_manager import get_parser_options_plugins
pytest_plugins = ['helpers_namespace']
@@ -51,7 +53,7 @@ def is_macos():
def running_in_docker():
# Docker creates a file named /.dockerenv (newer versions) or
# /.dockerinit (older) -- this is undocumented, not an offical test
- return os.path.exists('/.dockerenv') or os.path.exists('/.dockerinit')
+ return Path('/.dockerenv').exists() or Path('/.dockerinit').exists()
@pytest.helpers.register
@@ -62,125 +64,17 @@ def running_in_travis():
@pytest.helpers.register
def have_unpaper():
try:
- from ocrmypdf.exec import unpaper
-
unpaper.version()
- except Exception:
+ except Exception: # pylint: disable=broad-except
return False
return True
-TESTS_ROOT = os.path.abspath(os.path.dirname(__file__))
-SPOOF_PATH = os.path.join(TESTS_ROOT, 'spoof')
-PROJECT_ROOT = os.path.dirname(TESTS_ROOT)
+TESTS_ROOT = Path(__file__).parent.resolve()
+PROJECT_ROOT = TESTS_ROOT
OCRMYPDF = [sys.executable, '-m', 'ocrmypdf']
-WINDOWS_SHIM_TEMPLATE = """
-# This is a shim for Windows that has the same effect as a symlink to the target .py
-# file
-import os
-import subprocess
-import sys
-
-args = [sys.executable, {spoofer}, *sys.argv[1:]]
-p = subprocess.run(args, check=False, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
-sys.stdout.buffer.write(p.stdout)
-sys.stderr.buffer.write(p.stderr)
-sys.exit(p.returncode)
-"""
-
-assert ast.parse(WINDOWS_SHIM_TEMPLATE.format(spoofer=repr(r"C:\\Temp\\file.py")))
-
-
-@pytest.helpers.register
-def spoof(tmp_path_factory, **kwargs):
- """Modify PATH to override subprocess executables
-
- spoof(tmp_path_factory, program1='replacement', ...)
-
- For the test suite we need a way override executables, so that we can
- substitute desired results such as errors or just speed up OCR.
-
- On POSIXish platforms we create a temporary folder with overrides that
- are symlinks to the executables we want to run. We do not actually override
- PATH. We also set an environment variable _OCRMYPDF_TEST_PATH, which
- OCRmyPDF's subprocess wrapper will check before they use regular PATH. The
- output is a folder full of executables we are overriding. We can override
- multiple executables. The end result is a folder we can use in a PATH-style
- lookup to override some executables:
-
- /tmp/abcxyz/tesseract -> ocrmypdf/tests/resources/spoof/tesseract_crash.py
- /tmp/abcxyz/gs -> ocrmypdf/tests/resources/spoof/gs_backflip.py
-
- Windows needs extra help from us because usually, only the Administrator
- can create symlinks. Instead we create small Python scripts that call
- the programs we want, implementing the effect of a symlink. This is cleaner
- than creating Windows executables or trying to use non-Python scripts.
- The temporary folder generated for Windows could like:
-
- %TEMP%\abcxyz\tesseract.py:
- (script that runs ocrmypdf/tests/resources/spoof/tesseract_crash.py)
- %TEMP%\abcxyz\gswin32c.py:
- (script that runs ocrmypdf/tests/resources/spoof/gs_backflip.py)
- %TEMP%\abcxyz\gswin64c.py:
- (script that runs ocrmypdf/tests/resources/spoof/gs_backflip.py)
-
- We also address one quirk here, that Ghostscript may be known as gswin32c
- or gswin64c, depending on what the user installed (regardless of Windows
- itself). On POSIX, Ghostscript is just 'gs'. We handle the special case here
- too.
-
- All of this is intimately dependent on the machinery in ocrmypdf.exec.run().
- In particular, for Windows, that code has to know that if there is a .py
- file, it needs to run it with Python, since Windows does not like being
- asked to execute files.
-
- We don't overload PATH directly because we have some tests where we call
- ocrmypdf as a subprocess (to exercise the command line interface) and some
- tests where we call it as an API.
- """
- env = os.environ.copy()
- slug = '-'.join(v.replace('.py', '') for v in sorted(kwargs.values()))
- spoofer_base = tmp_path_factory.mktemp('spoofers')
- tmpdir = Path(spoofer_base / slug)
- tmpdir.mkdir(parents=True)
-
- for replace_program, with_spoof in kwargs.items():
- spoofer = Path(SPOOF_PATH) / with_spoof
- if os.name != 'nt':
- spoofer.chmod(0o755)
- (tmpdir / replace_program).symlink_to(spoofer)
- else:
- py_file = WINDOWS_SHIM_TEMPLATE.format(
- spoofer=repr(os.fspath(spoofer.absolute()))
- )
- if replace_program == 'gs':
- programs = ['gswin64c', 'gswin32c']
- else:
- programs = [replace_program]
- for prog in programs:
- (tmpdir / f'{prog}.py').write_text(py_file, encoding='utf-8')
-
- env['_OCRMYPDF_TEST_PATH'] = str(tmpdir) + os.pathsep + env['PATH']
- if os.name == 'nt':
- if '.py' not in env['PATHEXT'].lower():
- raise EnvironmentError("PATHEXT is not configured to support .py")
- return env
-
-
-@pytest.fixture
-def spoof_tesseract_noop(tmp_path_factory):
- return spoof(tmp_path_factory, tesseract='tesseract_noop.py')
-
-
-@pytest.fixture
-def spoof_tesseract_cache(tmp_path_factory):
- if running_in_docker():
- return os.environ.copy()
- return spoof(tmp_path_factory, tesseract="tesseract_cache.py")
-
-
@pytest.fixture
def resources():
return Path(TESTS_ROOT) / 'resources'
@@ -211,58 +105,43 @@ def no_outpdf(tmp_path):
@pytest.helpers.register
-def check_ocrmypdf(input_file, output_file, *args, env=None):
+def check_ocrmypdf(input_file, output_file, *args):
"""Run ocrmypdf and confirmed that a valid file was created"""
+ args = [str(input_file), str(output_file)] + [
+ str(arg) for arg in args if arg is not None
+ ]
- options = cli.parser.parse_args(
- [str(input_file), str(output_file)]
- + [str(arg) for arg in args if arg is not None]
- )
- api.check_options(options)
- if env:
- options.tesseract_env = env
- options.tesseract_env['_OCRMYPDF_TEST_INFILE'] = os.fspath(input_file)
- result = api.run_pipeline(options, api=True)
+ _parser, options, plugin_manager = get_parser_options_plugins(args=args)
+ api.check_options(options, plugin_manager)
+ result = api.run_pipeline(options, plugin_manager=plugin_manager, api=True)
assert result == 0
- assert os.path.exists(str(output_file)), "Output file not created"
- assert os.stat(str(output_file)).st_size > 100, "PDF too small or empty"
+ assert output_file.exists(), "Output file not created"
+ assert output_file.stat().st_size > 100, "PDF too small or empty"
return output_file
@pytest.helpers.register
-def run_ocrmypdf_api(input_file, output_file, *args, env=None):
+def run_ocrmypdf_api(input_file, output_file, *args):
"""Run ocrmypdf via API and let caller deal with results
Does not currently have a way to manipulate the PATH except for Tesseract.
"""
- options = cli.parser.parse_args(
- [str(input_file), str(output_file)]
- + [str(arg) for arg in args if arg is not None]
- )
- api.check_options(options)
- if env:
- options.tesseract_env = env.copy()
- options.tesseract_env['_OCRMYPDF_TEST_INFILE'] = os.fspath(input_file)
- first_path = env.get('_OCRMYPDF_TEST_PATH', '').split(os.pathsep)[0]
- if 'spoof' in first_path:
- assert 'gs' not in first_path, "use run_ocrmypdf() for gs"
- assert 'tesseract' in first_path
- if options.tesseract_env:
- assert all(isinstance(v, (str, bytes)) for v in options.tesseract_env.values())
+ args = [str(input_file), str(output_file)] + [
+ str(arg) for arg in args if arg is not None
+ ]
+ _parser, options, plugin_manager = get_parser_options_plugins(args=args)
- return api.run_pipeline(options, api=False)
+ api.check_options(options, plugin_manager)
+ return api.run_pipeline(options, plugin_manager=None, api=False)
@pytest.helpers.register
-def run_ocrmypdf(input_file, output_file, *args, env=None, universal_newlines=True):
+def run_ocrmypdf(input_file, output_file, *args, universal_newlines=True):
"Run ocrmypdf and let caller deal with results"
- if env is None:
- env = os.environ.copy()
-
p_args = (
OCRMYPDF
+ [str(arg) for arg in args if arg is not None]
@@ -274,10 +153,16 @@ def run_ocrmypdf(input_file, output_file, *args, env=None, universal_newlines=Tr
# Details: https://coverage.readthedocs.io/en/coverage-5.0/subprocess.html
coverage_rc = Path(__file__).parent.parent / '.coveragerc'
assert coverage_rc.exists()
+ env = os.environ.copy()
env['COVERAGE_PROCESS_START'] = os.fspath(coverage_rc)
p = run(
- p_args, stdout=PIPE, stderr=PIPE, universal_newlines=universal_newlines, env=env
+ p_args,
+ stdout=PIPE,
+ stderr=PIPE,
+ universal_newlines=universal_newlines,
+ env=env,
+ check=False,
)
# print(p.stderr)
return p, p.stdout, p.stderr
@@ -285,8 +170,6 @@ def run_ocrmypdf(input_file, output_file, *args, env=None, universal_newlines=Tr
@pytest.helpers.register
def first_page_dimensions(pdf):
- from ocrmypdf import pdfinfo
-
info = pdfinfo.PdfInfo(pdf)
page0 = info[0]
return (page0.width_inches, page0.height_inches)
diff --git a/tests/spoof/gs_feature_elision.py b/tests/plugins/gs_feature_elision.py
old mode 100755
new mode 100644
similarity index 59%
rename from tests/spoof/gs_feature_elision.py
rename to tests/plugins/gs_feature_elision.py
index a06deaf3..419855cb
--- a/tests/spoof/gs_feature_elision.py
+++ b/tests/plugins/gs_feature_elision.py
@@ -1,5 +1,4 @@
-#!/usr/bin/env python3
-# © 2016 James R. Barlow: github.com/jbarlow83
+# © 2020 James R. Barlow: github.com/jbarlow83
#
# Permission is hereby granted, free of charge, to any person obtaining a
# copy of this software and associated documentation files (the
@@ -20,34 +19,31 @@
# TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+from unittest.mock import patch
-import os
-import sys
-from subprocess import check_call
-
-from gs import real_ghostscript
-
-"""Replicate one type of Ghostscript feature elision warning during
-PDF/A creation."""
-
+from ocrmypdf import hookimpl
+from ocrmypdf.builtin_plugins import ghostscript
+from ocrmypdf.subprocess import run
elision_warning = """GPL Ghostscript 9.20: Setting Overprint Mode to 1
not permitted in PDF/A-2, overprint mode not set"""
-def main():
- if '--version' in sys.argv:
- print('9.20')
- print('SPOOFED: ' + os.path.basename(__file__))
- sys.exit(0)
- gs_args = ['gs'] + sys.argv[1:]
- check_call(gs_args)
-
- if '-sDEVICE=pdfwrite' in sys.argv[1:]:
- print(elision_warning)
-
- sys.exit(0)
+def run_append_stderr(*args, **kwargs):
+ proc = run(*args, **kwargs)
+ proc.stderr = b'\n'.join([proc.stderr, elision_warning.encode('utf-8')])
+ return proc
-if __name__ == '__main__':
- main()
+@hookimpl
+def generate_pdfa(pdf_pages, pdfmark, output_file, compression, pdf_version, pdfa_part):
+ with patch('ocrmypdf._exec.ghostscript.run', new=run_append_stderr):
+ ghostscript.generate_pdfa(
+ pdf_pages=pdf_pages,
+ pdfmark=pdfmark,
+ output_file=output_file,
+ compression=compression,
+ pdf_version=pdf_version,
+ pdfa_part=pdfa_part,
+ )
+ return output_file
diff --git a/tests/spoof/gs_pdfa_failure.py b/tests/plugins/gs_pdfa_failure.py
old mode 100755
new mode 100644
similarity index 59%
rename from tests/spoof/gs_pdfa_failure.py
rename to tests/plugins/gs_pdfa_failure.py
index 1d9fdf7d..dcad94f6
--- a/tests/spoof/gs_pdfa_failure.py
+++ b/tests/plugins/gs_pdfa_failure.py
@@ -1,5 +1,4 @@
-#!/usr/bin/env python3
-# © 2016 James R. Barlow: github.com/jbarlow83
+# © 2020 James R. Barlow: github.com/jbarlow83
#
# Permission is hereby granted, free of charge, to any person obtaining a
# copy of this software and associated documentation files (the
@@ -20,41 +19,33 @@
# TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
-import os
-import sys
+from unittest.mock import patch
-from gs import real_ghostscript
+from ocrmypdf import hookimpl
+from ocrmypdf.builtin_plugins import ghostscript
+from ocrmypdf.subprocess import run
-"""Replicate Ghostscript PDF/A conversion failure by suppressing some
-arguments"""
-
-
-def main():
- if '--version' in sys.argv:
- print('9.20')
- print('SPOOFED: ' + os.path.basename(__file__))
- sys.exit(0)
-
- # Unless some argument is calling for PDFA generation, forward to
- # real ghostscript
- if not any(arg.startswith('-dPDFA') for arg in sys.argv):
- real_ghostscript(sys.argv)
- return
-
+def run_rig_args(args, **kwargs):
# Remove the two arguments that tell ghostscript to create a PDF/A
# Does not remove the Postscript definition file - not necessary
# to cause PDF/A creation failure
- argv = []
- for arg in sys.argv:
- if arg.startswith('-dPDFA'):
- continue
- elif arg.startswith('-dPDFACompatibilityPolicy'):
- continue
- argv.append(arg)
-
- real_ghostscript(argv)
+ new_args = [
+ arg for arg in args if not arg.startswith('-dPDFA') and not arg.endswith('.ps')
+ ]
+ proc = run(new_args, **kwargs)
+ return proc
-if __name__ == '__main__':
- main()
+@hookimpl
+def generate_pdfa(pdf_pages, pdfmark, output_file, compression, pdf_version, pdfa_part):
+ with patch('ocrmypdf._exec.ghostscript.run', new=run_rig_args):
+ ghostscript.generate_pdfa(
+ pdf_pages=pdf_pages,
+ pdfmark=pdfmark,
+ output_file=output_file,
+ compression=compression,
+ pdf_version=pdf_version,
+ pdfa_part=pdfa_part,
+ )
+ return output_file
diff --git a/tests/spoof/gs_raster_failure.py b/tests/plugins/gs_raster_failure.py
old mode 100755
new mode 100644
similarity index 51%
rename from tests/spoof/gs_raster_failure.py
rename to tests/plugins/gs_raster_failure.py
index 7619aae2..98b1984c
--- a/tests/spoof/gs_raster_failure.py
+++ b/tests/plugins/gs_raster_failure.py
@@ -1,5 +1,4 @@
-#!/usr/bin/env python3
-# © 2016 James R. Barlow: github.com/jbarlow83
+# © 2020 James R. Barlow: github.com/jbarlow83
#
# Permission is hereby granted, free of charge, to any person obtaining a
# copy of this software and associated documentation files (the
@@ -20,30 +19,41 @@
# TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+from pathlib import Path
+from subprocess import CalledProcessError
+from unittest.mock import patch
-import os
-import sys
-
-from gs import real_ghostscript
-
-"""Replicate Ghostscript raster failure while allowing rendering"""
+from ocrmypdf import hookimpl
+from ocrmypdf.builtin_plugins import ghostscript
+from ocrmypdf.subprocess import run
-def main():
- if '--version' in sys.argv:
- print('9.20')
- print('SPOOFED: ' + os.path.basename(__file__))
- sys.exit(0)
-
- # For non-image rastering calls, use real ghostscript
- if '-sDEVICE=pdfwrite' in sys.argv or '-sDEVICE=txtwrite' in sys.argv:
- real_ghostscript(sys.argv)
- return
-
- # Fail
- print("ERROR: Ghost story archive not found", file=sys.stderr)
- sys.exit(1)
+def raise_gs_fail(*args, **kwargs):
+ raise CalledProcessError(
+ 1, 'gs', output=b"", stderr=b"ERROR: Ghost story archive not found"
+ )
-if __name__ == '__main__':
- main()
+@hookimpl
+def rasterize_pdf_page(
+ input_file,
+ output_file,
+ raster_device,
+ raster_dpi,
+ pageno,
+ page_dpi=None,
+ rotation=None,
+ filter_vector=False,
+) -> Path:
+ with patch('ocrmypdf._exec.ghostscript.run', new=raise_gs_fail):
+ ghostscript.rasterize_pdf_page(
+ input_file=input_file,
+ output_file=output_file,
+ raster_device=raster_device,
+ raster_dpi=raster_dpi,
+ pageno=pageno,
+ page_dpi=page_dpi,
+ rotation=rotation,
+ filter_vector=filter_vector,
+ )
+ return output_file
diff --git a/tests/spoof/gs_render_failure.py b/tests/plugins/gs_render_failure.py
old mode 100755
new mode 100644
similarity index 55%
rename from tests/spoof/gs_render_failure.py
rename to tests/plugins/gs_render_failure.py
index d0c1d60d..c27a5801
--- a/tests/spoof/gs_render_failure.py
+++ b/tests/plugins/gs_render_failure.py
@@ -1,5 +1,4 @@
-#!/usr/bin/env python3
-# © 2016-18 James R. Barlow: github.com/jbarlow83
+# © 2020 James R. Barlow: github.com/jbarlow83
#
# Permission is hereby granted, free of charge, to any person obtaining a
# copy of this software and associated documentation files (the
@@ -20,29 +19,30 @@
# TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
-"""Replicate Ghostscript render failure while allowing rasterizing"""
+from pathlib import Path
+from subprocess import CalledProcessError
+from unittest.mock import patch
-import os
-import sys
-
-from gs import real_ghostscript
+from ocrmypdf import hookimpl
+from ocrmypdf.builtin_plugins import ghostscript
+from ocrmypdf.subprocess import run
-def main():
- if '--version' in sys.argv:
- print('9.20')
- print('SPOOFED: ' + os.path.basename(__file__))
- sys.exit(0)
-
- # For any rasterize calls (device != pdfwrite) call real ghostscript
- if '-sDEVICE=pdfwrite' not in sys.argv:
- real_ghostscript(sys.argv)
- return
-
- # Fail
- print("ERROR: Casper is not a friendly ghost", file=sys.stderr)
- sys.exit(1)
+def raise_gs_fail(*args, **kwargs):
+ raise CalledProcessError(
+ 1, 'gs', output=b"", stderr=b"ERROR: Casper is not a friendly ghost"
+ )
-if __name__ == '__main__':
- main()
+@hookimpl
+def generate_pdfa(pdf_pages, pdfmark, output_file, compression, pdf_version, pdfa_part):
+ with patch('ocrmypdf._exec.ghostscript.run', new=raise_gs_fail):
+ ghostscript.generate_pdfa(
+ pdf_pages=pdf_pages,
+ pdfmark=pdfmark,
+ output_file=output_file,
+ compression=compression,
+ pdf_version=pdf_version,
+ pdfa_part=pdfa_part,
+ )
+ return output_file
diff --git a/tests/plugins/tesseract_badutf8.py b/tests/plugins/tesseract_badutf8.py
new file mode 100644
index 00000000..3511938d
--- /dev/null
+++ b/tests/plugins/tesseract_badutf8.py
@@ -0,0 +1,63 @@
+# © 2020 James R. Barlow: github.com/jbarlow83
+#
+# Permission is hereby granted, free of charge, to any person obtaining a
+# copy of this software and associated documentation files (the
+# "Software"), to deal in the Software without restriction, including
+# without limitation the rights to use, copy, modify, merge, publish,
+# distribute, sublicense, and/or sell copies of the Software, and to
+# permit persons to whom the Software is furnished to do so, subject to
+# the following conditions:
+#
+# The above copyright notice and this permission notice shall be included
+# in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
+# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+# CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
+# TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
+# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+"""Tesseract bad utf8
+
+In some cases, some versions of Tesseract can output binary gibberish or data
+that is not UTF-8 compatible, so we are forced to check that we can convert it
+and present it to the user.
+"""
+
+from subprocess import CalledProcessError
+from unittest.mock import patch
+
+from ocrmypdf import hookimpl
+from ocrmypdf.builtin_plugins.tesseract_ocr import TesseractOcrEngine
+
+
+def bad_utf8(*args, **kwargs):
+ raise CalledProcessError(
+ 1,
+ 'tesseract',
+ output=b'\x96\xb3\x8c\xf8\x82\xc8UTF-8\x0a', # "Invalid UTF-8" in Shift JIS
+ stderr=b"",
+ )
+
+
+class BadUtf8OcrEngine(TesseractOcrEngine):
+ @staticmethod
+ def generate_hocr(input_file, output_hocr, output_text, options):
+ with patch('ocrmypdf._exec.tesseract.run', new=bad_utf8):
+ TesseractOcrEngine.generate_hocr(
+ input_file, output_hocr, output_text, options
+ )
+
+ @staticmethod
+ def generate_pdf(input_file, output_pdf, output_text, options):
+ with patch('ocrmypdf._exec.tesseract.run', new=bad_utf8):
+ TesseractOcrEngine.generate_pdf(
+ input_file, output_pdf, output_text, options
+ )
+
+
+@hookimpl
+def get_ocr_engine():
+ return BadUtf8OcrEngine()
diff --git a/tests/plugins/tesseract_big_image_error.py b/tests/plugins/tesseract_big_image_error.py
new file mode 100644
index 00000000..04d0e0cd
--- /dev/null
+++ b/tests/plugins/tesseract_big_image_error.py
@@ -0,0 +1,61 @@
+# © 2020 James R. Barlow: github.com/jbarlow83
+#
+# Permission is hereby granted, free of charge, to any person obtaining a
+# copy of this software and associated documentation files (the
+# "Software"), to deal in the Software without restriction, including
+# without limitation the rights to use, copy, modify, merge, publish,
+# distribute, sublicense, and/or sell copies of the Software, and to
+# permit persons to whom the Software is furnished to do so, subject to
+# the following conditions:
+#
+# The above copyright notice and this permission notice shall be included
+# in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
+# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+# CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
+# TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
+# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+from subprocess import CalledProcessError
+from unittest.mock import patch
+
+from ocrmypdf import hookimpl
+from ocrmypdf.builtin_plugins.tesseract_ocr import TesseractOcrEngine
+
+
+def raise_size_exception(*args, **kwargs):
+ raise CalledProcessError(
+ 1,
+ 'tesseract',
+ output=b"Image too large: (33830, 14959)\nError during processing.",
+ stderr=b"",
+ )
+
+
+class BigImageErrorOcrEngine(TesseractOcrEngine):
+ @staticmethod
+ def get_orientation(input_file, options):
+ with patch('ocrmypdf._exec.tesseract.run', new=raise_size_exception):
+ return TesseractOcrEngine.get_orientation(input_file, options)
+
+ @staticmethod
+ def generate_hocr(input_file, output_hocr, output_text, options):
+ with patch('ocrmypdf._exec.tesseract.run', new=raise_size_exception):
+ TesseractOcrEngine.generate_hocr(
+ input_file, output_hocr, output_text, options
+ )
+
+ @staticmethod
+ def generate_pdf(input_file, output_pdf, output_text, options):
+ with patch('ocrmypdf._exec.tesseract.run', new=raise_size_exception):
+ TesseractOcrEngine.generate_pdf(
+ input_file, output_pdf, output_text, options
+ )
+
+
+@hookimpl
+def get_ocr_engine():
+ return BigImageErrorOcrEngine()
diff --git a/tests/spoof/tesseract_cache.py b/tests/plugins/tesseract_cache.py
old mode 100755
new mode 100644
similarity index 55%
rename from tests/spoof/tesseract_cache.py
rename to tests/plugins/tesseract_cache.py
index adf3e257..1df3fd98
--- a/tests/spoof/tesseract_cache.py
+++ b/tests/plugins/tesseract_cache.py
@@ -1,5 +1,4 @@
-#!/usr/bin/env python3
-# © 2016 James R. Barlow: github.com/jbarlow83
+# © 2020 James R. Barlow: github.com/jbarlow83
#
# Permission is hereby granted, free of charge, to any person obtaining a
# copy of this software and associated documentation files (the
@@ -22,11 +21,10 @@
"""Cache output of tesseract to speed up test suite
-The cache is keyed by an environment variable that slips the input test file
-from tests/resources/ to us. The input arguments are slugged into a hideous
-filename that more or less represents them literally. Joined together, this
-becomes the name of the cache folder. A few name files like stdout, stderr,
-hocr, pdf, describe the output to reproduce.
+The cache is keyed by by the input test file The input arguments are slugged
+into a hideous filename that more or less represents them literally. Joined
+together, this becomes the name of the cache folder. A few name files like
+stdout, stderr, hocr, pdf, describe the output to reproduce.
Changes to tests/resources/ or image processing algorithms don't trigger a
cache miss. By design, an input image that varies according to platform
@@ -40,10 +38,7 @@ information about the system that produced the results used when cache was
generated. This mainly a log to answer questions about how the files
were produced.
-For performance reasons, especially the slow performance of Tesseract on
-machines with AVX2, the cache is now bundled.
-
-Certain operations are not cached and routed to tesseract directly.
+Certain operations are not cached and routed to Tesseract OCR directly.
Assumes Tesseract 4.0.0-alpha or higher.
@@ -51,17 +46,23 @@ Assumes Tesseract 4.0.0-alpha or higher.
import argparse
import json
-import os
+import logging
import platform
import re
import shutil
-import subprocess
-import sys
+from functools import partial
from pathlib import Path
+from subprocess import PIPE, CalledProcessError, CompletedProcess
+from unittest.mock import patch
-__version__ = subprocess.check_output(
- ['tesseract', '--version'], stderr=subprocess.STDOUT
-).decode()
+from ocrmypdf import hookimpl
+from ocrmypdf.builtin_plugins.tesseract_ocr import TesseractOcrEngine
+from ocrmypdf.subprocess import run
+
+log = logging.getLogger(__name__)
+
+TESTS_ROOT = Path(__file__).resolve().parent.parent
+CACHE_ROOT = TESTS_ROOT / 'cache'
parser = argparse.ArgumentParser(
@@ -77,43 +78,15 @@ parser.add_argument('-c', action='append')
parser.add_argument('--psm', type=int)
parser.add_argument('--oem', type=int)
-TESTS_ROOT = Path(__file__).resolve().parent.parent
-CACHE_ROOT = TESTS_ROOT / 'cache'
-
-
-def real_tesseract():
- tess_args = ['tesseract'] + sys.argv[1:]
- os.execvp("tesseract", tess_args)
- return # Not reachable
-
-
-def main():
- if any(
- opt in sys.argv[1:]
- for opt in ('--print-parameters', '--list-langs', '--version')
- ):
- real_tesseract() # jump into real tesseract, replacing this process
-
- # Convert non-standard but supported -psm to --psm
- sys.argv = ['--psm' if arg == '-psm' else arg for arg in sys.argv]
-
- if '_OCRMYPDF_TEST_INFILE' not in os.environ:
- real_tesseract() # test not properly set up
- source = os.environ['_OCRMYPDF_TEST_INFILE'] # required
- args = parser.parse_args()
-
- cache_disabled = os.environ.get('_OCRMYPDF_CACHE_DISABLED', False)
-
- if args.imagename == 'stdin':
- real_tesseract()
+def get_cache_folder(source_pdf, run_args, parsed_args):
def slugs():
yield '' # so we don't start with a '-' which makes rm difficult
- for arg in sys.argv[1:]:
- if arg == args.imagename:
- yield Path(args.imagename).name
- elif arg == args.outputbase:
- yield Path(args.outputbase).name
+ for arg in run_args[1:]:
+ if arg == parsed_args.imagename:
+ yield Path(parsed_args.imagename).name
+ elif arg == parsed_args.outputbase:
+ yield Path(parsed_args.outputbase).name
elif arg == '-c' or arg.startswith('textonly'):
pass
else:
@@ -122,18 +95,26 @@ def main():
argv_slug = '__'.join(slugs())
argv_slug = argv_slug.replace('/', '___')
- cache_folder = Path(CACHE_ROOT) / Path(source).stem / argv_slug
+ return Path(CACHE_ROOT) / Path(source_pdf).stem / argv_slug
+
+
+def cached_run(options, run_args, **run_kwargs):
+ run_args = [str(arg) for arg in run_args] # flatten PosixPaths
+ args = parser.parse_args(run_args[1:])
+
+ if args.imagename in ('stdin', '-'):
+ return run(run_args, **run_kwargs)
+
+ source_file = options.input_file
+ cache_folder = get_cache_folder(source_file, run_args, args)
cache_folder.mkdir(parents=True, exist_ok=True)
- print(f"Tesseract cache folder {cache_folder} - ", end='', file=sys.stderr)
+ log.debug("Using Tesseract cache {cache_folder}")
- if (cache_folder / 'stderr.bin').exists() and not cache_disabled:
- # Cache hit
- print("HIT", file=sys.stderr)
+ if (cache_folder / 'stderr.bin').exists():
+ log.debug("Cache HIT")
# Replicate stdout/err
- sys.stdout.buffer.write((cache_folder / 'stdout.bin').read_bytes())
- sys.stderr.buffer.write((cache_folder / 'stderr.bin').read_bytes())
if args.outputbase != 'stdout':
if not args.configfiles:
args.configfiles.append('txt')
@@ -141,25 +122,28 @@ def main():
# cp cache -> output
tessfile = args.outputbase + '.' + configfile
shutil.copy(str(cache_folder / configfile) + '.bin', tessfile)
- sys.exit(0)
+ return CompletedProcess(
+ args=run_args,
+ returncode=0,
+ stdout=(cache_folder / 'stdout.bin').read_bytes(),
+ stderr=(cache_folder / 'stderr.bin').read_bytes(),
+ )
- # Cache miss
- print("MISS", file=sys.stderr)
+ log.debug("Cache MISS")
- # Call tesseract
- print(sys.argv[1:])
- p = subprocess.run(
- ['tesseract'] + sys.argv[1:], stdout=subprocess.PIPE, stderr=subprocess.PIPE
- )
- sys.stdout.buffer.write(p.stdout)
- sys.stderr.buffer.write(p.stderr)
-
- if p.returncode != 0:
- # Do not cache errors or crashes
- print("Tesseract error", file=sys.stderr)
- return p.returncode
+ cache_kwargs = {
+ k: v for k, v in run_kwargs.items() if k not in ('stdout', 'stderr')
+ }
+ assert cache_kwargs['check']
+ try:
+ p = run(run_args, stdout=PIPE, stderr=PIPE, **cache_kwargs)
+ except CalledProcessError as e:
+ log.exception(e)
+ raise # Pass exception onward
+ # Update cache
(cache_folder / 'stdout.bin').write_bytes(p.stdout)
+ (cache_folder / 'stderr.bin').write_bytes(p.stderr)
if args.outputbase != 'stdout':
if not args.configfiles:
@@ -172,27 +156,46 @@ def main():
tessfile = args.outputbase + '.' + configfile
shutil.copy(tessfile, str(cache_folder / configfile) + '.bin')
- (cache_folder / 'stderr.bin').write_bytes(p.stderr)
-
manifest = {}
- manifest['tesseract_version'] = __version__.replace('\n', ' ')
+ manifest['tesseract_version'] = TesseractOcrEngine.version().replace('\n', ' ')
manifest['platform'] = platform.platform()
manifest['python'] = platform.python_version()
- manifest['argv_slug'] = argv_slug
- manifest['sourcefile'] = str(Path(source).relative_to(TESTS_ROOT))
+ manifest['argv_slug'] = cache_folder.name
+ manifest['sourcefile'] = str(Path(source_file).relative_to(TESTS_ROOT))
def clean_sys_argv():
- for arg in sys.argv[1:]:
+ for arg in run_args[1:]:
yield re.sub(r'.*/com.github.ocrmypdf[^/]+[/](.*)', r'$TMPDIR/\1', arg)
manifest['args'] = list(clean_sys_argv())
-
- # pylint: disable=E1101
with (Path(CACHE_ROOT) / 'manifest.jsonl').open('a') as f:
json.dump(manifest, f)
f.write('\n')
f.flush()
+ return p
-if __name__ == '__main__':
- main()
+class CacheOcrEngine(TesseractOcrEngine):
+ @staticmethod
+ def get_orientation(input_file, options):
+ with patch('ocrmypdf._exec.tesseract.run', new=partial(cached_run, options)):
+ return TesseractOcrEngine.get_orientation(input_file, options)
+
+ @staticmethod
+ def generate_hocr(input_file, output_hocr, output_text, options):
+ with patch('ocrmypdf._exec.tesseract.run', new=partial(cached_run, options)):
+ TesseractOcrEngine.generate_hocr(
+ input_file, output_hocr, output_text, options
+ )
+
+ @staticmethod
+ def generate_pdf(input_file, output_pdf, output_text, options):
+ with patch('ocrmypdf._exec.tesseract.run', new=partial(cached_run, options)):
+ TesseractOcrEngine.generate_pdf(
+ input_file, output_pdf, output_text, options
+ )
+
+
+@hookimpl
+def get_ocr_engine():
+ return CacheOcrEngine()
diff --git a/tests/plugins/tesseract_crash.py b/tests/plugins/tesseract_crash.py
new file mode 100755
index 00000000..74c3970a
--- /dev/null
+++ b/tests/plugins/tesseract_crash.py
@@ -0,0 +1,64 @@
+# © 2020 James R. Barlow: github.com/jbarlow83
+#
+# Permission is hereby granted, free of charge, to any person obtaining a
+# copy of this software and associated documentation files (the
+# "Software"), to deal in the Software without restriction, including
+# without limitation the rights to use, copy, modify, merge, publish,
+# distribute, sublicense, and/or sell copies of the Software, and to
+# permit persons to whom the Software is furnished to do so, subject to
+# the following conditions:
+#
+# The above copyright notice and this permission notice shall be included
+# in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
+# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+# CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
+# TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
+# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+import signal
+import sys
+from subprocess import CalledProcessError
+from unittest.mock import patch
+
+from ocrmypdf import hookimpl
+from ocrmypdf.builtin_plugins.tesseract_ocr import TesseractOcrEngine
+
+
+def raise_crash(*args, **kwargs):
+ raise CalledProcessError(
+ 128 + signal.SIGABRT,
+ 'tesseract',
+ output=b"",
+ stderr=b"libc++abi.dylib: terminating with uncaught exception of type "
+ + b"std::bad_alloc: std::bad_alloc",
+ )
+
+
+class CrashOcrEngine(TesseractOcrEngine):
+ @staticmethod
+ def get_orientation(input_file, options):
+ with patch('ocrmypdf._exec.tesseract.run', new=raise_crash):
+ return TesseractOcrEngine.get_orientation(input_file, options)
+
+ @staticmethod
+ def generate_hocr(input_file, output_hocr, output_text, options):
+ with patch('ocrmypdf._exec.tesseract.run', new=raise_crash):
+ TesseractOcrEngine.generate_hocr(
+ input_file, output_hocr, output_text, options
+ )
+
+ @staticmethod
+ def generate_pdf(input_file, output_pdf, output_text, options):
+ with patch('ocrmypdf._exec.tesseract.run', new=raise_crash):
+ TesseractOcrEngine.generate_pdf(
+ input_file, output_pdf, output_text, options
+ )
+
+
+@hookimpl
+def get_ocr_engine():
+ return CrashOcrEngine()
diff --git a/tests/spoof/tesseract_noop.py b/tests/plugins/tesseract_noop.py
old mode 100755
new mode 100644
similarity index 51%
rename from tests/spoof/tesseract_noop.py
rename to tests/plugins/tesseract_noop.py
index 30f97209..26bfe1df
--- a/tests/spoof/tesseract_noop.py
+++ b/tests/plugins/tesseract_noop.py
@@ -1,5 +1,4 @@
-#!/usr/bin/env python3
-# © 2016 James R. Barlow: github.com/jbarlow83
+# © 2020 James R. Barlow: github.com/jbarlow83
#
# Permission is hereby granted, free of charge, to any person obtaining a
# copy of this software and associated documentation files (the
@@ -20,7 +19,7 @@
# TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
-"""Tesseract no-op spoof
+"""Tesseract no-op plugin
To quickly run tests where getting OCR output is not necessary.
@@ -31,21 +30,10 @@ In 'pdf' mode, convert the image to PDF using another program.
In orientation check mode, report the orientation is upright.
"""
-import sys
-from pathlib import Path
-
-import img2pdf
import pikepdf
from PIL import Image
-VERSION_STRING = '''tesseract 4.0.0
- leptonica-1.77.0
- libjpeg 9c : libpng 1.6.35 : libtiff 4.0.10 : zlib 1.2.11 : libopenjp2 2.3.0
- Found AVX2
- Found AVX
- Found SSE
-SPOOFED
-'''
+from ocrmypdf import OcrEngine, OrientationConfidence, hookimpl
HOCR_TEMPLATE = '''