From 4ff4ed24a80a4d4c5b84aba8412849993e5fb43c Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Wed, 4 Mar 2020 21:40:28 -0800 Subject: [PATCH 01/94] Refactor Windows executable shims --- src/ocrmypdf/exec/__init__.py | 34 +++++++++++++++++++++------------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/src/ocrmypdf/exec/__init__.py b/src/ocrmypdf/exec/__init__.py index 92e4d956..a19b2043 100644 --- a/src/ocrmypdf/exec/__init__.py +++ b/src/ocrmypdf/exec/__init__.py @@ -62,21 +62,10 @@ def run(args, *, env=None, **kwargs): # Search in spoof path if necessary program = _get_program(args, env) - - # If we are running a .py on Windows, ensure we call it with this Python - # (to support test suite shims) - if os.name == 'nt' and program.lower().endswith('.py'): - args = [sys.executable, program] + args[1:] - else: - args = [program] + args[1:] + args = [program] + args[1:] if os.name == 'nt': - paths = os.pathsep.join(os.get_exec_path(env)) - if not shutil.which(args[0], path=paths): - shimmed_path = shim_paths_with_program_files(env) - new_args0 = shutil.which(args[0], path=shimmed_path) - if new_args0: - args[0] = new_args0 + args = fix_windows_args(program, args, env) process_log = log.getChild(os.path.basename(program)) process_log.debug("Running: %s", args) @@ -95,6 +84,25 @@ def run(args, *, env=None, **kwargs): return proc +def fix_windows_args(program, args, env): + """Adjust our desired program and command line arguments for use on Windows""" + + # If we are running a .py on Windows, ensure we call it with this Python + # (to support test suite shims) + if program.lower().endswith('.py'): + args = [sys.executable] + args + + paths = os.pathsep.join(os.get_exec_path(env)) + if not shutil.which(args[0], path=paths): + # If the program we want is not on the PATH, add some interesting + # locations in %PROGRAMFILES% to the PATH and try again + shimmed_path = shim_paths_with_program_files(env) + new_args0 = shutil.which(args[0], path=shimmed_path) + if new_args0: + args[0] = new_args0 + return args + + def get_version(program, *, version_arg='--version', regex=r'(\d+(\.\d+)*)', env=None): """Get the version of the specified program""" args_prog = [program, version_arg] From d146d2b65c7cd99b6fffa4a5aac5247786b4686d Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Wed, 4 Mar 2020 21:24:13 -0800 Subject: [PATCH 02/94] The Great Logging Refactor Remove all instances of logger object being passed as parameters. This was a holdover from ruffus, and complicated a lot of simple things. --- src/ocrmypdf/__main__.py | 4 +-- src/ocrmypdf/_graft.py | 10 +++--- src/ocrmypdf/_jobcontext.py | 42 ++--------------------- src/ocrmypdf/_pipeline.py | 55 +++++++++++-------------------- src/ocrmypdf/_sync.py | 13 ++++---- src/ocrmypdf/exec/__init__.py | 3 -- src/ocrmypdf/exec/ghostscript.py | 10 +----- src/ocrmypdf/exec/qpdf.py | 8 +++-- src/ocrmypdf/exec/tesseract.py | 42 +++++++++++------------ src/ocrmypdf/exec/unpaper.py | 9 +++-- src/ocrmypdf/optimize.py | 50 ++++++++++++++-------------- src/ocrmypdf/pdfinfo/ghosttext.py | 4 +-- src/ocrmypdf/pdfinfo/info.py | 8 ++--- tests/test_ghostscript.py | 4 --- tests/test_optimize.py | 7 +--- tests/test_preprocessing.py | 11 +------ tests/test_rotation.py | 6 +--- tests/test_stdio.py | 2 +- tests/test_tess4.py | 18 +++------- 19 files changed, 107 insertions(+), 199 deletions(-) diff --git a/src/ocrmypdf/__main__.py b/src/ocrmypdf/__main__.py index bcfd6528..459b3040 100755 --- a/src/ocrmypdf/__main__.py +++ b/src/ocrmypdf/__main__.py @@ -21,13 +21,14 @@ import os import sys from . import __version__ -from ._jobcontext import make_logger from ._sync import run_pipeline from ._validation import check_closed_streams, check_options from .api import Verbosity, configure_logging from .cli import parser from .exceptions import BadArgsError, ExitCode, MissingDependencyError +log = logging.getLogger('ocrmypdf') + def run(args=None): options = parser.parse_args(args=args) @@ -47,7 +48,6 @@ def run(args=None): configure_logging( verbosity, progress_bar_friendly=options.progress_bar, manage_root_logger=True ) - log = make_logger('ocrmypdf') log.debug('ocrmypdf ' + __version__) try: check_options(options) diff --git a/src/ocrmypdf/_graft.py b/src/ocrmypdf/_graft.py index a535d492..fba24718 100644 --- a/src/ocrmypdf/_graft.py +++ b/src/ocrmypdf/_graft.py @@ -15,12 +15,14 @@ # You should have received a copy of the GNU General Public License # along with OCRmyPDF. If not, see . +import logging import os from contextlib import suppress from pathlib import Path import pikepdf +log = logging.getLogger(__name__) MAX_REPLACE_PAGES = 100 @@ -89,7 +91,7 @@ def strip_invisible_text(pdf, page): def _graft_text_layer( - *, pdf_base, page_num, text, font, font_key, procset, rotation, strip_old_text, log + *, pdf_base, page_num, text, font, font_key, procset, rotation, strip_old_text ): """Insert the text layer from text page 0 on to pdf_base at page_num""" @@ -179,7 +181,6 @@ def _find_font(text, pdf_base): class OcrGrafter: def __init__(self, context): self.context = context - self.log = context.log self.path_base = Path(context.origin).resolve() self.pdf_base = pikepdf.open(self.path_base) @@ -206,7 +207,7 @@ class OcrGrafter: if path_image is not None and path_image != self.path_base: # We are updating the old page with a rasterized PDF of the new # page (without changing objgen, to preserve references) - self.log.debug("Emplacement update") + log.debug("Emplacement update") with pikepdf.open(image) as pdf_image: self.emplacements += 1 foreign_image_page = pdf_image.pages[0] @@ -220,7 +221,7 @@ class OcrGrafter: content_rotation = autorotate_correction text_rotation = autorotate_correction text_misaligned = (text_rotation - content_rotation) % 360 - self.log.debug( + log.debug( f"Rotations for page {pageno}: [text, auto, misalign, content] = " f"{text_rotation}, {autorotate_correction}, " f"{text_misaligned}, {content_rotation}" @@ -238,7 +239,6 @@ class OcrGrafter: rotation=text_misaligned, procset=self.procset, strip_old_text=strip_old, - log=self.log, ) # Correct the rotation if applicable diff --git a/src/ocrmypdf/_jobcontext.py b/src/ocrmypdf/_jobcontext.py index 42c96282..59de5cdd 100644 --- a/src/ocrmypdf/_jobcontext.py +++ b/src/ocrmypdf/_jobcontext.py @@ -21,30 +21,10 @@ import shutil import sys -class PicklableLoggerMixin: - def __init__(self): - self._log = None - - @property - def log(self): - if not self._log: - self._log = self.get_logger() - return self._log - - def __getstate__(self): - # Python 3.6 is incapable of pickling a logger and marshalling it to another - # process (threading._RLock error), so we disconnect it before pickling, - # and create a new logger in the worker process. - state = self.__dict__.copy() - state['_log'] = None - return state - - -class PDFContext(PicklableLoggerMixin): +class PDFContext: """Holds our context for a particular run of the pipeline""" def __init__(self, options, work_folder, origin, pdfinfo): - PicklableLoggerMixin.__init__(self) self.options = options self.work_folder = work_folder self.origin = origin @@ -56,9 +36,6 @@ class PDFContext(PicklableLoggerMixin): if self.name == '-': self.name = 'stdin' - def get_logger(self): - return make_logger(self.options, filename=self.name) - def get_path(self, name): return os.path.join(self.work_folder, name) @@ -68,14 +45,13 @@ class PDFContext(PicklableLoggerMixin): yield PageContext(self, n) -class PageContext(PicklableLoggerMixin): +class PageContext: """Holds our context for a page Must be pickable, so only store intrinsic/simple data elements """ def __init__(self, pdf_context, pageno): - PicklableLoggerMixin.__init__(self) self.work_folder = pdf_context.work_folder self.origin = pdf_context.origin self.options = pdf_context.options @@ -84,9 +60,6 @@ class PageContext(PicklableLoggerMixin): self.pageinfo = pdf_context.pdfinfo[pageno] self._log = None - def get_logger(self): - return make_logger(self.options, filename=self.name, page=self.pageno + 1) - def get_path(self, name): return os.path.join(self.work_folder, "%06d_%s" % (self.pageno + 1, name)) @@ -111,14 +84,3 @@ class LogNamePageAdapter(logging.LoggerAdapter): '%4u: %s' % (self.extra['page'], msg), kwargs, ) - - -def make_logger(options=None, prefix='ocrmypdf', filename=None, page=None): - log = logging.getLogger(prefix) - if filename and page: - adapter = LogNamePageAdapter(log, dict(input_filename=filename, page=page)) - elif filename: - adapter = LogNameAdapter(log, dict(input_filename=filename)) - else: - adapter = log - return adapter diff --git a/src/ocrmypdf/_pipeline.py b/src/ocrmypdf/_pipeline.py index 58ac2495..98ed32fd 100644 --- a/src/ocrmypdf/_pipeline.py +++ b/src/ocrmypdf/_pipeline.py @@ -15,6 +15,7 @@ # You should have received a copy of the GNU General Public License # along with OCRmyPDF. If not, see . +import logging import os import re import sys @@ -44,10 +45,12 @@ from .optimize import optimize from .pdfa import generate_pdfa_ps from .pdfinfo import Colorspace, Encoding, PdfInfo +log = logging.getLogger(__name__) + VECTOR_PAGE_DPI = 400 -def triage_image_file(input_file, output_file, options, log): +def triage_image_file(input_file, output_file, options): log.info("Input file is not a PDF, checking if it is an image...") try: im = Image.open(input_file) @@ -124,7 +127,7 @@ def _pdf_guess_version(input_file, search_window=1024): return '' -def triage(original_filename, input_file, output_file, options, log): +def triage(original_filename, input_file, output_file, options): try: if _pdf_guess_version(input_file): if options.image_dpi: @@ -140,7 +143,7 @@ def triage(original_filename, input_file, output_file, options, log): msg = str(e).replace(input_file, original_filename) raise InputFileError(msg) from e - triage_image_file(input_file, output_file, options, log) + triage_image_file(input_file, output_file, options) return output_file @@ -156,7 +159,6 @@ def get_pdfinfo(input_file, detailed_page_analysis=False, progbar=False): def validate_pdfinfo_options(context): - log = context.log pdfinfo = context.pdfinfo options = context.options @@ -241,7 +243,6 @@ def get_canvas_square_dpi(pageinfo, options): def is_ocr_required(page_context): pageinfo = page_context.pageinfo options = page_context.options - log = page_context.log ocr_required = True @@ -322,7 +323,6 @@ def rasterize_preview(input_file, page_context): xres=canvas_dpi, yres=canvas_dpi, raster_device='jpeggray', - log=page_context.log, page_dpi=(page_dpi, page_dpi), pageno=page_context.pageinfo.pageno + 1, ) @@ -380,12 +380,11 @@ def get_orientation_correction(preview, page_context): preview, engine_mode=page_context.options.tesseract_oem, timeout=page_context.options.tesseract_timeout, - log=page_context.log, tesseract_env=page_context.options.tesseract_env, ) correction = orient_conf.angle % 360 - page_context.log.info(describe_rotation(page_context, orient_conf, correction)) + log.info(describe_rotation(page_context, orient_conf, correction)) if ( orient_conf.confidence >= page_context.options.rotate_pages_threshold and correction != 0 @@ -426,7 +425,7 @@ def rasterize( device = colorspaces[device_idx] - page_context.log.debug(f"Rasterize with {device}") + log.debug(f"Rasterize with {device}") # Produce the page image with square resolution or else deskew and OCR # will not work properly. @@ -439,7 +438,6 @@ def rasterize( xres=canvas_dpi, yres=canvas_dpi, raster_device=device, - log=page_context.log, page_dpi=(page_dpi, page_dpi), pageno=pageinfo.pageno + 1, rotation=correction, @@ -454,7 +452,7 @@ def preprocess_remove_background(input_file, page_context): leptonica.remove_background(input_file, output_file) return output_file else: - page_context.log.info("background removal skipped on mono page") + log.info("background removal skipped on mono page") return input_file @@ -470,13 +468,7 @@ def preprocess_clean(input_file, page_context): output_file = page_context.get_path('pp_clean.png') dpi = get_page_square_dpi(page_context.pageinfo, page_context.options) - unpaper.clean( - input_file, - output_file, - dpi, - page_context.log, - page_context.options.unpaper_args, - ) + unpaper.clean(input_file, output_file, dpi, page_context.options.unpaper_args) return output_file @@ -496,7 +488,7 @@ def create_ocr_image(image, page_context): draw = ImageDraw.ImageDraw(im) xres, yres = im.info['dpi'] - page_context.log.debug('resolution %r %r' % (xres, yres)) + log.debug('resolution %r %r' % (xres, yres)) if not options.force_ocr: # Do not mask text areas when forcing OCR, because we need to OCR @@ -520,7 +512,7 @@ def create_ocr_image(image, page_context): im.height - bbox[1] * yscale, ] pixcoords = [int(round(c)) for c in pixcoords] - page_context.log.debug('blanking %r', pixcoords) + log.debug('blanking %r', pixcoords) draw.rectangle(pixcoords, fill=white) # draw.rectangle(pixcoords, outline=pink) @@ -551,7 +543,6 @@ def ocr_tesseract_hocr(input_file, page_context): user_words=options.user_words, user_patterns=options.user_patterns, tesseract_env=options.tesseract_env, - log=page_context.log, ) return (hocr_out, hocr_text_out) @@ -591,11 +582,11 @@ def create_pdf_page_from_image(image, page_context): # This create a single page PDF with open(image, 'rb') as imfile, open(output_file, 'wb') as pdf: - page_context.log.debug('convert') + log.debug('convert') img2pdf.convert( imfile, with_pdfrw=False, layout_fun=layout_fun, outputstream=pdf ) - page_context.log.debug('convert done') + log.debug('convert done') return output_file @@ -631,7 +622,6 @@ def ocr_tesseract_textonly_pdf(input_image, page_context): user_words=options.user_words, user_patterns=options.user_patterns, tesseract_env=options.tesseract_env, - log=page_context.log, ) return (output_pdf, output_text) @@ -697,7 +687,7 @@ def convert_to_pdfa(input_pdf, input_ps_stub, context): try: len(pdf_file.docinfo) except TypeError: - context.log.error( + log.error( "File contains a malformed DocumentInfo block - continuing anyway" ) else: @@ -716,7 +706,6 @@ def convert_to_pdfa(input_pdf, input_ps_stub, context): pdf_pages=[fix_docinfo_file, input_ps_stub], output_file=output_file, compression=options.pdfa_image_compression, - log=context.log, pdfa_part=options.output_type[-1], # is pdfa-1, pdfa-2, or pdfa-3 ) @@ -738,22 +727,18 @@ def metadata_fixup(working_file, context): if not missing: return if options.output_type.startswith('pdfa'): - context.log.warning( + log.warning( "Some input metadata could not be copied because it is not " "permitted in PDF/A. You may wish to examine the output " "PDF's XMP metadata." ) - context.log.debug( - "The following metadata fields were not copied: %r", missing - ) + log.debug("The following metadata fields were not copied: %r", missing) else: - context.log.error( + log.error( "Some input metadata could not be copied." "You may wish to examine the output PDF's XMP metadata." ) - context.log.info( - "The following metadata fields were not copied: %r", missing - ) + log.info("The following metadata fields were not copied: %r", missing) with pikepdf.open(context.origin) as original, pikepdf.open(working_file) as pdf: docinfo = get_docinfo(original, options) @@ -819,7 +804,7 @@ def merge_sidecars(txt_files, context): def copy_final(input_file, output_file, context): - context.log.debug('%s -> %s', input_file, output_file) + log.debug('%s -> %s', input_file, output_file) with open(input_file, 'rb') as input_stream: if output_file == '-': copyfileobj(input_stream, sys.stdout.buffer) diff --git a/src/ocrmypdf/_sync.py b/src/ocrmypdf/_sync.py index 07c355ab..dbb7603e 100644 --- a/src/ocrmypdf/_sync.py +++ b/src/ocrmypdf/_sync.py @@ -30,7 +30,7 @@ import PIL from tqdm import tqdm from ._graft import OcrGrafter -from ._jobcontext import PDFContext, cleanup_working_files, make_logger +from ._jobcontext import PDFContext, cleanup_working_files from ._pipeline import ( convert_to_pdfa, copy_final, @@ -66,6 +66,8 @@ from .exec import qpdf from .helpers import available_cpu_count from .pdfa import file_claims_pdfa +log = logging.getLogger(__name__) + PageResult = namedtuple( 'PageResult', 'pageno, pdf_page_from_image, ocr, text, orientation_correction' ) @@ -231,7 +233,7 @@ def exec_concurrent(context): # Run exec_page_sync on every page context max_workers = min(len(context.pdfinfo), context.options.jobs) if max_workers > 1: - context.log.info("Start processing %d pages concurrently", max_workers) + log.info("Start processing %d pages concurrently", max_workers) # Tesseract 4.x can be multithreaded, and we also run multiple workers. We want # to manage how many threads it uses to avoid creating total threads than cores. @@ -250,7 +252,7 @@ def exec_concurrent(context): except ValueError: # OMP_THREAD_LIMIT initialized to non-numeric context.log.error("Environment variable OMP_THREAD_LIMIT is not numeric") if tess_threads > 1: - context.log.info("Using Tesseract OpenMP thread limit %d", tess_threads) + log.info("Using Tesseract OpenMP thread limit %d", tess_threads) if context.options.use_threads: from multiprocessing.dummy import Pool @@ -352,8 +354,6 @@ def configure_debug_logging(log_filename, prefix=''): def run_pipeline(options, api=False): - log = make_logger(options, __name__) - # Any changes to options will not take effect for options that are already # bound to function parameters in the pipeline. (For example # options.input_file, options.pdf_renderer are already bound.) @@ -377,7 +377,6 @@ def run_pipeline(options, api=False): start_input_file, os.path.join(work_folder, 'origin.pdf'), options, - log, ) # Gather pdfinfo and create context @@ -412,7 +411,7 @@ def run_pipeline(options, api=False): pdfa_info['conformance'], ) return ExitCode.pdfa_conversion_failed - if not qpdf.check(options.output_file, log): + if not qpdf.check(options.output_file): log.warning('Output file: The generated PDF is INVALID') return ExitCode.invalid_output_pdf report_output_file_size(options, start_input_file, options.output_file) diff --git a/src/ocrmypdf/exec/__init__.py b/src/ocrmypdf/exec/__init__.py index a19b2043..e2bbf0a5 100644 --- a/src/ocrmypdf/exec/__init__.py +++ b/src/ocrmypdf/exec/__init__.py @@ -268,9 +268,6 @@ def check_external_program( recommended=False, **kwargs, # To consume log parameter ): - if kwargs: - if not 'log' in kwargs: - log.warning('check_external_program(log=...) is deprecated') try: found_version = version_checker() except (CalledProcessError, FileNotFoundError, MissingDependencyError): diff --git a/src/ocrmypdf/exec/ghostscript.py b/src/ocrmypdf/exec/ghostscript.py index 856bc0c1..de4cbda2 100644 --- a/src/ocrmypdf/exec/ghostscript.py +++ b/src/ocrmypdf/exec/ghostscript.py @@ -34,7 +34,7 @@ from PIL import Image from ..exceptions import MissingDependencyError, SubprocessOutputError from . import get_version, run -gslog = logging.getLogger() +log = logging.getLogger(__name__) GS = 'gs' if os.name == 'nt': @@ -138,7 +138,6 @@ def rasterize_pdf( xres, yres, raster_device, - log, pageno=1, page_dpi=None, rotation=None, @@ -155,7 +154,6 @@ def rasterize_pdf( :param xres: resolution at which to rasterize page :param yres: :param raster_device: - :param log: :param pageno: page number to rasterize (beginning at page 1) :param page_dpi: resolution tuple (x, y) overriding output image DPI :param rotation: 0, 90, 180, 270: clockwise angle to rotate page @@ -165,8 +163,6 @@ def rasterize_pdf( res = round(xres, 6), round(yres, 6) if not page_dpi: page_dpi = res - if not log: - log = gslog args_gs = ( [ @@ -191,7 +187,6 @@ def rasterize_pdf( ] ) - log.debug(args_gs) try: p = run(args_gs, stdout=PIPE, stderr=PIPE, check=True) except CalledProcessError as e: @@ -224,7 +219,6 @@ def generate_pdfa( pdf_pages, output_file, compression, - log, threads=None, # deprecated parameter pdf_version='1.5', pdfa_part='2', @@ -246,8 +240,6 @@ def generate_pdfa( images entirely. (The feature was added in 9.23 but broken, and the 9.24 release of Ghostscript had regressions, so we don't support it until 9.25.) """ - if not log: - log = gslog if threads is not None: warnings.warn( "use of deprecated parameter 'threads'", category=DeprecationWarning diff --git a/src/ocrmypdf/exec/qpdf.py b/src/ocrmypdf/exec/qpdf.py index d46baf1c..32ef8cb6 100644 --- a/src/ocrmypdf/exec/qpdf.py +++ b/src/ocrmypdf/exec/qpdf.py @@ -17,22 +17,24 @@ """Interface to qpdf executable""" +import logging from io import StringIO import pikepdf +log = logging.getLogger(__name__) + def version(): return pikepdf.__libqpdf_version__ -def check(input_file, log=None): +def check(input_file): pdf = None try: pdf = pikepdf.open(input_file) except pikepdf.PdfError as e: - if log: - log.error(e) + log.error(e) return False else: messages = pdf.check() diff --git a/src/ocrmypdf/exec/tesseract.py b/src/ocrmypdf/exec/tesseract.py index abcbb2fa..4ebdb0cf 100644 --- a/src/ocrmypdf/exec/tesseract.py +++ b/src/ocrmypdf/exec/tesseract.py @@ -33,6 +33,8 @@ from ..exceptions import ( from ..helpers import page_number, safe_symlink from . import get_version, run +log = logging.getLogger(__name__) + OrientationConfidence = namedtuple('OrientationConfidence', ('angle', 'confidence')) HOCR_TEMPLATE = """ @@ -144,7 +146,7 @@ def tess_base_args(langs, engine_mode): return args -def get_orientation(input_file, engine_mode, timeout: float, log, tesseract_env=None): +def get_orientation(input_file, engine_mode, timeout: float, tesseract_env=None): args_tesseract = tess_base_args(['osd'], engine_mode) + [ '--psm', '0', @@ -165,7 +167,7 @@ def get_orientation(input_file, engine_mode, timeout: float, log, tesseract_env= except TimeoutExpired: return OrientationConfidence(angle=0, confidence=0.0) except CalledProcessError as e: - tesseract_log_output(log, e.output, input_file) + tesseract_log_output(e.output, input_file) if ( b'Too few characters. Skipping this page' in e.output or b'Image too large' in e.output @@ -187,9 +189,9 @@ def get_orientation(input_file, engine_mode, timeout: float, log, tesseract_env= return oc -def tesseract_log_output(mainlog, stdout, input_file): - log = TesseractLoggerAdapter( - mainlog, extra=mainlog.extra if hasattr(mainlog, 'extra') else None +def tesseract_log_output(stdout, input_file): + tlog = TesseractLoggerAdapter( + log, extra=log.extra if hasattr(log, 'extra') else None ) try: @@ -204,28 +206,28 @@ def tesseract_log_output(mainlog, stdout, input_file): elif line.startswith("Warning in pixReadMem"): continue elif 'diacritics' in line: - log.warning("lots of diacritics - possibly poor OCR") + tlog.warning("lots of diacritics - possibly poor OCR") elif line.startswith('OSD: Weak margin'): - log.warning("unsure about page orientation") + tlog.warning("unsure about page orientation") elif 'Error in pixScanForForeground' in line: pass # Appears to be spurious/problem with nonwhite borders elif 'Error in boxClipToRectangle' in line: pass # Always appears with pixScanForForeground message elif 'parameter not found: ' in line.lower(): - log.error(line.strip()) + tlog.error(line.strip()) problem = line.split('found: ')[1] raise TesseractConfigError(problem) elif 'error' in line.lower() or 'exception' in line.lower(): - log.error(line.strip()) + tlog.error(line.strip()) elif 'warning' in line.lower(): - log.warning(line.strip()) + tlog.warning(line.strip()) elif 'read_params_file' in line.lower(): - log.error(line.strip()) + tlog.error(line.strip()) else: - log.info(line.strip()) + tlog.info(line.strip()) -def page_timedout(log, input_file, timeout): +def page_timedout(input_file, timeout): if timeout == 0: return prefix = f"{(page_number(input_file)):4d}: [tesseract] " @@ -257,7 +259,6 @@ def generate_hocr( user_words, user_patterns, tesseract_env, - log, ): output_hocr = next(o for o in output_files if fspath(o).endswith('.hocr')) @@ -292,17 +293,17 @@ def generate_hocr( # Generate a HOCR file with no recognized text if tesseract times out # Temporary workaround to hocrTransform not being able to function if # it does not have a valid hOCR file. - page_timedout(log, input_file, timeout) + page_timedout(input_file, timeout) _generate_null_hocr(output_hocr, output_sidecar, input_file) except CalledProcessError as e: - tesseract_log_output(log, e.output, input_file) + tesseract_log_output(e.output, input_file) if b'Image too large' in e.output: _generate_null_hocr(output_hocr, output_sidecar, input_file) return raise SubprocessOutputError() from e else: - tesseract_log_output(log, stdout, input_file) + tesseract_log_output(stdout, input_file) # The sidecar text file will get the suffix .txt; rename it to # whatever caller wants it named if os.path.exists(prefix + '.txt'): @@ -340,7 +341,6 @@ def generate_pdf( user_words, user_patterns, tesseract_env, - log, ): """Use Tesseract to render a PDF. @@ -389,13 +389,13 @@ def generate_pdf( if os.path.exists(prefix + '.txt'): shutil.move(prefix + '.txt', output_text) except TimeoutExpired: - page_timedout(log, input_image, timeout) + page_timedout(input_image, timeout) use_skip_page(text_only, skip_pdf, output_pdf, output_text) except CalledProcessError as e: - tesseract_log_output(log, e.output, input_image) + tesseract_log_output(e.output, input_image) if b'Image too large' in e.output: use_skip_page(text_only, skip_pdf, output_pdf, output_text) return raise SubprocessOutputError() from e else: - tesseract_log_output(log, stdout, input_image) + tesseract_log_output(stdout, input_image) diff --git a/src/ocrmypdf/exec/unpaper.py b/src/ocrmypdf/exec/unpaper.py index 2984b455..0228beb6 100644 --- a/src/ocrmypdf/exec/unpaper.py +++ b/src/ocrmypdf/exec/unpaper.py @@ -20,6 +20,7 @@ """Interface to unpaper executable""" +import logging import os import shlex from functools import lru_cache @@ -32,13 +33,15 @@ from ..exceptions import MissingDependencyError, SubprocessOutputError from . import get_version from . import run as external_run +log = logging.getLogger(__name__) + @lru_cache(maxsize=1) def version(): return get_version('unpaper') -def run(input_file, output_file, dpi, log, mode_args): +def run(input_file, output_file, dpi, mode_args): args_unpaper = ['unpaper', '-v', '--dpi', str(dpi)] + mode_args SUFFIXES = {'1': '.pbm', 'L': '.pgm', 'RGB': '.ppm'} @@ -110,7 +113,7 @@ def validate_custom_args(args: str): return unpaper_args -def clean(input_file, output_file, dpi, log, unpaper_args=None): +def clean(input_file, output_file, dpi, unpaper_args=None): default_args = [ '--layout', 'none', @@ -124,4 +127,4 @@ def clean(input_file, output_file, dpi, log, unpaper_args=None): ] if not unpaper_args: unpaper_args = default_args - run(input_file, output_file, dpi, log, unpaper_args) + run(input_file, output_file, dpi, unpaper_args) diff --git a/src/ocrmypdf/optimize.py b/src/ocrmypdf/optimize.py index 839a8ff9..e6f0bd3f 100644 --- a/src/ocrmypdf/optimize.py +++ b/src/ocrmypdf/optimize.py @@ -16,6 +16,7 @@ # along with OCRmyPDF. If not, see . import concurrent.futures +import logging import sys import tempfile from collections import defaultdict @@ -33,6 +34,8 @@ from .exceptions import OutputFileAccessError from .exec import jbig2enc, pngquant from .helpers import safe_symlink +log = logging.getLogger(__name__) + DEFAULT_JPEG_QUALITY = 75 DEFAULT_PNG_QUALITY = 70 @@ -53,7 +56,7 @@ def tif_name(root, xref): return img_name(root, xref, '.tif') -def extract_image_filter(pike, root, log, image, xref): +def extract_image_filter(pike, root, image, xref): if image.Subtype != Name.Image: return None if image.Length < 100: @@ -79,8 +82,8 @@ def extract_image_filter(pike, root, log, image, xref): return pim, filtdp -def extract_image_jbig2(*, pike, root, log, image, xref, options): - result = extract_image_filter(pike, root, log, image, xref) +def extract_image_jbig2(*, pike, root, image, xref, options): + result = extract_image_filter(pike, root, image, xref) if result is None: return None pim, filtdp = result @@ -101,8 +104,8 @@ def extract_image_jbig2(*, pike, root, log, image, xref, options): return None -def extract_image_generic(*, pike, root, log, image, xref, options): - result = extract_image_filter(pike, root, log, image, xref) +def extract_image_generic(*, pike, root, image, xref, options): + result = extract_image_filter(pike, root, image, xref) if result is None: return None pim, filtdp = result @@ -170,7 +173,7 @@ def extract_image_generic(*, pike, root, log, image, xref, options): return None -def extract_images(pike, root, log, options, extract_fn): +def extract_images(pike, root, options, extract_fn): """Extract image using extract_fn Enumerate images on each page, lookup their xref/ID number in the PDF. @@ -212,7 +215,7 @@ def extract_images(pike, root, log, options, extract_fn): image = pike.get_object((xref, 0)) try: result = extract_fn( - pike=pike, root=root, log=log, image=image, xref=xref, options=options + pike=pike, root=root, image=image, xref=xref, options=options ) except Exception as e: log.debug("Image xref %s, error %s", xref, repr(e)) @@ -223,12 +226,12 @@ def extract_images(pike, root, log, options, extract_fn): yield pageno_for_xref[xref], xref, ext -def extract_images_generic(pike, root, log, options): +def extract_images_generic(pike, root, options): """Extract any >=2bpp image we think we can improve""" jpegs = [] pngs = [] - for _, xref, ext in extract_images(pike, root, log, options, extract_image_generic): + for _, xref, ext in extract_images(pike, root, options, extract_image_generic): log.debug('xref = %s ext = %s', xref, ext) if ext == '.png': pngs.append(xref) @@ -238,13 +241,11 @@ def extract_images_generic(pike, root, log, options): return jpegs, pngs -def extract_images_jbig2(pike, root, log, options): +def extract_images_jbig2(pike, root, options): """Extract any bitonal image that we think we can improve as JBIG2""" jbig2_groups = defaultdict(list) - for pageno, xref, ext in extract_images( - pike, root, log, options, extract_image_jbig2 - ): + for pageno, xref, ext in extract_images(pike, root, options, extract_image_jbig2): group = pageno // options.jbig2_page_group_size jbig2_groups[group].append((xref, ext)) @@ -256,7 +257,7 @@ def extract_images_jbig2(pike, root, log, options): return jbig2_groups -def _produce_jbig2_images(jbig2_groups, root, log, options): +def _produce_jbig2_images(jbig2_groups, root, options): """Produce JBIG2 images from their groups""" def jbig2_group_futures(executor, root, groups): @@ -304,7 +305,7 @@ def _produce_jbig2_images(jbig2_groups, root, log, options): pbar.update() -def convert_to_jbig2(pike, jbig2_groups, root, log, options): +def convert_to_jbig2(pike, jbig2_groups, root, options): """Convert images to JBIG2 and insert into PDF. When the JBIG2 page group size is > 1 we do several JBIG2 images at once @@ -318,7 +319,7 @@ def convert_to_jbig2(pike, jbig2_groups, root, log, options): and needs no dictionary. Currently this must be lossless JBIG2. """ - _produce_jbig2_images(jbig2_groups, root, log, options) + _produce_jbig2_images(jbig2_groups, root, options) for group, xref_exts in jbig2_groups.items(): prefix = f'group{group:08d}' @@ -342,7 +343,7 @@ def convert_to_jbig2(pike, jbig2_groups, root, log, options): ) -def transcode_jpegs(pike, jpegs, root, log, options): +def transcode_jpegs(pike, jpegs, root, options): for xref in tqdm( jpegs, desc="JPEGs", unit='image', disable=not options.progress_bar ): @@ -365,7 +366,7 @@ def transcode_jpegs(pike, jpegs, root, log, options): im_obj.write(compdata.read(), filter=Name.DCTDecode) -def transcode_pngs(pike, images, image_name_fn, root, log, options): +def transcode_pngs(pike, images, image_name_fn, root, options): modified = set() if options.optimize >= 2: png_quality = ( @@ -500,7 +501,6 @@ def rewrite_png(pike, im_obj, compdata, log): def optimize(input_file, output_file, context, save_settings): - log = context.log options = context.options if options.optimize == 0: safe_symlink(input_file, output_file) @@ -517,15 +517,15 @@ def optimize(input_file, output_file, context, save_settings): root = Path(output_file).parent / 'images' root.mkdir(exist_ok=True) - jpegs, pngs = extract_images_generic(pike, root, log, options) - transcode_jpegs(pike, jpegs, root, log, options) + jpegs, pngs = extract_images_generic(pike, root, options) + transcode_jpegs(pike, jpegs, root, options) # if options.optimize >= 2: # Try pngifying the jpegs - # transcode_pngs(pike, jpegs, jpg_name, root, log, options) - transcode_pngs(pike, pngs, png_name, root, log, options) + # transcode_pngs(pike, jpegs, jpg_name, root, options) + transcode_pngs(pike, pngs, png_name, root, options) - jbig2_groups = extract_images_jbig2(pike, root, log, options) - convert_to_jbig2(pike, jbig2_groups, root, log, options) + jbig2_groups = extract_images_jbig2(pike, root, options) + convert_to_jbig2(pike, jbig2_groups, root, options) target_file = Path(output_file).with_suffix('.opt.pdf') pike.remove_unreferenced_resources() diff --git a/src/ocrmypdf/pdfinfo/ghosttext.py b/src/ocrmypdf/pdfinfo/ghosttext.py index 9626fad7..07e72f19 100644 --- a/src/ocrmypdf/pdfinfo/ghosttext.py +++ b/src/ocrmypdf/pdfinfo/ghosttext.py @@ -21,7 +21,7 @@ import xml.etree.ElementTree as ET from ..exec import ghostscript -gslog = logging.getLogger() +log = logging.getLogger(__name__) # Forgive me for I have sinned # I am using regular expressions to parse XML. However the XML in this case, @@ -77,7 +77,7 @@ def page_get_textblocks(infile, pageno, xmltext, height): return [block for block in joined_blocks()] -def extract_text_xml(infile, pdf, pageno=None, log=gslog): +def extract_text_xml(infile, pdf, pageno=None): existing_text = ghostscript.extract_text(infile, pageno=None) existing_text = regex_remove_char_tags.sub(b' ', existing_text) diff --git a/src/ocrmypdf/pdfinfo/info.py b/src/ocrmypdf/pdfinfo/info.py index d304688b..329661b3 100644 --- a/src/ocrmypdf/pdfinfo/info.py +++ b/src/ocrmypdf/pdfinfo/info.py @@ -616,7 +616,7 @@ def _pdf_get_pageinfo(pdf, pageno: int, infile: PathLike, xmltext: str): return pageinfo -def _pdf_get_all_pageinfo(infile, detailed_analysis=False, log=None, progbar=False): +def _pdf_get_all_pageinfo(infile, detailed_analysis=False, progbar=False): pdf = pikepdf.open(infile) # Do not close in this function try: if pdf.is_encrypted: @@ -624,7 +624,7 @@ def _pdf_get_all_pageinfo(infile, detailed_analysis=False, log=None, progbar=Fal if detailed_analysis: pages_xml = None else: - pages_xml = ghosttext.extract_text_xml(infile, pdf, pageno=None, log=log) + pages_xml = ghosttext.extract_text_xml(infile, pdf, pageno=None) pages = [] for n, _ in tqdm( @@ -758,12 +758,12 @@ class PageInfo: class PdfInfo: """Get summary information about a PDF""" - def __init__(self, infile, detailed_page_analysis=False, log=logger, progbar=False): + def __init__(self, infile, detailed_page_analysis=False, progbar=False): self._infile = infile if ghostscript.version() in ('9.52',): detailed_page_analysis = True # txtwrite doesn't work in these versions self._pages, pdf = _pdf_get_all_pageinfo( - infile, detailed_page_analysis, log=log, progbar=progbar + infile, detailed_page_analysis, progbar=progbar ) self._needs_rendering = pdf.root.get('/NeedsRendering', False) self._has_acroform = False diff --git a/tests/test_ghostscript.py b/tests/test_ghostscript.py index 3aed104a..5bb7b76f 100644 --- a/tests/test_ghostscript.py +++ b/tests/test_ghostscript.py @@ -73,14 +73,12 @@ def test_rasterize_size(francais, outdir, caplog): target_size = Decimal('50.0'), Decimal('30.0') forced_dpi = 42.0, 4242.0 - log = logging.getLogger() rasterize_pdf( path, outdir / 'out.png', target_size[0] / page_size[0], target_size[1] / page_size[1], raster_device='pngmono', - log=log, page_dpi=forced_dpi, ) @@ -97,7 +95,6 @@ def test_rasterize_rotated(francais, outdir, caplog): target_size = Decimal('50.0'), Decimal('30.0') forced_dpi = 42.0, 4242.0 - log = logging.getLogger() caplog.set_level(logging.DEBUG) rasterize_pdf( path, @@ -105,7 +102,6 @@ def test_rasterize_rotated(francais, outdir, caplog): target_size[0] / page_size[0], target_size[1] / page_size[1], raster_device='pngmono', - log=log, page_dpi=forced_dpi, rotation=90, ) diff --git a/tests/test_optimize.py b/tests/test_optimize.py index 0c78d653..6cdc1c71 100644 --- a/tests/test_optimize.py +++ b/tests/test_optimize.py @@ -43,12 +43,7 @@ def test_mono_not_inverted(resources, outdir): opt.main(infile, outdir / 'out.pdf', level=3) rasterize_pdf( - outdir / 'out.pdf', - outdir / 'im.png', - xres=10, - yres=10, - raster_device='pnggray', - log=logging.getLogger(name='test_mono_not_inverted'), + outdir / 'out.pdf', outdir / 'im.png', xres=10, yres=10, raster_device='pnggray' ) with Image.open(fspath(outdir / 'im.png')) as im: diff --git a/tests/test_preprocessing.py b/tests/test_preprocessing.py index 4054e4e4..50eb6b56 100644 --- a/tests/test_preprocessing.py +++ b/tests/test_preprocessing.py @@ -55,7 +55,6 @@ def test_deskew(spoof_tesseract_noop, resources, outdir): xres=150, yres=150, raster_device='pngmono', - log=log, pageno=1, ) @@ -80,18 +79,10 @@ def test_remove_background(spoof_tesseract_noop, resources, outdir): env=spoof_tesseract_noop, ) - log = logging.getLogger() - output_png = outdir / 'remove_bg.png' ghostscript.rasterize_pdf( - output_pdf, - output_png, - xres=100, - yres=100, - raster_device='png16m', - log=log, - pageno=1, + output_pdf, output_png, xres=100, yres=100, raster_device='png16m', pageno=1 ) # The output image should contain pure white and black diff --git a/tests/test_rotation.py b/tests/test_rotation.py index 05d42300..796c2bfb 100644 --- a/tests/test_rotation.py +++ b/tests/test_rotation.py @@ -48,8 +48,6 @@ RENDERERS = ['hocr', 'sandwich'] def check_monochrome_correlation( outdir, reference_pdf, reference_pageno, test_pdf, test_pageno ): - gslog = logging.getLogger() - reference_png = outdir / f'{reference_pdf.name}.ref{reference_pageno:04d}.png' test_png = outdir / f'{test_pdf.name}.test{test_pageno:04d}.png' @@ -63,7 +61,6 @@ def check_monochrome_correlation( xres=100, yres=100, raster_device='pngmono', - log=gslog, pageno=pageno, rotation=0, ) @@ -268,7 +265,6 @@ def test_tesseract_orientation(resources, tmp_path): pix_rotated = pix.rotate_orth(2) # 180 degrees clockwise pix_rotated.write_implied_format(tmp_path / '000001.png') - log = logging.getLogger() tesseract.get_orientation( # Test results of this are unreliable - tmp_path / '000001.png', engine_mode='3', timeout=10, log=log + tmp_path / '000001.png', engine_mode='3', timeout=10 ) diff --git a/tests/test_stdio.py b/tests/test_stdio.py index 250ca6f2..150e40ff 100644 --- a/tests/test_stdio.py +++ b/tests/test_stdio.py @@ -74,7 +74,7 @@ def test_stdout(spoof_tesseract_noop, ocrmypdf_exec, resources, outpdf): ) assert p.returncode == ExitCode.ok - assert qpdf.check(output_file, log=None) + assert qpdf.check(output_file) @pytest.mark.skipif( diff --git a/tests/test_tess4.py b/tests/test_tess4.py index a66f2186..33110a43 100644 --- a/tests/test_tess4.py +++ b/tests/test_tess4.py @@ -86,8 +86,6 @@ def test_no_languages(tmp_path): def test_image_too_large_hocr(monkeypatch, resources, outdir): - log = logging.getLogger('test_image_too_large_hocr') - def dummy_run(args, *, env=None, **kwargs): raise subprocess.CalledProcessError(1, 'tesseract', output=b'Image too large') @@ -100,7 +98,6 @@ def test_image_too_large_hocr(monkeypatch, resources, outdir): tessconfig=[], timeout=180.0, pagesegmode=None, - log=log, user_words=None, user_patterns=None, tesseract_env=None, @@ -109,8 +106,6 @@ def test_image_too_large_hocr(monkeypatch, resources, outdir): def test_image_too_large_pdf(monkeypatch, resources, outdir): - log = logging.getLogger('test_image_too_large_pdf') - def dummy_run(args, *, env=None, **kwargs): raise subprocess.CalledProcessError(1, 'tesseract', output=b'Image too large') @@ -126,7 +121,6 @@ def test_image_too_large_pdf(monkeypatch, resources, outdir): tessconfig=[], timeout=180.0, pagesegmode=None, - log=log, user_words=None, user_patterns=None, tesseract_env=None, @@ -137,8 +131,7 @@ def test_image_too_large_pdf(monkeypatch, resources, outdir): def test_timeout(caplog): - log = logging.getLogger('test_timeout') - tesseract.page_timedout(log, '123456.png', 5) + tesseract.page_timedout('123456.png', 5) assert "123456" in caplog.text assert "took too long" in caplog.text @@ -160,10 +153,8 @@ def test_timeout(caplog): ], ) def test_tesseract_log_output(caplog, in_, logged): - log = logging.getLogger('tesseract_log_output') - log.setLevel(logging.INFO) - - tesseract.tesseract_log_output(log, in_, 'dummy') + caplog.set_level(logging.INFO) + tesseract.tesseract_log_output(in_, 'dummy') if logged == '': assert caplog.text == '' else: @@ -171,7 +162,6 @@ def test_tesseract_log_output(caplog, in_, logged): def test_tesseract_log_output_raises(caplog): - log = logging.getLogger('tesseract_log_output') with pytest.raises(tesseract.TesseractConfigError): - tesseract.tesseract_log_output(log, b'parameter not found: moo', 'dummy') + tesseract.tesseract_log_output(b'parameter not found: moo', 'dummy') assert 'not found' in caplog.text From af914893763fb8620be0cee7bc8b3c700f5025b8 Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Wed, 4 Mar 2020 21:24:40 -0800 Subject: [PATCH 03/94] Remove safe_symlink log= warning --- src/ocrmypdf/helpers.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/ocrmypdf/helpers.py b/src/ocrmypdf/helpers.py index ae326331..55707082 100644 --- a/src/ocrmypdf/helpers.py +++ b/src/ocrmypdf/helpers.py @@ -32,11 +32,6 @@ def safe_symlink(input_file: os.PathLike, soft_link_name: os.PathLike, *args, ** """ Helper function: relinks soft symbolic link if necessary """ - if len(args) == 1 and isinstance(args[0], logging.Logger): - log.warning("Deprecated: safe_symlink(,log)") - if 'log' in kwargs: - log.warning('Deprecated: safe_symlink(...log=)') - input_file = os.fspath(input_file) soft_link_name = os.fspath(soft_link_name) From a63d624052fe66b067ce62c4a67c2bc95a46f67a Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Wed, 15 Apr 2020 00:04:43 -0700 Subject: [PATCH 04/94] Improve logging of subprocess output --- src/ocrmypdf/exec/__init__.py | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/src/ocrmypdf/exec/__init__.py b/src/ocrmypdf/exec/__init__.py index e2bbf0a5..80e6ba98 100644 --- a/src/ocrmypdf/exec/__init__.py +++ b/src/ocrmypdf/exec/__init__.py @@ -23,6 +23,7 @@ import re import shutil import sys from collections.abc import Mapping +from contextlib import suppress from distutils.version import LooseVersion from functools import lru_cache from subprocess import PIPE, STDOUT, CalledProcessError @@ -67,19 +68,25 @@ def run(args, *, env=None, **kwargs): if os.name == 'nt': args = fix_windows_args(program, args, env) - process_log = log.getChild(os.path.basename(program)) - process_log.debug("Running: %s", args) + log.debug("Running: %s", args) + process_log = log.getChild('subprocess.' + os.path.basename(program)) if sys.version_info < (3, 7) and os.name == 'nt': # Can't use close_fds=True on Windows with Python 3.6 or older # https://bugs.python.org/issue19575, etc. kwargs['close_fds'] = False - proc = subprocess_run(args, env=env, **kwargs) - if process_log.isEnabledFor(logging.DEBUG): - try: - stderr = proc.stderr.decode('utf-8', 'replace') - except AttributeError: - stderr = proc.stderr - if stderr: + + stderr = None + try: + proc = subprocess_run(args, env=env, **kwargs) + except CalledProcessError as e: + stderr = getattr(e, 'stderr', None) + raise + else: + stderr = getattr(proc, 'stderr', None) + finally: + if process_log.isEnabledFor(logging.DEBUG) and stderr: + with suppress(AttributeError, UnicodeDecodeError): + stderr = stderr.decode('utf-8', 'replace') process_log.debug("stderr = %s", stderr) return proc From c2919f2e1ca16b838ab1247e79aaff04697b6a16 Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Wed, 15 Apr 2020 00:05:23 -0700 Subject: [PATCH 05/94] Reinstate logging of page numbers --- src/ocrmypdf/_jobcontext.py | 16 ---------- src/ocrmypdf/_logging.py | 60 +++++++++++++++++++++++++++++++++++++ src/ocrmypdf/_sync.py | 21 ++++++++++++- src/ocrmypdf/api.py | 41 ++++++------------------- 4 files changed, 89 insertions(+), 49 deletions(-) create mode 100644 src/ocrmypdf/_logging.py diff --git a/src/ocrmypdf/_jobcontext.py b/src/ocrmypdf/_jobcontext.py index 59de5cdd..a782d596 100644 --- a/src/ocrmypdf/_jobcontext.py +++ b/src/ocrmypdf/_jobcontext.py @@ -58,7 +58,6 @@ class PageContext: self.name = pdf_context.name self.pageno = pageno self.pageinfo = pdf_context.pdfinfo[pageno] - self._log = None def get_path(self, name): return os.path.join(self.work_folder, "%06d_%s" % (self.pageno + 1, name)) @@ -69,18 +68,3 @@ def cleanup_working_files(work_folder, options): print(f"Temporary working files retained at:\n{work_folder}", file=sys.stderr) else: shutil.rmtree(work_folder, ignore_errors=True) - - -class LogNameAdapter(logging.LoggerAdapter): - def process(self, msg, kwargs): - # return '[%s] %s' % (self.extra['input_filename'], msg), kwargs - return '%s' % (msg,), kwargs - - -class LogNamePageAdapter(logging.LoggerAdapter): - def process(self, msg, kwargs): - return ( - #'[%s:%05u] %s' % (self.extra['input_filename'], self.extra['page'], msg), - '%4u: %s' % (self.extra['page'], msg), - kwargs, - ) diff --git a/src/ocrmypdf/_logging.py b/src/ocrmypdf/_logging.py new file mode 100644 index 00000000..412552fa --- /dev/null +++ b/src/ocrmypdf/_logging.py @@ -0,0 +1,60 @@ +# © 2020 James R. Barlow: github.com/jbarlow83 +# +# This file is part of OCRmyPDF. +# +# OCRmyPDF is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# OCRmyPDF is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with OCRmyPDF. If not, see . + +import logging +import sys +from contextlib import suppress + +from tqdm import tqdm + + +class PageNumberFilter(logging.Filter): + def filter(self, record): + pageno = getattr(record, 'pageno', None) + if pageno is not None: + record.pageno = f' [{pageno:5d}]' + else: + record.pageno = '' + return True + + +class TqdmConsole: + """Wrapper to log messages in a way that is compatible with tqdm progress bar + + This routes log messages through tqdm so that it can print them above the + progress bar, and then refresh the progress bar, rather than overwriting + it which looks messy. + + For some reason Python 3.6 prints extra empty messages from time to time, + so we suppress those. + """ + + def __init__(self, file): + self.file = file + self.py36 = sys.version_info[0:2] == (3, 6) + + def write(self, msg): + # When no progress bar is active, tqdm.write() routes to print() + if self.py36: + if msg.strip() != '': + tqdm.write(msg.rstrip(), end='\n', file=self.file) + else: + tqdm.write(msg.rstrip(), end='\n', file=self.file) + + def flush(self): + with suppress(AttributeError): + self.file.flush() diff --git a/src/ocrmypdf/_sync.py b/src/ocrmypdf/_sync.py index dbb7603e..383e8dc7 100644 --- a/src/ocrmypdf/_sync.py +++ b/src/ocrmypdf/_sync.py @@ -31,6 +31,7 @@ from tqdm import tqdm from ._graft import OcrGrafter from ._jobcontext import PDFContext, cleanup_working_files +from ._logging import PageNumberFilter from ._pipeline import ( convert_to_pdfa, copy_final, @@ -72,6 +73,9 @@ PageResult = namedtuple( 'PageResult', 'pageno, pdf_page_from_image, ocr, text, orientation_correction' ) +tls = threading.local() +tls.pageno = None + def preprocess(page_context, image, remove_background, deskew, clean): if remove_background: @@ -83,8 +87,23 @@ def preprocess(page_context, image, remove_background, deskew, clean): return image +old_factory = logging.getLogRecordFactory() + + +def record_factory(*args, **kwargs): + record = old_factory(*args, **kwargs) + if hasattr(tls, 'pageno'): + record.pageno = tls.pageno + return record + + +logging.setLogRecordFactory(record_factory) + + def exec_page_sync(page_context): options = page_context.options + tls.pageno = page_context.pageno + 1 + orientation_correction = 0 pdf_page_from_image_out = None ocr_out = None @@ -346,7 +365,7 @@ def configure_debug_logging(log_filename, prefix=''): log_file_handler = logging.FileHandler(log_filename, delay=True) log_file_handler.setLevel(logging.DEBUG) formatter = logging.Formatter( - '[%(asctime)s] - %(name)s - %(levelname)7s - %(message)s' + '[%(asctime)s] - %(name)s - %(levelname)7s -%(pageno)s %(message)s' ) log_file_handler.setFormatter(formatter) logging.getLogger(prefix).addHandler(log_file_handler) diff --git a/src/ocrmypdf/api.py b/src/ocrmypdf/api.py index 8fbcc2b9..0eaaa5b7 100644 --- a/src/ocrmypdf/api.py +++ b/src/ocrmypdf/api.py @@ -23,41 +23,12 @@ from enum import IntEnum from pathlib import Path from typing import Dict, Iterable -from tqdm import tqdm - +from ._logging import PageNumberFilter, TqdmConsole from ._sync import run_pipeline from ._validation import check_options from .cli import parser -class TqdmConsole: - """Wrapper to log messages in a way that is compatible with tqdm progress bar - - This routes log messages through tqdm so that it can print them above the - progress bar, and then refresh the progress bar, rather than overwriting - it which looks messy. - - For some reason Python 3.6 prints extra empty messages from time to time, - so we suppress those. - """ - - def __init__(self, file): - self.file = file - self.py36 = sys.version_info[0:2] == (3, 6) - - def write(self, msg): - # When no progress bar is active, tqdm.write() routes to print() - if self.py36: - if msg.strip() != '': - tqdm.write(msg.rstrip(), end='\n', file=self.file) - else: - tqdm.write(msg.rstrip(), end='\n', file=self.file) - - def flush(self): - with suppress(AttributeError): - self.file.flush() - - class Verbosity(IntEnum): """Verbosity level for configure_logging.""" @@ -98,6 +69,7 @@ def configure_logging( """ prefix = '' if manage_root_logger else 'ocrmypdf' + log = logging.getLogger(prefix) log.setLevel(logging.DEBUG) @@ -113,9 +85,14 @@ def configure_logging( else: console.setLevel(logging.INFO) - formatter = logging.Formatter('%(levelname)7s - %(message)s') + console.addFilter(PageNumberFilter()) + if verbosity >= 2: - formatter = logging.Formatter('%(name)s - %(levelname)7s - %(message)s') + fmt = '%(levelname)7s %(name)s -%(pageno)s %(message)s' + else: + fmt = '%(levelname)7s -%(pageno)s %(message)s' + + formatter = logging.Formatter(fmt=fmt) console.setFormatter(formatter) log.addHandler(console) From f4f7946a0c2363a76524090221031df569e788fc Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Thu, 5 Mar 2020 13:55:48 -0800 Subject: [PATCH 06/94] Add colored logs --- requirements/main.txt | 1 + setup.py | 1 + src/ocrmypdf/api.py | 18 +++++++++++++++++- 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/requirements/main.txt b/requirements/main.txt index 5b56bd04..81e92848 100644 --- a/requirements/main.txt +++ b/requirements/main.txt @@ -2,6 +2,7 @@ # setup.py lists a separate set of requirements that are looser to simplify # installation cffi == 1.14.0 +coloredlogs == 14.0 # technically optional img2pdf == 0.3.3 pdfminer.six == 20200124 pikepdf == 1.10.2 diff --git a/setup.py b/setup.py index 0b4a61da..d0e05c0d 100644 --- a/setup.py +++ b/setup.py @@ -97,6 +97,7 @@ setup( install_requires=[ 'chardet >= 3.0.4, < 4', # unlisted requirement of pdfminer.six 20181108 'cffi >= 1.9.1', # must be a setup and install requirement + 'coloredlogs >= 14.0', # strictly optional 'img2pdf >= 0.3.0, < 0.4', # pure Python, so track HEAD closely 'pdfminer.six >= 20181108, <= 20200124', 'pikepdf >= 1.8.1, < 2', diff --git a/src/ocrmypdf/api.py b/src/ocrmypdf/api.py index 0eaaa5b7..91cd608a 100644 --- a/src/ocrmypdf/api.py +++ b/src/ocrmypdf/api.py @@ -28,6 +28,11 @@ from ._sync import run_pipeline from ._validation import check_options from .cli import parser +try: + import coloredlogs +except ModuleNotFoundError: + coloredlogs = None + class Verbosity(IntEnum): """Verbosity level for configure_logging.""" @@ -92,7 +97,18 @@ def configure_logging( else: fmt = '%(levelname)7s -%(pageno)s %(message)s' - formatter = logging.Formatter(fmt=fmt) + use_colors = progress_bar_friendly + if not coloredlogs: + use_colors = False + if use_colors: + if os.name == 'nt': + use_colors = coloredlogs.enable_ansi_support() + if use_colors: + use_colors = coloredlogs.terminal_supports_colors() + if use_colors: + formatter = coloredlogs.ColoredFormatter(fmt=fmt) + else: + formatter = logging.Formatter(fmt=fmt) console.setFormatter(formatter) log.addHandler(console) From 346da95899e1227d5e04a2d7225b5812086af9c5 Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Tue, 17 Mar 2020 21:06:07 -0700 Subject: [PATCH 07/94] Suppress loglevel since we have color now --- src/ocrmypdf/_logging.py | 2 +- src/ocrmypdf/api.py | 2 +- tests/test_main.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/ocrmypdf/_logging.py b/src/ocrmypdf/_logging.py index 412552fa..5126d97c 100644 --- a/src/ocrmypdf/_logging.py +++ b/src/ocrmypdf/_logging.py @@ -26,7 +26,7 @@ class PageNumberFilter(logging.Filter): def filter(self, record): pageno = getattr(record, 'pageno', None) if pageno is not None: - record.pageno = f' [{pageno:5d}]' + record.pageno = f'{pageno:5d} ' else: record.pageno = '' return True diff --git a/src/ocrmypdf/api.py b/src/ocrmypdf/api.py index 91cd608a..efa24c7f 100644 --- a/src/ocrmypdf/api.py +++ b/src/ocrmypdf/api.py @@ -95,7 +95,7 @@ def configure_logging( if verbosity >= 2: fmt = '%(levelname)7s %(name)s -%(pageno)s %(message)s' else: - fmt = '%(levelname)7s -%(pageno)s %(message)s' + fmt = '%(pageno)s%(message)s' use_colors = progress_bar_friendly if not coloredlogs: diff --git a/tests/test_main.py b/tests/test_main.py index 39abbe98..2f843536 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -331,7 +331,7 @@ def test_tesseract_crash_autorotate(spoof_tesseract_crash, resources, no_outpdf) ) assert p.returncode == ExitCode.child_process_error assert not os.path.exists(no_outpdf) - assert "ERROR" in err + assert "uncaught exception" in err print(out) print(err) From 2155bcacb46ba34bab7d62a9497dfd516e2a2c42 Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Wed, 15 Apr 2020 00:30:38 -0700 Subject: [PATCH 08/94] Loosen test language requirements - eng/deu --- tests/test_api.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/test_api.py b/tests/test_api.py index 2842d224..038fc778 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -62,5 +62,7 @@ def test_tqdm_console(): def test_language_list(): - with pytest.raises(ocrmypdf.exceptions.InputFileError): - ocrmypdf.ocr('doesnotexist.pdf', '_.pdf', language=['eng', 'ita']) + with pytest.raises( + [ocrmypdf.exceptions.InputFileError, ocrmypdf.exceptions.MissingDependencyError] + ): + ocrmypdf.ocr('doesnotexist.pdf', '_.pdf', language=['eng', 'deu']) From 9e3e4f2687690cc18e96c48afb7e91672bb09b96 Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Wed, 15 Apr 2020 02:17:55 -0700 Subject: [PATCH 09/94] Improve help text about aborting due to text --- docs/errors.rst | 6 ++++++ src/ocrmypdf/_pipeline.py | 3 ++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/docs/errors.rst b/docs/errors.rst index 825cd656..328080ad 100644 --- a/docs/errors.rst +++ b/docs/errors.rst @@ -22,6 +22,12 @@ As the error message suggests, your options are: - ``ocrmypdf --skip-text`` to skip OCR and other processing on any pages that contain text. Text pages will be copied into the output PDF without modification. +- ``ocrmypdf --redo-ocr`` to scan the file for any existing OCR + (non-printing text), remove it, and do OCR again. This is one way + to take advantage of improvements in OCR accuracy. Printable vector + text is excluded from OCR, so this can be used on files that contain + a mix of digital and scanned files. + Input file 'filename' is not a valid PDF ======================================== diff --git a/src/ocrmypdf/_pipeline.py b/src/ocrmypdf/_pipeline.py index 98ed32fd..7d994d0f 100644 --- a/src/ocrmypdf/_pipeline.py +++ b/src/ocrmypdf/_pipeline.py @@ -252,7 +252,8 @@ def is_ocr_required(page_context): elif pageinfo.has_text: if not options.force_ocr and not (options.skip_text or options.redo_ocr): raise PriorOcrFoundError( - "page already has text! - aborting (use --force-ocr to force OCR)" + "page already has text! - aborting (use --force-ocr to force OCR; " + " see also help for the arguments --skip-text and --redo-ocr" ) elif options.force_ocr: log.info("page already has text! - rasterizing text and running OCR anyway") From 957fb1494e4724686ad14e03abb440cfd6e01776 Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Wed, 15 Apr 2020 02:26:20 -0700 Subject: [PATCH 10/94] pytest picky about list vs tuple --- tests/test_api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_api.py b/tests/test_api.py index 038fc778..acc24683 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -63,6 +63,6 @@ def test_tqdm_console(): def test_language_list(): with pytest.raises( - [ocrmypdf.exceptions.InputFileError, ocrmypdf.exceptions.MissingDependencyError] + (ocrmypdf.exceptions.InputFileError, ocrmypdf.exceptions.MissingDependencyError) ): ocrmypdf.ocr('doesnotexist.pdf', '_.pdf', language=['eng', 'deu']) From 31b5f63f85029944269e6bfda0b6028b03e534f4 Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Fri, 3 Apr 2020 22:04:42 -0700 Subject: [PATCH 11/94] hocrtransform: cleanup/PEP8 Some API breaking changes. --- src/ocrmypdf/_pipeline.py | 8 ++--- src/ocrmypdf/hocrtransform.py | 68 ++++++++++++++++++----------------- tests/test_hocrtransform.py | 2 +- 3 files changed, 40 insertions(+), 38 deletions(-) diff --git a/src/ocrmypdf/_pipeline.py b/src/ocrmypdf/_pipeline.py index 7d994d0f..034b831c 100644 --- a/src/ocrmypdf/_pipeline.py +++ b/src/ocrmypdf/_pipeline.py @@ -597,10 +597,10 @@ def render_hocr_page(hocr, page_context): hocrtransform = HocrTransform(hocr, dpi) hocrtransform.to_pdf( output_file, - imageFileName=None, - showBoundingboxes=False, - invisibleText=True, - interwordSpaces=True, + image_filename=None, + show_bounding_boxes=False, + invisible_text=True, + interword_spaces=True, ) return output_file diff --git a/src/ocrmypdf/hocrtransform.py b/src/ocrmypdf/hocrtransform.py index 809f858e..a60fe959 100755 --- a/src/ocrmypdf/hocrtransform.py +++ b/src/ocrmypdf/hocrtransform.py @@ -64,9 +64,9 @@ class HocrTransform: {'ff': 'ff', 'ffi': 'f‌f‌i', 'ffl': 'f‌f‌l', 'fi': 'fi', 'fl': 'fl'} ) - def __init__(self, hocrFileName, dpi): + def __init__(self, hocr_filename: str, dpi: float): self.dpi = dpi - self.hocr = ElementTree.parse(hocrFileName) + self.hocr = ElementTree.parse(hocr_filename) # if the hOCR file has a namespace, ElementTree requires its use to # find elements @@ -114,12 +114,12 @@ class HocrTransform: return text @classmethod - def element_coordinates(cls, element): + def element_coordinates(cls, element) -> Rect: """ Returns a tuple containing the coordinates of the bounding box around an element """ - out = (0, 0, 0, 0) + out = Rect._make(0 for _ in range(4)) if 'title' in element.attrib: matches = cls.box_pattern.search(element.attrib['title']) if matches: @@ -136,7 +136,7 @@ class HocrTransform: matches = cls.baseline_pattern.search(element.attrib['title']) if matches: return float(matches.group(1)), int(matches.group(2)) - return (0, 0) + return (0.0, 0.0) def pt_from_pixel(self, pxl): """ @@ -145,7 +145,7 @@ class HocrTransform: return Rect._make((c / self.dpi * inch) for c in pxl) @classmethod - def replace_unsupported_chars(cls, s): + def replace_unsupported_chars(cls, s: str): """ Given an input string, returns the corresponding string that: - is available in the helvetica facetype @@ -155,12 +155,12 @@ class HocrTransform: def to_pdf( self, - outFileName, - imageFileName=None, - showBoundingboxes=False, - fontname="Helvetica", - invisibleText=False, - interwordSpaces=False, + out_filename: str, + image_filename: str = None, + show_bounding_boxes: bool = False, + fontname: str = "Helvetica", + invisible_text: bool = False, + interword_spaces: bool = False, ): """ Creates a PDF file with an image superimposed on top of the text. @@ -172,7 +172,9 @@ class HocrTransform: """ # create the PDF file # page size in points (1/72 in.) - pdf = Canvas(outFileName, pagesize=(self.width, self.height), pageCompression=1) + pdf = Canvas( + out_filename, pagesize=(self.width, self.height), pageCompression=1 + ) # draw bounding box for each paragraph # light blue for bounding box of paragraph @@ -190,7 +192,7 @@ class HocrTransform: pt = self.pt_from_pixel(pxl_coords) # draw the bbox border - if showBoundingboxes: # pragma: no cover + if show_bounding_boxes: # pragma: no cover pdf.rect( pt.x1, self.height - pt.y2, pt.x2 - pt.x1, pt.y2 - pt.y1, fill=1 ) @@ -205,9 +207,9 @@ class HocrTransform: line, "ocrx_word", fontname, - invisibleText, - interwordSpaces, - showBoundingboxes, + invisible_text, + interword_spaces, + show_bounding_boxes, ) if not found_lines: @@ -218,13 +220,13 @@ class HocrTransform: root, "ocrx_word", fontname, - invisibleText, - interwordSpaces, - showBoundingboxes, + invisible_text, + interword_spaces, + show_bounding_boxes, ) # put the image on the page, scaled to fill the page - if imageFileName is not None: - pdf.drawImage(imageFileName, 0, 0, width=self.width, height=self.height) + if image_filename is not None: + pdf.drawImage(image_filename, 0, 0, width=self.width, height=self.height) # finish up the page and save it pdf.showPage() @@ -236,13 +238,13 @@ class HocrTransform: def _do_line( self, - pdf, + pdf: Canvas, line, - elemclass, - fontname, - invisibleText, - interwordSpaces, - showBoundingboxes, + elemclass: str, + fontname: str, + invisible_text: bool, + interword_spaces: bool, + show_bounding_boxes: bool, ): pxl_line_coords = self.element_coordinates(line) line_box = self.pt_from_pixel(pxl_line_coords) @@ -262,14 +264,14 @@ class HocrTransform: # on a sloped baseline and the edge of the bounding box. fontsize = (line_height - abs(intercept)) / cos_a text.setFont(fontname, fontsize) - if invisibleText: + if invisible_text: text.setTextRenderMode(3) # Invisible (indicates OCR text) # Intercept is normally negative, so this places it above the bottom # of the line box baseline_y2 = self.height - (line_box.y2 + intercept) - if showBoundingboxes: # pragma: no cover + if show_bounding_boxes: # pragma: no cover # draw the baseline in magenta, dashed pdf.setDash() pdf.setStrokeColorRGB(0.95, 0.65, 0.95) @@ -298,7 +300,7 @@ class HocrTransform: pxl_coords = self.element_coordinates(elem) box = self.pt_from_pixel(pxl_coords) - if interwordSpaces: + if interword_spaces: # if `--interword-spaces` is true, append a space # to the end of each text element to allow simpler PDF viewers # such as PDF.js to better recognize words in search and copy @@ -318,7 +320,7 @@ class HocrTransform: font_width = pdf.stringWidth(elemtxt, fontname, fontsize) # draw the bbox border - if showBoundingboxes: # pragma: no cover + if show_bounding_boxes: # pragma: no cover pdf.rect( box.x1, self.height - line_box.y2, box_width, line_height, fill=0 ) @@ -385,5 +387,5 @@ if __name__ == "__main__": args.outputfile, args.image, args.boundingboxes, - interwordSpaces=args.interword_spaces, + interword_spaces=args.interword_spaces, ) diff --git a/tests/test_hocrtransform.py b/tests/test_hocrtransform.py index 19e4684d..13b1f601 100644 --- a/tests/test_hocrtransform.py +++ b/tests/test_hocrtransform.py @@ -41,6 +41,6 @@ def test_mono_image(blank_hocr, outdir): im.save(outdir / 'mono.tif', format='TIFF') hocr = hocrtransform.HocrTransform(str(blank_hocr), 300) - hocr.to_pdf(str(outdir / 'mono.pdf'), imageFileName=str(outdir / 'mono.tif')) + hocr.to_pdf(str(outdir / 'mono.pdf'), image_filename=str(outdir / 'mono.tif')) qpdf.check(str(outdir / 'mono.pdf')) From 4581027246bcf76a0a06fc9d9d1fe8230af24f3d Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Sun, 5 Jan 2020 17:51:09 -0800 Subject: [PATCH 12/94] Drop support for pdfminer.six 20181108 This version required a patch that has since been mainlined, and also did not declare its dependency on chardet correctly. We can remove both hacks now. --- setup.py | 3 +-- src/ocrmypdf/pdfinfo/layout.py | 48 ---------------------------------- 2 files changed, 1 insertion(+), 50 deletions(-) diff --git a/setup.py b/setup.py index d0e05c0d..eb0d783a 100644 --- a/setup.py +++ b/setup.py @@ -95,11 +95,10 @@ setup( use_scm_version={'version_scheme': 'post-release'}, cffi_modules=['src/ocrmypdf/lib/compile_leptonica.py:ffibuilder'], install_requires=[ - 'chardet >= 3.0.4, < 4', # unlisted requirement of pdfminer.six 20181108 'cffi >= 1.9.1', # must be a setup and install requirement 'coloredlogs >= 14.0', # strictly optional 'img2pdf >= 0.3.0, < 0.4', # pure Python, so track HEAD closely - 'pdfminer.six >= 20181108, <= 20200124', + 'pdfminer.six >= 20191110, <= 20200124', 'pikepdf >= 1.8.1, < 2', 'Pillow >= 6.2.0', 'reportlab >= 3.3.0', # oldest released version with sane image handling diff --git a/src/ocrmypdf/pdfinfo/layout.py b/src/ocrmypdf/pdfinfo/layout.py index af9d7961..f763a9eb 100644 --- a/src/ocrmypdf/pdfinfo/layout.py +++ b/src/ocrmypdf/pdfinfo/layout.py @@ -36,54 +36,6 @@ from ..exceptions import EncryptedPdfError STRIP_NAME = re.compile(r'[0-9]+') -# -# pdfminer 20181108 patches -# - -if pdfminer.__version__ == '20181108': - - def name2unicode(name): - """Fix pdfminer's name2unicode function - - Font cids that are mapped to names of the form /g123 seem to be, by convention - characters with no corresponding Unicode entry. These can be subsetted fonts - or symbolic fonts. There seems to be no way to map /g123 fonts to Unicode, - barring a ToUnicode data structure. - """ - if name in glyphname2unicode: - return glyphname2unicode[name] - if name.startswith('g') or name.startswith('a'): - raise KeyError(name) - if name.startswith('uni'): - try: - return chr(int(name[3:], 16)) - except ValueError: # Not hexadecimal - raise KeyError(name) - m = STRIP_NAME.search(name) - if not m: - raise KeyError(name) - return chr(int(m.group(0))) - - pdfminer.encodingdb.name2unicode = name2unicode - - original_PDFFont_init = PDFFont.__init__ - - def PDFFont__init__(self, descriptor, widths, default_width=None): - original_PDFFont_init(self, descriptor, widths, default_width) - # PDF spec says descent should be negative - # A font with a positive descent implies it floats entirely above the - # baseline, i.e. it's not really a baseline anymore. I have fonts that - # claim a positive descent, but treating descent as positive always seems - # to misposition text. - if self.descent > 0: - self.descent = -self.descent - - PDFFont.__init__ = PDFFont__init__ - -# -# end of pdfminer 20181108 patches -# - original_PDFSimpleFont_init = PDFSimpleFont.__init__ From 57771f06a32f4d540590956e20c6a540f8719ecc Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Thu, 16 Apr 2020 15:38:33 -0700 Subject: [PATCH 13/94] Refactor xy-pair for resolution to tuple --- src/ocrmypdf/_pipeline.py | 33 +++++++++++++---------------- src/ocrmypdf/exec/ghostscript.py | 13 ++++++------ src/ocrmypdf/pdfinfo/info.py | 36 +++++++++++++------------------- tests/test_ghostscript.py | 6 ++---- tests/test_main.py | 8 +++---- tests/test_optimize.py | 2 +- tests/test_pdfinfo.py | 10 ++++----- tests/test_preprocessing.py | 22 +++++++------------ tests/test_rotation.py | 3 +-- 9 files changed, 57 insertions(+), 76 deletions(-) diff --git a/src/ocrmypdf/_pipeline.py b/src/ocrmypdf/_pipeline.py index 034b831c..842b4f1a 100644 --- a/src/ocrmypdf/_pipeline.py +++ b/src/ocrmypdf/_pipeline.py @@ -201,12 +201,12 @@ def validate_pdfinfo_options(context): def get_page_dpi(pageinfo, options): "Get the DPI when nonsquare DPI is tolerable" xres = max( - pageinfo.xres or VECTOR_PAGE_DPI, + pageinfo.xyres[0] or VECTOR_PAGE_DPI, options.oversample or 0, VECTOR_PAGE_DPI if pageinfo.has_vector else 0, ) yres = max( - pageinfo.yres or VECTOR_PAGE_DPI, + pageinfo.xyres[1] or VECTOR_PAGE_DPI, options.oversample or 0, VECTOR_PAGE_DPI if pageinfo.has_vector else 0, ) @@ -215,8 +215,8 @@ def get_page_dpi(pageinfo, options): def get_page_square_dpi(pageinfo, options): "Get the DPI when we require xres == yres, scaled to physical units" - xres = pageinfo.xres or 0 - yres = pageinfo.yres or 0 + xres = pageinfo.xyres[0] or 0 + yres = pageinfo.xyres[1] or 0 userunit = pageinfo.userunit or 1 return float( max( @@ -232,8 +232,8 @@ def get_canvas_square_dpi(pageinfo, options): """Get the DPI when we require xres == yres, in Postscript units""" return float( max( - (pageinfo.xres) or VECTOR_PAGE_DPI, - (pageinfo.yres) or VECTOR_PAGE_DPI, + (pageinfo.xyres[0]) or VECTOR_PAGE_DPI, + (pageinfo.xyres[1]) or VECTOR_PAGE_DPI, VECTOR_PAGE_DPI if pageinfo.has_vector else 0, options.oversample or 0, ) @@ -321,9 +321,8 @@ def rasterize_preview(input_file, page_context): ghostscript.rasterize_pdf( input_file, output_file, - xres=canvas_dpi, - yres=canvas_dpi, raster_device='jpeggray', + xyres=(canvas_dpi, canvas_dpi), page_dpi=(page_dpi, page_dpi), pageno=page_context.pageinfo.pageno + 1, ) @@ -436,9 +435,8 @@ def rasterize( ghostscript.rasterize_pdf( input_file, output_file, - xres=canvas_dpi, - yres=canvas_dpi, raster_device=device, + xyres=(canvas_dpi, canvas_dpi), page_dpi=(page_dpi, page_dpi), pageno=pageinfo.pageno + 1, rotation=correction, @@ -488,8 +486,7 @@ def create_ocr_image(image, page_context): # pink = ImageColor.getcolor('#ff0080', im.mode) draw = ImageDraw.ImageDraw(im) - xres, yres = im.info['dpi'] - log.debug('resolution %r %r' % (xres, yres)) + log.debug('resolution %r', im.info['dpi']) if not options.force_ocr: # Do not mask text areas when forcing OCR, because we need to OCR @@ -505,12 +502,12 @@ def create_ocr_image(image, page_context): # without regard whatever resolution is in pageinfo (may differ or # be None) bbox = [float(v) for v in textarea] - xscale, yscale = float(xres) / 72.0, float(yres) / 72.0 + xyscale = tuple(float(coord) / 72.0 for coord in im.info['dpi']) pixcoords = [ - bbox[0] * xscale, - im.height - bbox[3] * yscale, - bbox[2] * xscale, - im.height - bbox[1] * yscale, + bbox[0] * xyscale[0], + im.height - bbox[3] * xyscale[1], + bbox[2] * xyscale[0], + im.height - bbox[1] * xyscale[1], ] pixcoords = [int(round(c)) for c in pixcoords] log.debug('blanking %r', pixcoords) @@ -524,7 +521,7 @@ def create_ocr_image(image, page_context): del draw # Pillow requires integer DPI - dpi = round(xres), round(yres) + dpi = tuple(round(coord) for coord in im.info['dpi']) im.save(output_file, dpi=dpi) return output_file diff --git a/src/ocrmypdf/exec/ghostscript.py b/src/ocrmypdf/exec/ghostscript.py index de4cbda2..d85ce801 100644 --- a/src/ocrmypdf/exec/ghostscript.py +++ b/src/ocrmypdf/exec/ghostscript.py @@ -135,32 +135,31 @@ def extract_text(input_file, pageno=1): def rasterize_pdf( input_file, output_file, - xres, - yres, + *, raster_device, + xyres, pageno=1, page_dpi=None, rotation=None, filter_vector=False, ): - """Rasterize one page of a PDF at resolution (xres, yres) in canvas units. + """Rasterize one page of a PDF at resolution xyres in canvas units. The image is sized to match the integer pixels dimensions implied by - (xres, yres) even if those numbers are noninteger. The image's DPI will + (xyres[0], xyres[1]) even if those numbers are noninteger. The image's DPI will be overridden with the values in page_dpi. :param input_file: pathlike :param output_file: pathlike - :param xres: resolution at which to rasterize page - :param yres: :param raster_device: + :param xyres: resolution at which to rasterize page :param pageno: page number to rasterize (beginning at page 1) :param page_dpi: resolution tuple (x, y) overriding output image DPI :param rotation: 0, 90, 180, 270: clockwise angle to rotate page :param filter_vector: if True, remove vector graphics objects :return: """ - res = round(xres, 6), round(yres, 6) + res = round(xyres[0], 6), round(xyres[1], 6) if not page_dpi: page_dpi = res diff --git a/src/ocrmypdf/pdfinfo/info.py b/src/ocrmypdf/pdfinfo/info.py index 329661b3..bb9605e1 100644 --- a/src/ocrmypdf/pdfinfo/info.py +++ b/src/ocrmypdf/pdfinfo/info.py @@ -356,12 +356,11 @@ class ImageInfo: return self._enc @property - def xres(self): - return _get_dpi(self._shorthand, (self._width, self._height))[0] - - @property - def yres(self): - return _get_dpi(self._shorthand, (self._width, self._height))[1] + def xyres(self): + return ( + _get_dpi(self._shorthand, (self._width, self._height))[0], + _get_dpi(self._shorthand, (self._width, self._height))[1], + ) def __repr__(self): class_locals = { @@ -371,7 +370,7 @@ class ImageInfo: } return ( "" + "{comp} {bpc} {enc} {xyres}>" ).format(**class_locals) @@ -607,9 +606,9 @@ def _pdf_get_pageinfo(pdf, pageno: int, infile: PathLike, xmltext: str): pageinfo['images'] = [im for im in contentsinfo if isinstance(im, ImageInfo)] if pageinfo['images']: - xres = Decimal(max(image.xres for image in pageinfo['images'])) - yres = Decimal(max(image.yres for image in pageinfo['images'])) - pageinfo['xres'], pageinfo['yres'] = xres, yres + xres = Decimal(max(image.xyres[0] for image in pageinfo['images'])) + yres = Decimal(max(image.xyres[1] for image in pageinfo['images'])) + pageinfo['xyres'] = xres, yres pageinfo['width_pixels'] = int(round(xres * pageinfo['width_inches'])) pageinfo['height_pixels'] = int(round(yres * pageinfo['height_inches'])) @@ -679,11 +678,11 @@ class PageInfo: @property def width_pixels(self): - return int(round(self.width_inches * self.xres)) + return int(round(self.width_inches * self.xyres[0])) @property def height_pixels(self): - return int(round(self.height_inches * self.yres)) + return int(round(self.height_inches * self.xyres[1])) @property def rotation(self): @@ -723,12 +722,8 @@ class PageInfo: ) @property - def xres(self): - return self._pageinfo.get('xres', None) - - @property - def yres(self): - return self._pageinfo.get('yres', None) + def xyres(self): + return self._pageinfo.get('xyres', (0, 0)) @property def userunit(self): @@ -743,14 +738,13 @@ class PageInfo: def __repr__(self): return ( - '' + '' ).format( self.pageno, self.width_inches, self.height_inches, self.rotation, - self.xres, - self.yres, + self.xyres, self.has_text, ) diff --git a/tests/test_ghostscript.py b/tests/test_ghostscript.py index 5bb7b76f..2e1543ea 100644 --- a/tests/test_ghostscript.py +++ b/tests/test_ghostscript.py @@ -76,9 +76,8 @@ def test_rasterize_size(francais, outdir, caplog): rasterize_pdf( path, outdir / 'out.png', - target_size[0] / page_size[0], - target_size[1] / page_size[1], raster_device='pngmono', + xyres=(target_size[0] / page_size[0], target_size[1] / page_size[1]), page_dpi=forced_dpi, ) @@ -99,9 +98,8 @@ def test_rasterize_rotated(francais, outdir, caplog): rasterize_pdf( path, outdir / 'out.png', - target_size[0] / page_size[0], - target_size[1] / page_size[1], raster_device='pngmono', + xyres=(target_size[0] / page_size[0], target_size[1] / page_size[1]), page_dpi=forced_dpi, rotation=90, ) diff --git a/tests/test_main.py b/tests/test_main.py index 2f843536..0583abfc 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -74,8 +74,8 @@ def test_oversample(spoof_tesseract_cache, renderer, resources, outpdf): pdfinfo = PdfInfo(oversampled_pdf) - print(pdfinfo[0].xres) - assert abs(pdfinfo[0].xres - 350) < 1 + print(pdfinfo[0].xyres[0]) + assert abs(pdfinfo[0].xyres[0] - 350) < 1 def test_repeat_ocr(resources, no_outpdf): @@ -393,8 +393,8 @@ def test_very_high_dpi(spoof_tesseract_cache, resources, outpdf): pdfinfo = PdfInfo(outpdf) image = pdfinfo[0].images[0] - assert isclose(image.xres, image.yres) - assert isclose(image.xres, 2400) + assert isclose(image.xyres[0], image.xyres[1]) + assert isclose(image.xyres[0], 2400) def test_overlay(spoof_tesseract_noop, resources, outpdf): diff --git a/tests/test_optimize.py b/tests/test_optimize.py index 6cdc1c71..5f198143 100644 --- a/tests/test_optimize.py +++ b/tests/test_optimize.py @@ -43,7 +43,7 @@ def test_mono_not_inverted(resources, outdir): opt.main(infile, outdir / 'out.pdf', level=3) rasterize_pdf( - outdir / 'out.pdf', outdir / 'im.png', xres=10, yres=10, raster_device='pnggray' + outdir / 'out.pdf', outdir / 'im.png', raster_device='pnggray', xyres=(10, 10) ) with Image.open(fspath(outdir / 'im.png')) as im: diff --git a/tests/test_pdfinfo.py b/tests/test_pdfinfo.py index facf3b6f..40f49c94 100644 --- a/tests/test_pdfinfo.py +++ b/tests/test_pdfinfo.py @@ -85,8 +85,8 @@ def test_single_page_image(outdir): assert pdfimage.color == Colorspace.gray # DPI in a 1"x1" is the image width - assert isclose(pdfimage.xres, 8) - assert isclose(pdfimage.yres, 8) + assert isclose(pdfimage.xyres[0], 8) + assert isclose(pdfimage.xyres[1], 8) def test_single_page_inline_image(outdir): @@ -105,7 +105,7 @@ def test_single_page_inline_image(outdir): info = pdfinfo.PdfInfo(filename) print(info) pdfimage = info[0].images[0] - assert isclose(pdfimage.xres, 8) + assert isclose(pdfimage.xyres[0], 8) assert pdfimage.color == Colorspace.gray assert pdfimage.width == 8 @@ -117,7 +117,7 @@ def test_jpeg(resources, outdir): pdfimage = pdf[0].images[0] assert pdfimage.enc == Encoding.jpeg - assert isclose(pdfimage.xres, 150) + assert isclose(pdfimage.xyres[0], 150) def test_form_xobject(resources): @@ -139,7 +139,7 @@ def test_no_contents(resources): def test_oversized_page(resources): pdf = pdfinfo.PdfInfo(resources / 'poster.pdf') image = pdf[0].images[0] - assert image.width * image.xres > 200, "this is supposed to be oversized" + assert image.width * image.xyres[0] > 200, "this is supposed to be oversized" def test_pickle(resources): diff --git a/tests/test_preprocessing.py b/tests/test_preprocessing.py index 50eb6b56..00e4aeda 100644 --- a/tests/test_preprocessing.py +++ b/tests/test_preprocessing.py @@ -50,12 +50,7 @@ def test_deskew(spoof_tesseract_noop, resources, outdir): deskewed_png = outdir / 'deskewed.png' ghostscript.rasterize_pdf( - deskewed_pdf, - deskewed_png, - xres=150, - yres=150, - raster_device='pngmono', - pageno=1, + deskewed_pdf, deskewed_png, raster_device='pngmono', xyres=(150, 150), pageno=1 ) pix = Pix.open(deskewed_png) @@ -82,7 +77,7 @@ def test_remove_background(spoof_tesseract_noop, resources, outdir): output_png = outdir / 'remove_bg.png' ghostscript.rasterize_pdf( - output_pdf, output_png, xres=100, yres=100, raster_device='png16m', pageno=1 + output_pdf, output_png, raster_device='png16m', xyres=(100, 100), pageno=1 ) # The output image should contain pure white and black @@ -122,7 +117,7 @@ def test_exotic_image( def test_non_square_resolution(renderer, spoof_tesseract_cache, resources, outpdf): # Confirm input image is non-square resolution in_pageinfo = PdfInfo(resources / 'aspect.pdf') - assert in_pageinfo[0].xres != in_pageinfo[0].yres + assert in_pageinfo[0].xyres[0] != in_pageinfo[0].xyres[1] check_ocrmypdf( resources / 'aspect.pdf', @@ -135,8 +130,7 @@ def test_non_square_resolution(renderer, spoof_tesseract_cache, resources, outpd out_pageinfo = PdfInfo(outpdf) # Confirm resolution was kept the same - assert in_pageinfo[0].xres == out_pageinfo[0].xres - assert in_pageinfo[0].yres == out_pageinfo[0].yres + assert in_pageinfo[0].xyres == out_pageinfo[0].xyres @pytest.mark.parametrize('renderer', RENDERERS) @@ -145,7 +139,7 @@ def test_convert_to_square_resolution( ): # Confirm input image is non-square resolution in_pageinfo = PdfInfo(resources / 'aspect.pdf') - assert in_pageinfo[0].xres != in_pageinfo[0].yres + assert in_pageinfo[0].xyres[0] != in_pageinfo[0].xyres[1] # --force-ocr requires means forced conversion to square resolution check_ocrmypdf( @@ -162,7 +156,7 @@ def test_convert_to_square_resolution( in_p0, out_p0 = in_pageinfo[0], out_pageinfo[0] # Resolution show now be equal - assert out_p0.xres == out_p0.yres + assert out_p0.xyres[0] == out_p0.xyres[1] # Page size should match input page size assert isclose(in_p0.width_inches, out_p0.width_inches) @@ -170,7 +164,7 @@ def test_convert_to_square_resolution( # Because we rasterized the page to produce a new image, it should occupy # the entire page - out_im_w = out_p0.images[0].width / out_p0.images[0].xres - out_im_h = out_p0.images[0].height / out_p0.images[0].yres + out_im_w = out_p0.images[0].width / out_p0.images[0].xyres[0] + out_im_h = out_p0.images[0].height / out_p0.images[0].xyres[1] assert isclose(out_p0.width_inches, out_im_w) assert isclose(out_p0.height_inches, out_im_h) diff --git a/tests/test_rotation.py b/tests/test_rotation.py index 796c2bfb..101ee8e7 100644 --- a/tests/test_rotation.py +++ b/tests/test_rotation.py @@ -58,9 +58,8 @@ def check_monochrome_correlation( ghostscript.rasterize_pdf( pdf, png, - xres=100, - yres=100, raster_device='pngmono', + xyres=(100, 100), pageno=pageno, rotation=0, ) From 94c52a6fa3d92f7a5f85af6f1fecdd7ae1e76310 Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Fri, 24 Apr 2020 04:12:05 -0700 Subject: [PATCH 14/94] Refactor 'xyres' into Resolution --- src/ocrmypdf/_pipeline.py | 70 +++++++++++++++++--------------- src/ocrmypdf/exec/ghostscript.py | 48 +++++++++++----------- src/ocrmypdf/helpers.py | 34 ++++++++++++++++ src/ocrmypdf/pdfinfo/info.py | 40 ++++++++---------- tests/test_ghostscript.py | 13 ++++-- tests/test_main.py | 8 ++-- tests/test_optimize.py | 6 ++- tests/test_pdfinfo.py | 10 ++--- tests/test_preprocessing.py | 25 ++++++++---- tests/test_rotation.py | 3 +- 10 files changed, 153 insertions(+), 104 deletions(-) diff --git a/src/ocrmypdf/_pipeline.py b/src/ocrmypdf/_pipeline.py index 842b4f1a..bb2f7e18 100644 --- a/src/ocrmypdf/_pipeline.py +++ b/src/ocrmypdf/_pipeline.py @@ -39,7 +39,7 @@ from .exceptions import ( UnsupportedImageFormatError, ) from .exec import ghostscript, tesseract -from .helpers import safe_symlink +from .helpers import Resolution, safe_symlink from .hocrtransform import HocrTransform from .optimize import optimize from .pdfa import generate_pdfa_ps @@ -99,7 +99,7 @@ def triage_image_file(input_file, output_file, options): layout_fun = img2pdf.default_layout_fun if options.image_dpi: layout_fun = img2pdf.get_fixed_dpi_layout_fun( - (options.image_dpi, options.image_dpi) + Resolution(options.image_dpi, options.image_dpi) ) with open(output_file, 'wb') as outf: img2pdf.convert( @@ -201,43 +201,45 @@ def validate_pdfinfo_options(context): def get_page_dpi(pageinfo, options): "Get the DPI when nonsquare DPI is tolerable" xres = max( - pageinfo.xyres[0] or VECTOR_PAGE_DPI, - options.oversample or 0, - VECTOR_PAGE_DPI if pageinfo.has_vector else 0, + pageinfo.dpi.x or VECTOR_PAGE_DPI, + options.oversample or 0.0, + VECTOR_PAGE_DPI if pageinfo.has_vector else 0.0, ) yres = max( - pageinfo.xyres[1] or VECTOR_PAGE_DPI, + pageinfo.dpi.y or VECTOR_PAGE_DPI, options.oversample or 0, - VECTOR_PAGE_DPI if pageinfo.has_vector else 0, + VECTOR_PAGE_DPI if pageinfo.has_vector else 0.0, ) - return (float(xres), float(yres)) + return Resolution(float(xres), float(yres)) -def get_page_square_dpi(pageinfo, options): +def get_page_square_dpi(pageinfo, options) -> Resolution: "Get the DPI when we require xres == yres, scaled to physical units" - xres = pageinfo.xyres[0] or 0 - yres = pageinfo.xyres[1] or 0 - userunit = pageinfo.userunit or 1 - return float( + xres = pageinfo.dpi.x or 0.0 + yres = pageinfo.dpi.y or 0.0 + userunit = float(pageinfo.userunit) or 1.0 + units = float( max( (xres * userunit) or VECTOR_PAGE_DPI, (yres * userunit) or VECTOR_PAGE_DPI, - VECTOR_PAGE_DPI if pageinfo.has_vector else 0, - options.oversample or 0, + VECTOR_PAGE_DPI if pageinfo.has_vector else 0.0, + options.oversample or 0.0, ) ) + return Resolution(units, units) -def get_canvas_square_dpi(pageinfo, options): +def get_canvas_square_dpi(pageinfo, options) -> Resolution: """Get the DPI when we require xres == yres, in Postscript units""" - return float( + units = float( max( - (pageinfo.xyres[0]) or VECTOR_PAGE_DPI, - (pageinfo.xyres[1]) or VECTOR_PAGE_DPI, - VECTOR_PAGE_DPI if pageinfo.has_vector else 0, - options.oversample or 0, + (pageinfo.dpi.x) or VECTOR_PAGE_DPI, + (pageinfo.dpi.y) or VECTOR_PAGE_DPI, + VECTOR_PAGE_DPI if pageinfo.has_vector else 0.0, + options.oversample or 0.0, ) ) + return Resolution(units, units) def is_ocr_required(page_context): @@ -322,8 +324,8 @@ def rasterize_preview(input_file, page_context): input_file, output_file, raster_device='jpeggray', - xyres=(canvas_dpi, canvas_dpi), - page_dpi=(page_dpi, page_dpi), + raster_dpi=canvas_dpi, + page_dpi=page_dpi, pageno=page_context.pageinfo.pageno + 1, ) return output_file @@ -436,8 +438,8 @@ def rasterize( input_file, output_file, raster_device=device, - xyres=(canvas_dpi, canvas_dpi), - page_dpi=(page_dpi, page_dpi), + raster_dpi=canvas_dpi, + page_dpi=page_dpi, pageno=pageinfo.pageno + 1, rotation=correction, filter_vector=remove_vectors, @@ -458,7 +460,7 @@ def preprocess_remove_background(input_file, page_context): def preprocess_deskew(input_file, page_context): output_file = page_context.get_path('pp_deskew.png') dpi = get_page_square_dpi(page_context.pageinfo, page_context.options) - leptonica.deskew(input_file, output_file, dpi) + leptonica.deskew(input_file, output_file, dpi.x) return output_file @@ -467,7 +469,7 @@ def preprocess_clean(input_file, page_context): output_file = page_context.get_path('pp_clean.png') dpi = get_page_square_dpi(page_context.pageinfo, page_context.options) - unpaper.clean(input_file, output_file, dpi, page_context.options.unpaper_args) + unpaper.clean(input_file, output_file, dpi.x, page_context.options.unpaper_args) return output_file @@ -558,12 +560,14 @@ def create_visible_page_jpg(image, page_context): # square DPI used to rasterize. When the preview image was # rasterized, it was also converted to square resolution, which is # what we want to give tesseract, so keep it square. - fallback_dpi = get_page_square_dpi(page_context.pageinfo, page_context.options) - dpi = im.info.get('dpi', (fallback_dpi, fallback_dpi)) + if 'dpi' in im.info: + dpi = Resolution(*im.info['dpi']) + else: + # Fallback to page-implied DPI + dpi = get_page_square_dpi(page_context.pageinfo, page_context.options) # Pillow requires integer DPI - dpi = round(dpi[0]), round(dpi[1]) - im.save(output_file, format='JPEG', dpi=dpi) + im.save(output_file, format='JPEG', dpi=dpi.to_int()) return output_file @@ -576,7 +580,7 @@ def create_pdf_page_from_image(image, page_context): # sandwich renderer would be fine. output_file = page_context.get_path('visible.pdf') dpi = get_page_square_dpi(page_context.pageinfo, page_context.options) - layout_fun = img2pdf.get_fixed_dpi_layout_fun((dpi, dpi)) + layout_fun = img2pdf.get_fixed_dpi_layout_fun(dpi) # This create a single page PDF with open(image, 'rb') as imfile, open(output_file, 'wb') as pdf: @@ -591,7 +595,7 @@ def create_pdf_page_from_image(image, page_context): def render_hocr_page(hocr, page_context): output_file = page_context.get_path('ocr_hocr.pdf') dpi = get_page_square_dpi(page_context.pageinfo, page_context.options) - hocrtransform = HocrTransform(hocr, dpi) + hocrtransform = HocrTransform(hocr, dpi.x) # square hocrtransform.to_pdf( output_file, image_filename=None, diff --git a/src/ocrmypdf/exec/ghostscript.py b/src/ocrmypdf/exec/ghostscript.py index d85ce801..5c27488f 100644 --- a/src/ocrmypdf/exec/ghostscript.py +++ b/src/ocrmypdf/exec/ghostscript.py @@ -21,7 +21,6 @@ import logging import os import re import warnings -from contextlib import suppress from functools import lru_cache from io import BytesIO from os import fspath @@ -31,8 +30,9 @@ from subprocess import PIPE, CalledProcessError from PIL import Image -from ..exceptions import MissingDependencyError, SubprocessOutputError -from . import get_version, run +from ocrmypdf.exceptions import MissingDependencyError, SubprocessOutputError +from ocrmypdf.exec import get_version, run +from ocrmypdf.helpers import Resolution log = logging.getLogger(__name__) @@ -62,7 +62,7 @@ def version(): return get_version(GS) -def jpeg_passthrough_available(): +def jpeg_passthrough_available() -> bool: """Returns True if the installed version of Ghostscript supports JPEG passthru Prior to 9.23, Ghostscript decode and re-encoded JPEGs internally. In 9.23 @@ -79,7 +79,7 @@ def jpeg_passthrough_available(): return version() >= '9.24' -def _gs_error_reported(stream): +def _gs_error_reported(stream) -> bool: return re.search(r'error', stream, flags=re.IGNORECASE) @@ -133,35 +133,35 @@ def extract_text(input_file, pageno=1): def rasterize_pdf( - input_file, - output_file, + input_file: os.PathLike, + output_file: os.PathLike, *, - raster_device, - xyres, - pageno=1, - page_dpi=None, - rotation=None, - filter_vector=False, + raster_device: str, + raster_dpi: Resolution, + pageno: int = 1, + page_dpi: Resolution = None, + rotation: int = None, + filter_vector: bool = False, ): - """Rasterize one page of a PDF at resolution xyres in canvas units. + """Rasterize one page of a PDF at resolution raster_dpi in canvas units. The image is sized to match the integer pixels dimensions implied by - (xyres[0], xyres[1]) even if those numbers are noninteger. The image's DPI will + raster_dpi even if those numbers are noninteger. The image's DPI will be overridden with the values in page_dpi. :param input_file: pathlike :param output_file: pathlike :param raster_device: - :param xyres: resolution at which to rasterize page + :param raster_dpi: resolution at which to rasterize page :param pageno: page number to rasterize (beginning at page 1) :param page_dpi: resolution tuple (x, y) overriding output image DPI :param rotation: 0, 90, 180, 270: clockwise angle to rotate page :param filter_vector: if True, remove vector graphics objects :return: """ - res = round(xyres[0], 6), round(xyres[1], 6) + raster_dpi = raster_dpi.round(6) if not page_dpi: - page_dpi = res + page_dpi = raster_dpi args_gs = ( [ @@ -173,7 +173,7 @@ def rasterize_pdf( f'-sDEVICE={raster_device}', f'-dFirstPage={pageno}', f'-dLastPage={pageno}', - f'-r{res[0]:f}x{res[1]:f}', + f'-r{raster_dpi.x:f}x{raster_dpi.y:f}', ] + (['-dFILTERVECTOR'] if filter_vector else []) + [ @@ -210,17 +210,17 @@ def rasterize_pdf( elif rotation == 270: im = im.transpose(Image.ROTATE_270) if rotation % 180 == 90: - page_dpi = page_dpi[1], page_dpi[0] + page_dpi = page_dpi.flip_axis() im.save(fspath(output_file), dpi=page_dpi) def generate_pdfa( pdf_pages, - output_file, - compression, + output_file: os.PathLike, + compression: str, threads=None, # deprecated parameter - pdf_version='1.5', - pdfa_part='2', + pdf_version: str = '1.5', + pdfa_part: str = '2', ): """Generate a PDF/A. diff --git a/src/ocrmypdf/helpers.py b/src/ocrmypdf/helpers.py index 55707082..c57aca35 100644 --- a/src/ocrmypdf/helpers.py +++ b/src/ocrmypdf/helpers.py @@ -20,14 +20,48 @@ import multiprocessing import os import shutil import warnings +from collections import namedtuple from collections.abc import Iterable from contextlib import suppress from functools import wraps +from math import inf, isclose from pathlib import Path log = logging.getLogger(__name__) +class Resolution(namedtuple('Resolution', ('x', 'y'))): + __slots__ = () + + def round(self, ndigits): + return Resolution(round(self.x, ndigits), round(self.y, ndigits)) + + def to_int(self): + return Resolution(int(round(self.x)), int(round(self.y))) + + @property + def is_square(self): + return isclose(self.x, self.y, rel_tol=1e-3) + + def take_max(self, vals, yvals=None): + if yvals is not None: + return Resolution(max(self.x, *vals), max(self.y, *yvals)) + max_x, max_y = self.x, self.y + for x, y in vals: + max_x = max(x, max_x) + max_y = max(y, max_y) + return Resolution(max_x, max_y) + + def flip_axis(self): + return Resolution(self.y, self.x) + + def __str__(self): + return f"{self.x:f}x{self.y:f}" + + def __repr__(self): + return f"Resolution({self.x}x{self.y} dpi)" + + def safe_symlink(input_file: os.PathLike, soft_link_name: os.PathLike, *args, **kwargs): """ Helper function: relinks soft symbolic link if necessary diff --git a/src/ocrmypdf/pdfinfo/info.py b/src/ocrmypdf/pdfinfo/info.py index bb9605e1..565ed745 100644 --- a/src/ocrmypdf/pdfinfo/info.py +++ b/src/ocrmypdf/pdfinfo/info.py @@ -32,6 +32,7 @@ from tqdm import tqdm from ocrmypdf.exceptions import EncryptedPdfError from ocrmypdf.exec import ghostscript +from ocrmypdf.helpers import Resolution from ocrmypdf.pdfinfo import ghosttext from ocrmypdf.pdfinfo.layout import get_page_analysis, get_text_boxes @@ -265,7 +266,7 @@ def _get_dpi(ctm_shorthand, image_size): dpi_w = scale_w * 72.0 dpi_h = scale_h * 72.0 - return dpi_w, dpi_h + return Resolution(dpi_w, dpi_h) class ImageInfo: @@ -356,11 +357,8 @@ class ImageInfo: return self._enc @property - def xyres(self): - return ( - _get_dpi(self._shorthand, (self._width, self._height))[0], - _get_dpi(self._shorthand, (self._width, self._height))[1], - ) + def dpi(self): + return _get_dpi(self._shorthand, (self._width, self._height)) def __repr__(self): class_locals = { @@ -370,7 +368,7 @@ class ImageInfo: } return ( "" + "{comp} {bpc} {enc} {dpi}>" ).format(**class_locals) @@ -606,11 +604,10 @@ def _pdf_get_pageinfo(pdf, pageno: int, infile: PathLike, xmltext: str): pageinfo['images'] = [im for im in contentsinfo if isinstance(im, ImageInfo)] if pageinfo['images']: - xres = Decimal(max(image.xyres[0] for image in pageinfo['images'])) - yres = Decimal(max(image.xyres[1] for image in pageinfo['images'])) - pageinfo['xyres'] = xres, yres - pageinfo['width_pixels'] = int(round(xres * pageinfo['width_inches'])) - pageinfo['height_pixels'] = int(round(yres * pageinfo['height_inches'])) + dpi = Resolution(0.0, 0.0).take_max(image.dpi for image in pageinfo['images']) + pageinfo['dpi'] = dpi + pageinfo['width_pixels'] = int(round(dpi.x * float(pageinfo['width_inches']))) + pageinfo['height_pixels'] = int(round(dpi.y * float(pageinfo['height_inches']))) return pageinfo @@ -678,11 +675,11 @@ class PageInfo: @property def width_pixels(self): - return int(round(self.width_inches * self.xyres[0])) + return int(round(float(self.width_inches) * self.dpi.x)) @property def height_pixels(self): - return int(round(self.height_inches * self.xyres[1])) + return int(round(float(self.height_inches) * self.dpi.y)) @property def rotation(self): @@ -722,8 +719,8 @@ class PageInfo: ) @property - def xyres(self): - return self._pageinfo.get('xyres', (0, 0)) + def dpi(self): + return self._pageinfo.get('dpi', Resolution(0.0, 0.0)) @property def userunit(self): @@ -738,14 +735,9 @@ class PageInfo: def __repr__(self): return ( - '' - ).format( - self.pageno, - self.width_inches, - self.height_inches, - self.rotation, - self.xyres, - self.has_text, + f'' ) diff --git a/tests/test_ghostscript.py b/tests/test_ghostscript.py index 2e1543ea..da04ea84 100644 --- a/tests/test_ghostscript.py +++ b/tests/test_ghostscript.py @@ -24,6 +24,7 @@ from PIL import Image from ocrmypdf.exceptions import ExitCode from ocrmypdf.exec.ghostscript import rasterize_pdf +from ocrmypdf.helpers import Resolution check_ocrmypdf = pytest.helpers.check_ocrmypdf run_ocrmypdf = pytest.helpers.run_ocrmypdf @@ -71,13 +72,15 @@ def test_rasterize_size(francais, outdir, caplog): assert pdf.pages[0].MediaBox[0] == pdf.pages[0].MediaBox[1] == 0 page_size = (page_size_pts[0] / Decimal(72), page_size_pts[1] / Decimal(72)) target_size = Decimal('50.0'), Decimal('30.0') - forced_dpi = 42.0, 4242.0 + forced_dpi = Resolution(42.0, 4242.0) rasterize_pdf( path, outdir / 'out.png', raster_device='pngmono', - xyres=(target_size[0] / page_size[0], target_size[1] / page_size[1]), + raster_dpi=Resolution( + target_size[0] / page_size[0], target_size[1] / page_size[1] + ), page_dpi=forced_dpi, ) @@ -92,14 +95,16 @@ def test_rasterize_rotated(francais, outdir, caplog): assert pdf.pages[0].MediaBox[0] == pdf.pages[0].MediaBox[1] == 0 page_size = (page_size_pts[0] / Decimal(72), page_size_pts[1] / Decimal(72)) target_size = Decimal('50.0'), Decimal('30.0') - forced_dpi = 42.0, 4242.0 + forced_dpi = Resolution(42.0, 4242.0) caplog.set_level(logging.DEBUG) rasterize_pdf( path, outdir / 'out.png', raster_device='pngmono', - xyres=(target_size[0] / page_size[0], target_size[1] / page_size[1]), + raster_dpi=Resolution( + target_size[0] / page_size[0], target_size[1] / page_size[1] + ), page_dpi=forced_dpi, rotation=90, ) diff --git a/tests/test_main.py b/tests/test_main.py index 0583abfc..d4146ccf 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -74,8 +74,8 @@ def test_oversample(spoof_tesseract_cache, renderer, resources, outpdf): pdfinfo = PdfInfo(oversampled_pdf) - print(pdfinfo[0].xyres[0]) - assert abs(pdfinfo[0].xyres[0] - 350) < 1 + print(pdfinfo[0].dpi.x) + assert abs(pdfinfo[0].dpi.x - 350) < 1 def test_repeat_ocr(resources, no_outpdf): @@ -393,8 +393,8 @@ def test_very_high_dpi(spoof_tesseract_cache, resources, outpdf): pdfinfo = PdfInfo(outpdf) image = pdfinfo[0].images[0] - assert isclose(image.xyres[0], image.xyres[1]) - assert isclose(image.xyres[0], 2400) + assert isclose(image.dpi.x, image.dpi.y) + assert isclose(image.dpi.x, 2400) def test_overlay(spoof_tesseract_noop, resources, outpdf): diff --git a/tests/test_optimize.py b/tests/test_optimize.py index 5f198143..36e00c5e 100644 --- a/tests/test_optimize.py +++ b/tests/test_optimize.py @@ -26,6 +26,7 @@ from PIL import Image from ocrmypdf import optimize as opt from ocrmypdf.exec import jbig2enc, pngquant from ocrmypdf.exec.ghostscript import rasterize_pdf +from ocrmypdf.helpers import Resolution check_ocrmypdf = pytest.helpers.check_ocrmypdf # pylint: disable=e1101 @@ -43,7 +44,10 @@ def test_mono_not_inverted(resources, outdir): opt.main(infile, outdir / 'out.pdf', level=3) rasterize_pdf( - outdir / 'out.pdf', outdir / 'im.png', raster_device='pnggray', xyres=(10, 10) + outdir / 'out.pdf', + outdir / 'im.png', + raster_device='pnggray', + raster_dpi=Resolution(10, 10), ) with Image.open(fspath(outdir / 'im.png')) as im: diff --git a/tests/test_pdfinfo.py b/tests/test_pdfinfo.py index 40f49c94..13fb8a8b 100644 --- a/tests/test_pdfinfo.py +++ b/tests/test_pdfinfo.py @@ -85,8 +85,8 @@ def test_single_page_image(outdir): assert pdfimage.color == Colorspace.gray # DPI in a 1"x1" is the image width - assert isclose(pdfimage.xyres[0], 8) - assert isclose(pdfimage.xyres[1], 8) + assert isclose(pdfimage.dpi.x, 8) + assert isclose(pdfimage.dpi.y, 8) def test_single_page_inline_image(outdir): @@ -105,7 +105,7 @@ def test_single_page_inline_image(outdir): info = pdfinfo.PdfInfo(filename) print(info) pdfimage = info[0].images[0] - assert isclose(pdfimage.xyres[0], 8) + assert isclose(pdfimage.dpi.x, 8) assert pdfimage.color == Colorspace.gray assert pdfimage.width == 8 @@ -117,7 +117,7 @@ def test_jpeg(resources, outdir): pdfimage = pdf[0].images[0] assert pdfimage.enc == Encoding.jpeg - assert isclose(pdfimage.xyres[0], 150) + assert isclose(pdfimage.dpi.x, 150) def test_form_xobject(resources): @@ -139,7 +139,7 @@ def test_no_contents(resources): def test_oversized_page(resources): pdf = pdfinfo.PdfInfo(resources / 'poster.pdf') image = pdf[0].images[0] - assert image.width * image.xyres[0] > 200, "this is supposed to be oversized" + assert image.width * image.dpi.x > 200, "this is supposed to be oversized" def test_pickle(resources): diff --git a/tests/test_preprocessing.py b/tests/test_preprocessing.py index 00e4aeda..b90517eb 100644 --- a/tests/test_preprocessing.py +++ b/tests/test_preprocessing.py @@ -22,6 +22,7 @@ import pytest from PIL import Image from ocrmypdf.exec import ghostscript +from ocrmypdf.helpers import Resolution from ocrmypdf.leptonica import Pix from ocrmypdf.pdfinfo import PdfInfo @@ -50,7 +51,11 @@ def test_deskew(spoof_tesseract_noop, resources, outdir): deskewed_png = outdir / 'deskewed.png' ghostscript.rasterize_pdf( - deskewed_pdf, deskewed_png, raster_device='pngmono', xyres=(150, 150), pageno=1 + deskewed_pdf, + deskewed_png, + raster_device='pngmono', + raster_dpi=Resolution(150, 150), + pageno=1, ) pix = Pix.open(deskewed_png) @@ -77,7 +82,11 @@ def test_remove_background(spoof_tesseract_noop, resources, outdir): output_png = outdir / 'remove_bg.png' ghostscript.rasterize_pdf( - output_pdf, output_png, raster_device='png16m', xyres=(100, 100), pageno=1 + output_pdf, + output_png, + raster_device='png16m', + raster_dpi=Resolution(100, 100), + pageno=1, ) # The output image should contain pure white and black @@ -117,7 +126,7 @@ def test_exotic_image( def test_non_square_resolution(renderer, spoof_tesseract_cache, resources, outpdf): # Confirm input image is non-square resolution in_pageinfo = PdfInfo(resources / 'aspect.pdf') - assert in_pageinfo[0].xyres[0] != in_pageinfo[0].xyres[1] + assert in_pageinfo[0].dpi.x != in_pageinfo[0].dpi.y check_ocrmypdf( resources / 'aspect.pdf', @@ -130,7 +139,7 @@ def test_non_square_resolution(renderer, spoof_tesseract_cache, resources, outpd out_pageinfo = PdfInfo(outpdf) # Confirm resolution was kept the same - assert in_pageinfo[0].xyres == out_pageinfo[0].xyres + assert in_pageinfo[0].dpi == out_pageinfo[0].dpi @pytest.mark.parametrize('renderer', RENDERERS) @@ -139,7 +148,7 @@ def test_convert_to_square_resolution( ): # Confirm input image is non-square resolution in_pageinfo = PdfInfo(resources / 'aspect.pdf') - assert in_pageinfo[0].xyres[0] != in_pageinfo[0].xyres[1] + assert in_pageinfo[0].dpi.x != in_pageinfo[0].dpi.y # --force-ocr requires means forced conversion to square resolution check_ocrmypdf( @@ -156,7 +165,7 @@ def test_convert_to_square_resolution( in_p0, out_p0 = in_pageinfo[0], out_pageinfo[0] # Resolution show now be equal - assert out_p0.xyres[0] == out_p0.xyres[1] + assert out_p0.dpi.x == out_p0.dpi.y # Page size should match input page size assert isclose(in_p0.width_inches, out_p0.width_inches) @@ -164,7 +173,7 @@ def test_convert_to_square_resolution( # Because we rasterized the page to produce a new image, it should occupy # the entire page - out_im_w = out_p0.images[0].width / out_p0.images[0].xyres[0] - out_im_h = out_p0.images[0].height / out_p0.images[0].xyres[1] + out_im_w = out_p0.images[0].width / out_p0.images[0].dpi.x + out_im_h = out_p0.images[0].height / out_p0.images[0].dpi.y assert isclose(out_p0.width_inches, out_im_w) assert isclose(out_p0.height_inches, out_im_h) diff --git a/tests/test_rotation.py b/tests/test_rotation.py index 101ee8e7..4ffa59f8 100644 --- a/tests/test_rotation.py +++ b/tests/test_rotation.py @@ -27,6 +27,7 @@ from PIL import Image from ocrmypdf import leptonica from ocrmypdf.exec import ghostscript, tesseract +from ocrmypdf.helpers import Resolution from ocrmypdf.pdfinfo import PdfInfo # pytest.helpers is dynamic @@ -59,7 +60,7 @@ def check_monochrome_correlation( pdf, png, raster_device='pngmono', - xyres=(100, 100), + raster_dpi=Resolution(100, 100), pageno=pageno, rotation=0, ) From d0d0a98dca15e188d8967e725846028158be8898 Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Sun, 5 Apr 2020 04:22:51 -0700 Subject: [PATCH 15/94] First cut at concurrent page scan Improvement appears on 168 page file. Needs refactoring --- src/ocrmypdf/pdfinfo/info.py | 87 +++++++++++++++++++++++++++++++----- 1 file changed, 76 insertions(+), 11 deletions(-) diff --git a/src/ocrmypdf/pdfinfo/info.py b/src/ocrmypdf/pdfinfo/info.py index 565ed745..656e94a5 100644 --- a/src/ocrmypdf/pdfinfo/info.py +++ b/src/ocrmypdf/pdfinfo/info.py @@ -17,11 +17,13 @@ # along with OCRmyPDF. If not, see . import logging +import os import re from collections import defaultdict, namedtuple from decimal import Decimal from enum import Enum from math import hypot, isclose +from multiprocessing import Pool from os import PathLike, fspath from pathlib import Path from warnings import warn @@ -612,6 +614,77 @@ def _pdf_get_pageinfo(pdf, pageno: int, infile: PathLike, xmltext: str): return pageinfo +worker_pdf = None + + +def _pdf_pageinfo_sync(args): + global worker_pdf + + infile, pageno, xmltext, detailed_analysis = args + page = PageInfo(worker_pdf, pageno, infile, xmltext, detailed_analysis) + return page + + +def _pdf_pageinfo_sync_init(infile): + global worker_pdf + worker_pdf = pikepdf.open(infile) + + +def _pdf_pageinfo_concurrent(pdf, infile, pages_xml, detailed_analysis, progbar): + pages = [None] * len(pdf.pages) + with tqdm( + total=len(pdf.pages), desc="Scan", unit='page', disable=not progbar + ) as pbar: + pool = Pool( + processes=4, # max_workers, + initializer=_pdf_pageinfo_sync_init, + initargs=(infile,), + ) + contexts = ( + (infile, n, pages_xml[n] if pages_xml else None, detailed_analysis) + for n in range(len(pdf.pages)) + ) + try: + results = pool.imap_unordered(_pdf_pageinfo_sync, contexts, chunksize=1) + while True: + try: + # page = results.next() + page = next(results) + pages[page.pageno] = page + pbar.update() + except StopIteration: + break + except KeyboardInterrupt: + pool.terminate() + raise + except Exception: + if not os.environ.get("PYTEST_CURRENT_TEST", ""): + # Unless inside pytest, exit immediately because no one wants + # to wait for child processes to finalize results that will be + # thrown away. Inside pytest, we want child processes to exit + # cleanly so that they output an error messages or coverage data + # we need from them. + pool.terminate() + raise + finally: + # Terminate log listener + # log_queue.put_nowait(None) + pool.close() + pool.join() + + # for n, _ in tqdm( + # enumerate(pdf.pages), + # total=len(pdf.pages), + # desc="Scan", + # unit='page', + # disable=not progbar, + # ): + # page_xml = pages_xml[n] if pages_xml else None + # page = PageInfo(pdf, n, infile, page_xml, detailed_analysis) + # pages.append(page) + return pages + + def _pdf_get_all_pageinfo(infile, detailed_analysis=False, progbar=False): pdf = pikepdf.open(infile) # Do not close in this function try: @@ -622,17 +695,9 @@ def _pdf_get_all_pageinfo(infile, detailed_analysis=False, progbar=False): else: pages_xml = ghosttext.extract_text_xml(infile, pdf, pageno=None) - pages = [] - for n, _ in tqdm( - enumerate(pdf.pages), - total=len(pdf.pages), - desc="Scan", - unit='page', - disable=not progbar, - ): - page_xml = pages_xml[n] if pages_xml else None - page = PageInfo(pdf, n, infile, page_xml, detailed_analysis) - pages.append(page) + pages = _pdf_pageinfo_concurrent( + pdf, infile, pages_xml, detailed_analysis, progbar + ) except Exception: pdf.close() raise From ce49fc26dd2a8bd5cf19c82fa446d850e51bae20 Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Fri, 10 Apr 2020 12:40:30 -0700 Subject: [PATCH 16/94] Do pikepdf.open() once instead of per worker --- src/ocrmypdf/pdfinfo/info.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/ocrmypdf/pdfinfo/info.py b/src/ocrmypdf/pdfinfo/info.py index 656e94a5..7df7f48e 100644 --- a/src/ocrmypdf/pdfinfo/info.py +++ b/src/ocrmypdf/pdfinfo/info.py @@ -620,14 +620,13 @@ worker_pdf = None def _pdf_pageinfo_sync(args): global worker_pdf - infile, pageno, xmltext, detailed_analysis = args + pageno, infile, xmltext, detailed_analysis = args page = PageInfo(worker_pdf, pageno, infile, xmltext, detailed_analysis) return page -def _pdf_pageinfo_sync_init(infile): - global worker_pdf - worker_pdf = pikepdf.open(infile) +def _pdf_pageinfo_sync_init(): + pass def _pdf_pageinfo_concurrent(pdf, infile, pages_xml, detailed_analysis, progbar): @@ -635,13 +634,15 @@ def _pdf_pageinfo_concurrent(pdf, infile, pages_xml, detailed_analysis, progbar) with tqdm( total=len(pdf.pages), desc="Scan", unit='page', disable=not progbar ) as pbar: + global worker_pdf + worker_pdf = pdf pool = Pool( processes=4, # max_workers, initializer=_pdf_pageinfo_sync_init, - initargs=(infile,), + initargs=tuple(), ) contexts = ( - (infile, n, pages_xml[n] if pages_xml else None, detailed_analysis) + (n, infile, pages_xml[n] if pages_xml else None, detailed_analysis) for n in range(len(pdf.pages)) ) try: From db3e75e33ef88471ce11acfbe801e633bf0fabce Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Fri, 10 Apr 2020 23:57:09 -0700 Subject: [PATCH 17/94] Refactor multiprocessing pool --- src/ocrmypdf/_concurrent.py | 110 +++++++++++++++++++++++++++++++++++ src/ocrmypdf/_sync.py | 72 +++++++---------------- src/ocrmypdf/pdfinfo/info.py | 2 +- 3 files changed, 133 insertions(+), 51 deletions(-) create mode 100644 src/ocrmypdf/_concurrent.py diff --git a/src/ocrmypdf/_concurrent.py b/src/ocrmypdf/_concurrent.py new file mode 100644 index 00000000..d6e49e91 --- /dev/null +++ b/src/ocrmypdf/_concurrent.py @@ -0,0 +1,110 @@ +# © 2020 James R. Barlow: github.com/jbarlow83 +# +# This file is part of OCRmyPDF. +# +# OCRmyPDF is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# OCRmyPDF is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with OCRmyPDF. If not, see . + +import logging +import logging.handlers +import multiprocessing +import os +import signal +import sys +import threading +from multiprocessing import Pool as ProcessPool +from multiprocessing.dummy import Pool as ThreadPool +from pathlib import Path + +from tqdm import tqdm + + +def log_listener(queue): + """Listen to the worker processes and forward the messages to logging + + For simplicity this is a thread rather than a process. Only one process + should actually write to sys.stderr or whatever we're using, so if this is + made into a process the main application needs to be directed to it. + + See https://docs.python.org/3/howto/logging-cookbook.html#logging-to-a-single-file-from-multiple-processes + """ + + while True: + try: + record = queue.get() + if record is None: + break + logger = logging.getLogger(record.name) + logger.handle(record) + except Exception: + import traceback + + print("Logging problem", file=sys.stderr) + traceback.print_exc(file=sys.stderr) + + +def exec_progress_pool( + *, + use_threads, + max_workers, + tqdm_kwargs, + task_initializer=None, + task_initargs=None, + task=None, + task_arguments=None, + task_finished=None, +): + log_queue = multiprocessing.Queue(-1) + listener = threading.Thread(target=log_listener, args=(log_queue,)) + + if use_threads: + pool_class = ThreadPool + else: + pool_class = ProcessPool + listener.start() + + with tqdm(**tqdm_kwargs) as pbar: + pool = pool_class( + processes=max_workers, + initializer=task_initializer, + initargs=(log_queue, *task_initargs), + ) + try: + results = pool.imap_unordered(task, task_arguments) + while True: + try: + result = results.next() + task_finished(result, pbar) + except StopIteration: + break + except KeyboardInterrupt: + # Terminate pool so we exit instantly + pool.terminate() + # Don't try listener.join() here, will deadlock + raise + except Exception: + if not os.environ.get("PYTEST_CURRENT_TEST", ""): + # Unless inside pytest, exit immediately because no one wants + # to wait for child processes to finalize results that will be + # thrown away. Inside pytest, we want child processes to exit + # cleanly so that they output an error messages or coverage data + # we need from them. + pool.terminate() + raise + finally: + # Terminate log listener + log_queue.put_nowait(None) + pool.close() + pool.join() + + listener.join() diff --git a/src/ocrmypdf/_sync.py b/src/ocrmypdf/_sync.py index 383e8dc7..7996f869 100644 --- a/src/ocrmypdf/_sync.py +++ b/src/ocrmypdf/_sync.py @@ -27,8 +27,8 @@ from pathlib import Path from tempfile import mkdtemp import PIL -from tqdm import tqdm +from ._concurrent import exec_progress_pool from ._graft import OcrGrafter from ._jobcontext import PDFContext, cleanup_working_files from ._logging import PageNumberFilter @@ -274,63 +274,35 @@ def exec_concurrent(context): log.info("Using Tesseract OpenMP thread limit %d", tess_threads) if context.options.use_threads: - from multiprocessing.dummy import Pool - initializer = worker_thread_init else: - Pool = multiprocessing.Pool initializer = worker_init sidecars = [None] * len(context.pdfinfo) ocrgraft = OcrGrafter(context) - log_queue = multiprocessing.Queue(-1) - listener = threading.Thread(target=log_listener, args=(log_queue,)) - listener.start() - with tqdm( - total=(2 * len(context.pdfinfo)), - desc='OCR', - unit='page', - unit_scale=0.5, - disable=not context.options.progress_bar, - ) as pbar: - pool = Pool( - processes=max_workers, - initializer=initializer, - initargs=(log_queue, PIL.Image.MAX_IMAGE_PIXELS), - ) - try: - results = pool.imap_unordered(exec_page_sync, context.get_page_contexts()) - while True: - try: - page_result = results.next() - sidecars[page_result.pageno] = page_result.text - pbar.update() - ocrgraft.graft_page(page_result) - pbar.update() - except StopIteration: - break - except KeyboardInterrupt: - # Terminate pool so we exit instantly - pool.terminate() - # Don't try listener.join() here, will deadlock - raise - except Exception: - if not os.environ.get("PYTEST_CURRENT_TEST", ""): - # Unless inside pytest, exit immediately because no one wants - # to wait for child processes to finalize results that will be - # thrown away. Inside pytest, we want child processes to exit - # cleanly so that they output an error messages or coverage data - # we need from them. - pool.terminate() - raise - finally: - # Terminate log listener - log_queue.put_nowait(None) - pool.close() - pool.join() + def update_page(result, pbar): + sidecars[result.pageno] = result.text + pbar.update() + ocrgraft.graft_page(result) + pbar.update() - listener.join() + exec_progress_pool( + use_threads=context.options.use_threads, + max_workers=max_workers, + tqdm_kwargs=dict( + total=(2 * len(context.pdfinfo)), + desc='OCR', + unit='page', + unit_scale=0.5, + disable=not context.options.progress_bar, + ), + task_initializer=initializer, + task_initargs=(PIL.Image.MAX_IMAGE_PIXELS,), + task=exec_page_sync, + task_arguments=context.get_page_contexts(), + task_finished=update_page, + ) # Output sidecar text if context.options.sidecar: diff --git a/src/ocrmypdf/pdfinfo/info.py b/src/ocrmypdf/pdfinfo/info.py index 7df7f48e..b228e769 100644 --- a/src/ocrmypdf/pdfinfo/info.py +++ b/src/ocrmypdf/pdfinfo/info.py @@ -637,7 +637,7 @@ def _pdf_pageinfo_concurrent(pdf, infile, pages_xml, detailed_analysis, progbar) global worker_pdf worker_pdf = pdf pool = Pool( - processes=4, # max_workers, + processes=1, # max_workers, initializer=_pdf_pageinfo_sync_init, initargs=tuple(), ) From af3c3c646606993d3fa6f16486844f0ae2a544e0 Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Sat, 11 Apr 2020 00:49:36 -0700 Subject: [PATCH 18/94] Further refactoring of concurrency concerns --- src/ocrmypdf/_concurrent.py | 30 ++++++++++- src/ocrmypdf/_sync.py | 53 ++------------------ src/ocrmypdf/pdfinfo/info.py | 96 ++++++++++++++---------------------- 3 files changed, 69 insertions(+), 110 deletions(-) diff --git a/src/ocrmypdf/_concurrent.py b/src/ocrmypdf/_concurrent.py index d6e49e91..724e6484 100644 --- a/src/ocrmypdf/_concurrent.py +++ b/src/ocrmypdf/_concurrent.py @@ -53,6 +53,27 @@ def log_listener(queue): traceback.print_exc(file=sys.stderr) +def process_init(queue, userfn, *userargs): + """Initialize a process pool worker""" + + # Ignore SIGINT (our parent process will kill us gracefully) + signal.signal(signal.SIGINT, signal.SIG_IGN) + + # Reconfigure the root logger for this process to send all messages to a queue + h = logging.handlers.QueueHandler(queue) + root = logging.getLogger() + root.handlers = [] + root.addHandler(h) + + if userfn: + userfn(*userargs) + + +def thread_init(_queue, userfn, *userargs): + if userfn: + userfn(*userargs) + + def exec_progress_pool( *, use_threads, @@ -67,17 +88,22 @@ def exec_progress_pool( log_queue = multiprocessing.Queue(-1) listener = threading.Thread(target=log_listener, args=(log_queue,)) + if not task_initargs: + task_initargs = tuple() + if use_threads: pool_class = ThreadPool + initializer = thread_init else: pool_class = ProcessPool + initializer = process_init listener.start() with tqdm(**tqdm_kwargs) as pbar: pool = pool_class( processes=max_workers, - initializer=task_initializer, - initargs=(log_queue, *task_initargs), + initializer=initializer, + initargs=(log_queue, task_initializer, *task_initargs), ) try: results = pool.imap_unordered(task, task_arguments) diff --git a/src/ocrmypdf/_sync.py b/src/ocrmypdf/_sync.py index 7996f869..998b67ab 100644 --- a/src/ocrmypdf/_sync.py +++ b/src/ocrmypdf/_sync.py @@ -199,53 +199,13 @@ def post_process(pdf_file, context): return optimize_pdf(pdf_out, context) -def worker_init(queue, max_pixels): - """Initialize a process pool worker""" - - # Ignore SIGINT (our parent process will kill us gracefully) - signal.signal(signal.SIGINT, signal.SIG_IGN) - - # Reconfigure the root logger for this process to send all messages to a queue - h = logging.handlers.QueueHandler(queue) - root = logging.getLogger() - root.handlers = [] - root.addHandler(h) - +def worker_init(max_pixels): # In Windows, child process will not inherit our change to this value in - # the parent process, so ensure workers get it set + # the parent process, so ensure workers get it set. Not needed when running + # threaded, but harmless to set again. PIL.Image.MAX_IMAGE_PIXELS = max_pixels -def worker_thread_init(_queue, max_pixels): - # This is probably not needed since threads should all see the same memory, - # but done for consistency. - PIL.Image.MAX_IMAGE_PIXELS = max_pixels - - -def log_listener(queue): - """Listen to the worker processes and forward the messages to logging - - For simplicity this is a thread rather than a process. Only one process - should actually write to sys.stderr or whatever we're using, so if this is - made into a process the main application needs to be directed to it. - - See https://docs.python.org/3/howto/logging-cookbook.html#logging-to-a-single-file-from-multiple-processes - """ - - while True: - try: - record = queue.get() - if record is None: - break - logger = logging.getLogger(record.name) - logger.handle(record) - except Exception: - import traceback - - print("Logging problem", file=sys.stderr) - traceback.print_exc(file=sys.stderr) - - def exec_concurrent(context): """Execute the pipeline concurrently""" @@ -273,11 +233,6 @@ def exec_concurrent(context): if tess_threads > 1: log.info("Using Tesseract OpenMP thread limit %d", tess_threads) - if context.options.use_threads: - initializer = worker_thread_init - else: - initializer = worker_init - sidecars = [None] * len(context.pdfinfo) ocrgraft = OcrGrafter(context) @@ -297,7 +252,7 @@ def exec_concurrent(context): unit_scale=0.5, disable=not context.options.progress_bar, ), - task_initializer=initializer, + task_initializer=worker_init, task_initargs=(PIL.Image.MAX_IMAGE_PIXELS,), task=exec_page_sync, task_arguments=context.get_page_contexts(), diff --git a/src/ocrmypdf/pdfinfo/info.py b/src/ocrmypdf/pdfinfo/info.py index b228e769..97ddb1d8 100644 --- a/src/ocrmypdf/pdfinfo/info.py +++ b/src/ocrmypdf/pdfinfo/info.py @@ -23,15 +23,14 @@ from collections import defaultdict, namedtuple from decimal import Decimal from enum import Enum from math import hypot, isclose -from multiprocessing import Pool from os import PathLike, fspath from pathlib import Path from warnings import warn import pikepdf from pikepdf import PdfMatrix -from tqdm import tqdm +from ocrmypdf._concurrent import exec_progress_pool from ocrmypdf.exceptions import EncryptedPdfError from ocrmypdf.exec import ghostscript from ocrmypdf.helpers import Resolution @@ -618,71 +617,50 @@ worker_pdf = None def _pdf_pageinfo_sync(args): - global worker_pdf - pageno, infile, xmltext, detailed_analysis = args page = PageInfo(worker_pdf, pageno, infile, xmltext, detailed_analysis) return page -def _pdf_pageinfo_sync_init(): - pass - - def _pdf_pageinfo_concurrent(pdf, infile, pages_xml, detailed_analysis, progbar): pages = [None] * len(pdf.pages) - with tqdm( - total=len(pdf.pages), desc="Scan", unit='page', disable=not progbar - ) as pbar: - global worker_pdf - worker_pdf = pdf - pool = Pool( - processes=1, # max_workers, - initializer=_pdf_pageinfo_sync_init, - initargs=tuple(), - ) - contexts = ( - (n, infile, pages_xml[n] if pages_xml else None, detailed_analysis) - for n in range(len(pdf.pages)) - ) - try: - results = pool.imap_unordered(_pdf_pageinfo_sync, contexts, chunksize=1) - while True: - try: - # page = results.next() - page = next(results) - pages[page.pageno] = page - pbar.update() - except StopIteration: - break - except KeyboardInterrupt: - pool.terminate() - raise - except Exception: - if not os.environ.get("PYTEST_CURRENT_TEST", ""): - # Unless inside pytest, exit immediately because no one wants - # to wait for child processes to finalize results that will be - # thrown away. Inside pytest, we want child processes to exit - # cleanly so that they output an error messages or coverage data - # we need from them. - pool.terminate() - raise - finally: - # Terminate log listener - # log_queue.put_nowait(None) - pool.close() - pool.join() - # for n, _ in tqdm( - # enumerate(pdf.pages), - # total=len(pdf.pages), - # desc="Scan", - # unit='page', - # disable=not progbar, - # ): - # page_xml = pages_xml[n] if pages_xml else None - # page = PageInfo(pdf, n, infile, page_xml, detailed_analysis) - # pages.append(page) + def update_pageinfo(result, pbar): + page = result + pages[page.pageno] = page + pbar.update() + + contexts = ( + (n, infile, pages_xml[n] if pages_xml else None, detailed_analysis) + for n in range(len(pdf.pages)) + ) + global worker_pdf + worker_pdf = pdf + + if os.name == 'nt': + # We can't parallelize on Windows, because Windows cannot fork. + # We are trying to fork, then take advantage of the preloaded pikepdf.Pdf + # object in memory to save time reloading it, hence the silly global + # variable. Hey, it works. Threads are not helpful here because they + # will all just fight over the lock. So on Windows just run sequentially. + use_threads = True + max_workers = 1 + else: + use_threads = False + max_workers = min(len(pages), 16) + + exec_progress_pool( + use_threads=use_threads, + max_workers=1, + tqdm_kwargs=dict( + total=len(pdf.pages), desc="Scan", unit='page', disable=not progbar + ), + task_initializer=None, + task_initargs=None, + task=_pdf_pageinfo_sync, + task_arguments=contexts, + task_finished=update_pageinfo, + ) return pages From 7513f5425c7fbd7c8ef9bcdca7016d5d1cdcd055 Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Sat, 11 Apr 2020 01:21:07 -0700 Subject: [PATCH 19/94] Fix some broken tests --- src/ocrmypdf/_concurrent.py | 18 +++++++++--------- src/ocrmypdf/pdfinfo/info.py | 2 +- tests/test_validation.py | 2 +- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/ocrmypdf/_concurrent.py b/src/ocrmypdf/_concurrent.py index 724e6484..7253ab89 100644 --- a/src/ocrmypdf/_concurrent.py +++ b/src/ocrmypdf/_concurrent.py @@ -24,7 +24,7 @@ import sys import threading from multiprocessing import Pool as ProcessPool from multiprocessing.dummy import Pool as ThreadPool -from pathlib import Path +from typing import Callable, Iterable, Optional from tqdm import tqdm @@ -76,14 +76,14 @@ def thread_init(_queue, userfn, *userargs): def exec_progress_pool( *, - use_threads, - max_workers, - tqdm_kwargs, - task_initializer=None, - task_initargs=None, - task=None, - task_arguments=None, - task_finished=None, + use_threads: bool, + max_workers: int, + tqdm_kwargs: dict, + task_initializer: Optional[Callable] = None, + task_initargs: Optional[tuple] = None, + task: Optional[Callable] = None, + task_arguments: Optional[Iterable] = None, + task_finished: Optional[Callable] = None, ): log_queue = multiprocessing.Queue(-1) listener = threading.Thread(target=log_listener, args=(log_queue,)) diff --git a/src/ocrmypdf/pdfinfo/info.py b/src/ocrmypdf/pdfinfo/info.py index 97ddb1d8..c5e0f9db 100644 --- a/src/ocrmypdf/pdfinfo/info.py +++ b/src/ocrmypdf/pdfinfo/info.py @@ -651,7 +651,7 @@ def _pdf_pageinfo_concurrent(pdf, infile, pages_xml, detailed_analysis, progbar) exec_progress_pool( use_threads=use_threads, - max_workers=1, + max_workers=max_workers, tqdm_kwargs=dict( total=len(pdf.pages), desc="Scan", unit='page', disable=not progbar ), diff --git a/tests/test_validation.py b/tests/test_validation.py index af1eadea..864dd05a 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -150,7 +150,7 @@ def test_false_action_store_true(): @pytest.mark.parametrize('progress_bar', [True, False]) def test_no_progress_bar(progress_bar, resources): opts = make_opts(progress_bar=progress_bar, input_file=(resources / 'trivial.pdf')) - with patch('ocrmypdf.pdfinfo.info.tqdm', autospec=True) as tqdmpatch: + with patch('ocrmypdf._concurrent.tqdm', autospec=True) as tqdmpatch: vd.check_options(opts) pdfinfo = PdfInfo(opts.input_file, progbar=opts.progress_bar) assert pdfinfo is not None From 86145a8c76c714f8d690f53e709b2024cf6526b5 Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Tue, 14 Apr 2020 22:58:59 -0700 Subject: [PATCH 20/94] Some wrong with forking worker_pdf, just open it once per page for now --- src/ocrmypdf/pdfinfo/info.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/ocrmypdf/pdfinfo/info.py b/src/ocrmypdf/pdfinfo/info.py index c5e0f9db..b4da45c1 100644 --- a/src/ocrmypdf/pdfinfo/info.py +++ b/src/ocrmypdf/pdfinfo/info.py @@ -613,12 +613,13 @@ def _pdf_get_pageinfo(pdf, pageno: int, infile: PathLike, xmltext: str): return pageinfo -worker_pdf = None +# worker_pdf = None def _pdf_pageinfo_sync(args): pageno, infile, xmltext, detailed_analysis = args - page = PageInfo(worker_pdf, pageno, infile, xmltext, detailed_analysis) + with pikepdf.open(infile) as worker_pdf: + page = PageInfo(worker_pdf, pageno, infile, xmltext, detailed_analysis) return page @@ -634,8 +635,8 @@ def _pdf_pageinfo_concurrent(pdf, infile, pages_xml, detailed_analysis, progbar) (n, infile, pages_xml[n] if pages_xml else None, detailed_analysis) for n in range(len(pdf.pages)) ) - global worker_pdf - worker_pdf = pdf + # global worker_pdf + # worker_pdf = pdf if os.name == 'nt': # We can't parallelize on Windows, because Windows cannot fork. From 8c381a022729e41c04ef38e74230e350d538e127 Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Tue, 14 Apr 2020 23:02:48 -0700 Subject: [PATCH 21/94] Replace task_initargs with use of partial() --- src/ocrmypdf/_concurrent.py | 18 +++++++----------- src/ocrmypdf/_sync.py | 4 ++-- src/ocrmypdf/pdfinfo/info.py | 1 - 3 files changed, 9 insertions(+), 14 deletions(-) diff --git a/src/ocrmypdf/_concurrent.py b/src/ocrmypdf/_concurrent.py index 7253ab89..a321f28d 100644 --- a/src/ocrmypdf/_concurrent.py +++ b/src/ocrmypdf/_concurrent.py @@ -53,7 +53,7 @@ def log_listener(queue): traceback.print_exc(file=sys.stderr) -def process_init(queue, userfn, *userargs): +def process_init(queue, user_init): """Initialize a process pool worker""" # Ignore SIGINT (our parent process will kill us gracefully) @@ -65,13 +65,13 @@ def process_init(queue, userfn, *userargs): root.handlers = [] root.addHandler(h) - if userfn: - userfn(*userargs) + if user_init: + user_init() -def thread_init(_queue, userfn, *userargs): - if userfn: - userfn(*userargs) +def thread_init(_queue, user_init): + if user_init: + user_init() def exec_progress_pool( @@ -80,7 +80,6 @@ def exec_progress_pool( max_workers: int, tqdm_kwargs: dict, task_initializer: Optional[Callable] = None, - task_initargs: Optional[tuple] = None, task: Optional[Callable] = None, task_arguments: Optional[Iterable] = None, task_finished: Optional[Callable] = None, @@ -88,9 +87,6 @@ def exec_progress_pool( log_queue = multiprocessing.Queue(-1) listener = threading.Thread(target=log_listener, args=(log_queue,)) - if not task_initargs: - task_initargs = tuple() - if use_threads: pool_class = ThreadPool initializer = thread_init @@ -103,7 +99,7 @@ def exec_progress_pool( pool = pool_class( processes=max_workers, initializer=initializer, - initargs=(log_queue, task_initializer, *task_initargs), + initargs=(log_queue, task_initializer), ) try: results = pool.imap_unordered(task, task_arguments) diff --git a/src/ocrmypdf/_sync.py b/src/ocrmypdf/_sync.py index 998b67ab..f54c32a6 100644 --- a/src/ocrmypdf/_sync.py +++ b/src/ocrmypdf/_sync.py @@ -23,6 +23,7 @@ import signal import sys import threading from collections import namedtuple +from functools import partial from pathlib import Path from tempfile import mkdtemp @@ -252,8 +253,7 @@ def exec_concurrent(context): unit_scale=0.5, disable=not context.options.progress_bar, ), - task_initializer=worker_init, - task_initargs=(PIL.Image.MAX_IMAGE_PIXELS,), + task_initializer=partial(worker_init, PIL.Image.MAX_IMAGE_PIXELS), task=exec_page_sync, task_arguments=context.get_page_contexts(), task_finished=update_page, diff --git a/src/ocrmypdf/pdfinfo/info.py b/src/ocrmypdf/pdfinfo/info.py index b4da45c1..26a5ba4e 100644 --- a/src/ocrmypdf/pdfinfo/info.py +++ b/src/ocrmypdf/pdfinfo/info.py @@ -657,7 +657,6 @@ def _pdf_pageinfo_concurrent(pdf, infile, pages_xml, detailed_analysis, progbar) total=len(pdf.pages), desc="Scan", unit='page', disable=not progbar ), task_initializer=None, - task_initargs=None, task=_pdf_pageinfo_sync, task_arguments=contexts, task_finished=update_pageinfo, From 27a3b80376533ea39c3c17e8152c89d03caaba27 Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Sun, 26 Apr 2020 03:25:38 -0700 Subject: [PATCH 22/94] Use once-per-worker pikepdf init --- setup.py | 2 +- src/ocrmypdf/pdfinfo/info.py | 16 ++++++++++------ 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/setup.py b/setup.py index eb0d783a..da695597 100644 --- a/setup.py +++ b/setup.py @@ -98,7 +98,7 @@ setup( 'cffi >= 1.9.1', # must be a setup and install requirement 'coloredlogs >= 14.0', # strictly optional 'img2pdf >= 0.3.0, < 0.4', # pure Python, so track HEAD closely - 'pdfminer.six >= 20191110, <= 20200124', + 'pdfminer.six >= 20191110, <= 20200402', 'pikepdf >= 1.8.1, < 2', 'Pillow >= 6.2.0', 'reportlab >= 3.3.0', # oldest released version with sane image handling diff --git a/src/ocrmypdf/pdfinfo/info.py b/src/ocrmypdf/pdfinfo/info.py index 26a5ba4e..bb232ee5 100644 --- a/src/ocrmypdf/pdfinfo/info.py +++ b/src/ocrmypdf/pdfinfo/info.py @@ -22,6 +22,7 @@ import re from collections import defaultdict, namedtuple from decimal import Decimal from enum import Enum +from functools import partial from math import hypot, isclose from os import PathLike, fspath from pathlib import Path @@ -613,13 +614,18 @@ def _pdf_get_pageinfo(pdf, pageno: int, infile: PathLike, xmltext: str): return pageinfo -# worker_pdf = None +worker_pdf = None + + +def _pdf_pageinfo_sync_init(infile): + global worker_pdf # pylint: disable=global-statement + worker_pdf = pikepdf.open(infile) def _pdf_pageinfo_sync(args): + global worker_pdf # pylint: disable=global-statement pageno, infile, xmltext, detailed_analysis = args - with pikepdf.open(infile) as worker_pdf: - page = PageInfo(worker_pdf, pageno, infile, xmltext, detailed_analysis) + page = PageInfo(worker_pdf, pageno, infile, xmltext, detailed_analysis) return page @@ -635,8 +641,6 @@ def _pdf_pageinfo_concurrent(pdf, infile, pages_xml, detailed_analysis, progbar) (n, infile, pages_xml[n] if pages_xml else None, detailed_analysis) for n in range(len(pdf.pages)) ) - # global worker_pdf - # worker_pdf = pdf if os.name == 'nt': # We can't parallelize on Windows, because Windows cannot fork. @@ -656,7 +660,7 @@ def _pdf_pageinfo_concurrent(pdf, infile, pages_xml, detailed_analysis, progbar) tqdm_kwargs=dict( total=len(pdf.pages), desc="Scan", unit='page', disable=not progbar ), - task_initializer=None, + task_initializer=partial(_pdf_pageinfo_sync_init, infile), task=_pdf_pageinfo_sync, task_arguments=contexts, task_finished=update_pageinfo, From 2c07515907da1b071c4a5e64e66249d9ff8ba7aa Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Sun, 26 Apr 2020 03:33:31 -0700 Subject: [PATCH 23/94] macOS - use spawn for multiprocessing See bpo-33725. This is the default for 3.8, opt-in for 3.7 and older. --- src/ocrmypdf/__main__.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/ocrmypdf/__main__.py b/src/ocrmypdf/__main__.py index 459b3040..5d3a5d50 100755 --- a/src/ocrmypdf/__main__.py +++ b/src/ocrmypdf/__main__.py @@ -19,6 +19,7 @@ import logging import os import sys +from multiprocessing import set_start_method from . import __version__ from ._sync import run_pipeline @@ -66,4 +67,6 @@ def run(args=None): if __name__ == '__main__': + if sys.platform == 'darwin' and sys.version_info < (3, 8): + set_start_method('spawn') # see python bpo-33725 sys.exit(run()) From 991db17fdeb212f524e3be499c542b904b3e97af Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Sat, 11 Apr 2020 16:03:00 -0700 Subject: [PATCH 24/94] Remove Ghostscript-based text extraction While faster than Python based methods, we've outgrown the limited amount of information Ghostscript provides with this feature, and it repeats an analysis we have to do anyway to learn what images are present. --- src/ocrmypdf/_pipeline.py | 6 +- src/ocrmypdf/_sync.py | 6 +- src/ocrmypdf/exec/ghostscript.py | 49 --------- src/ocrmypdf/pdfinfo/ghosttext.py | 102 ------------------ src/ocrmypdf/pdfinfo/info.py | 58 +++------- tests/cache/manifest.jsonl | 1 + .../hocr.bin | 30 ++++++ .../stderr.bin | 1 + .../stdout.bin | 0 .../txt.bin | 3 + tests/test_main.py | 4 +- tests/test_pdfinfo.py | 25 +---- 12 files changed, 57 insertions(+), 228 deletions(-) delete mode 100644 src/ocrmypdf/pdfinfo/ghosttext.py create mode 100644 tests/cache/multipage/__-l__eng__000002_ocr.png__000002_ocr_hocr__hocr__txt/hocr.bin create mode 100644 tests/cache/multipage/__-l__eng__000002_ocr.png__000002_ocr_hocr__hocr__txt/stderr.bin create mode 100644 tests/cache/multipage/__-l__eng__000002_ocr.png__000002_ocr_hocr__hocr__txt/stdout.bin create mode 100644 tests/cache/multipage/__-l__eng__000002_ocr.png__000002_ocr_hocr__hocr__txt/txt.bin diff --git a/src/ocrmypdf/_pipeline.py b/src/ocrmypdf/_pipeline.py index bb2f7e18..3be65faa 100644 --- a/src/ocrmypdf/_pipeline.py +++ b/src/ocrmypdf/_pipeline.py @@ -147,11 +147,9 @@ def triage(original_filename, input_file, output_file, options): return output_file -def get_pdfinfo(input_file, detailed_page_analysis=False, progbar=False): +def get_pdfinfo(input_file, progbar=False): try: - return PdfInfo( - input_file, detailed_page_analysis=detailed_page_analysis, progbar=progbar - ) + return PdfInfo(input_file, progbar=progbar) except pikepdf.PasswordError: raise EncryptedPdfError() except pikepdf.PdfError: diff --git a/src/ocrmypdf/_sync.py b/src/ocrmypdf/_sync.py index f54c32a6..9f5c65d8 100644 --- a/src/ocrmypdf/_sync.py +++ b/src/ocrmypdf/_sync.py @@ -326,11 +326,7 @@ def run_pipeline(options, api=False): ) # Gather pdfinfo and create context - pdfinfo = get_pdfinfo( - origin_pdf, - detailed_page_analysis=options.redo_ocr, - progbar=options.progress_bar, - ) + pdfinfo = get_pdfinfo(origin_pdf, progbar=options.progress_bar) context = PDFContext(options, work_folder, origin_pdf, pdfinfo) diff --git a/src/ocrmypdf/exec/ghostscript.py b/src/ocrmypdf/exec/ghostscript.py index 5c27488f..44b81682 100644 --- a/src/ocrmypdf/exec/ghostscript.py +++ b/src/ocrmypdf/exec/ghostscript.py @@ -83,55 +83,6 @@ def _gs_error_reported(stream) -> bool: return re.search(r'error', stream, flags=re.IGNORECASE) -def extract_text(input_file, pageno=1): - """Use the txtwrite device to get text layout information out - - For details on options of -dTextFormat see - https://www.ghostscript.com/doc/current/VectorDevices.htm#TXT - - Format is like - - - - - - :param pageno: number of page to extract, or all pages if None - :return: XML-ish text representation in bytes - """ - - if pageno is not None: - pages = ['-dFirstPage=%i' % pageno, '-dLastPage=%i' % pageno] - else: - pages = [] - - # Note due to bug https://bugs.ghostscript.com/show_bug.cgi?id=701971 - # Ghostscript <= 9.50 will truncate output unless we write to stdout, so - # don't write to a file. - args_gs = ( - [ - GS, - '-dQUIET', - '-dSAFER', - '-dBATCH', - '-dNOPAUSE', - '-sDEVICE=txtwrite', - '-dTextFormat=0', - ] - + pages - + ['-o', '-', fspath(input_file), "-sstdout=%stderr"] - ) - - try: - p = run(args_gs, stdout=PIPE, stderr=PIPE, check=True) - except CalledProcessError as e: - raise SubprocessOutputError( - 'Ghostscript text extraction failed\n%s\n%s' - % (input_file, e.stderr.decode(errors='replace')) - ) - - return p.stdout - - def rasterize_pdf( input_file: os.PathLike, output_file: os.PathLike, diff --git a/src/ocrmypdf/pdfinfo/ghosttext.py b/src/ocrmypdf/pdfinfo/ghosttext.py deleted file mode 100644 index 07e72f19..00000000 --- a/src/ocrmypdf/pdfinfo/ghosttext.py +++ /dev/null @@ -1,102 +0,0 @@ -# © 2018 James R. Barlow: github.com/jbarlow83 -# -# This file is part of OCRmyPDF. -# -# OCRmyPDF is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# OCRmyPDF is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with OCRmyPDF. If not, see . - -import logging -import re -import xml.etree.ElementTree as ET - -from ..exec import ghostscript - -log = logging.getLogger(__name__) - -# Forgive me for I have sinned -# I am using regular expressions to parse XML. However the XML in this case, -# generated by Ghostscript, is self-consistent enough to be parseable. -regex_remove_char_tags = re.compile( - br""" - ] # anything single character but > - | \">\" # special case: trap ">" - )* - /> # terminate with '/>' -""", - re.VERBOSE, -) - - -def page_get_textblocks(infile, pageno, xmltext, height): - """Get text boxes out of Ghostscript txtwrite xml""" - - root = xmltext - if not hasattr(xmltext, 'findall'): - return [] - - def blocks(): - for span in root.findall('.//span'): - bbox_str = span.attrib['bbox'] - font_size = span.attrib['size'] - pts = [int(pt) for pt in bbox_str.split()] - pts[1] = pts[1] - int(float(font_size) + 0.5) - bbox_topdown = tuple(pts) - bb = bbox_topdown - bbox_bottomup = (bb[0], height - bb[3], bb[2], height - bb[1]) - yield bbox_bottomup - - def joined_blocks(): - prev = None - for bbox in blocks(): - if prev is None: - prev = bbox - if bbox[1] == prev[1] and bbox[3] == prev[3]: - gap = prev[2] - bbox[0] - height = abs(bbox[3] - bbox[1]) - if gap < height: - # Join boxes - prev = (prev[0], prev[1], bbox[2], bbox[3]) - continue - # yield previously joined bboxes and start anew - yield prev - prev = bbox - if prev is not None: - yield prev - - return [block for block in joined_blocks()] - - -def extract_text_xml(infile, pdf, pageno=None): - existing_text = ghostscript.extract_text(infile, pageno=None) - existing_text = regex_remove_char_tags.sub(b' ', existing_text) - - try: - root = ET.fromstringlist([b'\n', existing_text, b'\n']) - page_xml = root.findall('page') - except ET.ParseError as e: - log.error( - "An error occurred while attempting to retrieve existing text in " - "the input file. Will attempt to continue assuming that there is " - "no existing text in the file. The error was:" - ) - log.error(e) - page_xml = [None] * len(pdf.pages) - - page_count_difference = len(pdf.pages) - len(page_xml) - if page_count_difference != 0: - log.error("The number of pages in the input file is inconsistent.") - log.error(f"Expected {len(pdf.pages)}, txtwrite says {len(page_xml)}") - if page_count_difference > 0: - page_xml.extend([None] * page_count_difference) - return page_xml diff --git a/src/ocrmypdf/pdfinfo/info.py b/src/ocrmypdf/pdfinfo/info.py index bb232ee5..bd7641c4 100644 --- a/src/ocrmypdf/pdfinfo/info.py +++ b/src/ocrmypdf/pdfinfo/info.py @@ -33,9 +33,7 @@ from pikepdf import PdfMatrix from ocrmypdf._concurrent import exec_progress_pool from ocrmypdf.exceptions import EncryptedPdfError -from ocrmypdf.exec import ghostscript from ocrmypdf.helpers import Resolution -from ocrmypdf.pdfinfo import ghosttext from ocrmypdf.pdfinfo.layout import get_page_analysis, get_text_boxes logger = logging.getLogger() @@ -557,7 +555,7 @@ def simplify_textboxes(miner, textbox_getter): yield TextboxInfo(box.bbox, visible, corrupt) -def _pdf_get_pageinfo(pdf, pageno: int, infile: PathLike, xmltext: str): +def _pdf_get_pageinfo(pdf, pageno: int, infile: PathLike): pageinfo = {} pageinfo['pageno'] = pageno pageinfo['images'] = [] @@ -567,16 +565,10 @@ def _pdf_get_pageinfo(pdf, pageno: int, infile: PathLike, xmltext: str): width_pt = mediabox[2] - mediabox[0] height_pt = mediabox[3] - mediabox[1] - if xmltext is not None: - bboxes = ghosttext.page_get_textblocks( - fspath(infile), pageno, xmltext=xmltext, height=height_pt - ) - pageinfo['bboxes'] = bboxes - else: - pscript5_mode = str(pdf.docinfo.get('/Creator')).startswith('PScript5') - miner = get_page_analysis(infile, pageno, pscript5_mode) - pageinfo['textboxes'] = list(simplify_textboxes(miner, get_text_boxes)) - bboxes = (box.bbox for box in pageinfo['textboxes']) + pscript5_mode = str(pdf.docinfo.get('/Creator')).startswith('PScript5') + miner = get_page_analysis(infile, pageno, pscript5_mode) + pageinfo['textboxes'] = list(simplify_textboxes(miner, get_text_boxes)) + bboxes = (box.bbox for box in pageinfo['textboxes']) pageinfo['has_text'] = _page_has_text(bboxes, width_pt, height_pt) @@ -624,12 +616,12 @@ def _pdf_pageinfo_sync_init(infile): def _pdf_pageinfo_sync(args): global worker_pdf # pylint: disable=global-statement - pageno, infile, xmltext, detailed_analysis = args - page = PageInfo(worker_pdf, pageno, infile, xmltext, detailed_analysis) + pageno, infile = args + page = PageInfo(worker_pdf, pageno, infile) return page -def _pdf_pageinfo_concurrent(pdf, infile, pages_xml, detailed_analysis, progbar): +def _pdf_pageinfo_concurrent(pdf, infile, progbar): pages = [None] * len(pdf.pages) def update_pageinfo(result, pbar): @@ -637,11 +629,7 @@ def _pdf_pageinfo_concurrent(pdf, infile, pages_xml, detailed_analysis, progbar) pages[page.pageno] = page pbar.update() - contexts = ( - (n, infile, pages_xml[n] if pages_xml else None, detailed_analysis) - for n in range(len(pdf.pages)) - ) - + contexts = ((n, infile) for n in range(len(pdf.pages))) if os.name == 'nt': # We can't parallelize on Windows, because Windows cannot fork. # We are trying to fork, then take advantage of the preloaded pikepdf.Pdf @@ -668,19 +656,12 @@ def _pdf_pageinfo_concurrent(pdf, infile, pages_xml, detailed_analysis, progbar) return pages -def _pdf_get_all_pageinfo(infile, detailed_analysis=False, progbar=False): +def _pdf_get_all_pageinfo(infile, progbar=False): pdf = pikepdf.open(infile) # Do not close in this function try: if pdf.is_encrypted: raise EncryptedPdfError() # Triggered by encryption with empty passwd - if detailed_analysis: - pages_xml = None - else: - pages_xml = ghosttext.extract_text_xml(infile, pdf, pageno=None) - - pages = _pdf_pageinfo_concurrent( - pdf, infile, pages_xml, detailed_analysis, progbar - ) + pages = _pdf_pageinfo_concurrent(pdf, infile, progbar) except Exception: pdf.close() raise @@ -689,11 +670,10 @@ def _pdf_get_all_pageinfo(infile, detailed_analysis=False, progbar=False): class PageInfo: - def __init__(self, pdf, pageno, infile, xmltext, detailed_analysis=False): + def __init__(self, pdf, pageno, infile): self._pageno = pageno self._infile = infile - self._pageinfo = _pdf_get_pageinfo(pdf, pageno, infile, xmltext) - self._detailed_analysis = detailed_analysis + self._pageinfo = _pdf_get_pageinfo(pdf, pageno, infile) @property def pageno(self): @@ -705,8 +685,6 @@ class PageInfo: @property def has_corrupt_text(self): - if not self._detailed_analysis: - raise NotImplementedError('Did not do detailed analysis') return any(tbox.is_corrupt for tbox in self._pageinfo['textboxes']) @property @@ -757,7 +735,7 @@ class PageInfo: if 'textboxes' not in self._pageinfo: if visible is not None and corrupt is not None: - raise NotImplementedError('Ghostscript textboxes cannot be classified') + raise NotImplementedError('Incomplete information on textboxes') return self._pageinfo['bboxes'] return ( @@ -792,13 +770,9 @@ class PageInfo: class PdfInfo: """Get summary information about a PDF""" - def __init__(self, infile, detailed_page_analysis=False, progbar=False): + def __init__(self, infile, progbar=False): self._infile = infile - if ghostscript.version() in ('9.52',): - detailed_page_analysis = True # txtwrite doesn't work in these versions - self._pages, pdf = _pdf_get_all_pageinfo( - infile, detailed_page_analysis, progbar=progbar - ) + self._pages, pdf = _pdf_get_all_pageinfo(infile, progbar=progbar) self._needs_rendering = pdf.root.get('/NeedsRendering', False) self._has_acroform = False if '/AcroForm' in pdf.root: diff --git a/tests/cache/manifest.jsonl b/tests/cache/manifest.jsonl index 05a0e86c..23e50166 100644 --- a/tests/cache/manifest.jsonl +++ b/tests/cache/manifest.jsonl @@ -69,3 +69,4 @@ {"tesseract_version": "tesseract 4.1.0 leptonica-1.78.0 libgif 5.1.4 : libjpeg 9c : libpng 1.6.37 : libtiff 4.1.0 : zlib 1.2.11 : libwebp 1.0.3 : libopenjp2 2.3.1 Found AVX2 Found AVX Found SSE ", "platform": "Darwin-18.7.0-x86_64-i386-64bit", "python": "3.7.5", "argv_slug": "__-l__eng__000002_ocr.png__000002_ocr_tess__pdf__txt", "sourcefile": "resources/3small.pdf", "args": ["-l", "eng", "-c", "textonly_pdf=1", "$TMPDIR/000002_ocr.png", "$TMPDIR/000002_ocr_tess", "pdf", "txt"]} {"tesseract_version": "tesseract 4.1.0 leptonica-1.78.0 libgif 5.1.4 : libjpeg 9c : libpng 1.6.37 : libtiff 4.1.0 : zlib 1.2.11 : libwebp 1.0.3 : libopenjp2 2.3.1 Found AVX2 Found AVX Found SSE ", "platform": "Darwin-18.7.0-x86_64-i386-64bit", "python": "3.7.5", "argv_slug": "__-l__eng__000001_ocr.png__000001_ocr_tess__pdf__txt", "sourcefile": "resources/3small.pdf", "args": ["-l", "eng", "-c", "textonly_pdf=1", "$TMPDIR/000001_ocr.png", "$TMPDIR/000001_ocr_tess", "pdf", "txt"]} {"tesseract_version": "tesseract 4.1.0 leptonica-1.78.0 libgif 5.1.4 : libjpeg 9c : libpng 1.6.37 : libtiff 4.1.0 : zlib 1.2.11 : libwebp 1.0.3 : libopenjp2 2.3.1 Found AVX2 Found AVX Found SSE ", "platform": "Darwin-18.7.0-x86_64-i386-64bit", "python": "3.7.5", "argv_slug": "__-l__eng__000003_ocr.png__000003_ocr_tess__pdf__txt", "sourcefile": "resources/3small.pdf", "args": ["-l", "eng", "-c", "textonly_pdf=1", "$TMPDIR/000003_ocr.png", "$TMPDIR/000003_ocr_tess", "pdf", "txt"]} +{"tesseract_version": "tesseract 4.1.1 leptonica-1.79.0 libgif 5.2.1 : libjpeg 9d : libpng 1.6.37 : libtiff 4.1.0 : zlib 1.2.11 : libwebp 1.1.0 : libopenjp2 2.3.1 Found AVX2 Found AVX Found FMA Found SSE ", "platform": "Darwin-18.7.0-x86_64-i386-64bit", "python": "3.7.7", "argv_slug": "__-l__eng__000002_ocr.png__000002_ocr_hocr__hocr__txt", "sourcefile": "resources/multipage.pdf", "args": ["-l", "eng", "$TMPDIR/000002_ocr.png", "$TMPDIR/000002_ocr_hocr", "hocr", "txt"]} diff --git a/tests/cache/multipage/__-l__eng__000002_ocr.png__000002_ocr_hocr__hocr__txt/hocr.bin b/tests/cache/multipage/__-l__eng__000002_ocr.png__000002_ocr_hocr__hocr__txt/hocr.bin new file mode 100644 index 00000000..27e769f6 --- /dev/null +++ b/tests/cache/multipage/__-l__eng__000002_ocr.png__000002_ocr_hocr__hocr__txt/hocr.bin @@ -0,0 +1,30 @@ + + + + + + + + + + +
+
+

+ + 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/test_main.py b/tests/test_main.py index d4146ccf..9b0cd17e 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -101,10 +101,10 @@ def test_skip_ocr(spoof_tesseract_cache, resources, outpdf): def test_redo_ocr(resources, outpdf): in_ = resources / 'graph_ocred.pdf' - before = PdfInfo(in_, detailed_page_analysis=True) + before = PdfInfo(in_) out = outpdf out = check_ocrmypdf(in_, out, '--redo-ocr') - after = PdfInfo(out, detailed_page_analysis=True) + after = PdfInfo(out) assert before[0].has_text and after[0].has_text assert ( before[0].get_textareas() != after[0].get_textareas() diff --git a/tests/test_pdfinfo.py b/tests/test_pdfinfo.py index 13fb8a8b..cfa90d94 100644 --- a/tests/test_pdfinfo.py +++ b/tests/test_pdfinfo.py @@ -151,22 +151,6 @@ def test_pickle(resources): pickle.dumps(pdf) -def test_regex(): - rx = pdfinfo.ghosttext.regex_remove_char_tags - - must_match = [ - b'', - b'', - b'', - ] - must_not_match = [b'', b'', b'', b'
'] - - for s in must_match: - assert rx.match(s) - for s in must_not_match: - assert not rx.match(s) - - def test_vector(resources): filename = resources / 'vector.pdf' pdf = pdfinfo.PdfInfo(filename) @@ -184,16 +168,9 @@ def test_ocr_detection(resources): @pytest.mark.parametrize( 'testfile', ('truetype_font_nomapping.pdf', 'type3_font_nomapping.pdf') ) -@pytest.mark.xfail( - ghostscript.version() in ('9.52',), reason="gs 9.52 txtwrite doesn't work" -) def test_corrupt_font_detection(resources, testfile): filename = resources / testfile - with pytest.raises(NotImplementedError): - pdf = pdfinfo.PdfInfo(filename) - pdf[0].has_corrupt_text - - pdf = pdfinfo.PdfInfo(filename, detailed_page_analysis=True) + pdf = pdfinfo.PdfInfo(filename) assert pdf[0].has_corrupt_text From 18c4aa10bf524b864763879e9091f5da7787035f Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Sun, 26 Apr 2020 04:21:15 -0700 Subject: [PATCH 25/94] Adjust number of workers for concurrent page scanning --- src/ocrmypdf/_pipeline.py | 4 ++-- src/ocrmypdf/_sync.py | 6 +++++- src/ocrmypdf/pdfinfo/info.py | 35 ++++++++++++++++++----------------- 3 files changed, 25 insertions(+), 20 deletions(-) diff --git a/src/ocrmypdf/_pipeline.py b/src/ocrmypdf/_pipeline.py index 3be65faa..2128adf3 100644 --- a/src/ocrmypdf/_pipeline.py +++ b/src/ocrmypdf/_pipeline.py @@ -147,9 +147,9 @@ def triage(original_filename, input_file, output_file, options): return output_file -def get_pdfinfo(input_file, progbar=False): +def get_pdfinfo(input_file, progbar=False, max_workers=None): try: - return PdfInfo(input_file, progbar=progbar) + return PdfInfo(input_file, progbar=progbar, max_workers=max_workers) except pikepdf.PasswordError: raise EncryptedPdfError() except pikepdf.PdfError: diff --git a/src/ocrmypdf/_sync.py b/src/ocrmypdf/_sync.py index 9f5c65d8..e6e44271 100644 --- a/src/ocrmypdf/_sync.py +++ b/src/ocrmypdf/_sync.py @@ -326,7 +326,11 @@ def run_pipeline(options, api=False): ) # Gather pdfinfo and create context - pdfinfo = get_pdfinfo(origin_pdf, progbar=options.progress_bar) + pdfinfo = get_pdfinfo( + origin_pdf, + progbar=options.progress_bar, + max_workers=options.jobs if not options.use_threads else 1, # To help debug + ) context = PDFContext(options, work_folder, origin_pdf, pdfinfo) diff --git a/src/ocrmypdf/pdfinfo/info.py b/src/ocrmypdf/pdfinfo/info.py index bd7641c4..86bb059d 100644 --- a/src/ocrmypdf/pdfinfo/info.py +++ b/src/ocrmypdf/pdfinfo/info.py @@ -33,7 +33,7 @@ from pikepdf import PdfMatrix from ocrmypdf._concurrent import exec_progress_pool from ocrmypdf.exceptions import EncryptedPdfError -from ocrmypdf.helpers import Resolution +from ocrmypdf.helpers import Resolution, available_cpu_count from ocrmypdf.pdfinfo.layout import get_page_analysis, get_text_boxes logger = logging.getLogger() @@ -621,7 +621,7 @@ def _pdf_pageinfo_sync(args): return page -def _pdf_pageinfo_concurrent(pdf, infile, progbar): +def _pdf_pageinfo_concurrent(pdf, infile, progbar, max_workers): pages = [None] * len(pdf.pages) def update_pageinfo(result, pbar): @@ -629,22 +629,21 @@ def _pdf_pageinfo_concurrent(pdf, infile, progbar): pages[page.pageno] = page pbar.update() + if max_workers is None: + max_workers = available_cpu_count() + contexts = ((n, infile) for n in range(len(pdf.pages))) - if os.name == 'nt': - # We can't parallelize on Windows, because Windows cannot fork. - # We are trying to fork, then take advantage of the preloaded pikepdf.Pdf - # object in memory to save time reloading it, hence the silly global - # variable. Hey, it works. Threads are not helpful here because they - # will all just fight over the lock. So on Windows just run sequentially. + + use_threads = False # No performance gain if threaded due to GIL + n_workers = min(1 + len(pages) // 4, max_workers) + if n_workers == 1: + # But if we decided on only one worker, there is no point in using + # a separate process. use_threads = True - max_workers = 1 - else: - use_threads = False - max_workers = min(len(pages), 16) exec_progress_pool( use_threads=use_threads, - max_workers=max_workers, + max_workers=n_workers, tqdm_kwargs=dict( total=len(pdf.pages), desc="Scan", unit='page', disable=not progbar ), @@ -656,12 +655,12 @@ def _pdf_pageinfo_concurrent(pdf, infile, progbar): return pages -def _pdf_get_all_pageinfo(infile, progbar=False): +def _pdf_get_all_pageinfo(infile, progbar=False, max_workers=None): pdf = pikepdf.open(infile) # Do not close in this function try: if pdf.is_encrypted: raise EncryptedPdfError() # Triggered by encryption with empty passwd - pages = _pdf_pageinfo_concurrent(pdf, infile, progbar) + pages = _pdf_pageinfo_concurrent(pdf, infile, progbar, max_workers) except Exception: pdf.close() raise @@ -770,9 +769,11 @@ class PageInfo: class PdfInfo: """Get summary information about a PDF""" - def __init__(self, infile, progbar=False): + def __init__(self, infile, progbar=False, max_workers=None): self._infile = infile - self._pages, pdf = _pdf_get_all_pageinfo(infile, progbar=progbar) + self._pages, pdf = _pdf_get_all_pageinfo( + infile, progbar=progbar, max_workers=max_workers + ) self._needs_rendering = pdf.root.get('/NeedsRendering', False) self._has_acroform = False if '/AcroForm' in pdf.root: From 8b54ce338f1ba0880ae770d944700bd424984fb3 Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Sun, 26 Apr 2020 05:09:42 -0700 Subject: [PATCH 26/94] setup: remove deprecated message about removeal of --force parameter --- setup.py | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/setup.py b/setup.py index da695597..45e0ae1e 100644 --- a/setup.py +++ b/setup.py @@ -27,22 +27,6 @@ if sys.version_info < (3, 6): print("Python 3.6 or newer is required", file=sys.stderr) sys.exit(1) - -# pylint: disable=w0613 - - -command = next((arg for arg in sys.argv[1:] if not arg.startswith('-')), '') -if command.startswith('install') or command in [ - 'check', - 'test', - 'nosetests', - 'easy_install', -]: - forced = '--force' in sys.argv - if forced: - print("The argument --force is deprecated. Please discontinue use.") - - if 'upload' in sys.argv[1:]: print('Use twine to upload the package - setup.py upload is insecure') sys.exit(1) From c84d0f606d5558b491d1ab7c00434c63f932aef0 Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Sun, 26 Apr 2020 05:11:11 -0700 Subject: [PATCH 27/94] ghostscript: remove deprecated argument from generate_pdfa --- src/ocrmypdf/exec/ghostscript.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/ocrmypdf/exec/ghostscript.py b/src/ocrmypdf/exec/ghostscript.py index 44b81682..b58e30cb 100644 --- a/src/ocrmypdf/exec/ghostscript.py +++ b/src/ocrmypdf/exec/ghostscript.py @@ -169,7 +169,6 @@ def generate_pdfa( pdf_pages, output_file: os.PathLike, compression: str, - threads=None, # deprecated parameter pdf_version: str = '1.5', pdfa_part: str = '2', ): @@ -190,10 +189,6 @@ def generate_pdfa( images entirely. (The feature was added in 9.23 but broken, and the 9.24 release of Ghostscript had regressions, so we don't support it until 9.25.) """ - if threads is not None: - warnings.warn( - "use of deprecated parameter 'threads'", category=DeprecationWarning - ) compression_args = [] if compression == 'jpeg': From 168fc6077478c5aadfbdd00e612ab8c8e7642f68 Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Sun, 26 Apr 2020 05:14:59 -0700 Subject: [PATCH 28/94] Update release notes with v10 changes --- docs/release_notes.rst | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/docs/release_notes.rst b/docs/release_notes.rst index 86a1b9f3..3c01b831 100644 --- a/docs/release_notes.rst +++ b/docs/release_notes.rst @@ -13,6 +13,31 @@ Note that it is licensed under GPLv3, so scripts that ``import ocrmypdf`` and are released publicly should probably also be licensed under GPLv3. +v10.0.0 (not yet released) +========================== + +**Breaking changes** + +- Support for pdfminer.six version 20181108 has been dropped, along with a + monkeypatch that made this version work. +- Ghostscript is no longer used for finding the location of text in PDFs, and + APIs related to this feature have been removed. +- Output messages are now displayed in color (when supported by the terminal) + and prefixes describing the severity of the message are removed. As such + programs that parse OCRmyPDF's log message will need to be revised. (Please + consider using OCRmyPDF as a library instead.) +- Code describing the resolution in DPI of images was refactored into a + ``ocrmypdf.helpers.Resolution`` class. +- A deprecated parameter in ``ocrmypdf.exec.ghostscript.generate_pdfa`` was + removed. +- The ``ocrmypdf.hocrtransform`` module has been updated to follow PEP8 naming + conventions. + +**New features** + +- PDF page scanning is now parallelized across CPUs, speeding up the "Scan" + phase for files with a high page count. +- Colored log messages. v9.7.1 ====== From 8f5c95f0f4aee6d50ec0c82d75133cd5621cd6cf Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Sun, 26 Apr 2020 05:33:26 -0700 Subject: [PATCH 29/94] Remove last vestiges of command line usage of qpdf - change to check_pdf --- docs/advanced.rst | 3 +- docs/errors.rst | 8 +-- docs/release_notes.rst | 1 + docs/security.rst | 9 ++-- src/ocrmypdf/_sync.py | 5 +- src/ocrmypdf/_validation.py | 7 --- src/ocrmypdf/exec/qpdf.py | 63 ----------------------- src/ocrmypdf/helpers.py | 37 +++++++++++++ tests/{test_qpdf.py => test_check_pdf.py} | 8 +-- tests/test_hocrtransform.py | 4 +- tests/test_main.py | 7 +-- tests/test_stdio.py | 4 +- tests/test_userunit.py | 2 +- 13 files changed, 63 insertions(+), 95 deletions(-) delete mode 100644 src/ocrmypdf/exec/qpdf.py rename tests/{test_qpdf.py => test_check_pdf.py} (82%) diff --git a/docs/advanced.rst b/docs/advanced.rst index 6f8567ba..9c30f1c5 100644 --- a/docs/advanced.rst +++ b/docs/advanced.rst @@ -148,7 +148,8 @@ In addition to tesseract, OCRmyPDF uses the following external binaries: - ``gs`` (Ghostscript) - ``unpaper`` -- ``qpdf`` +- ``pngquant`` +- ``jbig2`` In each case OCRmyPDF will search the ``PATH`` environment variable to locate the binaries. diff --git a/docs/errors.rst b/docs/errors.rst index 328080ad..bf0b53d0 100644 --- a/docs/errors.rst +++ b/docs/errors.rst @@ -32,10 +32,10 @@ As the error message suggests, your options are: Input file 'filename' is not a valid PDF ======================================== -OCRmyPDF passes files through qpdf, a program that fixes errors in PDFs, -before it tries to work on them. In most cases this happens because the -PDF is corrupt and truncated (incomplete file copying) and not much can -be done. +OCRmyPDF checks files with pikepdf, a library that in turn uses libqpdf to fixes +errors in PDFs, before it tries to work on them. In most cases this happens +because the PDF is corrupt and truncated (incomplete file copying) and not much +can be done. You can try rewriting the file with Ghostscript: diff --git a/docs/release_notes.rst b/docs/release_notes.rst index 3c01b831..3c0bfa90 100644 --- a/docs/release_notes.rst +++ b/docs/release_notes.rst @@ -30,6 +30,7 @@ v10.0.0 (not yet released) ``ocrmypdf.helpers.Resolution`` class. - A deprecated parameter in ``ocrmypdf.exec.ghostscript.generate_pdfa`` was removed. +- The deprecated module ``ocrmypdf.exec.qpdf`` was removed. - The ``ocrmypdf.hocrtransform`` module has been updated to follow PEP8 naming conventions. diff --git a/docs/security.rst b/docs/security.rst index bcc69e8e..36246960 100644 --- a/docs/security.rst +++ b/docs/security.rst @@ -68,7 +68,7 @@ license, OCRmyPDF's GPL license, and any other licenses. Setting aside these concerns, a side effect of OCRmyPDF is it may incidentally sanitize PDFs that contain certain types of malware. It -runs ``qpdf`` to repair the PDF, which could correct malformed PDF +repairs the PDF with pikepdf/libqpdf, which could correct malformed PDF structures that are part of an attack. When PDF/A output is selected (the default), the input PDF is partially reconstructed by Ghostscript. When ``--force-ocr`` is used, all pages are rasterized and reconverted @@ -144,10 +144,9 @@ set, the document cannot be viewed without the password. Either way, OCRmyPDF does not remove passwords from PDFs and exits with an error on encountering them. -``qpdf``, one of OCRmyPDF's dependencies, can remove passwords. If the -owner and user password are set, a password is required for ``qpdf``. If -only the owner password is set, then the password can be stripped, even -if one does not have the owner password. +``qpdf`` can remove passwords. If the owner and user password are set, a +password is required for ``qpdf``. If only the owner password is set, then the +password can be stripped, even if one does not have the owner password. After OCR is applied, password protection is not permitted on PDF/A documents but the file can be converted to regular PDF. diff --git a/src/ocrmypdf/_sync.py b/src/ocrmypdf/_sync.py index e6e44271..77ecfa9b 100644 --- a/src/ocrmypdf/_sync.py +++ b/src/ocrmypdf/_sync.py @@ -64,8 +64,7 @@ from ._validation import ( report_output_file_size, ) from .exceptions import ExitCode, ExitCodeException -from .exec import qpdf -from .helpers import available_cpu_count +from .helpers import available_cpu_count, check_pdf from .pdfa import file_claims_pdfa log = logging.getLogger(__name__) @@ -357,7 +356,7 @@ def run_pipeline(options, api=False): pdfa_info['conformance'], ) return ExitCode.pdfa_conversion_failed - if not qpdf.check(options.output_file): + if not check_pdf(options.output_file): log.warning('Output file: The generated PDF is INVALID') return ExitCode.invalid_output_pdf report_output_file_size(options, start_input_file, options.output_file) diff --git a/src/ocrmypdf/_validation.py b/src/ocrmypdf/_validation.py index ef440e0f..8be0b16e 100644 --- a/src/ocrmypdf/_validation.py +++ b/src/ocrmypdf/_validation.py @@ -38,7 +38,6 @@ from .exec import ( ghostscript, jbig2enc, pngquant, - qpdf, tesseract, unpaper, ) @@ -473,9 +472,3 @@ def check_dependency_versions(options): "supported. Please upgrade to a newer version, or downgrade to the " "previous version." ) - check_external_program( - program='qpdf', - package='qpdf', - version_checker=qpdf.version, - need_version='8.0.2', - ) diff --git a/src/ocrmypdf/exec/qpdf.py b/src/ocrmypdf/exec/qpdf.py deleted file mode 100644 index 32ef8cb6..00000000 --- a/src/ocrmypdf/exec/qpdf.py +++ /dev/null @@ -1,63 +0,0 @@ -# © 2017 James R. Barlow: github.com/jbarlow83 -# -# This file is part of OCRmyPDF. -# -# OCRmyPDF is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# OCRmyPDF is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with OCRmyPDF. If not, see . - -"""Interface to qpdf executable""" - -import logging -from io import StringIO - -import pikepdf - -log = logging.getLogger(__name__) - - -def version(): - return pikepdf.__libqpdf_version__ - - -def check(input_file): - pdf = None - try: - pdf = pikepdf.open(input_file) - except pikepdf.PdfError as e: - log.error(e) - return False - else: - messages = pdf.check() - for msg in messages: - if 'error' in msg.lower(): - log.error(msg) - else: - log.warning(msg) - - sio = StringIO() - linearize = None - try: - pdf.check_linearization(sio) - except RuntimeError: - pass - else: - linearize = sio.getvalue() - if linearize: - log.warning(linearize) - - if not messages and not linearize: - return True - return False - finally: - if pdf: - pdf.close() diff --git a/src/ocrmypdf/helpers.py b/src/ocrmypdf/helpers.py index c57aca35..69f4d9bf 100644 --- a/src/ocrmypdf/helpers.py +++ b/src/ocrmypdf/helpers.py @@ -24,9 +24,12 @@ from collections import namedtuple from collections.abc import Iterable from contextlib import suppress from functools import wraps +from io import StringIO from math import inf, isclose from pathlib import Path +import pikepdf + log = logging.getLogger(__name__) @@ -165,6 +168,40 @@ def is_file_writable(test_file: os.PathLike): return False +def check_pdf(input_file): + pdf = None + try: + pdf = pikepdf.open(input_file) + except pikepdf.PdfError as e: + log.error(e) + return False + else: + messages = pdf.check() + for msg in messages: + if 'error' in msg.lower(): + log.error(msg) + else: + log.warning(msg) + + sio = StringIO() + linearize = None + try: + pdf.check_linearization(sio) + except RuntimeError: + pass + else: + linearize = sio.getvalue() + if linearize: + log.warning(linearize) + + if not messages and not linearize: + return True + return False + finally: + if pdf: + pdf.close() + + def deprecated(func): """Warn that function is deprecated""" diff --git a/tests/test_qpdf.py b/tests/test_check_pdf.py similarity index 82% rename from tests/test_qpdf.py rename to tests/test_check_pdf.py index 0e925249..b3516e90 100644 --- a/tests/test_qpdf.py +++ b/tests/test_check_pdf.py @@ -17,9 +17,9 @@ import pytest -import ocrmypdf.exec.qpdf as qpdf +from ocrmypdf.helpers import check_pdf -def test_qpdf_error(resources): - assert qpdf.check(resources / 'blank.pdf') - assert not qpdf.check(__file__) +def test_pdf_error(resources): + assert check_pdf(resources / 'blank.pdf') + assert not check_pdf(__file__) diff --git a/tests/test_hocrtransform.py b/tests/test_hocrtransform.py index 13b1f601..e00f8365 100644 --- a/tests/test_hocrtransform.py +++ b/tests/test_hocrtransform.py @@ -21,8 +21,8 @@ import pytest from PIL import Image from ocrmypdf import hocrtransform -from ocrmypdf.exec import qpdf from ocrmypdf.exec.tesseract import HOCR_TEMPLATE +from ocrmypdf.helpers import check_pdf # pylint: disable=redefined-outer-name @@ -43,4 +43,4 @@ def test_mono_image(blank_hocr, outdir): hocr = hocrtransform.HocrTransform(str(blank_hocr), 300) hocr.to_pdf(str(outdir / 'mono.pdf'), image_filename=str(outdir / 'mono.tif')) - qpdf.check(str(outdir / 'mono.pdf')) + check_pdf(str(outdir / 'mono.pdf')) diff --git a/tests/test_main.py b/tests/test_main.py index 9b0cd17e..e69ed192 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -29,7 +29,8 @@ from PIL import Image import ocrmypdf from ocrmypdf.exceptions import ExitCode, MissingDependencyError -from ocrmypdf.exec import ghostscript, qpdf, tesseract +from ocrmypdf.exec import ghostscript, tesseract +from ocrmypdf.helpers import check_pdf from ocrmypdf.pdfa import file_claims_pdfa from ocrmypdf.pdfinfo import Colorspace, Encoding, PdfInfo @@ -529,8 +530,8 @@ def test_skip_big_with_no_images(spoof_tesseract_noop, resources, outpdf): @pytest.mark.skipif( - '8.0.0' <= qpdf.version() <= '8.0.1', - reason="qpdf regression on pages with no contents", + '8.0.0' <= pikepdf.__libqpdf_version__ <= '8.0.1', + reason="libqpdf regression on pages with no contents", ) def test_no_contents(spoof_tesseract_noop, resources, outpdf): check_ocrmypdf( diff --git a/tests/test_stdio.py b/tests/test_stdio.py index 150e40ff..e57c11f0 100644 --- a/tests/test_stdio.py +++ b/tests/test_stdio.py @@ -23,7 +23,7 @@ from subprocess import DEVNULL, PIPE, CalledProcessError, Popen, run import pytest from ocrmypdf.exceptions import ExitCode -from ocrmypdf.exec import qpdf +from ocrmypdf.helpers import check_pdf # pytest.helpers is dynamic # pylint: disable=no-member,redefined-outer-name @@ -74,7 +74,7 @@ def test_stdout(spoof_tesseract_noop, ocrmypdf_exec, resources, outpdf): ) assert p.returncode == ExitCode.ok - assert qpdf.check(output_file) + assert check_pdf(output_file) @pytest.mark.skipif( diff --git a/tests/test_userunit.py b/tests/test_userunit.py index 83ad01d4..41d21aa2 100644 --- a/tests/test_userunit.py +++ b/tests/test_userunit.py @@ -39,7 +39,7 @@ def test_userunit_ghostscript_fails(poster, no_outpdf, caplog): assert 'not supported by Ghostscript' in caplog.text -def test_userunit_qpdf_passes(spoof_tesseract_cache, poster, outpdf): +def test_userunit_pdf_passes(spoof_tesseract_cache, poster, outpdf): before = PdfInfo(poster) check_ocrmypdf(poster, outpdf, '--output-type=pdf', env=spoof_tesseract_cache) From 016dfd420c7d42d01583ac6902c8926bd6a96126 Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Thu, 30 Apr 2020 04:11:38 -0700 Subject: [PATCH 30/94] Add warning if problematic --tesseract-pagesegmode is selected Fixes #549 --- src/ocrmypdf/_validation.py | 5 +++++ tests/test_validation.py | 6 ++++++ 2 files changed, 11 insertions(+) diff --git a/src/ocrmypdf/_validation.py b/src/ocrmypdf/_validation.py index 8be0b16e..bfc25d02 100644 --- a/src/ocrmypdf/_validation.py +++ b/src/ocrmypdf/_validation.py @@ -276,6 +276,11 @@ def check_options_advanced(options): "Tesseract 4.0 ignores --user-words and --user-patterns, so these " "arguments have no effect." ) + if options.tesseract_pagesegmode in (0, 2): + log.warning( + "The --tesseract-pagesegmode argument you select will disable OCR. " + "This may cause processing to fail." + ) def check_options_metadata(options): diff --git a/tests/test_validation.py b/tests/test_validation.py index 864dd05a..f183aa46 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -210,3 +210,9 @@ def test_version_comparison(): version_checker=lambda: '1.0', need_version='2.0', ) + + +def test_pagesegmode_warning(caplog): + opts = make_opts(tesseract_pagesegmode='0') + vd.check_options_advanced(opts) + assert 'disable OCR' in caplog.text From 82bce463aece0c2e423a2fc7c0d4319546e77b24 Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Fri, 1 May 2020 02:15:23 -0700 Subject: [PATCH 31/94] Start pluggy-based plugin system --- setup.cfg | 2 +- src/ocrmypdf/__init__.py | 13 +++++--- src/ocrmypdf/_jobcontext.py | 3 +- src/ocrmypdf/_pipeline.py | 1 + src/ocrmypdf/_pluginspec.py | 66 +++++++++++++++++++++++++++++++++++++ src/ocrmypdf/_sync.py | 49 +++++++++++++++++++++------ src/ocrmypdf/cli.py | 6 ++++ src/ocrmypdf/example.py | 6 ++++ 8 files changed, 130 insertions(+), 16 deletions(-) create mode 100644 src/ocrmypdf/_pluginspec.py create mode 100644 src/ocrmypdf/example.py diff --git a/setup.cfg b/setup.cfg index f307a2e5..3cb3db9d 100644 --- a/setup.cfg +++ b/setup.cfg @@ -23,7 +23,7 @@ force_grid_wrap=0 use_parentheses=True line_length=88 known_first_party = ocrmypdf -known_third_party = PIL,_cffi_backend,cffi,flask,gs,img2pdf,pdfminer,pikepdf,pkg_resources,pytest,reportlab,setuptools,sphinx_rtd_theme,tqdm,watchdog,werkzeug +known_third_party = PIL,_cffi_backend,cffi,flask,gs,img2pdf,pdfminer,pikepdf,pkg_resources,pluggy,pytest,reportlab,setuptools,sphinx_rtd_theme,tqdm,watchdog,werkzeug [metadata] license_file = LICENSE diff --git a/src/ocrmypdf/__init__.py b/src/ocrmypdf/__init__.py index 2f76bf4f..2326efb2 100644 --- a/src/ocrmypdf/__init__.py +++ b/src/ocrmypdf/__init__.py @@ -15,10 +15,13 @@ # You should have received a copy of the GNU General Public License # along with OCRmyPDF. If not, see . -from . import helpers, hocrtransform, leptonica, pdfa, pdfinfo -from ._version import PROGRAM_NAME, __version__ -from .api import Verbosity, configure_logging, ocr -from .exceptions import ( + +from pluggy import HookimplMarker + +from ocrmypdf import helpers, hocrtransform, leptonica, pdfa, pdfinfo +from ocrmypdf._version import PROGRAM_NAME, __version__ +from ocrmypdf.api import Verbosity, configure_logging, ocr +from ocrmypdf.exceptions import ( BadArgsError, DpiError, EncryptedPdfError, @@ -33,3 +36,5 @@ from .exceptions import ( TesseractConfigError, UnsupportedImageFormatError, ) + +hookimpl = HookimplMarker('ocrmypdf') diff --git a/src/ocrmypdf/_jobcontext.py b/src/ocrmypdf/_jobcontext.py index a782d596..eac75d5e 100644 --- a/src/ocrmypdf/_jobcontext.py +++ b/src/ocrmypdf/_jobcontext.py @@ -24,11 +24,12 @@ import sys class PDFContext: """Holds our context for a particular run of the pipeline""" - def __init__(self, options, work_folder, origin, pdfinfo): + def __init__(self, options, work_folder, origin, pdfinfo, plugin_manager): self.options = options self.work_folder = work_folder self.origin = origin self.pdfinfo = pdfinfo + self.plugin_manager = plugin_manager if options: self.name = os.path.basename(options.input_file) else: diff --git a/src/ocrmypdf/_pipeline.py b/src/ocrmypdf/_pipeline.py index 2128adf3..6a691b81 100644 --- a/src/ocrmypdf/_pipeline.py +++ b/src/ocrmypdf/_pipeline.py @@ -194,6 +194,7 @@ def validate_pdfinfo_options(context): "form and all filled form fields. The output PDF will be " "'flattened' and will no longer be fillable." ) + context.plugin_manager.hook.prepare(options=options) def get_page_dpi(pageinfo, options): diff --git a/src/ocrmypdf/_pluginspec.py b/src/ocrmypdf/_pluginspec.py new file mode 100644 index 00000000..c3aac6f2 --- /dev/null +++ b/src/ocrmypdf/_pluginspec.py @@ -0,0 +1,66 @@ +# © 2020 James R. Barlow: github.com/jbarlow83 +# +# This file is part of OCRmyPDF. +# +# OCRmyPDF is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# OCRmyPDF is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with OCRmyPDF. If not, see . + +from argparse import Namespace + +import pluggy +from PIL import Image + +from ocrmypdf.pdfinfo import PdfInfo + +hookspec = pluggy.HookspecMarker('ocrmypdf') + +# pylint: disable=unused-argument + + +@hookspec +def prepare(options: Namespace) -> None: + """Called to notify a plugin that a file will be processed. + + The plugin may modify the options. All objects that are in options must + be picklable so they can be marshalled to child worker processes. + + Typically, a plugin will call ``registry.register_plugin(__name__)`` to register + all of its public functions with the plugin registry. Functions that are + not intended for registration should be prefixed with an underscore. + Functions that imported from other modules will be ignored by + ``.register_plugin()``. For example if you use ``from os import basename``, + ``basename`` will not be registered. + """ + + +@hookspec +def validate(pdfinfo: PdfInfo, options: Namespace) -> None: + """Called to give a plugin an opportunity to review options and pdfinfo. + + options contains the "work order" to process a particular file. pdfinfo + contains information about the input file obtained after loading and + parsing. + + The plugin may raise InputFileError or any ExitCodeException to request + normal termination. If the plugin raises another exception type, ocrmypdf + will abort with an error and hold the plugin responsible. + """ + + +@hookspec +def filter_ocr_image(image: Image) -> Image: + """Called to filter the image before it is sent to OCR. + + This is the image that OCR sees, not what the user sees when they view the + PDF. + """ diff --git a/src/ocrmypdf/_sync.py b/src/ocrmypdf/_sync.py index 77ecfa9b..69da25bc 100644 --- a/src/ocrmypdf/_sync.py +++ b/src/ocrmypdf/_sync.py @@ -15,6 +15,7 @@ # You should have received a copy of the GNU General Public License # along with OCRmyPDF. If not, see . +import importlib import logging import logging.handlers import multiprocessing @@ -28,12 +29,14 @@ from pathlib import Path from tempfile import mkdtemp import PIL +import pluggy -from ._concurrent import exec_progress_pool -from ._graft import OcrGrafter -from ._jobcontext import PDFContext, cleanup_working_files -from ._logging import PageNumberFilter -from ._pipeline import ( +from ocrmypdf import _pluginspec +from ocrmypdf._concurrent import exec_progress_pool +from ocrmypdf._graft import OcrGrafter +from ocrmypdf._jobcontext import PDFContext, cleanup_working_files +from ocrmypdf._logging import PageNumberFilter +from ocrmypdf._pipeline import ( convert_to_pdfa, copy_final, create_ocr_image, @@ -58,14 +61,14 @@ from ._pipeline import ( triage, validate_pdfinfo_options, ) -from ._validation import ( +from ocrmypdf._validation import ( check_requested_output_file, create_input_file, report_output_file_size, ) -from .exceptions import ExitCode, ExitCodeException -from .helpers import available_cpu_count, check_pdf -from .pdfa import file_claims_pdfa +from ocrmypdf.exceptions import ExitCode, ExitCodeException +from ocrmypdf.helpers import available_cpu_count, check_pdf +from ocrmypdf.pdfa import file_claims_pdfa log = logging.getLogger(__name__) @@ -298,6 +301,24 @@ def configure_debug_logging(log_filename, prefix=''): return log_file_handler +def _load_object_from_module(location): + """Load a object given a module location + + For location=a.b.c, will effectively run "from a.b import c" + + Example: + _load_object_from_module("a.b.c") + + """ + module_parts = location.split('.') + module_name = '.'.join(module_parts[:-1]) + object_name = module_parts[-1] + module = importlib.import_module(module_name) + obj = getattr(module, object_name) + log.debug(f"Loaded object: from {module_name} import {object_name}") + return obj + + def run_pipeline(options, api=False): # Any changes to options will not take effect for options that are already # bound to function parameters in the pipeline. (For example @@ -312,6 +333,14 @@ def run_pipeline(options, api=False): ): debug_log_handler = configure_debug_logging(Path(work_folder) / "debug.log") + pm = pluggy.PluginManager('ocrmypdf') + pm.add_hookspecs(_pluginspec) + + for name in options.plugins: + # module = _load_object_from_module(name) + module = importlib.import_module(name) + pm.register(module) + try: check_requested_output_file(options) start_input_file, original_filename = create_input_file(options, work_folder) @@ -331,7 +360,7 @@ def run_pipeline(options, api=False): max_workers=options.jobs if not options.use_threads else 1, # To help debug ) - context = PDFContext(options, work_folder, origin_pdf, pdfinfo) + context = PDFContext(options, work_folder, origin_pdf, pdfinfo, pm) # Validate options are okay for this pdf validate_pdfinfo_options(context) diff --git a/src/ocrmypdf/cli.py b/src/ocrmypdf/cli.py index e28162e7..48a90339 100644 --- a/src/ocrmypdf/cli.py +++ b/src/ocrmypdf/cli.py @@ -480,6 +480,12 @@ advanced.add_argument( "which do not benefit. If the threshold is 0 it will be apply to all files. " "Set the threshold very high to disable.", ) +advanced.add_argument( + '--plugins', + action='append', + default=[], + help="Path to a folder than contains plugins.", +) debugging = parser.add_argument_group( "Debugging", "Arguments to help with troubleshooting and debugging" diff --git a/src/ocrmypdf/example.py b/src/ocrmypdf/example.py new file mode 100644 index 00000000..a890bb3e --- /dev/null +++ b/src/ocrmypdf/example.py @@ -0,0 +1,6 @@ +import ocrmypdf + + +@ocrmypdf.hookimpl +def prepare(options): + raise ValueError('foo') From d8ff4485f8482411480431c7774833f8cc916439 Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Fri, 1 May 2020 02:18:11 -0700 Subject: [PATCH 32/94] Move samefile to helpers --- src/ocrmypdf/_sync.py | 9 +-------- src/ocrmypdf/helpers.py | 7 +++++++ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/ocrmypdf/_sync.py b/src/ocrmypdf/_sync.py index 69da25bc..0497f5f1 100644 --- a/src/ocrmypdf/_sync.py +++ b/src/ocrmypdf/_sync.py @@ -67,7 +67,7 @@ from ocrmypdf._validation import ( report_output_file_size, ) from ocrmypdf.exceptions import ExitCode, ExitCodeException -from ocrmypdf.helpers import available_cpu_count, check_pdf +from ocrmypdf.helpers import available_cpu_count, check_pdf, samefile from ocrmypdf.pdfa import file_claims_pdfa log = logging.getLogger(__name__) @@ -283,13 +283,6 @@ class NeverRaise(Exception): pass # pylint: disable=unnecessary-pass -def samefile(f1, f2): - if os.name == 'nt': - return f1 == f2 - else: - return os.path.samefile(f1, f2) - - def configure_debug_logging(log_filename, prefix=''): log_file_handler = logging.FileHandler(log_filename, delay=True) log_file_handler.setLevel(logging.DEBUG) diff --git a/src/ocrmypdf/helpers.py b/src/ocrmypdf/helpers.py index 69f4d9bf..0bfe694a 100644 --- a/src/ocrmypdf/helpers.py +++ b/src/ocrmypdf/helpers.py @@ -104,6 +104,13 @@ def safe_symlink(input_file: os.PathLike, soft_link_name: os.PathLike, *args, ** os.symlink(os.path.abspath(input_file), soft_link_name) +def samefile(f1, f2): + if os.name == 'nt': + return f1 == f2 + else: + return os.path.samefile(f1, f2) + + def is_iterable_notstr(thing): return isinstance(thing, Iterable) and not isinstance(thing, str) From 5eb4fe00525dfec915b2b394eae05a7aa12e44fc Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Fri, 1 May 2020 02:18:31 -0700 Subject: [PATCH 33/94] Refactor plugin setup to get_plugin_manager --- src/ocrmypdf/_sync.py | 33 ++++++++------------------------- 1 file changed, 8 insertions(+), 25 deletions(-) diff --git a/src/ocrmypdf/_sync.py b/src/ocrmypdf/_sync.py index 0497f5f1..ac5a8bef 100644 --- a/src/ocrmypdf/_sync.py +++ b/src/ocrmypdf/_sync.py @@ -18,9 +18,7 @@ import importlib import logging import logging.handlers -import multiprocessing import os -import signal import sys import threading from collections import namedtuple @@ -294,22 +292,14 @@ def configure_debug_logging(log_filename, prefix=''): return log_file_handler -def _load_object_from_module(location): - """Load a object given a module location +def get_plugin_manager(options): + pm = pluggy.PluginManager('ocrmypdf') + pm.add_hookspecs(_pluginspec) - For location=a.b.c, will effectively run "from a.b import c" - - Example: - _load_object_from_module("a.b.c") - - """ - module_parts = location.split('.') - module_name = '.'.join(module_parts[:-1]) - object_name = module_parts[-1] - module = importlib.import_module(module_name) - obj = getattr(module, object_name) - log.debug(f"Loaded object: from {module_name} import {object_name}") - return obj + for name in options.plugins: + module = importlib.import_module(name) + pm.register(module) + return pm def run_pipeline(options, api=False): @@ -326,14 +316,7 @@ def run_pipeline(options, api=False): ): debug_log_handler = configure_debug_logging(Path(work_folder) / "debug.log") - pm = pluggy.PluginManager('ocrmypdf') - pm.add_hookspecs(_pluginspec) - - for name in options.plugins: - # module = _load_object_from_module(name) - module = importlib.import_module(name) - pm.register(module) - + pm = get_plugin_manager(options) try: check_requested_output_file(options) start_input_file, original_filename = create_input_file(options, work_folder) From 8d2535e327d98ac8dc3995880117b026e5a43edd Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Fri, 1 May 2020 02:39:50 -0700 Subject: [PATCH 34/94] Get pluggy to work with forking workers --- src/ocrmypdf/_jobcontext.py | 15 +++++++++++++++ src/ocrmypdf/_pipeline.py | 3 +++ src/ocrmypdf/_plugin_manager.py | 15 +++++++++++++++ src/ocrmypdf/_sync.py | 13 ++----------- src/ocrmypdf/example.py | 11 ++++++++--- src/ocrmypdf/{_pluginspec.py => pluginspec.py} | 0 6 files changed, 43 insertions(+), 14 deletions(-) create mode 100644 src/ocrmypdf/_plugin_manager.py rename src/ocrmypdf/{_pluginspec.py => pluginspec.py} (100%) diff --git a/src/ocrmypdf/_jobcontext.py b/src/ocrmypdf/_jobcontext.py index eac75d5e..ea48380b 100644 --- a/src/ocrmypdf/_jobcontext.py +++ b/src/ocrmypdf/_jobcontext.py @@ -19,6 +19,9 @@ import logging import os import shutil import sys +from functools import partial + +from ocrmypdf._plugin_manager import get_plugin_manager class PDFContext: @@ -59,10 +62,22 @@ class PageContext: self.name = pdf_context.name self.pageno = pageno self.pageinfo = pdf_context.pdfinfo[pageno] + self.plugin_manager = pdf_context.plugin_manager def get_path(self, name): return os.path.join(self.work_folder, "%06d_%s" % (self.pageno + 1, name)) + def __getstate__(self): + state = self.__dict__.copy() + del state['plugin_manager'] + state['construct_plugin_manager'] = partial(get_plugin_manager, self.options) + return state + + def __setstate__(self, state): + self.__dict__.update(state) + self.plugin_manager = self.__dict__['construct_plugin_manager']() + del self.__dict__['construct_plugin_manager'] + def cleanup_working_files(work_folder, options): if options.keep_temporary_files: diff --git a/src/ocrmypdf/_pipeline.py b/src/ocrmypdf/_pipeline.py index 6a691b81..182a2888 100644 --- a/src/ocrmypdf/_pipeline.py +++ b/src/ocrmypdf/_pipeline.py @@ -521,6 +521,9 @@ def create_ocr_image(image, page_context): im = pix.topil() del draw + + im = page_context.plugin_manager.hook.filter_ocr_image(image=im) + # Pillow requires integer DPI dpi = tuple(round(coord) for coord in im.info['dpi']) im.save(output_file, dpi=dpi) diff --git a/src/ocrmypdf/_plugin_manager.py b/src/ocrmypdf/_plugin_manager.py new file mode 100644 index 00000000..a7f7987e --- /dev/null +++ b/src/ocrmypdf/_plugin_manager.py @@ -0,0 +1,15 @@ +import importlib + +import pluggy + +from ocrmypdf import pluginspec + + +def get_plugin_manager(options): + pm = pluggy.PluginManager('ocrmypdf') + pm.add_hookspecs(pluginspec) + + for name in options.plugins: + module = importlib.import_module(name) + pm.register(module) + return pm diff --git a/src/ocrmypdf/_sync.py b/src/ocrmypdf/_sync.py index ac5a8bef..6f21bb09 100644 --- a/src/ocrmypdf/_sync.py +++ b/src/ocrmypdf/_sync.py @@ -29,7 +29,7 @@ from tempfile import mkdtemp import PIL import pluggy -from ocrmypdf import _pluginspec +from ocrmypdf import pluginspec from ocrmypdf._concurrent import exec_progress_pool from ocrmypdf._graft import OcrGrafter from ocrmypdf._jobcontext import PDFContext, cleanup_working_files @@ -59,6 +59,7 @@ from ocrmypdf._pipeline import ( triage, validate_pdfinfo_options, ) +from ocrmypdf._plugin_manager import get_plugin_manager from ocrmypdf._validation import ( check_requested_output_file, create_input_file, @@ -292,16 +293,6 @@ def configure_debug_logging(log_filename, prefix=''): return log_file_handler -def get_plugin_manager(options): - pm = pluggy.PluginManager('ocrmypdf') - pm.add_hookspecs(_pluginspec) - - for name in options.plugins: - module = importlib.import_module(name) - pm.register(module) - return pm - - def run_pipeline(options, api=False): # Any changes to options will not take effect for options that are already # bound to function parameters in the pipeline. (For example diff --git a/src/ocrmypdf/example.py b/src/ocrmypdf/example.py index a890bb3e..755e0f57 100644 --- a/src/ocrmypdf/example.py +++ b/src/ocrmypdf/example.py @@ -1,6 +1,11 @@ -import ocrmypdf +from ocrmypdf import hookimpl -@ocrmypdf.hookimpl +@hookimpl def prepare(options): - raise ValueError('foo') + pass + + +@hookimpl +def filter_ocr_image(image): + return image diff --git a/src/ocrmypdf/_pluginspec.py b/src/ocrmypdf/pluginspec.py similarity index 100% rename from src/ocrmypdf/_pluginspec.py rename to src/ocrmypdf/pluginspec.py From be107b4fedb838b1c4b4599abd75488edf6f6fa1 Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Fri, 1 May 2020 02:56:41 -0700 Subject: [PATCH 35/94] Set up filter_ocr_image hook --- src/ocrmypdf/_pipeline.py | 2 +- src/ocrmypdf/_plugin_manager.py | 17 +++++++++++++++++ src/ocrmypdf/_sync.py | 2 ++ src/ocrmypdf/example.py | 9 +++++++++ src/ocrmypdf/pluginspec.py | 9 +-------- 5 files changed, 30 insertions(+), 9 deletions(-) diff --git a/src/ocrmypdf/_pipeline.py b/src/ocrmypdf/_pipeline.py index 182a2888..142c2983 100644 --- a/src/ocrmypdf/_pipeline.py +++ b/src/ocrmypdf/_pipeline.py @@ -194,7 +194,7 @@ def validate_pdfinfo_options(context): "form and all filled form fields. The output PDF will be " "'flattened' and will no longer be fillable." ) - context.plugin_manager.hook.prepare(options=options) + context.plugin_manager.hook.validate(pdfinfo=pdfinfo, options=options) def get_page_dpi(pageinfo, options): diff --git a/src/ocrmypdf/_plugin_manager.py b/src/ocrmypdf/_plugin_manager.py index a7f7987e..2c3df5a1 100644 --- a/src/ocrmypdf/_plugin_manager.py +++ b/src/ocrmypdf/_plugin_manager.py @@ -1,3 +1,20 @@ +# © 2020 James R. Barlow: github.com/jbarlow83 +# +# This file is part of OCRmyPDF. +# +# OCRmyPDF is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# OCRmyPDF is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with OCRmyPDF. If not, see . + import importlib import pluggy diff --git a/src/ocrmypdf/_sync.py b/src/ocrmypdf/_sync.py index 6f21bb09..1691eb00 100644 --- a/src/ocrmypdf/_sync.py +++ b/src/ocrmypdf/_sync.py @@ -320,6 +320,8 @@ def run_pipeline(options, api=False): options, ) + pm.hook.prepare(options=options) + # Gather pdfinfo and create context pdfinfo = get_pdfinfo( origin_pdf, diff --git a/src/ocrmypdf/example.py b/src/ocrmypdf/example.py index 755e0f57..dda37bf0 100644 --- a/src/ocrmypdf/example.py +++ b/src/ocrmypdf/example.py @@ -1,11 +1,20 @@ +import logging + from ocrmypdf import hookimpl +log = logging.getLogger(__name__) + @hookimpl def prepare(options): pass +@hookimpl +def validate(pdfinfo, options): + pass + + @hookimpl def filter_ocr_image(image): return image diff --git a/src/ocrmypdf/pluginspec.py b/src/ocrmypdf/pluginspec.py index c3aac6f2..86657c9d 100644 --- a/src/ocrmypdf/pluginspec.py +++ b/src/ocrmypdf/pluginspec.py @@ -33,13 +33,6 @@ def prepare(options: Namespace) -> None: The plugin may modify the options. All objects that are in options must be picklable so they can be marshalled to child worker processes. - - Typically, a plugin will call ``registry.register_plugin(__name__)`` to register - all of its public functions with the plugin registry. Functions that are - not intended for registration should be prefixed with an underscore. - Functions that imported from other modules will be ignored by - ``.register_plugin()``. For example if you use ``from os import basename``, - ``basename`` will not be registered. """ @@ -57,7 +50,7 @@ def validate(pdfinfo: PdfInfo, options: Namespace) -> None: """ -@hookspec +@hookspec(firstresult=True) def filter_ocr_image(image: Image) -> Image: """Called to filter the image before it is sent to OCR. From 23d558ad8c4149a503aed237893cf6b8af6761e9 Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Sat, 2 May 2020 01:37:24 -0700 Subject: [PATCH 36/94] Allow plugins to add command line arguments --- src/ocrmypdf/__main__.py | 18 ++++++++++++------ src/ocrmypdf/_pipeline.py | 4 +++- src/ocrmypdf/cli.py | 10 ++++++++++ src/ocrmypdf/example.py | 10 +++++++++- src/ocrmypdf/pluginspec.py | 10 ++++++++-- 5 files changed, 42 insertions(+), 10 deletions(-) diff --git a/src/ocrmypdf/__main__.py b/src/ocrmypdf/__main__.py index 5d3a5d50..c62c9409 100755 --- a/src/ocrmypdf/__main__.py +++ b/src/ocrmypdf/__main__.py @@ -21,17 +21,23 @@ import os import sys from multiprocessing import set_start_method -from . import __version__ -from ._sync import run_pipeline -from ._validation import check_closed_streams, check_options -from .api import Verbosity, configure_logging -from .cli import parser -from .exceptions import BadArgsError, ExitCode, MissingDependencyError +from ocrmypdf import __version__ +from ocrmypdf._plugin_manager import get_plugin_manager +from ocrmypdf._sync import run_pipeline +from ocrmypdf._validation import check_closed_streams, check_options +from ocrmypdf.api import Verbosity, configure_logging +from ocrmypdf.cli import parser, plugins_only_parser +from ocrmypdf.exceptions import BadArgsError, ExitCode, MissingDependencyError log = logging.getLogger('ocrmypdf') def run(args=None): + pre_options, _unused = plugins_only_parser.parse_known_args(args=args) + if pre_options.plugins: + pm = get_plugin_manager(pre_options) + pm.hook.install_cli(parser=parser) + options = parser.parse_args(args=args) if not check_closed_streams(options): diff --git a/src/ocrmypdf/_pipeline.py b/src/ocrmypdf/_pipeline.py index 142c2983..a8d6365f 100644 --- a/src/ocrmypdf/_pipeline.py +++ b/src/ocrmypdf/_pipeline.py @@ -522,7 +522,9 @@ def create_ocr_image(image, page_context): del draw - im = page_context.plugin_manager.hook.filter_ocr_image(image=im) + im = page_context.plugin_manager.hook.filter_ocr_image( + page=page_context, image=im + ) # Pillow requires integer DPI dpi = tuple(round(coord) for coord in im.info['dpi']) diff --git a/src/ocrmypdf/cli.py b/src/ocrmypdf/cli.py index 48a90339..d243364a 100644 --- a/src/ocrmypdf/cli.py +++ b/src/ocrmypdf/cli.py @@ -497,3 +497,13 @@ debugging.add_argument( help="Keep temporary files (helpful for debugging)", ) debugging.add_argument('--tesseract-env', type=str, help=argparse.SUPPRESS) + +plugins_only_parser = ArgumentParser( + prog=_PROGRAM_NAME, fromfile_prefix_chars='@', add_help=False, allow_abbrev=False +) +plugins_only_parser.add_argument( + '--plugins', + action='append', + default=[], + help="Path to a folder than contains plugins.", +) diff --git a/src/ocrmypdf/example.py b/src/ocrmypdf/example.py index dda37bf0..73b1b544 100644 --- a/src/ocrmypdf/example.py +++ b/src/ocrmypdf/example.py @@ -5,6 +5,11 @@ from ocrmypdf import hookimpl log = logging.getLogger(__name__) +@hookimpl +def install_cli(parser): + parser.add_argument('--invert', action='store_true') + + @hookimpl def prepare(options): pass @@ -16,5 +21,8 @@ def validate(pdfinfo, options): @hookimpl -def filter_ocr_image(image): +def filter_ocr_image(page, image): + if page.options.invert: + log.info("inverting") + return image.invert() return image diff --git a/src/ocrmypdf/pluginspec.py b/src/ocrmypdf/pluginspec.py index 86657c9d..5b5d7493 100644 --- a/src/ocrmypdf/pluginspec.py +++ b/src/ocrmypdf/pluginspec.py @@ -15,11 +15,12 @@ # You should have received a copy of the GNU General Public License # along with OCRmyPDF. If not, see . -from argparse import Namespace +from argparse import ArgumentParser, Namespace import pluggy from PIL import Image +from ocrmypdf._jobcontext import PageContext from ocrmypdf.pdfinfo import PdfInfo hookspec = pluggy.HookspecMarker('ocrmypdf') @@ -27,6 +28,11 @@ hookspec = pluggy.HookspecMarker('ocrmypdf') # pylint: disable=unused-argument +@hookspec +def install_cli(parser: ArgumentParser) -> None: + """Allows the plugin to add its own command line arguments.""" + + @hookspec def prepare(options: Namespace) -> None: """Called to notify a plugin that a file will be processed. @@ -51,7 +57,7 @@ def validate(pdfinfo: PdfInfo, options: Namespace) -> None: @hookspec(firstresult=True) -def filter_ocr_image(image: Image) -> Image: +def filter_ocr_image(page: PageContext, image: Image) -> Image: """Called to filter the image before it is sent to OCR. This is the image that OCR sees, not what the user sees when they view the From 8c9a8fc85cb0cafdd41ab305d20077cc21e8540a Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Sat, 2 May 2020 03:32:55 -0700 Subject: [PATCH 37/94] pluginspec: avoid circular reference --- src/ocrmypdf/pluginspec.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/ocrmypdf/pluginspec.py b/src/ocrmypdf/pluginspec.py index 5b5d7493..e95241e0 100644 --- a/src/ocrmypdf/pluginspec.py +++ b/src/ocrmypdf/pluginspec.py @@ -20,9 +20,6 @@ from argparse import ArgumentParser, Namespace import pluggy from PIL import Image -from ocrmypdf._jobcontext import PageContext -from ocrmypdf.pdfinfo import PdfInfo - hookspec = pluggy.HookspecMarker('ocrmypdf') # pylint: disable=unused-argument @@ -43,7 +40,7 @@ def prepare(options: Namespace) -> None: @hookspec -def validate(pdfinfo: PdfInfo, options: Namespace) -> None: +def validate(pdfinfo: 'PdfInfo', options: Namespace) -> None: """Called to give a plugin an opportunity to review options and pdfinfo. options contains the "work order" to process a particular file. pdfinfo @@ -57,7 +54,7 @@ def validate(pdfinfo: PdfInfo, options: Namespace) -> None: @hookspec(firstresult=True) -def filter_ocr_image(page: PageContext, image: Image) -> Image: +def filter_ocr_image(page: 'PageContext', image: Image) -> Image: """Called to filter the image before it is sent to OCR. This is the image that OCR sees, not what the user sees when they view the From e02f6c1e97c4353834f7c982ec2d79c15b60aef7 Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Sat, 2 May 2020 03:34:31 -0700 Subject: [PATCH 38/94] Support plugin invocation with API --- src/ocrmypdf/__main__.py | 11 +- src/ocrmypdf/_jobcontext.py | 12 +- src/ocrmypdf/_pipeline.py | 4 +- src/ocrmypdf/_plugin_manager.py | 6 +- src/ocrmypdf/_sync.py | 9 +- src/ocrmypdf/api.py | 32 +- src/ocrmypdf/cli.py | 791 ++++++++++++++++---------------- src/ocrmypdf/optimize.py | 2 +- tests/conftest.py | 8 +- tests/test_metadata.py | 17 +- tests/test_unpaper.py | 4 +- tests/test_validation.py | 5 +- 12 files changed, 472 insertions(+), 429 deletions(-) diff --git a/src/ocrmypdf/__main__.py b/src/ocrmypdf/__main__.py index c62c9409..b6dc0466 100755 --- a/src/ocrmypdf/__main__.py +++ b/src/ocrmypdf/__main__.py @@ -26,7 +26,7 @@ from ocrmypdf._plugin_manager import get_plugin_manager from ocrmypdf._sync import run_pipeline from ocrmypdf._validation import check_closed_streams, check_options from ocrmypdf.api import Verbosity, configure_logging -from ocrmypdf.cli import parser, plugins_only_parser +from ocrmypdf.cli import get_parser, plugins_only_parser from ocrmypdf.exceptions import BadArgsError, ExitCode, MissingDependencyError log = logging.getLogger('ocrmypdf') @@ -34,9 +34,10 @@ log = logging.getLogger('ocrmypdf') def run(args=None): pre_options, _unused = plugins_only_parser.parse_known_args(args=args) - if pre_options.plugins: - pm = get_plugin_manager(pre_options) - pm.hook.install_cli(parser=parser) + plugin_manager = get_plugin_manager(pre_options.plugins) + + parser = get_parser() + plugin_manager.hook.install_cli(parser=parser) options = parser.parse_args(args=args) @@ -68,7 +69,7 @@ def run(args=None): log.error(e) return ExitCode.missing_dependency - result = run_pipeline(options=options) + result = run_pipeline(options=options, plugin_manager=plugin_manager) return result diff --git a/src/ocrmypdf/_jobcontext.py b/src/ocrmypdf/_jobcontext.py index ea48380b..7158abe8 100644 --- a/src/ocrmypdf/_jobcontext.py +++ b/src/ocrmypdf/_jobcontext.py @@ -69,13 +69,19 @@ class PageContext: def __getstate__(self): state = self.__dict__.copy() - del state['plugin_manager'] - state['construct_plugin_manager'] = partial(get_plugin_manager, self.options) + if state['plugin_manager'] is not None: + del state['plugin_manager'] + state['construct_plugin_manager'] = partial( + get_plugin_manager, self.options.plugins + ) return state def __setstate__(self, state): self.__dict__.update(state) - self.plugin_manager = self.__dict__['construct_plugin_manager']() + if 'construct_plugin_manager' in state: + self.plugin_manager = state['construct_plugin_manager']() + else: + self.plugin_manager = None del self.__dict__['construct_plugin_manager'] diff --git a/src/ocrmypdf/_pipeline.py b/src/ocrmypdf/_pipeline.py index a8d6365f..cd82fea4 100644 --- a/src/ocrmypdf/_pipeline.py +++ b/src/ocrmypdf/_pipeline.py @@ -522,9 +522,11 @@ def create_ocr_image(image, page_context): del draw - im = page_context.plugin_manager.hook.filter_ocr_image( + filter_im = page_context.plugin_manager.hook.filter_ocr_image( page=page_context, image=im ) + if filter_im is not None: + im = filter_im # Pillow requires integer DPI dpi = tuple(round(coord) for coord in im.info['dpi']) diff --git a/src/ocrmypdf/_plugin_manager.py b/src/ocrmypdf/_plugin_manager.py index 2c3df5a1..295e955c 100644 --- a/src/ocrmypdf/_plugin_manager.py +++ b/src/ocrmypdf/_plugin_manager.py @@ -16,17 +16,17 @@ # along with OCRmyPDF. If not, see . import importlib +from typing import List import pluggy from ocrmypdf import pluginspec -def get_plugin_manager(options): +def get_plugin_manager(plugins: List[str]): pm = pluggy.PluginManager('ocrmypdf') pm.add_hookspecs(pluginspec) - - for name in options.plugins: + for name in plugins: module = importlib.import_module(name) pm.register(module) return pm diff --git a/src/ocrmypdf/_sync.py b/src/ocrmypdf/_sync.py index 1691eb00..0834993d 100644 --- a/src/ocrmypdf/_sync.py +++ b/src/ocrmypdf/_sync.py @@ -293,12 +293,14 @@ def configure_debug_logging(log_filename, prefix=''): return log_file_handler -def run_pipeline(options, api=False): +def run_pipeline(options, *, plugin_manager, api=False): # Any changes to options will not take effect for options that are already # bound to function parameters in the pipeline. (For example # options.input_file, options.pdf_renderer are already bound.) if not options.jobs: options.jobs = available_cpu_count() + if not plugin_manager: + plugin_manager = get_plugin_manager([]) work_folder = mkdtemp(prefix="com.github.ocrmypdf.") debug_log_handler = None @@ -307,7 +309,6 @@ def run_pipeline(options, api=False): ): debug_log_handler = configure_debug_logging(Path(work_folder) / "debug.log") - pm = get_plugin_manager(options) try: check_requested_output_file(options) start_input_file, original_filename = create_input_file(options, work_folder) @@ -320,7 +321,7 @@ def run_pipeline(options, api=False): options, ) - pm.hook.prepare(options=options) + plugin_manager.hook.prepare(options=options) # Gather pdfinfo and create context pdfinfo = get_pdfinfo( @@ -329,7 +330,7 @@ def run_pipeline(options, api=False): max_workers=options.jobs if not options.use_threads else 1, # To help debug ) - context = PDFContext(options, work_folder, origin_pdf, pdfinfo, pm) + context = PDFContext(options, work_folder, origin_pdf, pdfinfo, plugin_manager) # Validate options are okay for this pdf validate_pdfinfo_options(context) diff --git a/src/ocrmypdf/api.py b/src/ocrmypdf/api.py index efa24c7f..cc7e103f 100644 --- a/src/ocrmypdf/api.py +++ b/src/ocrmypdf/api.py @@ -18,15 +18,17 @@ import logging import os import sys +from argparse import ArgumentParser from contextlib import suppress from enum import IntEnum from pathlib import Path from typing import Dict, Iterable -from ._logging import PageNumberFilter, TqdmConsole -from ._sync import run_pipeline -from ._validation import check_options -from .cli import parser +from ocrmypdf._logging import PageNumberFilter, TqdmConsole +from ocrmypdf._plugin_manager import get_plugin_manager +from ocrmypdf._sync import run_pipeline +from ocrmypdf._validation import check_options +from ocrmypdf.cli import get_parser, plugins_only_parser try: import coloredlogs @@ -125,7 +127,13 @@ def configure_logging( return log -def create_options(*, input_file: os.PathLike, output_file: os.PathLike, **kwargs): +def create_options( + *, + input_file: os.PathLike, + output_file: os.PathLike, + parser: ArgumentParser, + **kwargs, +): cmdline = [] deferred = [] @@ -223,9 +231,11 @@ def ocr( # pylint: disable=unused-argument user_words: os.PathLike = None, user_patterns: os.PathLike = None, fast_web_view: float = None, + plugins: Iterable[str] = None, keep_temporary_files: bool = None, progress_bar: bool = None, tesseract_env: Dict[str, str] = None, + **kwargs, ): """Run OCRmyPDF on one PDF or image. @@ -260,7 +270,15 @@ def ocr( # pylint: disable=unused-argument Returns: :class:`ocrmypdf.ExitCode` """ + if not plugins: + plugins = [] - options = create_options(**locals()) + parser = get_parser() + _plugin_manager = get_plugin_manager(plugins) + _plugin_manager.hook.install_cli(parser=parser) + + options = create_options( + **{k: v for k, v in locals().items() if not k.startswith('_')} + ) check_options(options) - return run_pipeline(options, api=True) + return run_pipeline(options=options, plugin_manager=_plugin_manager, api=True) diff --git a/src/ocrmypdf/cli.py b/src/ocrmypdf/cli.py index d243364a..3edf6cd7 100644 --- a/src/ocrmypdf/cli.py +++ b/src/ocrmypdf/cli.py @@ -17,10 +17,8 @@ import argparse -from ._version import PROGRAM_NAME as _PROGRAM_NAME -from ._version import __version__ as _VERSION - -__all__ = ['parser'] +from ocrmypdf._version import PROGRAM_NAME as _PROGRAM_NAME +from ocrmypdf._version import __version__ as _VERSION def numeric(basetype, min_=None, max_=None): @@ -56,18 +54,19 @@ class ArgumentParser(argparse.ArgumentParser): raise ValueError(message) -parser = ArgumentParser( - prog=_PROGRAM_NAME, - fromfile_prefix_chars='@', - formatter_class=argparse.RawDescriptionHelpFormatter, - description="""\ +def get_parser(): + parser = ArgumentParser( + prog=_PROGRAM_NAME, + fromfile_prefix_chars='@', + formatter_class=argparse.RawDescriptionHelpFormatter, + description="""\ Generates a searchable PDF or PDF/A from a regular PDF. OCRmyPDF rasterizes each page of the input PDF, optionally corrects page rotation and performs image processing, runs the Tesseract OCR engine on the image, and then creates a PDF from the OCR information. """, - epilog="""\ + epilog="""\ OCRmyPDF attempts to keep the output file at about the same size. If a file contains losslessly compressed images, and output file will be losslessly compressed as well. @@ -108,395 +107,409 @@ Online documentation is located at: https://ocrmypdf.readthedocs.io/en/latest/introduction.html """, -) + ) -parser.add_argument( - 'input_file', - metavar="input_pdf_or_image", - help="PDF file containing the images to be OCRed (or '-' to read from " - "standard input)", -) -parser.add_argument( - 'output_file', - metavar="output_pdf", - help="Output searchable PDF file (or '-' to write to standard output). " - "Existing files will be ovewritten. If same as input file, the " - "input file will be updated only if processing is successful.", -) -parser.add_argument( - '-l', - '--language', - action='append', - help="Language(s) of the file to be OCRed (see tesseract --list-langs for " - "all language packs installed in your system). Use -l eng+deu for " - "multiple languages.", -) -parser.add_argument( - '--image-dpi', - metavar='DPI', - type=int, - help="For input image instead of PDF, use this DPI instead of file's.", -) -parser.add_argument( - '--output-type', - choices=['pdfa', 'pdf', 'pdfa-1', 'pdfa-2', 'pdfa-3'], - default='pdfa', - help="Choose output type. 'pdfa' creates a PDF/A-2b compliant file for " - "long term archiving (default, recommended) but may not suitable " - "for users who want their file altered as little as possible. 'pdfa' " - "also has problems with full Unicode text. 'pdf' attempts to " - "preserve file contents as much as possible. 'pdf-a1' creates a " - "PDF/A1-b file. 'pdf-a2' is equivalent to 'pdfa'. 'pdf-a3' creates a " - "PDF/A3-b file.", -) + parser.add_argument( + 'input_file', + metavar="input_pdf_or_image", + help="PDF file containing the images to be OCRed (or '-' to read from " + "standard input)", + ) + parser.add_argument( + 'output_file', + metavar="output_pdf", + help="Output searchable PDF file (or '-' to write to standard output). " + "Existing files will be ovewritten. If same as input file, the " + "input file will be updated only if processing is successful.", + ) + parser.add_argument( + '-l', + '--language', + action='append', + help="Language(s) of the file to be OCRed (see tesseract --list-langs for " + "all language packs installed in your system). Use -l eng+deu for " + "multiple languages.", + ) + parser.add_argument( + '--image-dpi', + metavar='DPI', + type=int, + help="For input image instead of PDF, use this DPI instead of file's.", + ) + parser.add_argument( + '--output-type', + choices=['pdfa', 'pdf', 'pdfa-1', 'pdfa-2', 'pdfa-3'], + default='pdfa', + help="Choose output type. 'pdfa' creates a PDF/A-2b compliant file for " + "long term archiving (default, recommended) but may not suitable " + "for users who want their file altered as little as possible. 'pdfa' " + "also has problems with full Unicode text. 'pdf' attempts to " + "preserve file contents as much as possible. 'pdf-a1' creates a " + "PDF/A1-b file. 'pdf-a2' is equivalent to 'pdfa'. 'pdf-a3' creates a " + "PDF/A3-b file.", + ) -# Use null string '\0' as sentinel to indicate the user supplied no argument, -# since that is the only invalid character for filepaths on all platforms -# bool('\0') is True in Python -parser.add_argument( - '--sidecar', - nargs='?', - const='\0', - default=None, - metavar='FILE', - help="Generate sidecar text files that contain the same text recognized " - "by Tesseract. This may be useful for building a OCR text database. " - "If FILE is omitted, the sidecar file be named {output_file}.txt " - "If FILE is set to '-', the sidecar is written to stdout (a " - "convenient way to preview OCR quality). The output file and sidecar " - "may not both use stdout at the same time.", -) + # Use null string '\0' as sentinel to indicate the user supplied no argument, + # since that is the only invalid character for filepaths on all platforms + # bool('\0') is True in Python + parser.add_argument( + '--sidecar', + nargs='?', + const='\0', + default=None, + metavar='FILE', + help="Generate sidecar text files that contain the same text recognized " + "by Tesseract. This may be useful for building a OCR text database. " + "If FILE is omitted, the sidecar file be named {output_file}.txt " + "If FILE is set to '-', the sidecar is written to stdout (a " + "convenient way to preview OCR quality). The output file and sidecar " + "may not both use stdout at the same time.", + ) -parser.add_argument( - '--version', - action='version', - version=_VERSION, - help="Print program version and exit", -) + parser.add_argument( + '--version', + action='version', + version=_VERSION, + help="Print program version and exit", + ) -jobcontrol = parser.add_argument_group("Job control options") -jobcontrol.add_argument( - '-j', - '--jobs', - metavar='N', - type=numeric(int, 0, 256), - help="Use up to N CPU cores simultaneously (default: use all).", -) -jobcontrol.add_argument( - '-q', '--quiet', action='store_true', help="Suppress INFO messages" -) -jobcontrol.add_argument( - '-v', - '--verbose', - type=numeric(int, 0, 2), - default=0, - const=1, - nargs='?', - help="Print more verbose messages for each additional verbose level. Use " - "`-v 1` typically for much more detailed logging. Higher numbers " - "are probably only useful in debugging.", -) -jobcontrol.add_argument( - '--no-progress-bar', - action='store_false', - dest='progress_bar', - help=argparse.SUPPRESS, -) -jobcontrol.add_argument('--use-threads', action='store_true', help=argparse.SUPPRESS) + jobcontrol = parser.add_argument_group("Job control options") + jobcontrol.add_argument( + '-j', + '--jobs', + metavar='N', + type=numeric(int, 0, 256), + help="Use up to N CPU cores simultaneously (default: use all).", + ) + jobcontrol.add_argument( + '-q', '--quiet', action='store_true', help="Suppress INFO messages" + ) + jobcontrol.add_argument( + '-v', + '--verbose', + type=numeric(int, 0, 2), + default=0, + const=1, + nargs='?', + help="Print more verbose messages for each additional verbose level. Use " + "`-v 1` typically for much more detailed logging. Higher numbers " + "are probably only useful in debugging.", + ) + jobcontrol.add_argument( + '--no-progress-bar', + action='store_false', + dest='progress_bar', + help=argparse.SUPPRESS, + ) + jobcontrol.add_argument( + '--use-threads', action='store_true', help=argparse.SUPPRESS + ) -metadata = parser.add_argument_group( - "Metadata options", - "Set output PDF/A metadata (default: copy input document's metadata)", -) -metadata.add_argument( - '--title', type=str, help="Set document title (place multiple words in quotes)" -) -metadata.add_argument('--author', type=str, help="Set document author") -metadata.add_argument('--subject', type=str, help="Set document subject description") -metadata.add_argument('--keywords', type=str, help="Set document keywords") + metadata = parser.add_argument_group( + "Metadata options", + "Set output PDF/A metadata (default: copy input document's metadata)", + ) + metadata.add_argument( + '--title', type=str, help="Set document title (place multiple words in quotes)" + ) + metadata.add_argument('--author', type=str, help="Set document author") + metadata.add_argument( + '--subject', type=str, help="Set document subject description" + ) + metadata.add_argument('--keywords', type=str, help="Set document keywords") -preprocessing = parser.add_argument_group( - "Image preprocessing options", - "Options to improve the quality of the final PDF and OCR", -) -preprocessing.add_argument( - '-r', - '--rotate-pages', - action='store_true', - help="Automatically rotate pages based on detected text orientation", -) -preprocessing.add_argument( - '--remove-background', - action='store_true', - help="Attempt to remove background from gray or color pages, setting it " - "to white ", -) -preprocessing.add_argument( - '-d', '--deskew', action='store_true', help="Deskew each page before performing OCR" -) -preprocessing.add_argument( - '-c', - '--clean', - action='store_true', - help="Clean pages from scanning artifacts before performing OCR, and send " - "the cleaned page to OCR, but do not include the cleaned page in " - "the output", -) -preprocessing.add_argument( - '-i', - '--clean-final', - action='store_true', - help="Clean page as above, and incorporate the cleaned image in the final " - "PDF. Might remove desired content.", -) -preprocessing.add_argument( - '--unpaper-args', - type=str, - default=None, - help="A quoted string of arguments to pass to unpaper. Requires --clean. " - "Example: --unpaper-args '--layout double'.", -) -preprocessing.add_argument( - '--oversample', - metavar='DPI', - type=numeric(int, 0, 5000), - default=0, - help="Oversample images to at least the specified DPI, to improve OCR " - "results slightly", -) -preprocessing.add_argument( - '--remove-vectors', - action='store_true', - help="EXPERIMENTAL. Mask out any vector objects in the PDF so that they " - "will not be included in OCR. This can eliminate false characters.", -) -preprocessing.add_argument( - '--threshold', - action='store_true', - help="EXPERIMENTAL. Threshold image to 1bpp before sending it to Tesseract for OCR. Can " - "improve OCR quality compared to Tesseract's thresholder.", -) + preprocessing = parser.add_argument_group( + "Image preprocessing options", + "Options to improve the quality of the final PDF and OCR", + ) + preprocessing.add_argument( + '-r', + '--rotate-pages', + action='store_true', + help="Automatically rotate pages based on detected text orientation", + ) + preprocessing.add_argument( + '--remove-background', + action='store_true', + help="Attempt to remove background from gray or color pages, setting it " + "to white ", + ) + preprocessing.add_argument( + '-d', + '--deskew', + action='store_true', + help="Deskew each page before performing OCR", + ) + preprocessing.add_argument( + '-c', + '--clean', + action='store_true', + help="Clean pages from scanning artifacts before performing OCR, and send " + "the cleaned page to OCR, but do not include the cleaned page in " + "the output", + ) + preprocessing.add_argument( + '-i', + '--clean-final', + action='store_true', + help="Clean page as above, and incorporate the cleaned image in the final " + "PDF. Might remove desired content.", + ) + preprocessing.add_argument( + '--unpaper-args', + type=str, + default=None, + help="A quoted string of arguments to pass to unpaper. Requires --clean. " + "Example: --unpaper-args '--layout double'.", + ) + preprocessing.add_argument( + '--oversample', + metavar='DPI', + type=numeric(int, 0, 5000), + default=0, + help="Oversample images to at least the specified DPI, to improve OCR " + "results slightly", + ) + preprocessing.add_argument( + '--remove-vectors', + action='store_true', + help="EXPERIMENTAL. Mask out any vector objects in the PDF so that they " + "will not be included in OCR. This can eliminate false characters.", + ) + preprocessing.add_argument( + '--threshold', + action='store_true', + help=( + "EXPERIMENTAL. Threshold image to 1bpp before sending it to Tesseract " + "for OCR. Can improve OCR quality compared to Tesseract's thresholder." + ), + ) -ocrsettings = parser.add_argument_group("OCR options", "Control how OCR is applied") -ocrsettings.add_argument( - '-f', - '--force-ocr', - action='store_true', - help="Rasterize any text or vector objects on each page, apply OCR, and " - "save the rastered output (this rewrites the PDF)", -) -ocrsettings.add_argument( - '-s', - '--skip-text', - action='store_true', - help="Skip OCR on any pages that already contain text, but include the " - "page in final output; useful for PDFs that contain a mix of " - "images, text pages, and/or previously OCRed pages", -) -ocrsettings.add_argument( - '--redo-ocr', - action='store_true', - help="Attempt to detect and remove the hidden OCR layer from files that " - "were previously OCRed with OCRmyPDF or another program. Apply OCR " - "to text found in raster images. Existing visible text objects will " - "not be changed. If there is no existing OCR, OCR will be added.", -) -ocrsettings.add_argument( - '--skip-big', - type=numeric(float, 0, 5000), - metavar='MPixels', - help="Skip OCR on pages larger than the specified amount of megapixels, " - "but include skipped pages in final output", -) + ocrsettings = parser.add_argument_group("OCR options", "Control how OCR is applied") + ocrsettings.add_argument( + '-f', + '--force-ocr', + action='store_true', + help="Rasterize any text or vector objects on each page, apply OCR, and " + "save the rastered output (this rewrites the PDF)", + ) + ocrsettings.add_argument( + '-s', + '--skip-text', + action='store_true', + help="Skip OCR on any pages that already contain text, but include the " + "page in final output; useful for PDFs that contain a mix of " + "images, text pages, and/or previously OCRed pages", + ) + ocrsettings.add_argument( + '--redo-ocr', + action='store_true', + help="Attempt to detect and remove the hidden OCR layer from files that " + "were previously OCRed with OCRmyPDF or another program. Apply OCR " + "to text found in raster images. Existing visible text objects will " + "not be changed. If there is no existing OCR, OCR will be added.", + ) + ocrsettings.add_argument( + '--skip-big', + type=numeric(float, 0, 5000), + metavar='MPixels', + help="Skip OCR on pages larger than the specified amount of megapixels, " + "but include skipped pages in final output", + ) -optimizing = parser.add_argument_group( - "Optimization options", "Control how the PDF is optimized after OCR" -) -optimizing.add_argument( - '-O', - '--optimize', - type=int, - choices=range(0, 4), - default=1, - help=( - "Control how PDF is optimized after processing:" - "0 - do not optimize; " - "1 - do safe, lossless optimizations (default); " - "2 - do some lossy optimizations; " - "3 - do aggressive lossy optimizations (including lossy JBIG2)" - ), -) -optimizing.add_argument( - '--jpeg-quality', - type=numeric(int, 0, 100), - default=0, - metavar='Q', - help=( - "Adjust JPEG quality level for JPEG optimization. " - "100 is best quality and largest output size; " - "1 is lowest quality and smallest output; " - "0 uses the default." - ), -) -optimizing.add_argument( - '--jpg-quality', - type=numeric(int, 0, 100), - default=0, - metavar='Q', - dest='jpeg_quality', - help=argparse.SUPPRESS, # Alias for --jpeg-quality -) -optimizing.add_argument( - '--png-quality', - type=numeric(int, 0, 100), - default=0, - metavar='Q', - help=( - "Adjust PNG quality level to use when quantizing PNGs. " - "Values have same meaning as with --jpeg-quality" - ), -) -optimizing.add_argument( - '--jbig2-lossy', - action='store_true', - help=( - "Enable JBIG2 lossy mode (better compression, not suitable for some " - "use cases - see documentation)." - ), -) -optimizing.add_argument( - '--jbig2-page-group-size', - type=numeric(int, 1, 10000), - default=0, - metavar='N', - # Adjust number of pages to consider at once for JBIG2 compression - help=argparse.SUPPRESS, -) + optimizing = parser.add_argument_group( + "Optimization options", "Control how the PDF is optimized after OCR" + ) + optimizing.add_argument( + '-O', + '--optimize', + type=int, + choices=range(0, 4), + default=1, + help=( + "Control how PDF is optimized after processing:" + "0 - do not optimize; " + "1 - do safe, lossless optimizations (default); " + "2 - do some lossy optimizations; " + "3 - do aggressive lossy optimizations (including lossy JBIG2)" + ), + ) + optimizing.add_argument( + '--jpeg-quality', + type=numeric(int, 0, 100), + default=0, + metavar='Q', + help=( + "Adjust JPEG quality level for JPEG optimization. " + "100 is best quality and largest output size; " + "1 is lowest quality and smallest output; " + "0 uses the default." + ), + ) + optimizing.add_argument( + '--jpg-quality', + type=numeric(int, 0, 100), + default=0, + metavar='Q', + dest='jpeg_quality', + help=argparse.SUPPRESS, # Alias for --jpeg-quality + ) + optimizing.add_argument( + '--png-quality', + type=numeric(int, 0, 100), + default=0, + metavar='Q', + help=( + "Adjust PNG quality level to use when quantizing PNGs. " + "Values have same meaning as with --jpeg-quality" + ), + ) + optimizing.add_argument( + '--jbig2-lossy', + action='store_true', + help=( + "Enable JBIG2 lossy mode (better compression, not suitable for some " + "use cases - see documentation)." + ), + ) + optimizing.add_argument( + '--jbig2-page-group-size', + type=numeric(int, 1, 10000), + default=0, + metavar='N', + # Adjust number of pages to consider at once for JBIG2 compression + help=argparse.SUPPRESS, + ) -advanced = parser.add_argument_group( - "Advanced", "Advanced options to control Tesseract's OCR behavior" -) -advanced.add_argument( - '--pages', - type=str, - help="Limit OCR to the specified pages (ranges or comma separated), skipping others", -) -advanced.add_argument( - '--max-image-mpixels', - action='store', - type=numeric(float, 0), - metavar='MPixels', - help="Set maximum number of pixels to unpack before treating an image as a " - "decompression bomb", - default=128.0, -) -advanced.add_argument( - '--tesseract-config', - action='append', - metavar='CFG', - default=[], - help="Additional Tesseract configuration files -- see documentation", -) -advanced.add_argument( - '--tesseract-pagesegmode', - action='store', - type=int, - metavar='PSM', - choices=range(0, 14), - help="Set Tesseract page segmentation mode (see tesseract --help)", -) -advanced.add_argument( - '--tesseract-oem', - action='store', - type=int, - metavar='MODE', - choices=range(0, 4), - help=( - "Set Tesseract 4.0 OCR engine mode: " - "0 - original Tesseract only; " - "1 - neural nets LSTM only; " - "2 - Tesseract + LSTM; " - "3 - default." - ), -) -advanced.add_argument( - '--pdf-renderer', - choices=['auto', 'hocr', 'sandwich'], - default='auto', - help="Choose OCR PDF renderer - the default option is to let OCRmyPDF " - "choose. See documentation for discussion.", -) -advanced.add_argument( - '--tesseract-timeout', - default=180.0, - type=numeric(float, 0), - metavar='SECONDS', - help='Give up on OCR after the timeout, but copy the preprocessed page ' - 'into the final output', -) -advanced.add_argument( - '--rotate-pages-threshold', - default=14.0, - type=numeric(float, 0, 1000), - metavar='CONFIDENCE', - help="Only rotate pages when confidence is above this value (arbitrary " - "units reported by tesseract)", -) -advanced.add_argument( - '--pdfa-image-compression', - choices=['auto', 'jpeg', 'lossless'], - default='auto', - help="Specify how to compress images in the output PDF/A. 'auto' lets " - "OCRmyPDF decide. 'jpeg' changes all grayscale and color images to " - "JPEG compression. 'lossless' uses PNG-style lossless compression " - "for all images. Monochrome images are always compressed using a " - "lossless codec. Compression settings " - "are applied to all pages, including those for which OCR was " - "skipped. Not supported for --output-type=pdf ; that setting " - "preserves the original compression of all images.", -) -advanced.add_argument( - '--user-words', - metavar='FILE', - help="Specify the location of the Tesseract user words file. This is a " - "list of words Tesseract should consider while performing OCR in " - "addition to its standard language dictionaries. This can improve " - "OCR quality especially for specialized and technical documents.", -) -advanced.add_argument( - '--user-patterns', - metavar='FILE', - help="Specify the location of the Tesseract user patterns file.", -) -advanced.add_argument( - '--fast-web-view', - type=numeric(float, 0), - default=1.0, - metavar="MEGABYTES", - help="If the size of file is more than this threshold (in MB), then " - "linearize the PDF for fast web viewing. This allows the PDF to be " - "displayed before it is fully downloaded in web browsers, but increases " - "the space required slightly. By default we skip this for small files " - "which do not benefit. If the threshold is 0 it will be apply to all files. " - "Set the threshold very high to disable.", -) -advanced.add_argument( - '--plugins', - action='append', - default=[], - help="Path to a folder than contains plugins.", -) + advanced = parser.add_argument_group( + "Advanced", "Advanced options to control Tesseract's OCR behavior" + ) + advanced.add_argument( + '--pages', + type=str, + help=( + "Limit OCR to the specified pages (ranges or comma separated), " + "skipping others", + ), + ) + advanced.add_argument( + '--max-image-mpixels', + action='store', + type=numeric(float, 0), + metavar='MPixels', + help="Set maximum number of pixels to unpack before treating an image as a " + "decompression bomb", + default=128.0, + ) + advanced.add_argument( + '--tesseract-config', + action='append', + metavar='CFG', + default=[], + help="Additional Tesseract configuration files -- see documentation", + ) + advanced.add_argument( + '--tesseract-pagesegmode', + action='store', + type=int, + metavar='PSM', + choices=range(0, 14), + help="Set Tesseract page segmentation mode (see tesseract --help)", + ) + advanced.add_argument( + '--tesseract-oem', + action='store', + type=int, + metavar='MODE', + choices=range(0, 4), + help=( + "Set Tesseract 4.0 OCR engine mode: " + "0 - original Tesseract only; " + "1 - neural nets LSTM only; " + "2 - Tesseract + LSTM; " + "3 - default." + ), + ) + advanced.add_argument( + '--pdf-renderer', + choices=['auto', 'hocr', 'sandwich'], + default='auto', + help="Choose OCR PDF renderer - the default option is to let OCRmyPDF " + "choose. See documentation for discussion.", + ) + advanced.add_argument( + '--tesseract-timeout', + default=180.0, + type=numeric(float, 0), + metavar='SECONDS', + help='Give up on OCR after the timeout, but copy the preprocessed page ' + 'into the final output', + ) + advanced.add_argument( + '--rotate-pages-threshold', + default=14.0, + type=numeric(float, 0, 1000), + metavar='CONFIDENCE', + help="Only rotate pages when confidence is above this value (arbitrary " + "units reported by tesseract)", + ) + advanced.add_argument( + '--pdfa-image-compression', + choices=['auto', 'jpeg', 'lossless'], + default='auto', + help="Specify how to compress images in the output PDF/A. 'auto' lets " + "OCRmyPDF decide. 'jpeg' changes all grayscale and color images to " + "JPEG compression. 'lossless' uses PNG-style lossless compression " + "for all images. Monochrome images are always compressed using a " + "lossless codec. Compression settings " + "are applied to all pages, including those for which OCR was " + "skipped. Not supported for --output-type=pdf ; that setting " + "preserves the original compression of all images.", + ) + advanced.add_argument( + '--user-words', + metavar='FILE', + help="Specify the location of the Tesseract user words file. This is a " + "list of words Tesseract should consider while performing OCR in " + "addition to its standard language dictionaries. This can improve " + "OCR quality especially for specialized and technical documents.", + ) + advanced.add_argument( + '--user-patterns', + metavar='FILE', + help="Specify the location of the Tesseract user patterns file.", + ) + advanced.add_argument( + '--fast-web-view', + type=numeric(float, 0), + default=1.0, + metavar="MEGABYTES", + help="If the size of file is more than this threshold (in MB), then " + "linearize the PDF for fast web viewing. This allows the PDF to be " + "displayed before it is fully downloaded in web browsers, but increases " + "the space required slightly. By default we skip this for small files " + "which do not benefit. If the threshold is 0 it will be apply to all files. " + "Set the threshold very high to disable.", + ) + advanced.add_argument( + '--plugins', + action='append', + default=[], + help="Path to a folder than contains plugins.", + ) + + debugging = parser.add_argument_group( + "Debugging", "Arguments to help with troubleshooting and debugging" + ) + debugging.add_argument( + '-k', + '--keep-temporary-files', + action='store_true', + help="Keep temporary files (helpful for debugging)", + ) + debugging.add_argument('--tesseract-env', type=str, help=argparse.SUPPRESS) + return parser -debugging = parser.add_argument_group( - "Debugging", "Arguments to help with troubleshooting and debugging" -) -debugging.add_argument( - '-k', - '--keep-temporary-files', - action='store_true', - help="Keep temporary files (helpful for debugging)", -) -debugging.add_argument('--tesseract-env', type=str, help=argparse.SUPPRESS) plugins_only_parser = ArgumentParser( prog=_PROGRAM_NAME, fromfile_prefix_chars='@', add_help=False, allow_abbrev=False diff --git a/src/ocrmypdf/optimize.py b/src/ocrmypdf/optimize.py index e6f0bd3f..9459b70e 100644 --- a/src/ocrmypdf/optimize.py +++ b/src/ocrmypdf/optimize.py @@ -582,7 +582,7 @@ def main(infile, outfile, level, jobs=1): ) with TemporaryDirectory() as td: - context = PDFContext(options, td, infile, None) + context = PDFContext(options, td, infile, None, None) tmpout = Path(td) / 'out.pdf' optimize( infile, diff --git a/tests/conftest.py b/tests/conftest.py index 8724ead5..54960cdc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -214,7 +214,7 @@ def no_outpdf(tmp_path): def check_ocrmypdf(input_file, output_file, *args, env=None): """Run ocrmypdf and confirmed that a valid file was created""" - options = cli.parser.parse_args( + options = cli.get_parser().parse_args( [str(input_file), str(output_file)] + [str(arg) for arg in args if arg is not None] ) @@ -222,7 +222,7 @@ def check_ocrmypdf(input_file, output_file, *args, env=None): if env: options.tesseract_env = env options.tesseract_env['_OCRMYPDF_TEST_INFILE'] = os.fspath(input_file) - result = api.run_pipeline(options, api=True) + result = api.run_pipeline(options, plugin_manager=None, api=True) assert result == 0 assert os.path.exists(str(output_file)), "Output file not created" @@ -238,7 +238,7 @@ def run_ocrmypdf_api(input_file, output_file, *args, env=None): Does not currently have a way to manipulate the PATH except for Tesseract. """ - options = cli.parser.parse_args( + options = cli.get_parser().parse_args( [str(input_file), str(output_file)] + [str(arg) for arg in args if arg is not None] ) @@ -253,7 +253,7 @@ def run_ocrmypdf_api(input_file, output_file, *args, env=None): if options.tesseract_env: assert all(isinstance(v, (str, bytes)) for v in options.tesseract_env.values()) - return api.run_pipeline(options, api=False) + return api.run_pipeline(options, plugin_manager=None, api=False) @pytest.helpers.register diff --git a/tests/test_metadata.py b/tests/test_metadata.py index 270a8b62..79e63604 100644 --- a/tests/test_metadata.py +++ b/tests/test_metadata.py @@ -32,7 +32,7 @@ from pikepdf.models.metadata import decode_pdf_date from ocrmypdf._jobcontext import PDFContext from ocrmypdf._pipeline import convert_to_pdfa -from ocrmypdf.cli import parser +from ocrmypdf.cli import get_parser from ocrmypdf.exceptions import ExitCode from ocrmypdf.pdfa import SRGB_ICC_PROFILE, file_claims_pdfa, generate_pdfa_ps from ocrmypdf.pdfinfo import PdfInfo @@ -290,16 +290,15 @@ def test_kodak_toc(resources, outpdf, spoof_tesseract_noop): def test_metadata_fixup_warning(resources, outdir, caplog): - from ocrmypdf.__main__ import parser from ocrmypdf._pipeline import metadata_fixup - options = parser.parse_args( + options = get_parser().parse_args( args=['--output-type', 'pdfa-2', 'graph.pdf', 'out.pdf'] ) copyfile(resources / 'graph.pdf', outdir / 'graph.pdf') - context = PDFContext(options, outdir, outdir / 'graph.pdf', None) + context = PDFContext(options, outdir, outdir / 'graph.pdf', None, None) metadata_fixup(working_file=outdir / 'graph.pdf', context=context) for record in caplog.records: assert record.levelname != 'WARNING' @@ -310,7 +309,7 @@ def test_metadata_fixup_warning(resources, outdir, caplog): meta['prism2:publicationName'] = 'OCRmyPDF Test' graph.save(outdir / 'graph_mod.pdf') - context = PDFContext(options, outdir, outdir / 'graph_mod.pdf', None) + context = PDFContext(options, outdir, outdir / 'graph_mod.pdf', None, None) metadata_fixup(working_file=outdir / 'graph.pdf', context=context) assert any(record.levelname == 'WARNING' for record in caplog.records) @@ -326,11 +325,11 @@ def test_prevent_gs_invalid_xml(resources, outdir): Title=b'String with trailing nul\x00' ) - options = parser.parse_args( + options = get_parser().parse_args( args=['-j', '1', '--output-type', 'pdfa-2', 'a.pdf', 'b.pdf'] ) pdfinfo = PdfInfo(outdir / 'layers.rendered.pdf') - context = PDFContext(options, outdir, outdir / 'layers.rendered.pdf', pdfinfo) + context = PDFContext(options, outdir, outdir / 'layers.rendered.pdf', pdfinfo, None) convert_to_pdfa( str(outdir / 'layers.rendered.pdf'), str(outdir / 'pdfa.ps'), context @@ -357,11 +356,11 @@ def test_malformed_docinfo(caplog, resources, outdir): pike.trailer.Info = pikepdf.Stream(pike, b"") pike.save(outdir / 'layers.rendered.pdf', fix_metadata_version=False) - options = parser.parse_args( + options = get_parser().parse_args( args=['-j', '1', '--output-type', 'pdfa-2', 'a.pdf', 'b.pdf'] ) pdfinfo = PdfInfo(outdir / 'layers.rendered.pdf') - context = PDFContext(options, outdir, outdir / 'layers.rendered.pdf', pdfinfo) + context = PDFContext(options, outdir, outdir / 'layers.rendered.pdf', pdfinfo, None) convert_to_pdfa( str(outdir / 'layers.rendered.pdf'), str(outdir / 'pdfa.ps'), context diff --git a/tests/test_unpaper.py b/tests/test_unpaper.py index f0e90b80..7753d340 100644 --- a/tests/test_unpaper.py +++ b/tests/test_unpaper.py @@ -21,7 +21,7 @@ from unittest.mock import patch import pytest from ocrmypdf._validation import check_options -from ocrmypdf.cli import parser +from ocrmypdf.cli import get_parser from ocrmypdf.exceptions import ExitCode, MissingDependencyError from ocrmypdf.exec import unpaper @@ -51,7 +51,7 @@ def spoof_unpaper_oldversion(tmp_path_factory): def test_no_unpaper(resources, no_outpdf): input_ = fspath(resources / "c02-22.pdf") output = fspath(no_outpdf) - options = parser.parse_args(args=["--clean", input_, output]) + options = get_parser().parse_args(args=["--clean", input_, output]) with patch("ocrmypdf.exec.unpaper.version") as mock_unpaper_version: mock_unpaper_version.side_effect = FileNotFoundError("unpaper") diff --git a/tests/test_validation.py b/tests/test_validation.py index f183aa46..1a453e78 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -23,6 +23,7 @@ import pytest import ocrmypdf._validation as vd from ocrmypdf.api import create_options +from ocrmypdf.cli import get_parser from ocrmypdf.exceptions import BadArgsError, MissingDependencyError from ocrmypdf.pdfinfo import PdfInfo @@ -30,7 +31,9 @@ from ocrmypdf.pdfinfo import PdfInfo def make_opts(input_file='a.pdf', output_file='b.pdf', language='eng', **kwargs): if language is not None: kwargs['language'] = language - return create_options(input_file=input_file, output_file=output_file, **kwargs) + return create_options( + input_file=input_file, output_file=output_file, parser=get_parser(), **kwargs + ) def test_hocr_notlatin_warning(caplog): From 5dbc080fa034702a076db37b4d60091cb71dfedf Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Sat, 2 May 2020 04:32:46 -0700 Subject: [PATCH 39/94] Rename PDFContext->PdfContext --- src/ocrmypdf/_jobcontext.py | 2 +- src/ocrmypdf/_sync.py | 4 ++-- src/ocrmypdf/optimize.py | 4 ++-- tests/test_metadata.py | 10 +++++----- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/ocrmypdf/_jobcontext.py b/src/ocrmypdf/_jobcontext.py index 7158abe8..938c908b 100644 --- a/src/ocrmypdf/_jobcontext.py +++ b/src/ocrmypdf/_jobcontext.py @@ -24,7 +24,7 @@ from functools import partial from ocrmypdf._plugin_manager import get_plugin_manager -class PDFContext: +class PdfContext: """Holds our context for a particular run of the pipeline""" def __init__(self, options, work_folder, origin, pdfinfo, plugin_manager): diff --git a/src/ocrmypdf/_sync.py b/src/ocrmypdf/_sync.py index 0834993d..a6bc3093 100644 --- a/src/ocrmypdf/_sync.py +++ b/src/ocrmypdf/_sync.py @@ -32,7 +32,7 @@ import pluggy from ocrmypdf import pluginspec from ocrmypdf._concurrent import exec_progress_pool from ocrmypdf._graft import OcrGrafter -from ocrmypdf._jobcontext import PDFContext, cleanup_working_files +from ocrmypdf._jobcontext import PdfContext, cleanup_working_files from ocrmypdf._logging import PageNumberFilter from ocrmypdf._pipeline import ( convert_to_pdfa, @@ -330,7 +330,7 @@ def run_pipeline(options, *, plugin_manager, api=False): max_workers=options.jobs if not options.use_threads else 1, # To help debug ) - context = PDFContext(options, work_folder, origin_pdf, pdfinfo, plugin_manager) + context = PdfContext(options, work_folder, origin_pdf, pdfinfo, plugin_manager) # Validate options are okay for this pdf validate_pdfinfo_options(context) diff --git a/src/ocrmypdf/optimize.py b/src/ocrmypdf/optimize.py index 9459b70e..e4fc1048 100644 --- a/src/ocrmypdf/optimize.py +++ b/src/ocrmypdf/optimize.py @@ -29,7 +29,7 @@ from PIL import Image from tqdm import tqdm from . import leptonica -from ._jobcontext import PDFContext +from ._jobcontext import PdfContext from .exceptions import OutputFileAccessError from .exec import jbig2enc, pngquant from .helpers import safe_symlink @@ -582,7 +582,7 @@ def main(infile, outfile, level, jobs=1): ) with TemporaryDirectory() as td: - context = PDFContext(options, td, infile, None, None) + context = PdfContext(options, td, infile, None, None) tmpout = Path(td) / 'out.pdf' optimize( infile, diff --git a/tests/test_metadata.py b/tests/test_metadata.py index 79e63604..102c9f28 100644 --- a/tests/test_metadata.py +++ b/tests/test_metadata.py @@ -30,7 +30,7 @@ import pikepdf import pytest from pikepdf.models.metadata import decode_pdf_date -from ocrmypdf._jobcontext import PDFContext +from ocrmypdf._jobcontext import PdfContext from ocrmypdf._pipeline import convert_to_pdfa from ocrmypdf.cli import get_parser from ocrmypdf.exceptions import ExitCode @@ -298,7 +298,7 @@ def test_metadata_fixup_warning(resources, outdir, caplog): copyfile(resources / 'graph.pdf', outdir / 'graph.pdf') - context = PDFContext(options, outdir, outdir / 'graph.pdf', None, None) + context = PdfContext(options, outdir, outdir / 'graph.pdf', None, None) metadata_fixup(working_file=outdir / 'graph.pdf', context=context) for record in caplog.records: assert record.levelname != 'WARNING' @@ -309,7 +309,7 @@ def test_metadata_fixup_warning(resources, outdir, caplog): meta['prism2:publicationName'] = 'OCRmyPDF Test' graph.save(outdir / 'graph_mod.pdf') - context = PDFContext(options, outdir, outdir / 'graph_mod.pdf', None, None) + context = PdfContext(options, outdir, outdir / 'graph_mod.pdf', None, None) metadata_fixup(working_file=outdir / 'graph.pdf', context=context) assert any(record.levelname == 'WARNING' for record in caplog.records) @@ -329,7 +329,7 @@ def test_prevent_gs_invalid_xml(resources, outdir): args=['-j', '1', '--output-type', 'pdfa-2', 'a.pdf', 'b.pdf'] ) pdfinfo = PdfInfo(outdir / 'layers.rendered.pdf') - context = PDFContext(options, outdir, outdir / 'layers.rendered.pdf', pdfinfo, None) + context = PdfContext(options, outdir, outdir / 'layers.rendered.pdf', pdfinfo, None) convert_to_pdfa( str(outdir / 'layers.rendered.pdf'), str(outdir / 'pdfa.ps'), context @@ -360,7 +360,7 @@ def test_malformed_docinfo(caplog, resources, outdir): args=['-j', '1', '--output-type', 'pdfa-2', 'a.pdf', 'b.pdf'] ) pdfinfo = PdfInfo(outdir / 'layers.rendered.pdf') - context = PDFContext(options, outdir, outdir / 'layers.rendered.pdf', pdfinfo, None) + context = PdfContext(options, outdir, outdir / 'layers.rendered.pdf', pdfinfo, None) convert_to_pdfa( str(outdir / 'layers.rendered.pdf'), str(outdir / 'pdfa.ps'), context From c85278b31d546d85f425803df761b258502b2183 Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Sun, 3 May 2020 00:51:17 -0700 Subject: [PATCH 40/94] Delinting --- src/ocrmypdf/__main__.py | 2 +- src/ocrmypdf/_jobcontext.py | 1 - src/ocrmypdf/_pipeline.py | 42 +++++++++++++++------------------- src/ocrmypdf/_sync.py | 6 +---- src/ocrmypdf/_validation.py | 3 +-- src/ocrmypdf/api.py | 3 +-- src/ocrmypdf/exec/tesseract.py | 21 ++++++++--------- src/ocrmypdf/helpers.py | 2 +- src/ocrmypdf/leptonica.py | 18 ++++----------- src/ocrmypdf/optimize.py | 14 ++++++------ src/ocrmypdf/pdfinfo/info.py | 6 ++--- tests/conftest.py | 18 ++++++++------- tests/test_main.py | 21 ++++++++--------- tests/test_metadata.py | 16 ++++--------- tests/test_optimize.py | 1 - tests/test_tess4.py | 8 +++---- tests/test_unpaper.py | 13 ++--------- tests/test_validation.py | 4 ++-- 18 files changed, 79 insertions(+), 120 deletions(-) diff --git a/src/ocrmypdf/__main__.py b/src/ocrmypdf/__main__.py index b6dc0466..411948e2 100755 --- a/src/ocrmypdf/__main__.py +++ b/src/ocrmypdf/__main__.py @@ -56,7 +56,7 @@ def run(args=None): configure_logging( verbosity, progress_bar_friendly=options.progress_bar, manage_root_logger=True ) - log.debug('ocrmypdf ' + __version__) + log.debug('ocrmypdf %s', __version__) try: check_options(options) except ValueError as e: diff --git a/src/ocrmypdf/_jobcontext.py b/src/ocrmypdf/_jobcontext.py index 938c908b..c5ffa5b1 100644 --- a/src/ocrmypdf/_jobcontext.py +++ b/src/ocrmypdf/_jobcontext.py @@ -15,7 +15,6 @@ # You should have received a copy of the GNU General Public License # along with OCRmyPDF. If not, see . -import logging import os import shutil import sys diff --git a/src/ocrmypdf/_pipeline.py b/src/ocrmypdf/_pipeline.py index cd82fea4..196a8d70 100644 --- a/src/ocrmypdf/_pipeline.py +++ b/src/ocrmypdf/_pipeline.py @@ -20,30 +20,29 @@ import os import re import sys from datetime import datetime, timezone -from pathlib import Path from shutil import copyfileobj import img2pdf import pikepdf from pikepdf.models.metadata import encode_pdf_date -from PIL import Image +from PIL import Image, ImageColor, ImageDraw -from . import leptonica -from ._version import PROGRAM_NAME -from ._version import __version__ as VERSION -from .exceptions import ( +from ocrmypdf import leptonica +from ocrmypdf._version import PROGRAM_NAME +from ocrmypdf._version import __version__ as VERSION +from ocrmypdf.exceptions import ( DpiError, EncryptedPdfError, InputFileError, PriorOcrFoundError, UnsupportedImageFormatError, ) -from .exec import ghostscript, tesseract -from .helpers import Resolution, safe_symlink -from .hocrtransform import HocrTransform -from .optimize import optimize -from .pdfa import generate_pdfa_ps -from .pdfinfo import Colorspace, Encoding, PdfInfo +from ocrmypdf.exec import ghostscript, tesseract, unpaper +from ocrmypdf.helpers import Resolution, safe_symlink +from ocrmypdf.hocrtransform import HocrTransform +from ocrmypdf.optimize import optimize +from ocrmypdf.pdfa import generate_pdfa_ps +from ocrmypdf.pdfinfo import Colorspace, Encoding, PdfInfo log = logging.getLogger(__name__) @@ -63,8 +62,8 @@ def triage_image_file(input_file, output_file, options): log.info("Input file is an image") if 'dpi' in im.info: if im.info['dpi'] <= (96, 96) and not options.image_dpi: - log.info("Image size: (%d, %d)" % im.size) - log.info("Image resolution: (%d, %d)" % im.info['dpi']) + log.info("Image size: (%d, %d)", *im.size) + log.info("Image resolution: (%d, %d)", *im.info['dpi']) log.error( "Input file is an image, but the resolution (DPI) is " "not credible. Estimate the resolution at which the " @@ -72,7 +71,7 @@ def triage_image_file(input_file, output_file, options): ) raise DpiError() elif not options.image_dpi: - log.info("Image size: (%d, %d)" % im.size) + log.info("Image size: (%d, %d)", *im.size) log.error( "Input file is an image, but has no resolution (DPI) " "in its metadata. Estimate the resolution at which " @@ -261,7 +260,7 @@ def is_ocr_required(page_context): ocr_required = True elif options.redo_ocr: if pageinfo.has_corrupt_text: - log.warn( + log.warning( "some text on this page cannot be mapped to characters: " "consider using --force-ocr instead" ) @@ -288,7 +287,7 @@ def is_ocr_required(page_context): ) elif options.force_ocr: # Warn the user they might not want to do this - log.warn( + log.warning( "page has no images - " "all vector content will be " f"rasterized at {VECTOR_PAGE_DPI} DPI, losing some resolution and likely " @@ -308,7 +307,7 @@ def is_ocr_required(page_context): pixel_count = pageinfo.width_pixels * pageinfo.height_pixels if pixel_count > (options.skip_big * 1_000_000): ocr_required = False - log.warn( + log.warning( "page too big, skipping OCR " f"({(pixel_count / 1_000_000):.1f} MPixels > {options.skip_big:.1f} MPixels --skip-big)" ) @@ -464,8 +463,6 @@ def preprocess_deskew(input_file, page_context): def preprocess_clean(input_file, page_context): - from .exec import unpaper - output_file = page_context.get_path('pp_clean.png') dpi = get_page_square_dpi(page_context.pageinfo, page_context.options) unpaper.clean(input_file, output_file, dpi.x, page_context.options.unpaper_args) @@ -480,9 +477,6 @@ def create_ocr_image(image, page_context): output_file = page_context.get_path('ocr.png') options = page_context.options with Image.open(image) as im: - from PIL import ImageColor - from PIL import ImageDraw - white = ImageColor.getcolor('#ffffff', im.mode) # pink = ImageColor.getcolor('#ff0080', im.mode) draw = ImageDraw.ImageDraw(im) @@ -811,7 +805,7 @@ def merge_sidecars(txt_files, context): return output_file -def copy_final(input_file, output_file, context): +def copy_final(input_file, output_file, _context): log.debug('%s -> %s', input_file, output_file) with open(input_file, 'rb') as input_stream: if output_file == '-': diff --git a/src/ocrmypdf/_sync.py b/src/ocrmypdf/_sync.py index a6bc3093..1d256c92 100644 --- a/src/ocrmypdf/_sync.py +++ b/src/ocrmypdf/_sync.py @@ -15,7 +15,6 @@ # You should have received a copy of the GNU General Public License # along with OCRmyPDF. If not, see . -import importlib import logging import logging.handlers import os @@ -27,13 +26,10 @@ from pathlib import Path from tempfile import mkdtemp import PIL -import pluggy -from ocrmypdf import pluginspec from ocrmypdf._concurrent import exec_progress_pool from ocrmypdf._graft import OcrGrafter from ocrmypdf._jobcontext import PdfContext, cleanup_working_files -from ocrmypdf._logging import PageNumberFilter from ocrmypdf._pipeline import ( convert_to_pdfa, copy_final, @@ -372,7 +368,7 @@ def run_pipeline(options, *, plugin_manager, api=False): else: log.error(type(e).__name__) return e.exit_code - except (Exception if not api else NeverRaise) as e: + except (Exception if not api else NeverRaise) as e: # pylint: disable=broad-except log.exception("An exception occurred while executing the pipeline") return ExitCode.other_error finally: diff --git a/src/ocrmypdf/_validation.py b/src/ocrmypdf/_validation.py index bfc25d02..0a755d3c 100644 --- a/src/ocrmypdf/_validation.py +++ b/src/ocrmypdf/_validation.py @@ -21,6 +21,7 @@ import locale import logging import os import sys +import unicodedata from pathlib import Path from shutil import copyfileobj @@ -284,8 +285,6 @@ def check_options_advanced(options): def check_options_metadata(options): - import unicodedata - docinfo = [options.title, options.author, options.keywords, options.subject] for s in (m for m in docinfo if m): for c in s: diff --git a/src/ocrmypdf/api.py b/src/ocrmypdf/api.py index cc7e103f..16e151b2 100644 --- a/src/ocrmypdf/api.py +++ b/src/ocrmypdf/api.py @@ -19,7 +19,6 @@ import logging import os import sys from argparse import ArgumentParser -from contextlib import suppress from enum import IntEnum from pathlib import Path from typing import Dict, Iterable @@ -28,7 +27,7 @@ from ocrmypdf._logging import PageNumberFilter, TqdmConsole from ocrmypdf._plugin_manager import get_plugin_manager from ocrmypdf._sync import run_pipeline from ocrmypdf._validation import check_options -from ocrmypdf.cli import get_parser, plugins_only_parser +from ocrmypdf.cli import get_parser try: import coloredlogs diff --git a/src/ocrmypdf/exec/tesseract.py b/src/ocrmypdf/exec/tesseract.py index 4ebdb0cf..731746e4 100644 --- a/src/ocrmypdf/exec/tesseract.py +++ b/src/ocrmypdf/exec/tesseract.py @@ -25,13 +25,15 @@ from contextlib import suppress from os import fspath from subprocess import PIPE, STDOUT, CalledProcessError, TimeoutExpired -from ..exceptions import ( +from PIL import Image + +from ocrmypdf.exceptions import ( MissingDependencyError, SubprocessOutputError, TesseractConfigError, ) -from ..helpers import page_number, safe_symlink -from . import get_version, run +from ocrmypdf.exec import get_version, run +from ocrmypdf.helpers import safe_symlink log = logging.getLogger(__name__) @@ -133,7 +135,7 @@ def languages(tesseract_env=None): for line in output.splitlines(): if line.startswith('Error'): raise MissingDependencyError(lang_error(output)) - header, *rest = output.splitlines() + _header, *rest = output.splitlines() return set(lang.strip() for lang in rest) @@ -227,18 +229,15 @@ def tesseract_log_output(stdout, input_file): tlog.info(line.strip()) -def page_timedout(input_file, timeout): +def page_timedout(timeout): if timeout == 0: return - prefix = f"{(page_number(input_file)):4d}: [tesseract] " - log.warning(prefix + " took too long to OCR - skipping") + log.warning("[tesseract] took too long to OCR - skipping") def _generate_null_hocr(output_hocr, output_sidecar, image): """Produce a .hocr file that reports no text detected on a page that is the same size as the input image.""" - from PIL import Image - with Image.open(image) as im: w, h = im.size @@ -293,7 +292,7 @@ def generate_hocr( # Generate a HOCR file with no recognized text if tesseract times out # Temporary workaround to hocrTransform not being able to function if # it does not have a valid hOCR file. - page_timedout(input_file, timeout) + page_timedout(timeout) _generate_null_hocr(output_hocr, output_sidecar, input_file) except CalledProcessError as e: tesseract_log_output(e.output, input_file) @@ -389,7 +388,7 @@ def generate_pdf( if os.path.exists(prefix + '.txt'): shutil.move(prefix + '.txt', output_text) except TimeoutExpired: - page_timedout(input_image, timeout) + page_timedout(timeout) use_skip_page(text_only, skip_pdf, output_pdf, output_text) except CalledProcessError as e: tesseract_log_output(e.output, input_image) diff --git a/src/ocrmypdf/helpers.py b/src/ocrmypdf/helpers.py index 0bfe694a..46a9ea80 100644 --- a/src/ocrmypdf/helpers.py +++ b/src/ocrmypdf/helpers.py @@ -25,7 +25,7 @@ from collections.abc import Iterable from contextlib import suppress from functools import wraps from io import StringIO -from math import inf, isclose +from math import isclose from pathlib import Path import pikepdf diff --git a/src/ocrmypdf/leptonica.py b/src/ocrmypdf/leptonica.py index 328b0630..0a8f6cba 100644 --- a/src/ocrmypdf/leptonica.py +++ b/src/ocrmypdf/leptonica.py @@ -29,7 +29,7 @@ from collections.abc import Sequence from contextlib import suppress from ctypes.util import find_library from functools import lru_cache -from io import BytesIO +from io import BytesIO, UnsupportedOperation from os import fspath from tempfile import TemporaryFile @@ -96,7 +96,6 @@ class _LeptonicaErrorTrap: self.no_stderr = False def __enter__(self): - from io import UnsupportedOperation self.tmpfile = TemporaryFile() @@ -351,7 +350,7 @@ class Pix(LeptonicaObject): py_file.write(buffer) @classmethod - def frompil(self, pillow_image): + def frompil(cls, pillow_image): """Create a copy of a PIL.Image from this Pix""" bio = BytesIO() pillow_image.save(bio, format='png', compress_level=1) @@ -363,7 +362,7 @@ class Pix(LeptonicaObject): def topil(self): """Returns a PIL.Image version of this Pix""" - from PIL import Image + from PIL import Image # pylint: disable=import-outside-toplevel # Leptonica manages data in words, so it implicitly does an endian # swap. Tell Pillow about this when it reads the data. @@ -534,16 +533,7 @@ class Pix(LeptonicaObject): ) return Pix(thresh_pix) - def crop_to_foreground( - self, - threshold=128, - mindist=70, - erasedist=30, - pagenum=0, - showmorph=0, - display=0, - pdfdir=ffi.NULL, - ): + def crop_to_foreground(self, threshold=128, mindist=70, erasedist=30, showmorph=0): if get_leptonica_version() < 'leptonica-1.76': # Leptonica 1.76 changed the API for pixFindPageForeground; we don't # support the old version diff --git a/src/ocrmypdf/optimize.py b/src/ocrmypdf/optimize.py index e4fc1048..7438c180 100644 --- a/src/ocrmypdf/optimize.py +++ b/src/ocrmypdf/optimize.py @@ -217,7 +217,7 @@ def extract_images(pike, root, options, extract_fn): result = extract_fn( pike=pike, root=root, image=image, xref=xref, options=options ) - except Exception as e: + except Exception as e: # pylint: disable=broad-except log.debug("Image xref %s, error %s", xref, repr(e)) errors += 1 else: @@ -422,12 +422,12 @@ def transcode_pngs(pike, images, image_name_fn, root, options): ) continue if compdata.type == leptonica.lept.L_FLATE_ENCODE: - return rewrite_png(pike, im_obj, compdata, log) + return rewrite_png(pike, im_obj, compdata) elif compdata.type == leptonica.lept.L_G4_ENCODE: - return rewrite_png_as_g4(pike, im_obj, compdata, log) + return rewrite_png_as_g4(pike, im_obj, compdata) -def rewrite_png_as_g4(pike, im_obj, compdata, log): +def rewrite_png_as_g4(pike, im_obj, compdata): im_obj.BitsPerComponent = 1 im_obj.Width = compdata.w im_obj.Height = compdata.h @@ -447,7 +447,7 @@ def rewrite_png_as_g4(pike, im_obj, compdata, log): return -def rewrite_png(pike, im_obj, compdata, log): +def rewrite_png(pike, im_obj, compdata): # When a PNG is inserted into a PDF, we more or less copy the IDAT section from # the PDF and transfer the rest of the PNG headers to PDF image metadata. # One thing we have to do is tell the PDF reader whether a predictor was used @@ -553,8 +553,8 @@ def optimize(input_file, output_file, context, save_settings): def main(infile, outfile, level, jobs=1): - from tempfile import TemporaryDirectory - from shutil import copy + from tempfile import TemporaryDirectory # pylint: disable=import-outside-toplevel + from shutil import copy # pylint: disable=import-outside-toplevel class OptimizeOptions: """Emulate ocrmypdf's options""" diff --git a/src/ocrmypdf/pdfinfo/info.py b/src/ocrmypdf/pdfinfo/info.py index 86bb059d..42ed372f 100644 --- a/src/ocrmypdf/pdfinfo/info.py +++ b/src/ocrmypdf/pdfinfo/info.py @@ -17,14 +17,13 @@ # along with OCRmyPDF. If not, see . import logging -import os import re from collections import defaultdict, namedtuple from decimal import Decimal from enum import Enum from functools import partial from math import hypot, isclose -from os import PathLike, fspath +from os import PathLike from pathlib import Path from warnings import warn @@ -821,13 +820,14 @@ class PdfInfo: def main(): + # pylint: disable=import-outside-toplevel import argparse + from pprint import pprint parser = argparse.ArgumentParser() parser.add_argument('infile') args = parser.parse_args() pagesinfo, pdfinfo = _pdf_get_all_pageinfo(args.infile) - from pprint import pprint pprint(pdfinfo) for page in pagesinfo: diff --git a/tests/conftest.py b/tests/conftest.py index 54960cdc..d1cc9ef1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -24,7 +24,8 @@ from subprocess import PIPE, run import pytest -from ocrmypdf import api, cli +from ocrmypdf import api, cli, pdfinfo +from ocrmypdf.exec import unpaper pytest_plugins = ['helpers_namespace'] @@ -62,10 +63,8 @@ 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 @@ -95,7 +94,7 @@ 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 + r"""Modify PATH to override subprocess executables spoof(tmp_path_factory, program1='replacement', ...) @@ -277,7 +276,12 @@ def run_ocrmypdf(input_file, output_file, *args, env=None, universal_newlines=Tr 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 +289,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/test_main.py b/tests/test_main.py index e69ed192..cca9c4eb 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -29,8 +29,7 @@ from PIL import Image import ocrmypdf from ocrmypdf.exceptions import ExitCode, MissingDependencyError -from ocrmypdf.exec import ghostscript, tesseract -from ocrmypdf.helpers import check_pdf +from ocrmypdf.exec import get_version, ghostscript, tesseract from ocrmypdf.pdfa import file_claims_pdfa from ocrmypdf.pdfinfo import Colorspace, Encoding, PdfInfo @@ -311,7 +310,7 @@ def test_pagesegmode(renderer, spoof_tesseract_cache, resources, outpdf): @pytest.mark.parametrize('renderer', RENDERERS) -def test_tesseract_crash(renderer, spoof_tesseract_crash, resources, no_outpdf, caplog): +def test_tesseract_crash(renderer, spoof_tesseract_crash, resources, no_outpdf): p, _, err = run_ocrmypdf( resources / 'ccitt.pdf', no_outpdf, @@ -410,7 +409,7 @@ def test_destination_not_writable(spoof_tesseract_noop, resources, outdir): protected_file = outdir / 'protected.pdf' protected_file.touch() protected_file.chmod(0o400) # Read-only - p, out, err = run_ocrmypdf( + p, _out, _err = run_ocrmypdf( resources / 'jbig2.pdf', protected_file, env=spoof_tesseract_noop ) assert p.returncode == ExitCode.file_access_error, "Expected error" @@ -448,7 +447,7 @@ THIS FILE IS INVALID ''' ) - p, out, err = run_ocrmypdf( + p, _out, err = run_ocrmypdf( resources / 'ccitt.pdf', outdir / 'out.pdf', '--pdf-renderer', @@ -568,6 +567,7 @@ def test_compression_preserved( stdin=input_stream, universal_newlines=True, env=spoof_tesseract_noop, + check=False, ) if im.mode in ('RGBA', 'LA'): @@ -629,6 +629,7 @@ def test_compression_changed( stdin=input_stream, universal_newlines=True, env=spoof_tesseract_noop, + check=False, ) assert p.returncode == ExitCode.ok, p.stderr @@ -711,10 +712,10 @@ def test_pdfa_n(spoof_tesseract_cache, pdfa_level, resources, outpdf): ) @pytest.mark.slow def test_decompression_bomb(resources, outpdf): - p, out, err = run_ocrmypdf(resources / 'hugemono.pdf', outpdf) + p, _out, err = run_ocrmypdf(resources / 'hugemono.pdf', outpdf) assert 'decompression bomb' in err - p, out, err = run_ocrmypdf( + p, _out, err = run_ocrmypdf( resources / 'hugemono.pdf', outpdf, '--max-image-mpixels', '2000' ) assert p.returncode == 0 @@ -736,7 +737,7 @@ def test_text_curves(spoof_tesseract_noop, resources, outpdf): def test_output_is_dir(spoof_tesseract_noop, resources, outdir): - p, out, err = run_ocrmypdf( + p, _out, err = run_ocrmypdf( resources / 'trivial.pdf', outdir, '--force-ocr', env=spoof_tesseract_noop ) assert p.returncode == ExitCode.file_access_error @@ -747,7 +748,7 @@ def test_output_is_dir(spoof_tesseract_noop, resources, outdir): def test_output_is_symlink(spoof_tesseract_noop, resources, outdir): sym = Path(outdir / 'this_is_a_symlink') sym.symlink_to(outdir / 'out.pdf') - p, out, err = run_ocrmypdf( + p, _out, err = run_ocrmypdf( resources / 'trivial.pdf', sym, '--force-ocr', env=spoof_tesseract_noop ) assert p.returncode == ExitCode.ok, err @@ -761,8 +762,6 @@ def test_livecycle(resources, no_outpdf): def test_version_check(): - from ocrmypdf.exec import get_version - with pytest.raises(MissingDependencyError): get_version('NOT_FOUND_UNLIKELY_ON_PATH') diff --git a/tests/test_metadata.py b/tests/test_metadata.py index 102c9f28..9a92c65a 100644 --- a/tests/test_metadata.py +++ b/tests/test_metadata.py @@ -17,21 +17,18 @@ import datetime -import logging import mmap -import os from datetime import timezone from os import fspath -from pathlib import Path -from shutil import copyfile, move -from unittest.mock import MagicMock, patch +from shutil import copyfile +from unittest.mock import patch import pikepdf import pytest from pikepdf.models.metadata import decode_pdf_date from ocrmypdf._jobcontext import PdfContext -from ocrmypdf._pipeline import convert_to_pdfa +from ocrmypdf._pipeline import convert_to_pdfa, metadata_fixup from ocrmypdf.cli import get_parser from ocrmypdf.exceptions import ExitCode from ocrmypdf.pdfa import SRGB_ICC_PROFILE, file_claims_pdfa, generate_pdfa_ps @@ -192,9 +189,8 @@ def test_xml_metadata_preserved(spoof_tesseract_noop, output_type, resources, ou input_file = resources / 'graph.pdf' try: - from libxmp import consts - from libxmp.utils import file_to_dict - except Exception: + from libxmp.utils import file_to_dict # pylint: disable=import-outside-toplevel + except Exception: # pylint: disable=broad-except pytest.skip("libxmp not available or libexempi3 not installed") before = file_to_dict(str(input_file)) @@ -290,8 +286,6 @@ def test_kodak_toc(resources, outpdf, spoof_tesseract_noop): def test_metadata_fixup_warning(resources, outdir, caplog): - from ocrmypdf._pipeline import metadata_fixup - options = get_parser().parse_args( args=['--output-type', 'pdfa-2', 'graph.pdf', 'out.pdf'] ) diff --git a/tests/test_optimize.py b/tests/test_optimize.py index 36e00c5e..f9f6747c 100644 --- a/tests/test_optimize.py +++ b/tests/test_optimize.py @@ -15,7 +15,6 @@ # You should have received a copy of the GNU General Public License # along with OCRmyPDF. If not, see . -import logging from os import fspath from pathlib import Path diff --git a/tests/test_tess4.py b/tests/test_tess4.py index 33110a43..4fada7d4 100644 --- a/tests/test_tess4.py +++ b/tests/test_tess4.py @@ -18,7 +18,6 @@ import logging import os import subprocess -from contextlib import contextmanager from os import fspath from pathlib import Path @@ -60,8 +59,8 @@ def test_skip_pages_does_not_replicate(resources, basename, outdir): for page in info: assert len(page.images) == 1, "skipped page was replicated" - for n in range(len(info_in)): - assert info[n].width_inches == info_in[n].width_inches + for n, info_out_n in enumerate(info): + assert info_out_n.width_inches == info_in[n].width_inches def test_content_preservation(resources, outpdf): @@ -131,8 +130,7 @@ def test_image_too_large_pdf(monkeypatch, resources, outdir): def test_timeout(caplog): - tesseract.page_timedout('123456.png', 5) - assert "123456" in caplog.text + tesseract.page_timedout(5) assert "took too long" in caplog.text diff --git a/tests/test_unpaper.py b/tests/test_unpaper.py index 7753d340..836ef0a8 100644 --- a/tests/test_unpaper.py +++ b/tests/test_unpaper.py @@ -23,24 +23,15 @@ import pytest from ocrmypdf._validation import check_options from ocrmypdf.cli import get_parser from ocrmypdf.exceptions import ExitCode, MissingDependencyError -from ocrmypdf.exec import unpaper # pytest.helpers is dynamic -# pylint: disable=no-member +# pylint: disable=no-member,redefined-outer-name # pylint: disable=w0612 check_ocrmypdf = pytest.helpers.check_ocrmypdf run_ocrmypdf = pytest.helpers.run_ocrmypdf spoof = pytest.helpers.spoof - - -def have_unpaper(): - try: - unpaper.version() - except Exception: - return False - else: - return True +have_unpaper = pytest.helpers.have_unpaper @pytest.fixture diff --git a/tests/test_validation.py b/tests/test_validation.py index 1a453e78..35c21fa0 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -145,9 +145,9 @@ def test_report_file_size(tmp_path, caplog): def test_false_action_store_true(): opts = make_opts(keep_temporary_files=True) - assert opts.keep_temporary_files == True + assert opts.keep_temporary_files opts = make_opts(keep_temporary_files=False) - assert opts.keep_temporary_files == False + assert not opts.keep_temporary_files @pytest.mark.parametrize('progress_bar', [True, False]) From fe4296c53b151a2a408f093b0700c537d82a7dd2 Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Sun, 3 May 2020 00:53:47 -0700 Subject: [PATCH 41/94] safe_symlink: remove deprecated params --- src/ocrmypdf/helpers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ocrmypdf/helpers.py b/src/ocrmypdf/helpers.py index 46a9ea80..ff4bb13a 100644 --- a/src/ocrmypdf/helpers.py +++ b/src/ocrmypdf/helpers.py @@ -65,7 +65,7 @@ class Resolution(namedtuple('Resolution', ('x', 'y'))): return f"Resolution({self.x}x{self.y} dpi)" -def safe_symlink(input_file: os.PathLike, soft_link_name: os.PathLike, *args, **kwargs): +def safe_symlink(input_file: os.PathLike, soft_link_name: os.PathLike): """ Helper function: relinks soft symbolic link if necessary """ From 75c34b873a46a2561f8c4fb75c9f1010567d0a70 Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Sun, 3 May 2020 02:04:57 -0700 Subject: [PATCH 42/94] optimize: convert from executor to progress pool --- src/ocrmypdf/_concurrent.py | 7 ++- src/ocrmypdf/optimize.py | 88 +++++++++++++++++++------------------ 2 files changed, 51 insertions(+), 44 deletions(-) diff --git a/src/ocrmypdf/_concurrent.py b/src/ocrmypdf/_concurrent.py index a321f28d..cf46bae3 100644 --- a/src/ocrmypdf/_concurrent.py +++ b/src/ocrmypdf/_concurrent.py @@ -46,7 +46,7 @@ def log_listener(queue): break logger = logging.getLogger(record.name) logger.handle(record) - except Exception: + except Exception: # pylint: disable=broad-except import traceback print("Logging problem", file=sys.stderr) @@ -106,7 +106,10 @@ def exec_progress_pool( while True: try: result = results.next() - task_finished(result, pbar) + if task_finished: + task_finished(result, pbar) + else: + pbar.update() except StopIteration: break except KeyboardInterrupt: diff --git a/src/ocrmypdf/optimize.py b/src/ocrmypdf/optimize.py index 7438c180..41bf7c60 100644 --- a/src/ocrmypdf/optimize.py +++ b/src/ocrmypdf/optimize.py @@ -15,11 +15,11 @@ # You should have received a copy of the GNU General Public License # along with OCRmyPDF. If not, see . -import concurrent.futures import logging import sys import tempfile from collections import defaultdict +from functools import partial from os import fspath from pathlib import Path @@ -28,11 +28,12 @@ from pikepdf import Dictionary, Name from PIL import Image from tqdm import tqdm -from . import leptonica -from ._jobcontext import PdfContext -from .exceptions import OutputFileAccessError -from .exec import jbig2enc, pngquant -from .helpers import safe_symlink +from ocrmypdf import leptonica +from ocrmypdf._concurrent import exec_progress_pool +from ocrmypdf._jobcontext import PdfContext +from ocrmypdf.exceptions import OutputFileAccessError +from ocrmypdf.exec import jbig2enc, pngquant +from ocrmypdf.helpers import safe_symlink log = logging.getLogger(__name__) @@ -260,49 +261,49 @@ def extract_images_jbig2(pike, root, options): def _produce_jbig2_images(jbig2_groups, root, options): """Produce JBIG2 images from their groups""" - def jbig2_group_futures(executor, root, groups): + def jbig2_group_args(root, groups): for group, xref_exts in groups.items(): prefix = f'group{group:08d}' - future = executor.submit( - jbig2enc.convert_group, + yield dict( cwd=fspath(root), infiles=(img_name(root, xref, ext) for xref, ext in xref_exts), out_prefix=prefix, ) - yield future - def jbig2_single_futures(executor, root, groups): + def jbig2_single_args(root, groups): for group, xref_exts in groups.items(): prefix = f'group{group:08d}' # Second loop is to ensure multiple images per page are unpacked for n, xref_ext in enumerate(xref_exts): xref, ext = xref_ext - future = executor.submit( - jbig2enc.convert_single, + yield dict( cwd=fspath(root), infile=img_name(root, xref, ext), outfile=root / f'{prefix}.{n:04d}', ) - yield future + + def convert_generic(fn, kwargs_dict): + return fn(**kwargs_dict) if options.jbig2_page_group_size > 1: - jbig2_futures = jbig2_group_futures + jbig2_args = jbig2_group_args + jbig2_convert = partial(convert_generic, jbig2enc.convert_group) else: - jbig2_futures = jbig2_single_futures + jbig2_args = jbig2_single_args + jbig2_convert = partial(convert_generic, jbig2enc.convert_single) - with concurrent.futures.ThreadPoolExecutor(max_workers=options.jobs) as executor: - futures = jbig2_futures(executor, root, jbig2_groups) - with tqdm( + exec_progress_pool( + use_threads=True, + max_workers=options.jobs, + tqdm_kwargs=dict( total=len(jbig2_groups), desc="JBIG2", unit='item', disable=not options.progress_bar, - ) as pbar: - for future in concurrent.futures.as_completed(futures): - proc = future.result() - if proc.stderr: - log.debug(proc.stderr.decode()) - pbar.update() + ), + task=jbig2_convert, + task_arguments=jbig2_args(root, jbig2_groups), + ) def convert_to_jbig2(pike, jbig2_groups, root, options): @@ -373,30 +374,33 @@ def transcode_pngs(pike, images, image_name_fn, root, options): max(10, options.png_quality - 10), min(100, options.png_quality + 10), ) - with concurrent.futures.ThreadPoolExecutor( - max_workers=options.jobs - ) as executor: - futures = [] + + def pngquant_args(): for xref in images: log.debug(image_name_fn(root, xref)) - futures.append( - executor.submit( - pngquant.quantize, - image_name_fn(root, xref), - png_name(root, xref), - png_quality[0], - png_quality[1], - ) + yield ( + image_name_fn(root, xref), + png_name(root, xref), + png_quality[0], + png_quality[1], ) modified.add(xref) - with tqdm( + + def pngquant_fn(args): + pngquant.quantize(*args) + + exec_progress_pool( + use_threads=True, + max_workers=options.jobs, + tqdm_kwargs=dict( desc="PNGs", - total=len(futures), + total=len(images), unit='image', disable=not options.progress_bar, - ) as pbar: - for _future in concurrent.futures.as_completed(futures): - pbar.update() + ), + task=pngquant_fn, + task_arguments=pngquant_args(), + ) for xref in modified: im_obj = pike.get_object(xref, 0) From 32759c902522a80d59572fc3cbd4de148a6023a1 Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Wed, 6 May 2020 00:43:40 -0700 Subject: [PATCH 43/94] Change argument from --plugins to --plugin --- src/ocrmypdf/cli.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/ocrmypdf/cli.py b/src/ocrmypdf/cli.py index 3edf6cd7..d6ba9202 100644 --- a/src/ocrmypdf/cli.py +++ b/src/ocrmypdf/cli.py @@ -492,10 +492,11 @@ Online documentation is located at: "Set the threshold very high to disable.", ) advanced.add_argument( - '--plugins', + '--plugin', + dest='plugins', action='append', default=[], - help="Path to a folder than contains plugins.", + help="Name of plugin to import.", ) debugging = parser.add_argument_group( @@ -515,8 +516,9 @@ plugins_only_parser = ArgumentParser( prog=_PROGRAM_NAME, fromfile_prefix_chars='@', add_help=False, allow_abbrev=False ) plugins_only_parser.add_argument( - '--plugins', + '--plugin', + dest='plugins', action='append', default=[], - help="Path to a folder than contains plugins.", + help="Name of plugin to import.", ) From dd361ecd059a5fb0eaae160a093eef71c10bcd77 Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Wed, 6 May 2020 00:44:40 -0700 Subject: [PATCH 44/94] Support importing plugin by filename --- src/ocrmypdf/_plugin_manager.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/ocrmypdf/_plugin_manager.py b/src/ocrmypdf/_plugin_manager.py index 295e955c..9ccfc3b0 100644 --- a/src/ocrmypdf/_plugin_manager.py +++ b/src/ocrmypdf/_plugin_manager.py @@ -16,6 +16,9 @@ # along with OCRmyPDF. If not, see . import importlib +import importlib.util +import sys +from pathlib import Path from typing import List import pluggy @@ -27,6 +30,15 @@ def get_plugin_manager(plugins: List[str]): pm = pluggy.PluginManager('ocrmypdf') pm.add_hookspecs(pluginspec) for name in plugins: - module = importlib.import_module(name) + if name.endswith('.py'): + # Import by filename + module_name = Path(name).stem + spec = importlib.util.spec_from_file_location(module_name, name) + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + spec.loader.exec_module(module) + else: + # Import by dotted module name + module = importlib.import_module(name) pm.register(module) return pm From 39888ae8c9485806df9d93bbb7bc431cd391c366 Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Wed, 6 May 2020 01:10:09 -0700 Subject: [PATCH 45/94] Rename install_cli to add_options --- src/ocrmypdf/__main__.py | 2 +- src/ocrmypdf/api.py | 2 +- src/ocrmypdf/pluginspec.py | 8 ++++++-- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/ocrmypdf/__main__.py b/src/ocrmypdf/__main__.py index 411948e2..01f0a478 100755 --- a/src/ocrmypdf/__main__.py +++ b/src/ocrmypdf/__main__.py @@ -37,7 +37,7 @@ def run(args=None): plugin_manager = get_plugin_manager(pre_options.plugins) parser = get_parser() - plugin_manager.hook.install_cli(parser=parser) + plugin_manager.hook.add_options(parser=parser) options = parser.parse_args(args=args) diff --git a/src/ocrmypdf/api.py b/src/ocrmypdf/api.py index 16e151b2..42638a58 100644 --- a/src/ocrmypdf/api.py +++ b/src/ocrmypdf/api.py @@ -274,7 +274,7 @@ def ocr( # pylint: disable=unused-argument parser = get_parser() _plugin_manager = get_plugin_manager(plugins) - _plugin_manager.hook.install_cli(parser=parser) + _plugin_manager.hook.add_options(parser=parser) options = create_options( **{k: v for k, v in locals().items() if not k.startswith('_')} diff --git a/src/ocrmypdf/pluginspec.py b/src/ocrmypdf/pluginspec.py index e95241e0..76eed1cc 100644 --- a/src/ocrmypdf/pluginspec.py +++ b/src/ocrmypdf/pluginspec.py @@ -26,8 +26,12 @@ hookspec = pluggy.HookspecMarker('ocrmypdf') @hookspec -def install_cli(parser: ArgumentParser) -> None: - """Allows the plugin to add its own command line arguments.""" +def add_options(parser: ArgumentParser) -> None: + """Allows the plugin to add its own command line arguments. + + Even if you do not intend to use plugins in a command line context, you + should use this function to create your options. + """ @hookspec From 6f4286e1b11b580c0d31e476906e659d828749b6 Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Wed, 6 May 2020 01:10:32 -0700 Subject: [PATCH 46/94] New hook: filter_page_image --- src/ocrmypdf/_sync.py | 6 ++++++ src/ocrmypdf/example.py | 20 +++++++++++++++----- src/ocrmypdf/pluginspec.py | 28 +++++++++++++++++++++++++--- 3 files changed, 46 insertions(+), 8 deletions(-) diff --git a/src/ocrmypdf/_sync.py b/src/ocrmypdf/_sync.py index 1d256c92..f15dbbaf 100644 --- a/src/ocrmypdf/_sync.py +++ b/src/ocrmypdf/_sync.py @@ -165,6 +165,12 @@ def exec_page_sync(page_context): visible_image_out = create_visible_page_jpg( visible_image_out, page_context ) + visible_image_out = ( + page_context.plugin_manager.hook.filter_page_image( + page=page_context, image_filename=Path(visible_image_out) + ) + or visible_image_out + ) pdf_page_from_image_out = create_pdf_page_from_image( visible_image_out, page_context ) diff --git a/src/ocrmypdf/example.py b/src/ocrmypdf/example.py index 73b1b544..926831c3 100644 --- a/src/ocrmypdf/example.py +++ b/src/ocrmypdf/example.py @@ -1,13 +1,15 @@ import logging +from PIL import Image + from ocrmypdf import hookimpl log = logging.getLogger(__name__) @hookimpl -def install_cli(parser): - parser.add_argument('--invert', action='store_true') +def add_options(parser): + parser.add_argument('--grayscale-ocr', action='store_true') @hookimpl @@ -22,7 +24,15 @@ def validate(pdfinfo, options): @hookimpl def filter_ocr_image(page, image): - if page.options.invert: - log.info("inverting") - return image.invert() + if page.options.grayscale_ocr: + log.info("graying") + return image.convert('L') return image + + +@hookimpl +def filter_page_image(page, image_filename): + output = image_filename.with_suffix('.jpg') + with Image.open(image_filename) as im: + im.save(output) + return output diff --git a/src/ocrmypdf/pluginspec.py b/src/ocrmypdf/pluginspec.py index 76eed1cc..3a9620c4 100644 --- a/src/ocrmypdf/pluginspec.py +++ b/src/ocrmypdf/pluginspec.py @@ -16,6 +16,8 @@ # along with OCRmyPDF. If not, see . from argparse import ArgumentParser, Namespace +from pathlib import Path +from typing import Optional import pluggy from PIL import Image @@ -49,11 +51,15 @@ def validate(pdfinfo: 'PdfInfo', options: Namespace) -> None: options contains the "work order" to process a particular file. pdfinfo contains information about the input file obtained after loading and - parsing. + parsing. The plugin may modify the options. For example, you could decide + that a certain type of file should be treated with ``options.force_ocr = True`` + based on information in its pdfinfo. The plugin may raise InputFileError or any ExitCodeException to request - normal termination. If the plugin raises another exception type, ocrmypdf - will abort with an error and hold the plugin responsible. + normal termination. ocrmypdf will hold the plugin responsible for raising + exceptions of any other type. + + The return value is ignored. To abort processing, raise an ExitCodeException. """ @@ -64,3 +70,19 @@ def filter_ocr_image(page: 'PageContext', image: Image) -> Image: This is the image that OCR sees, not what the user sees when they view the PDF. """ + + +@hookspec(firstresult=True) +def filter_page_image(page: 'PageContext', image_filename: Path) -> Path: + """Called to filter the whole page before it is inserted into the PDF. + + A whole page image is only produced when preprocessing command line arguments + are issued or when ``--force-ocr`` is issued. If no whole page is image is + produced for a given page, this function will not be called. This is not + the image that will be shown to OCR. + + ocrmypdf will create the PDF page based on the image format used. If you + convert the image to a JPEG, the output page will be created as a JPEG, etc. + Note that the ocrmypdf image optimization stage may ultimately chose a + different format. + """ From 85cbf94a6e76338b764708a26075a49e2639ce8e Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Wed, 6 May 2020 02:53:47 -0700 Subject: [PATCH 47/94] Convert many uses of str paths to Path --- src/ocrmypdf/_concurrent.py | 2 +- src/ocrmypdf/_graft.py | 11 ++++++----- src/ocrmypdf/_jobcontext.py | 19 +++++++++++-------- src/ocrmypdf/_pipeline.py | 9 ++++++--- src/ocrmypdf/_sync.py | 7 ++----- src/ocrmypdf/_validation.py | 6 +++--- src/ocrmypdf/exec/unpaper.py | 7 ++++--- src/ocrmypdf/hocrtransform.py | 14 ++++++++++---- src/ocrmypdf/pdfinfo/info.py | 5 ++--- tests/conftest.py | 14 +++++++------- tests/test_main.py | 6 +++--- 11 files changed, 55 insertions(+), 45 deletions(-) diff --git a/src/ocrmypdf/_concurrent.py b/src/ocrmypdf/_concurrent.py index cf46bae3..6d608eb6 100644 --- a/src/ocrmypdf/_concurrent.py +++ b/src/ocrmypdf/_concurrent.py @@ -47,7 +47,7 @@ def log_listener(queue): logger = logging.getLogger(record.name) logger.handle(record) except Exception: # pylint: disable=broad-except - import traceback + import traceback # pylint: disable=import-outside-toplevel print("Logging problem", file=sys.stderr) traceback.print_exc(file=sys.stderr) diff --git a/src/ocrmypdf/_graft.py b/src/ocrmypdf/_graft.py index fba24718..667067b2 100644 --- a/src/ocrmypdf/_graft.py +++ b/src/ocrmypdf/_graft.py @@ -16,7 +16,6 @@ # along with OCRmyPDF. If not, see . import logging -import os from contextlib import suppress from pathlib import Path @@ -181,7 +180,7 @@ def _find_font(text, pdf_base): class OcrGrafter: def __init__(self, context): self.context = context - self.path_base = Path(context.origin).resolve() + self.path_base = context.origin self.pdf_base = pikepdf.open(self.path_base) self.font, self.font_key = None, None @@ -264,12 +263,14 @@ class OcrGrafter: # {interim_count} is the opened file we were updateing # {interim_count - 1} can be deleted # {interim_count + 1} is the new file will produce and open - old_file = self.output_file + f'_working{self.interim_count - 1}.pdf' + old_file = self.output_file.with_suffix(f'.working{self.interim_count - 1}.pdf') if not self.context.options.keep_temporary_files: with suppress(FileNotFoundError): - os.unlink(old_file) + old_file.unlink() - next_file = self.output_file + f'_working{self.interim_count + 1}.pdf' + next_file = self.output_file.with_suffix( + f'.working{self.interim_count + 1}.pdf' + ) self.pdf_base.save(next_file) self.pdf_base.close() diff --git a/src/ocrmypdf/_jobcontext.py b/src/ocrmypdf/_jobcontext.py index c5ffa5b1..3091fbc8 100644 --- a/src/ocrmypdf/_jobcontext.py +++ b/src/ocrmypdf/_jobcontext.py @@ -19,6 +19,7 @@ import os import shutil import sys from functools import partial +from pathlib import Path from ocrmypdf._plugin_manager import get_plugin_manager @@ -26,10 +27,12 @@ from ocrmypdf._plugin_manager import get_plugin_manager class PdfContext: """Holds our context for a particular run of the pipeline""" - def __init__(self, options, work_folder, origin, pdfinfo, plugin_manager): + def __init__( + self, options, work_folder: Path, origin: Path, pdfinfo, plugin_manager + ): self.options = options - self.work_folder = work_folder - self.origin = origin + self.work_folder = Path(work_folder) + self.origin = Path(origin) self.pdfinfo = pdfinfo self.plugin_manager = plugin_manager if options: @@ -39,8 +42,8 @@ class PdfContext: if self.name == '-': self.name = 'stdin' - def get_path(self, name): - return os.path.join(self.work_folder, name) + def get_path(self, name: str) -> Path: + return self.work_folder / name def get_page_contexts(self): npages = len(self.pdfinfo) @@ -54,7 +57,7 @@ class PageContext: Must be pickable, so only store intrinsic/simple data elements """ - def __init__(self, pdf_context, pageno): + def __init__(self, pdf_context: PdfContext, pageno): self.work_folder = pdf_context.work_folder self.origin = pdf_context.origin self.options = pdf_context.options @@ -63,8 +66,8 @@ class PageContext: self.pageinfo = pdf_context.pdfinfo[pageno] self.plugin_manager = pdf_context.plugin_manager - def get_path(self, name): - return os.path.join(self.work_folder, "%06d_%s" % (self.pageno + 1, name)) + def get_path(self, name: str) -> Path: + return self.work_folder / ("%06d_%s" % (self.pageno + 1, name)) def __getstate__(self): state = self.__dict__.copy() diff --git a/src/ocrmypdf/_pipeline.py b/src/ocrmypdf/_pipeline.py index 196a8d70..9f2fa423 100644 --- a/src/ocrmypdf/_pipeline.py +++ b/src/ocrmypdf/_pipeline.py @@ -55,7 +55,7 @@ def triage_image_file(input_file, output_file, options): im = Image.open(input_file) except EnvironmentError as e: # Recover the original filename - log.error(str(e).replace(input_file, options.input_file)) + log.error(str(e).replace(str(input_file), str(options.input_file))) raise UnsupportedImageFormatError() from e with im: @@ -102,7 +102,10 @@ def triage_image_file(input_file, output_file, options): ) with open(output_file, 'wb') as outf: img2pdf.convert( - input_file, layout_fun=layout_fun, with_pdfrw=False, outputstream=outf + os.fspath(input_file), + layout_fun=layout_fun, + with_pdfrw=False, + outputstream=outf, ) log.info("Successfully converted to PDF, processing...") except img2pdf.ImageOpenError as e: @@ -139,7 +142,7 @@ def triage(original_filename, input_file, output_file, options): return output_file except EnvironmentError as e: log.debug(f"Temporary file was at: {input_file}") - msg = str(e).replace(input_file, original_filename) + msg = str(e).replace(str(input_file), original_filename) raise InputFileError(msg) from e triage_image_file(input_file, output_file, options) diff --git a/src/ocrmypdf/_sync.py b/src/ocrmypdf/_sync.py index f15dbbaf..fc5a4ea4 100644 --- a/src/ocrmypdf/_sync.py +++ b/src/ocrmypdf/_sync.py @@ -304,7 +304,7 @@ def run_pipeline(options, *, plugin_manager, api=False): if not plugin_manager: plugin_manager = get_plugin_manager([]) - work_folder = mkdtemp(prefix="com.github.ocrmypdf.") + work_folder = Path(mkdtemp(prefix="com.github.ocrmypdf.")) debug_log_handler = None if (options.keep_temporary_files or options.verbose >= 1) and not os.environ.get( 'PYTEST_CURRENT_TEST', '' @@ -317,10 +317,7 @@ def run_pipeline(options, *, plugin_manager, api=False): # Triage image or pdf origin_pdf = triage( - original_filename, - start_input_file, - os.path.join(work_folder, 'origin.pdf'), - options, + original_filename, start_input_file, work_folder / 'origin.pdf', options ) plugin_manager.hook.prepare(options=options) diff --git a/src/ocrmypdf/_validation.py b/src/ocrmypdf/_validation.py index 0a755d3c..d7acbf09 100644 --- a/src/ocrmypdf/_validation.py +++ b/src/ocrmypdf/_validation.py @@ -376,17 +376,17 @@ def log_page_orientations(pdfinfo): log.info('Page orientations detected: %s', ' '.join(orientations)) -def create_input_file(options, work_folder): +def create_input_file(options, work_folder: Path) -> (Path, str): if options.input_file == '-': # stdin log.info('reading file from standard input') - target = os.path.join(work_folder, 'stdin') + target = work_folder / 'stdin' with open(target, 'wb') as stream_buffer: copyfileobj(sys.stdin.buffer, stream_buffer) return target, "" else: try: - target = os.path.join(work_folder, 'origin') + target = work_folder / 'origin' safe_symlink(options.input_file, target) return target, os.fspath(options.input_file) except FileNotFoundError: diff --git a/src/ocrmypdf/exec/unpaper.py b/src/ocrmypdf/exec/unpaper.py index 0228beb6..80243129 100644 --- a/src/ocrmypdf/exec/unpaper.py +++ b/src/ocrmypdf/exec/unpaper.py @@ -24,6 +24,7 @@ import logging import os import shlex from functools import lru_cache +from pathlib import Path from subprocess import PIPE, STDOUT, CalledProcessError from tempfile import TemporaryDirectory @@ -67,8 +68,8 @@ def run(input_file, output_file, dpi, mode_args): "Failed to convert image to a supported format." ) from e - input_pnm = os.path.join(tmpdir, f'input{suffix}') - output_pnm = os.path.join(tmpdir, f'output{suffix}') + input_pnm = Path(tmpdir) / f'input{suffix}' + output_pnm = Path(tmpdir) / f'output{suffix}' im.save(input_pnm, format='PPM') # To prevent any shenanigans from accepting arbitrary parameters in @@ -78,7 +79,7 @@ def run(input_file, output_file, dpi, mode_args): # 3) append absolute paths for the input and output file # This should ensure that a user cannot clobber some other file with # their unpaper arguments (whether intentionally or otherwise) - args_unpaper.extend([input_pnm, output_pnm]) + args_unpaper.extend([os.fspath(input_pnm), os.fspath(output_pnm)]) try: proc = external_run( args_unpaper, diff --git a/src/ocrmypdf/hocrtransform.py b/src/ocrmypdf/hocrtransform.py index a60fe959..6240d7ee 100755 --- a/src/ocrmypdf/hocrtransform.py +++ b/src/ocrmypdf/hocrtransform.py @@ -29,9 +29,11 @@ # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. import argparse +import os import re from collections import namedtuple from math import atan, cos, sin +from pathlib import Path from xml.etree import ElementTree from reportlab.lib.units import inch @@ -155,8 +157,8 @@ class HocrTransform: def to_pdf( self, - out_filename: str, - image_filename: str = None, + out_filename: Path, + image_filename: Path = None, show_bounding_boxes: bool = False, fontname: str = "Helvetica", invisible_text: bool = False, @@ -173,7 +175,9 @@ class HocrTransform: # create the PDF file # page size in points (1/72 in.) pdf = Canvas( - out_filename, pagesize=(self.width, self.height), pageCompression=1 + os.fspath(out_filename), + pagesize=(self.width, self.height), + pageCompression=1, ) # draw bounding box for each paragraph @@ -226,7 +230,9 @@ class HocrTransform: ) # put the image on the page, scaled to fill the page if image_filename is not None: - pdf.drawImage(image_filename, 0, 0, width=self.width, height=self.height) + pdf.drawImage( + os.fspath(image_filename), 0, 0, width=self.width, height=self.height + ) # finish up the page and save it pdf.showPage() diff --git a/src/ocrmypdf/pdfinfo/info.py b/src/ocrmypdf/pdfinfo/info.py index 42ed372f..fa2dcc05 100644 --- a/src/ocrmypdf/pdfinfo/info.py +++ b/src/ocrmypdf/pdfinfo/info.py @@ -820,9 +820,8 @@ class PdfInfo: def main(): - # pylint: disable=import-outside-toplevel - import argparse - from pprint import pprint + import argparse # pylint: disable=import-outside-toplevel + from pprint import pprint # pylint: disable=import-outside-toplevel parser = argparse.ArgumentParser() parser.add_argument('infile') diff --git a/tests/conftest.py b/tests/conftest.py index d1cc9ef1..9599369a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -52,7 +52,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 @@ -69,9 +69,9 @@ def have_unpaper(): 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() +SPOOF_PATH = TESTS_ROOT / 'spoof' +PROJECT_ROOT = TESTS_ROOT OCRMYPDF = [sys.executable, '-m', 'ocrmypdf'] @@ -146,7 +146,7 @@ def spoof(tmp_path_factory, **kwargs): tmpdir.mkdir(parents=True) for replace_program, with_spoof in kwargs.items(): - spoofer = Path(SPOOF_PATH) / with_spoof + spoofer = SPOOF_PATH / with_spoof if os.name != 'nt': spoofer.chmod(0o755) (tmpdir / replace_program).symlink_to(spoofer) @@ -224,8 +224,8 @@ def check_ocrmypdf(input_file, output_file, *args, env=None): result = api.run_pipeline(options, plugin_manager=None, 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 diff --git a/tests/test_main.py b/tests/test_main.py index cca9c4eb..9fcb49d7 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -210,7 +210,7 @@ def test_force_ocr_on_pdf_with_no_images(spoof_tesseract_crash, resources, no_ou resources / 'blank.pdf', no_outpdf, '--force-ocr', env=spoof_tesseract_crash ) assert p.returncode == ExitCode.child_process_error - assert not os.path.exists(no_outpdf) + assert not no_outpdf.exists() @pytest.mark.skipif( @@ -321,7 +321,7 @@ def test_tesseract_crash(renderer, spoof_tesseract_crash, resources, no_outpdf): env=spoof_tesseract_crash, ) assert p.returncode == ExitCode.child_process_error - assert not os.path.exists(no_outpdf) + assert not no_outpdf.exists() assert "SubprocessOutputError" in err @@ -330,7 +330,7 @@ def test_tesseract_crash_autorotate(spoof_tesseract_crash, resources, no_outpdf) resources / 'ccitt.pdf', no_outpdf, '-r', env=spoof_tesseract_crash ) assert p.returncode == ExitCode.child_process_error - assert not os.path.exists(no_outpdf) + assert not no_outpdf.exists() assert "uncaught exception" in err print(out) print(err) From 1b086f60a9b82e86aac214aab5390bda1b6d0d43 Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Wed, 6 May 2020 12:37:44 -0700 Subject: [PATCH 48/94] tesseract.py: api cleanup --- src/ocrmypdf/_pipeline.py | 3 +- src/ocrmypdf/exec/tesseract.py | 58 ++++++++++++++++------------------ tests/test_tess4.py | 7 ++-- 3 files changed, 33 insertions(+), 35 deletions(-) diff --git a/src/ocrmypdf/_pipeline.py b/src/ocrmypdf/_pipeline.py index 9f2fa423..477ea703 100644 --- a/src/ocrmypdf/_pipeline.py +++ b/src/ocrmypdf/_pipeline.py @@ -537,7 +537,8 @@ def ocr_tesseract_hocr(input_file, page_context): options = page_context.options tesseract.generate_hocr( input_file=input_file, - output_files=[hocr_out, hocr_text_out], + output_hocr=hocr_out, + output_sidecar=hocr_text_out, language=options.language, engine_mode=options.tesseract_oem, tessconfig=options.tesseract_config, diff --git a/src/ocrmypdf/exec/tesseract.py b/src/ocrmypdf/exec/tesseract.py index 731746e4..19e78739 100644 --- a/src/ocrmypdf/exec/tesseract.py +++ b/src/ocrmypdf/exec/tesseract.py @@ -23,7 +23,9 @@ import shutil from collections import namedtuple from contextlib import suppress from os import fspath +from pathlib import Path from subprocess import PIPE, STDOUT, CalledProcessError, TimeoutExpired +from typing import List, Optional from PIL import Image @@ -139,7 +141,7 @@ def languages(tesseract_env=None): return set(lang.strip() for lang in rest) -def tess_base_args(langs, engine_mode): +def tess_base_args(langs: List[str], engine_mode) -> List[str]: args = ['tesseract'] if langs: args.extend(['-l', '+'.join(langs)]) @@ -148,7 +150,7 @@ def tess_base_args(langs, engine_mode): return args -def get_orientation(input_file, engine_mode, timeout: float, tesseract_env=None): +def get_orientation(input_file: Path, engine_mode, timeout: float, tesseract_env=None): args_tesseract = tess_base_args(['osd'], engine_mode) + [ '--psm', '0', @@ -169,7 +171,7 @@ def get_orientation(input_file, engine_mode, timeout: float, tesseract_env=None) except TimeoutExpired: return OrientationConfidence(angle=0, confidence=0.0) except CalledProcessError as e: - tesseract_log_output(e.output, input_file) + tesseract_log_output(e.output) if ( b'Too few characters. Skipping this page' in e.output or b'Image too large' in e.output @@ -191,7 +193,7 @@ def get_orientation(input_file, engine_mode, timeout: float, tesseract_env=None) return oc -def tesseract_log_output(stdout, input_file): +def tesseract_log_output(stdout): tlog = TesseractLoggerAdapter( log, extra=log.extra if hasattr(log, 'extra') else None ) @@ -241,15 +243,14 @@ def _generate_null_hocr(output_hocr, output_sidecar, image): with Image.open(image) as im: w, h = im.size - with open(output_hocr, 'w', encoding="utf-8") as f: - f.write(HOCR_TEMPLATE.format(w, h)) - with open(output_sidecar, 'w', encoding='utf-8') as f: - f.write('[skipped page]') + output_hocr.write_text(HOCR_TEMPLATE.format(w, h), encoding='utf-8') + output_sidecar.write_text('[skipped page]', encoding='utf-8') def generate_hocr( - input_file, - output_files, + input_file: Path, + output_hocr: Path, + output_sidecar: Path, language: list, engine_mode, tessconfig: list, @@ -259,10 +260,7 @@ def generate_hocr( user_patterns, tesseract_env, ): - - output_hocr = next(o for o in output_files if fspath(o).endswith('.hocr')) - output_sidecar = next(o for o in output_files if fspath(o).endswith('.txt')) - prefix = os.path.splitext(output_hocr)[0] + prefix = output_hocr.with_suffix('') args_tesseract = tess_base_args(language, engine_mode) @@ -295,46 +293,44 @@ def generate_hocr( page_timedout(timeout) _generate_null_hocr(output_hocr, output_sidecar, input_file) except CalledProcessError as e: - tesseract_log_output(e.output, input_file) + tesseract_log_output(e.output) if b'Image too large' in e.output: _generate_null_hocr(output_hocr, output_sidecar, input_file) return raise SubprocessOutputError() from e else: - tesseract_log_output(stdout, input_file) + tesseract_log_output(stdout) # The sidecar text file will get the suffix .txt; rename it to # whatever caller wants it named - if os.path.exists(prefix + '.txt'): - shutil.move(prefix + '.txt', output_sidecar) + if prefix.with_suffix('.txt').exists(): + shutil.move(prefix.with_suffix('.txt'), output_sidecar) def use_skip_page(text_only, skip_pdf, output_pdf, output_text): - with open(output_text, 'w') as f: - f.write('[skipped page]') + output_text.write_text('[skipped page]', encoding='utf-8') if skip_pdf and not text_only: # Substitute a "skipped page" with suppress(FileNotFoundError): - os.remove(output_pdf) # In case it was partially created + output_pdf.unlink() # In case it was partially created safe_symlink(skip_pdf, output_pdf) return # Or normally, just write a 0 byte file to the output to indicate a skip - with open(output_pdf, 'wb') as out: - out.write(b'') + output_pdf.write_bytes(b'') def generate_pdf( *, - input_image, - skip_pdf=None, - output_pdf, - output_text, - language: list, + input_image: Path, + skip_pdf: Optional[Path] = None, + output_pdf: Path, + output_text: Path, + language: List[str], engine_mode, text_only: bool, - tessconfig: list, + tessconfig: List[str], timeout: float, pagesegmode: int, user_words, @@ -391,10 +387,10 @@ def generate_pdf( page_timedout(timeout) use_skip_page(text_only, skip_pdf, output_pdf, output_text) except CalledProcessError as e: - tesseract_log_output(e.output, input_image) + tesseract_log_output(e.output) if b'Image too large' in e.output: use_skip_page(text_only, skip_pdf, output_pdf, output_text) return raise SubprocessOutputError() from e else: - tesseract_log_output(stdout, input_image) + tesseract_log_output(stdout) diff --git a/tests/test_tess4.py b/tests/test_tess4.py index 4fada7d4..6a524d64 100644 --- a/tests/test_tess4.py +++ b/tests/test_tess4.py @@ -91,7 +91,8 @@ def test_image_too_large_hocr(monkeypatch, resources, outdir): monkeypatch.setattr(tesseract, 'run', dummy_run) tesseract.generate_hocr( input_file=resources / 'crom.png', - output_files=[outdir / 'out.hocr', outdir / 'out.txt'], + output_hocr=outdir / 'out.hocr', + output_sidecar=outdir / 'out.txt', language=['eng'], engine_mode=None, tessconfig=[], @@ -152,7 +153,7 @@ def test_timeout(caplog): ) def test_tesseract_log_output(caplog, in_, logged): caplog.set_level(logging.INFO) - tesseract.tesseract_log_output(in_, 'dummy') + tesseract.tesseract_log_output(in_) if logged == '': assert caplog.text == '' else: @@ -161,5 +162,5 @@ def test_tesseract_log_output(caplog, in_, logged): def test_tesseract_log_output_raises(caplog): with pytest.raises(tesseract.TesseractConfigError): - tesseract.tesseract_log_output(b'parameter not found: moo', 'dummy') + tesseract.tesseract_log_output(b'parameter not found: moo') assert 'not found' in caplog.text From e760622a5c4ea69cb90e9e147daaa62e4ed03f8a Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Thu, 7 May 2020 02:03:42 -0700 Subject: [PATCH 49/94] graft: refactor --- src/ocrmypdf/_graft.py | 198 +++++++++++++++++++++-------------------- src/ocrmypdf/_sync.py | 7 +- 2 files changed, 108 insertions(+), 97 deletions(-) diff --git a/src/ocrmypdf/_graft.py b/src/ocrmypdf/_graft.py index 667067b2..768b94ca 100644 --- a/src/ocrmypdf/_graft.py +++ b/src/ocrmypdf/_graft.py @@ -89,94 +89,6 @@ def strip_invisible_text(pdf, page): page.Contents = pikepdf.Stream(pdf, content_stream) -def _graft_text_layer( - *, pdf_base, page_num, text, font, font_key, procset, rotation, strip_old_text -): - """Insert the text layer from text page 0 on to pdf_base at page_num""" - - log.debug("Grafting") - if Path(text).stat().st_size == 0: - return - - # This is a pointer indicating a specific page in the base file - pdf_text = pikepdf.open(text) - pdf_text_contents = pdf_text.pages[0].Contents.read_bytes() - - base_page = pdf_base.pages.p(page_num) - - # The text page always will be oriented up by this stage but the original - # content may have a rotation applied. Wrap the text stream with a rotation - # so it will be oriented the same way as the rest of the page content. - # (Previous versions OCRmyPDF rotated the content layer to match the text.) - mediabox = [float(pdf_text.pages[0].MediaBox[v]) for v in range(4)] - wt, ht = mediabox[2] - mediabox[0], mediabox[3] - mediabox[1] - - mediabox = [float(base_page.MediaBox[v]) for v in range(4)] - wp, hp = mediabox[2] - mediabox[0], mediabox[3] - mediabox[1] - - translate = pikepdf.PdfMatrix().translated(-wt / 2, -ht / 2) - untranslate = pikepdf.PdfMatrix().translated(wp / 2, hp / 2) - corner = pikepdf.PdfMatrix().translated(mediabox[0], mediabox[1]) - # -rotation because the input is a clockwise angle and this formula - # uses CCW - rotation = -rotation % 360 - rotate = pikepdf.PdfMatrix().rotated(rotation) - - # Because of rounding of DPI, we might get a text layer that is not - # identically sized to the target page. Scale to adjust. Normally this - # is within 0.998. - if rotation in (90, 270): - wt, ht = ht, wt - scale_x = wp / wt - scale_y = hp / ht - - # log.debug('%r', scale_x, scale_y) - scale = pikepdf.PdfMatrix().scaled(scale_x, scale_y) - - # Translate the text so it is centered at (0, 0), rotate it there, adjust - # for a size different between initial and text PDF, then untranslate, and - # finally move the lower left corner to match the mediabox - ctm = translate @ rotate @ scale @ untranslate @ corner - - pdf_text_contents = b'q %s cm\n' % ctm.encode() + pdf_text_contents + b'\nQ\n' - - new_text_layer = pikepdf.Stream(pdf_base, pdf_text_contents) - - if strip_old_text: - strip_invisible_text(pdf_base, base_page) - - base_page.page_contents_add(new_text_layer, prepend=True) - - _update_page_resources( - page=base_page, font=font, font_key=font_key, procset=procset - ) - pdf_text.close() - - -def _find_font(text, pdf_base): - """Copy a font from the filename text into pdf_base""" - - font, font_key = None, None - possible_font_names = ('/f-0-0', '/F1') - try: - with pikepdf.open(text) as pdf_text: - try: - pdf_text_fonts = pdf_text.pages[0].Resources.get('/Font', {}) - except (AttributeError, IndexError, KeyError): - return None, None - for f in possible_font_names: - pdf_text_font = pdf_text_fonts.get(f, None) - if pdf_text_font is not None: - font_key = f - break - if pdf_text_font: - font = pdf_base.copy_foreign(pdf_text_font) - return font, font_key - except (FileNotFoundError, pikepdf.PdfError): - # PdfError occurs if a 0-length file is written e.g. due to OCR timeout - return None, None - - class OcrGrafter: def __init__(self, context): self.context = context @@ -195,10 +107,11 @@ class OcrGrafter: self.emplacements = 1 self.interim_count = 0 - def graft_page(self, page_result): - pageno, image, text, _sidecar, autorotate_correction = page_result - if text and not self.font: - self.font, self.font_key = _find_font(text, self.pdf_base) + def graft_page( + self, *, pageno: int, image: Path, textpdf: Path, autorotate_correction: int + ): + if textpdf and not self.font: + self.font, self.font_key = self._find_font(textpdf) emplaced_page = False content_rotation = self.pdfinfo[pageno].rotation @@ -226,13 +139,12 @@ class OcrGrafter: f"{text_misaligned}, {content_rotation}" ) - if text and self.font: + if textpdf and self.font: # Graft the text layer onto this page, whether new or old strip_old = self.context.options.redo_ocr - _graft_text_layer( - pdf_base=self.pdf_base, + self._graft_text_layer( page_num=pageno + 1, - text=text, + textpdf=textpdf, font=self.font, font_key=self.font_key, rotation=text_misaligned, @@ -283,3 +195,97 @@ class OcrGrafter: self.pdf_base.save(self.output_file) self.pdf_base.close() return self.output_file + + def _find_font(self, text): + """Copy a font from the filename text into pdf_base""" + + font, font_key = None, None + possible_font_names = ('/f-0-0', '/F1') + try: + with pikepdf.open(text) as pdf_text: + try: + pdf_text_fonts = pdf_text.pages[0].Resources.get('/Font', {}) + except (AttributeError, IndexError, KeyError): + return None, None + for f in possible_font_names: + pdf_text_font = pdf_text_fonts.get(f, None) + if pdf_text_font is not None: + font_key = f + break + if pdf_text_font: + font = self.pdf_base.copy_foreign(pdf_text_font) + return font, font_key + except (FileNotFoundError, pikepdf.PdfError): + # PdfError occurs if a 0-length file is written e.g. due to OCR timeout + return None, None + + def _graft_text_layer( + self, + *, + page_num: int, + textpdf: Path, + font: pikepdf.Object, + font_key: pikepdf.Object, + procset: pikepdf.Object, + rotation: int, + strip_old_text: bool, + ): + """Insert the text layer from text page 0 on to pdf_base at page_num""" + + log.debug("Grafting") + if Path(textpdf).stat().st_size == 0: + return + + # This is a pointer indicating a specific page in the base file + pdf_text = pikepdf.open(textpdf) + pdf_text_contents = pdf_text.pages[0].Contents.read_bytes() + + base_page = self.pdf_base.pages.p(page_num) + + # The text page always will be oriented up by this stage but the original + # content may have a rotation applied. Wrap the text stream with a rotation + # so it will be oriented the same way as the rest of the page content. + # (Previous versions OCRmyPDF rotated the content layer to match the text.) + mediabox = [float(pdf_text.pages[0].MediaBox[v]) for v in range(4)] + wt, ht = mediabox[2] - mediabox[0], mediabox[3] - mediabox[1] + + mediabox = [float(base_page.MediaBox[v]) for v in range(4)] + wp, hp = mediabox[2] - mediabox[0], mediabox[3] - mediabox[1] + + translate = pikepdf.PdfMatrix().translated(-wt / 2, -ht / 2) + untranslate = pikepdf.PdfMatrix().translated(wp / 2, hp / 2) + corner = pikepdf.PdfMatrix().translated(mediabox[0], mediabox[1]) + # -rotation because the input is a clockwise angle and this formula + # uses CCW + rotation = -rotation % 360 + rotate = pikepdf.PdfMatrix().rotated(rotation) + + # Because of rounding of DPI, we might get a text layer that is not + # identically sized to the target page. Scale to adjust. Normally this + # is within 0.998. + if rotation in (90, 270): + wt, ht = ht, wt + scale_x = wp / wt + scale_y = hp / ht + + # log.debug('%r', scale_x, scale_y) + scale = pikepdf.PdfMatrix().scaled(scale_x, scale_y) + + # Translate the text so it is centered at (0, 0), rotate it there, adjust + # for a size different between initial and text PDF, then untranslate, and + # finally move the lower left corner to match the mediabox + ctm = translate @ rotate @ scale @ untranslate @ corner + + pdf_text_contents = b'q %s cm\n' % ctm.encode() + pdf_text_contents + b'\nQ\n' + + new_text_layer = pikepdf.Stream(self.pdf_base, pdf_text_contents) + + if strip_old_text: + strip_invisible_text(self.pdf_base, base_page) + + base_page.page_contents_add(new_text_layer, prepend=True) + + _update_page_resources( + page=base_page, font=font, font_key=font_key, procset=procset + ) + pdf_text.close() diff --git a/src/ocrmypdf/_sync.py b/src/ocrmypdf/_sync.py index fc5a4ea4..710bda5c 100644 --- a/src/ocrmypdf/_sync.py +++ b/src/ocrmypdf/_sync.py @@ -243,7 +243,12 @@ def exec_concurrent(context): def update_page(result, pbar): sidecars[result.pageno] = result.text pbar.update() - ocrgraft.graft_page(result) + ocrgraft.graft_page( + pageno=result.pageno, + image=result.pdf_page_from_image, + textpdf=result.ocr, + autorotate_correction=result.orientation_correction, + ) pbar.update() exec_progress_pool( From 9462f0a28fd73f235ee4a79b440b144f4406f673 Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Thu, 7 May 2020 02:59:24 -0700 Subject: [PATCH 50/94] graft: more refactoring --- src/ocrmypdf/_graft.py | 92 ++++++++++++++++++++++-------------------- 1 file changed, 48 insertions(+), 44 deletions(-) diff --git a/src/ocrmypdf/_graft.py b/src/ocrmypdf/_graft.py index 768b94ca..de9ebda0 100644 --- a/src/ocrmypdf/_graft.py +++ b/src/ocrmypdf/_graft.py @@ -161,10 +161,13 @@ class OcrGrafter: self.save_and_reload() def save_and_reload(self): - # Periodically save and reload the Pdf object. This will keep a - # lid on our memory usage for very large files. Attach the font to - # page 1 even if page 1 doesn't use it, so we have a way to get it - # back. + """Save and reload the Pdf. + + This will keep a lid on our memory usage for very large files. Attach + the font to page 1 even if page 1 doesn't use it, so we have a way to get it + back. + """ + page0 = self.pdf_base.pages[0] _update_page_resources( page=page0, font=self.font, font_key=self.font_key, procset=self.procset @@ -237,55 +240,56 @@ class OcrGrafter: return # This is a pointer indicating a specific page in the base file - pdf_text = pikepdf.open(textpdf) - pdf_text_contents = pdf_text.pages[0].Contents.read_bytes() + with pikepdf.open(textpdf) as pdf_text: + pdf_text_contents = pdf_text.pages[0].Contents.read_bytes() - base_page = self.pdf_base.pages.p(page_num) + base_page = self.pdf_base.pages.p(page_num) - # The text page always will be oriented up by this stage but the original - # content may have a rotation applied. Wrap the text stream with a rotation - # so it will be oriented the same way as the rest of the page content. - # (Previous versions OCRmyPDF rotated the content layer to match the text.) - mediabox = [float(pdf_text.pages[0].MediaBox[v]) for v in range(4)] - wt, ht = mediabox[2] - mediabox[0], mediabox[3] - mediabox[1] + # The text page always will be oriented up by this stage but the original + # content may have a rotation applied. Wrap the text stream with a rotation + # so it will be oriented the same way as the rest of the page content. + # (Previous versions OCRmyPDF rotated the content layer to match the text.) + mediabox = [float(pdf_text.pages[0].MediaBox[v]) for v in range(4)] + wt, ht = mediabox[2] - mediabox[0], mediabox[3] - mediabox[1] - mediabox = [float(base_page.MediaBox[v]) for v in range(4)] - wp, hp = mediabox[2] - mediabox[0], mediabox[3] - mediabox[1] + mediabox = [float(base_page.MediaBox[v]) for v in range(4)] + wp, hp = mediabox[2] - mediabox[0], mediabox[3] - mediabox[1] - translate = pikepdf.PdfMatrix().translated(-wt / 2, -ht / 2) - untranslate = pikepdf.PdfMatrix().translated(wp / 2, hp / 2) - corner = pikepdf.PdfMatrix().translated(mediabox[0], mediabox[1]) - # -rotation because the input is a clockwise angle and this formula - # uses CCW - rotation = -rotation % 360 - rotate = pikepdf.PdfMatrix().rotated(rotation) + translate = pikepdf.PdfMatrix().translated(-wt / 2, -ht / 2) + untranslate = pikepdf.PdfMatrix().translated(wp / 2, hp / 2) + corner = pikepdf.PdfMatrix().translated(mediabox[0], mediabox[1]) + # -rotation because the input is a clockwise angle and this formula + # uses CCW + rotation = -rotation % 360 + rotate = pikepdf.PdfMatrix().rotated(rotation) - # Because of rounding of DPI, we might get a text layer that is not - # identically sized to the target page. Scale to adjust. Normally this - # is within 0.998. - if rotation in (90, 270): - wt, ht = ht, wt - scale_x = wp / wt - scale_y = hp / ht + # Because of rounding of DPI, we might get a text layer that is not + # identically sized to the target page. Scale to adjust. Normally this + # is within 0.998. + if rotation in (90, 270): + wt, ht = ht, wt + scale_x = wp / wt + scale_y = hp / ht - # log.debug('%r', scale_x, scale_y) - scale = pikepdf.PdfMatrix().scaled(scale_x, scale_y) + # log.debug('%r', scale_x, scale_y) + scale = pikepdf.PdfMatrix().scaled(scale_x, scale_y) - # Translate the text so it is centered at (0, 0), rotate it there, adjust - # for a size different between initial and text PDF, then untranslate, and - # finally move the lower left corner to match the mediabox - ctm = translate @ rotate @ scale @ untranslate @ corner + # Translate the text so it is centered at (0, 0), rotate it there, adjust + # for a size different between initial and text PDF, then untranslate, and + # finally move the lower left corner to match the mediabox + ctm = translate @ rotate @ scale @ untranslate @ corner - pdf_text_contents = b'q %s cm\n' % ctm.encode() + pdf_text_contents + b'\nQ\n' + pdf_text_contents = ( + b'q %s cm\n' % ctm.encode() + pdf_text_contents + b'\nQ\n' + ) - new_text_layer = pikepdf.Stream(self.pdf_base, pdf_text_contents) + new_text_layer = pikepdf.Stream(self.pdf_base, pdf_text_contents) - if strip_old_text: - strip_invisible_text(self.pdf_base, base_page) + if strip_old_text: + strip_invisible_text(self.pdf_base, base_page) - base_page.page_contents_add(new_text_layer, prepend=True) + base_page.page_contents_add(new_text_layer, prepend=True) - _update_page_resources( - page=base_page, font=font, font_key=font_key, procset=procset - ) - pdf_text.close() + _update_page_resources( + page=base_page, font=font, font_key=font_key, procset=procset + ) From 7a12908db904cfbda9a43e5ea805d210d5c0652c Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Thu, 7 May 2020 03:27:39 -0700 Subject: [PATCH 51/94] Relocate example plugin --- misc/example_plugin.py | 53 +++++++++++++++++++++++++++++++++++++++++ src/ocrmypdf/example.py | 38 ----------------------------- 2 files changed, 53 insertions(+), 38 deletions(-) create mode 100644 misc/example_plugin.py delete mode 100644 src/ocrmypdf/example.py diff --git a/misc/example_plugin.py b/misc/example_plugin.py new file mode 100644 index 00000000..d6c93363 --- /dev/null +++ b/misc/example_plugin.py @@ -0,0 +1,53 @@ +# © 2020 James R Barlow: https://github.com/jbarlow83 +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import logging + +from PIL import Image + +from ocrmypdf import hookimpl + +log = logging.getLogger(__name__) + + +@hookimpl +def add_options(parser): + parser.add_argument('--grayscale-ocr', action='store_true') + + +@hookimpl +def prepare(options): + pass + + +@hookimpl +def validate(pdfinfo, options): + pass + + +@hookimpl +def filter_ocr_image(page, image): + if page.options.grayscale_ocr: + log.info("graying") + return image.convert('L') + return image + + +@hookimpl +def filter_page_image(page, image_filename): + output = image_filename.with_suffix('.jpg') + with Image.open(image_filename) as im: + im.save(output) + return output diff --git a/src/ocrmypdf/example.py b/src/ocrmypdf/example.py deleted file mode 100644 index 926831c3..00000000 --- a/src/ocrmypdf/example.py +++ /dev/null @@ -1,38 +0,0 @@ -import logging - -from PIL import Image - -from ocrmypdf import hookimpl - -log = logging.getLogger(__name__) - - -@hookimpl -def add_options(parser): - parser.add_argument('--grayscale-ocr', action='store_true') - - -@hookimpl -def prepare(options): - pass - - -@hookimpl -def validate(pdfinfo, options): - pass - - -@hookimpl -def filter_ocr_image(page, image): - if page.options.grayscale_ocr: - log.info("graying") - return image.convert('L') - return image - - -@hookimpl -def filter_page_image(page, image_filename): - output = image_filename.with_suffix('.jpg') - with Image.open(image_filename) as im: - im.save(output) - return output From 417dbd43f6d4dfa4f87e939547c8e77e485187f1 Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Thu, 7 May 2020 03:53:37 -0700 Subject: [PATCH 52/94] docs: plugin documentation --- docs/api.rst | 6 +-- docs/index.rst | 1 + docs/plugins.rst | 80 ++++++++++++++++++++++++++++++++------ src/ocrmypdf/pluginspec.py | 15 +++---- 4 files changed, 80 insertions(+), 22 deletions(-) diff --git a/docs/api.rst b/docs/api.rst index c38bb400..b724b116 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -105,8 +105,8 @@ Reference :members: :undoc-members: -.. autoclass:: ocrmypdf.ExitCode +.. autofunction:: ocrmypdf.configure_logging + +.. automodule:: ocrmypdf.exceptions :members: :undoc-members: - -.. autofunction:: ocrmypdf.configure_logging diff --git a/docs/index.rst b/docs/index.rst index e28f3234..8e9cd991 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -34,6 +34,7 @@ image processing and OCR to existing PDFs. :maxdepth: 2 api + plugins contributing Indices and tables diff --git a/docs/plugins.rst b/docs/plugins.rst index e22cea36..f2ac0b94 100644 --- a/docs/plugins.rst +++ b/docs/plugins.rst @@ -2,23 +2,79 @@ Plugins ======= -You can use plugins to customize the behavior of OCRmyPDF at certain -points of interest. +You can use plugins to customize the behavior of OCRmyPDF at certain points of +interest. -Currently, it is possible to: - override the decision for whether or not -to perform OCR on a particular file - modify the image is about to be -sent for OCR +Currently, it is possible to: + +- add new command line arguments +- override the decision for whether or not to perform OCR on a particular file +- modify the image is about to be sent for OCR +- modify the page image before it is converted to PDF + +OCRmyPDF plugins are based on the Python ``pluggy`` package and conform to its +conventions. Note that: plugins installed with as setuptools entrypoints are +not checked currently, because OCRmyPDF assumes you may not want to enable +plugins for all files. Also, plugins must be functions, not classes. How plugins are imported ======================== -Plugins are imported on demand, by the OCRmyPDF worker process that -needs to use them. As such, plugins cannot share state with each other, -and will be imported many times, once for each worker process. +Plugins are imported on demand, by the OCRmyPDF worker process that needs to use +them. As such, plugins cannot share state with other plugins, cannot rely on +their module's or the interpreter's global state, and should expect asynchronous +copies of themselves to be running. Plugins can write intermediate files to the +folder specified in ``options.work_folder``. -Plugins currently cannot override the same hook. +Plugins should work whether executed in threads or processes. -How plugins are invoked -======================= +Script plugins +============== -Plugins may be called from the command line: +Script plugins may be called from the command line, by specifying the name of a file. + +.. code-block:: bash + + ocrmypdf --plugin example_plugin.py input.pdf output.pdf + +Multiple plugins may be called by issuing the ``--plugin`` argument multiple times. + +Packaged plugins +================ + +Installed plugins may be installed into the same virtual environment as OCRmyPDF +is installed into. They may be invoked using Python standard module naming. + +.. code-block:: bash + + ocrmypdf --plugin ocrmypdf_fancypants.pockets.contents input.pdf output.pdf + +OCRmyPDF does not automatically import plugins, because the assumption is that +plugins affect different files differently and you may not want them activated +all the time. The command line or ``ocrmypdf.ocr(plugin='...')`` must call +for them. + +Third parties that wish to distribute packages for ocrmypdf should package them +as packaged plugins, and these modules should begin with the name ``ocrmypdf_`` +similar to ``pytest`` packages such as ``pytest-cov`` (the package) and +``pytest_cov`` (the module). + +Plugin hooks +============ + +A plugin may provide the following hooks. Hooks should be decorated with +``ocrmypdf.hookimpl``, for example: + +.. code-block:: python + + from ocrmpydf import hookimpl + + @hookimpl + def prepare(options): + pass + +The following is a complete list of hooks that may be installed and when +they are called. + +.. automodule:: ocrmypdf.pluginspec + :members: diff --git a/src/ocrmypdf/pluginspec.py b/src/ocrmypdf/pluginspec.py index 3a9620c4..6063ce54 100644 --- a/src/ocrmypdf/pluginspec.py +++ b/src/ocrmypdf/pluginspec.py @@ -40,26 +40,27 @@ def add_options(parser: ArgumentParser) -> None: def prepare(options: Namespace) -> None: """Called to notify a plugin that a file will be processed. - The plugin may modify the options. All objects that are in options must + The plugin may modify the *options*. All objects that are in options must be picklable so they can be marshalled to child worker processes. """ @hookspec def validate(pdfinfo: 'PdfInfo', options: Namespace) -> None: - """Called to give a plugin an opportunity to review options and pdfinfo. + """Called to give a plugin an opportunity to review *options* and *pdfinfo*. - options contains the "work order" to process a particular file. pdfinfo + *options* contains the "work order" to process a particular file. *pdfinfo* contains information about the input file obtained after loading and - parsing. The plugin may modify the options. For example, you could decide + parsing. The plugin may modify the *options*. For example, you could decide that a certain type of file should be treated with ``options.force_ocr = True`` - based on information in its pdfinfo. + based on information in its *pdfinfo*. - The plugin may raise InputFileError or any ExitCodeException to request + The plugin may raise :class:`ocrmypdf.exceptions.InputFileError` or any + :class:`ocrmypdf.exceptions.ExitCodeException` to request normal termination. ocrmypdf will hold the plugin responsible for raising exceptions of any other type. - The return value is ignored. To abort processing, raise an ExitCodeException. + The return value is ignored. To abort processing, raise an ``ExitCodeException``. """ From 4b98ce391b161b92c37f724cb85b755023367896 Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Thu, 7 May 2020 03:54:27 -0700 Subject: [PATCH 53/94] docs: rename security->pdfsecurity so github won't misinterpret it --- docs/index.rst | 2 +- docs/{security.rst => pdfsecurity.rst} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename docs/{security.rst => pdfsecurity.rst} (100%) diff --git a/docs/index.rst b/docs/index.rst index 8e9cd991..e7f69f7e 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -26,7 +26,7 @@ image processing and OCR to existing PDFs. docker advanced batch - security + pdfsecurity errors .. toctree:: diff --git a/docs/security.rst b/docs/pdfsecurity.rst similarity index 100% rename from docs/security.rst rename to docs/pdfsecurity.rst From 790ff58f675bab850fca41a8ac0c2918bc8dbd96 Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Thu, 7 May 2020 22:19:21 -0700 Subject: [PATCH 54/94] Add fix for bug in Windows Python 3.6/3.7 TypeError: argument of type 'WindowsPath' is not iterable --- docs/api.rst | 4 ++-- src/ocrmypdf/exec/__init__.py | 5 +++++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/api.rst b/docs/api.rst index b724b116..ce6583fc 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -56,8 +56,8 @@ OCRmyPDF does not. On Windows, the script that calls ``ocrmypdf.ocr()`` must be protected by an "ifmain" guard (``if __name__ == '__main__'``) or you must use ``ocrmypdf.ocr(...use_threads=True)``. If you do not take at least one - of these steps, Windows fork semantics will prevent OCRmyPDF from working - correct. + of these steps, Windows process semantics will prevent OCRmyPDF from working + correctly. Logging ------- diff --git a/src/ocrmypdf/exec/__init__.py b/src/ocrmypdf/exec/__init__.py index 80e6ba98..d400820f 100644 --- a/src/ocrmypdf/exec/__init__.py +++ b/src/ocrmypdf/exec/__init__.py @@ -94,6 +94,11 @@ def run(args, *, env=None, **kwargs): def fix_windows_args(program, args, env): """Adjust our desired program and command line arguments for use on Windows""" + if sys.version_info < (3, 8): + # bpo-33617 - Windows needs manual Path -> str conversion + args = [os.fspath(arg) for arg in args] + program = os.fspath(program) + # If we are running a .py on Windows, ensure we call it with this Python # (to support test suite shims) if program.lower().endswith('.py'): From fd7497f00d2bdc9263fc348a6a868c46ec3479ef Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Fri, 8 May 2020 03:44:39 -0700 Subject: [PATCH 55/94] Remove old function tesseract.v4() --- src/ocrmypdf/exec/tesseract.py | 5 ----- tests/{test_tess4.py => test_tesseract.py} | 6 +----- 2 files changed, 1 insertion(+), 10 deletions(-) rename tests/{test_tess4.py => test_tesseract.py} (98%) diff --git a/src/ocrmypdf/exec/tesseract.py b/src/ocrmypdf/exec/tesseract.py index 19e78739..aea9a0ce 100644 --- a/src/ocrmypdf/exec/tesseract.py +++ b/src/ocrmypdf/exec/tesseract.py @@ -69,11 +69,6 @@ def version(tesseract_env=None): return get_version('tesseract', regex=r'tesseract\s(.+)', env=tesseract_env) -def v4(tesseract_env=None): - "Is this Tesseract v4.0?" - return version(tesseract_env) >= '4' - - def has_textonly_pdf(tesseract_env=None, langs=None): """Does Tesseract have textonly_pdf capability? diff --git a/tests/test_tess4.py b/tests/test_tesseract.py similarity index 98% rename from tests/test_tess4.py rename to tests/test_tesseract.py index 6a524d64..3f921de8 100644 --- a/tests/test_tess4.py +++ b/tests/test_tesseract.py @@ -27,17 +27,13 @@ from ocrmypdf import pdfinfo from ocrmypdf.exceptions import MissingDependencyError from ocrmypdf.exec import tesseract -# pylint: disable=no-member,w0621 +# pylint: disable=no-member,redefined-outer-name check_ocrmypdf = pytest.helpers.check_ocrmypdf run_ocrmypdf = pytest.helpers.run_ocrmypdf spoof = pytest.helpers.spoof -def test_tesseract_v4(): - assert tesseract.v4() - - @pytest.mark.parametrize('basename', ['graph_ocred.pdf', 'cardinal.pdf']) def test_skip_pages_does_not_replicate(resources, basename, outdir): infile = resources / basename From 977665d2b6175f0ac27759bd26df779feb2e2635 Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Fri, 8 May 2020 03:49:33 -0700 Subject: [PATCH 56/94] Delint some tests --- src/ocrmypdf/pdfinfo/layout.py | 3 +-- tests/test_graft.py | 1 - tests/test_metadata.py | 7 +++---- 3 files changed, 4 insertions(+), 7 deletions(-) diff --git a/src/ocrmypdf/pdfinfo/layout.py b/src/ocrmypdf/pdfinfo/layout.py index f763a9eb..db159c2e 100644 --- a/src/ocrmypdf/pdfinfo/layout.py +++ b/src/ocrmypdf/pdfinfo/layout.py @@ -25,10 +25,9 @@ import pdfminer.encodingdb import pdfminer.pdfdevice import pdfminer.pdfinterp from pdfminer.converter import PDFLayoutAnalyzer -from pdfminer.glyphlist import glyphname2unicode from pdfminer.layout import LAParams, LTChar, LTPage, LTTextBox from pdfminer.pdfdocument import PDFTextExtractionNotAllowed -from pdfminer.pdffont import PDFFont, PDFSimpleFont, PDFUnicodeNotDefined +from pdfminer.pdffont import PDFSimpleFont, PDFUnicodeNotDefined from pdfminer.pdfpage import PDFPage from pdfminer.utils import bbox2str, matrix2str diff --git a/tests/test_graft.py b/tests/test_graft.py index 65cba7c8..1329e869 100644 --- a/tests/test_graft.py +++ b/tests/test_graft.py @@ -15,7 +15,6 @@ # You should have received a copy of the GNU General Public License # along with OCRmyPDF. If not, see . -import os from unittest.mock import patch import pikepdf diff --git a/tests/test_metadata.py b/tests/test_metadata.py index 9a92c65a..acb8f31c 100644 --- a/tests/test_metadata.py +++ b/tests/test_metadata.py @@ -41,7 +41,6 @@ except ImportError: # pytest.helpers is dynamic # pylint: disable=no-member -# pylint: disable=w0612 pytestmark = pytest.mark.filterwarnings('ignore:.*XMLParser.*:DeprecationWarning') @@ -77,7 +76,7 @@ def test_override_metadata(spoof_tesseract_noop, output_type, resources, outpdf) german = 'Du siehst den Wald vor lauter Bäumen nicht.' chinese = '孔子' - p, out, err = run_ocrmypdf( + p, _out, err = run_ocrmypdf( input_file, outpdf, '--title', @@ -113,7 +112,7 @@ def test_high_unicode(spoof_tesseract_noop, resources, no_outpdf): input_file = resources / 'c02-22.pdf' high_unicode = 'U+1030C is: 𐌌' - p, out, err = run_ocrmypdf( + p, _out, err = run_ocrmypdf( input_file, no_outpdf, '--subject', @@ -275,7 +274,7 @@ def test_srgb_in_unicode_path(tmp_path): def test_kodak_toc(resources, outpdf, spoof_tesseract_noop): - output = check_ocrmypdf( + _output = check_ocrmypdf( resources / 'kcs.pdf', outpdf, '--output-type', 'pdf', env=spoof_tesseract_noop ) From 33b68454f35b483aac5edc349d90253143b0ac18 Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Fri, 8 May 2020 03:49:49 -0700 Subject: [PATCH 57/94] watcher: cleanup getenv casting --- misc/watcher.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/misc/watcher.py b/misc/watcher.py index 168c9998..097865fa 100644 --- a/misc/watcher.py +++ b/misc/watcher.py @@ -32,11 +32,11 @@ import ocrmypdf INPUT_DIRECTORY = os.getenv('OCR_INPUT_DIRECTORY', '/input') OUTPUT_DIRECTORY = os.getenv('OCR_OUTPUT_DIRECTORY', '/output') -OUTPUT_DIRECTORY_YEAR_MONTH = bool(os.getenv('OCR_OUTPUT_DIRECTORY_YEAR_MONTH', False)) -ON_SUCCESS_DELETE = bool(os.getenv('OCR_ON_SUCCESS_DELETE', False)) -DESKEW = bool(os.getenv('OCR_DESKEW', False)) +OUTPUT_DIRECTORY_YEAR_MONTH = bool(os.getenv('OCR_OUTPUT_DIRECTORY_YEAR_MONTH', '')) +ON_SUCCESS_DELETE = bool(os.getenv('OCR_ON_SUCCESS_DELETE', '')) +DESKEW = bool(os.getenv('OCR_DESKEW', '')) OCR_JSON_SETTINGS = json.loads(os.getenv('OCR_JSON_SETTINGS', '{}')) -POLL_NEW_FILE_SECONDS = os.getenv('OCR_POLL_NEW_FILE_SECONDS', 1) +POLL_NEW_FILE_SECONDS = int(os.getenv('OCR_POLL_NEW_FILE_SECONDS', '1')) LOGLEVEL = os.environ.get('OCR_LOGLEVEL', 'INFO').upper() PATTERNS = ['*.pdf'] From 2fae9b655e9c5c7271cee74b869511858a42a9b5 Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Tue, 12 May 2020 01:07:01 -0700 Subject: [PATCH 58/94] Remove **kwargs from check_external_program; deprecated --- src/ocrmypdf/exec/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/ocrmypdf/exec/__init__.py b/src/ocrmypdf/exec/__init__.py index d400820f..44ef5e3e 100644 --- a/src/ocrmypdf/exec/__init__.py +++ b/src/ocrmypdf/exec/__init__.py @@ -278,7 +278,6 @@ def check_external_program( need_version, required_for=None, recommended=False, - **kwargs, # To consume log parameter ): try: found_version = version_checker() From 4b986a5943e578c2a0efa187792333757c024c8f Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Tue, 12 May 2020 01:28:36 -0700 Subject: [PATCH 59/94] cli: make ArgumentParser._api_mode private --- src/ocrmypdf/api.py | 2 +- src/ocrmypdf/cli.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/ocrmypdf/api.py b/src/ocrmypdf/api.py index 42638a58..f53107fa 100644 --- a/src/ocrmypdf/api.py +++ b/src/ocrmypdf/api.py @@ -174,7 +174,7 @@ def create_options( cmdline.append(str(input_file)) cmdline.append(str(output_file)) - parser.api_mode = True + parser._api_mode = True options = parser.parse_args(cmdline) for keyword, val in deferred: setattr(options, keyword, val) diff --git a/src/ocrmypdf/cli.py b/src/ocrmypdf/cli.py index d6ba9202..cea8e351 100644 --- a/src/ocrmypdf/cli.py +++ b/src/ocrmypdf/cli.py @@ -45,10 +45,10 @@ class ArgumentParser(argparse.ArgumentParser): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.api_mode = False + self._api_mode = False def error(self, message): - if not self.api_mode: + if not self._api_mode: super().error(message) return raise ValueError(message) From a87c81a64fc0347f2a2624b69f18d257f16dc239 Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Tue, 12 May 2020 01:28:50 -0700 Subject: [PATCH 60/94] helpers: remove unnecessary isinstance test --- src/ocrmypdf/helpers.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/ocrmypdf/helpers.py b/src/ocrmypdf/helpers.py index ff4bb13a..964f7670 100644 --- a/src/ocrmypdf/helpers.py +++ b/src/ocrmypdf/helpers.py @@ -144,11 +144,7 @@ def is_file_writable(test_file: os.PathLike): the location is writable. """ try: - if not isinstance(test_file, Path): - p = Path(test_file) - else: - p = test_file - + p = Path(test_file) if p.is_symlink(): p = p.resolve(strict=False) From db8c37e58cb6d7f572f8437da5cad24f78d68def Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Tue, 12 May 2020 01:34:10 -0700 Subject: [PATCH 61/94] Refactor ocrmypdf.exec.__init__.py --- src/ocrmypdf/exec/__init__.py | 292 +------------------------------- src/ocrmypdf/exec/_support.py | 302 ++++++++++++++++++++++++++++++++++ src/ocrmypdf/leptonica.py | 6 +- 3 files changed, 312 insertions(+), 288 deletions(-) create mode 100644 src/ocrmypdf/exec/_support.py diff --git a/src/ocrmypdf/exec/__init__.py b/src/ocrmypdf/exec/__init__.py index 44ef5e3e..13b4e48c 100644 --- a/src/ocrmypdf/exec/__init__.py +++ b/src/ocrmypdf/exec/__init__.py @@ -1,4 +1,4 @@ -# © 2016 James R. Barlow: github.com/jbarlow83 +# © 2020 James R. Barlow: github.com/jbarlow83 # # This file is part of OCRmyPDF. # @@ -17,287 +17,9 @@ """Wrappers to manage subprocess calls""" -import logging -import os -import re -import shutil -import sys -from collections.abc import Mapping -from contextlib import suppress -from distutils.version import LooseVersion -from functools import lru_cache -from subprocess import PIPE, STDOUT, CalledProcessError -from subprocess import run as subprocess_run - -from ..exceptions import ExitCode, MissingDependencyError - -log = logging.getLogger(__name__) - - -def _get_program(args, env=None): - program = args[0] - test_path = env.get('_OCRMYPDF_TEST_PATH', '') - if test_path: - program = shutil.which(program, path=test_path) - return program - - -def run(args, *, env=None, **kwargs): - """Wrapper around subprocess.run() - - The main purpose of this wrapper is to allow us to substitute the main program - for a spoof in the test suite. The hidden variable _OCRMYPDF_TEST_PATH replaces - the main PATH as a location to check for programs to run. - - Secondly we have to account for behavioral differences in Windows in particular. - Creating symbolic links in Windows requires administrator privileges and - may not work if for some reason we're using a FAT file system or the temporary - folder is on a different drive from the working folder. The test suite - works around this by creating shim Python scripts that perform the same function - as a symbolic link, but those shims require support on this side, to ensure - we call them with Python. - - """ - if not env: - env = os.environ - - # Search in spoof path if necessary - program = _get_program(args, env) - args = [program] + args[1:] - - if os.name == 'nt': - args = fix_windows_args(program, args, env) - - log.debug("Running: %s", args) - process_log = log.getChild('subprocess.' + os.path.basename(program)) - if sys.version_info < (3, 7) and os.name == 'nt': - # Can't use close_fds=True on Windows with Python 3.6 or older - # https://bugs.python.org/issue19575, etc. - kwargs['close_fds'] = False - - stderr = None - try: - proc = subprocess_run(args, env=env, **kwargs) - except CalledProcessError as e: - stderr = getattr(e, 'stderr', None) - raise - else: - stderr = getattr(proc, 'stderr', None) - finally: - if process_log.isEnabledFor(logging.DEBUG) and stderr: - with suppress(AttributeError, UnicodeDecodeError): - stderr = stderr.decode('utf-8', 'replace') - process_log.debug("stderr = %s", stderr) - return proc - - -def fix_windows_args(program, args, env): - """Adjust our desired program and command line arguments for use on Windows""" - - if sys.version_info < (3, 8): - # bpo-33617 - Windows needs manual Path -> str conversion - args = [os.fspath(arg) for arg in args] - program = os.fspath(program) - - # If we are running a .py on Windows, ensure we call it with this Python - # (to support test suite shims) - if program.lower().endswith('.py'): - args = [sys.executable] + args - - paths = os.pathsep.join(os.get_exec_path(env)) - if not shutil.which(args[0], path=paths): - # If the program we want is not on the PATH, add some interesting - # locations in %PROGRAMFILES% to the PATH and try again - shimmed_path = shim_paths_with_program_files(env) - new_args0 = shutil.which(args[0], path=shimmed_path) - if new_args0: - args[0] = new_args0 - return args - - -def get_version(program, *, version_arg='--version', regex=r'(\d+(\.\d+)*)', env=None): - """Get the version of the specified program""" - args_prog = [program, version_arg] - try: - proc = run( - args_prog, - close_fds=True, - universal_newlines=True, - stdout=PIPE, - stderr=STDOUT, - check=True, - env=env, - ) - output = proc.stdout - except FileNotFoundError as e: - raise MissingDependencyError( - f"Could not find program '{program}' on the PATH" - ) from e - except CalledProcessError as e: - if e.returncode != 0: - raise MissingDependencyError( - f"Ran program '{program}' but it exited with an error:\n{e.output}" - ) from e - raise MissingDependencyError( - f"Could not find program '{program}' on the PATH" - ) from e - try: - version = re.match(regex, output.strip()).group(1) - except AttributeError as e: - raise MissingDependencyError( - f"The program '{program}' did not report its version. " - f"Message was:\n{output}" - ) - - return version - - -def shim_paths_with_program_files(env=None): - if not env: - env = os.environ - program_files = env.get('PROGRAMFILES', '') - if not program_files: - return env.get('PATH', '') - paths = [] - try: - for dirname in os.listdir(program_files): - if dirname.lower() == 'tesseract-ocr': - paths.append(os.path.join(program_files, dirname)) - elif dirname.lower() == 'gs': - try: - latest_gs = max( - os.listdir(os.path.join(program_files, dirname)), - key=lambda d: float(d[2:]), - ) - except (FileNotFoundError, NotADirectoryError): - continue - paths.append(os.path.join(program_files, dirname, latest_gs, 'bin')) - except EnvironmentError: - pass - paths.extend(path for path in os.get_exec_path(env) if path not in set(paths)) - return os.pathsep.join(paths) - - -missing_program = ''' -The program '{program}' could not be executed or was not found on your -system PATH. -''' - -missing_optional_program = ''' -The program '{program}' could not be executed or was not found on your -system PATH. This program is required when you use the -{required_for} arguments. You could try omitting these arguments, or install -the package. -''' - -missing_recommend_program = ''' -The program '{program}' could not be executed or was not found on your -system PATH. This program is recommended when using the {required_for} arguments, -but not required, so we will proceed. For best results, install the program. -''' - -old_version = ''' -OCRmyPDF requires '{program}' {need_version} or higher. Your system appears -to have {found_version}. Please update this program. -''' - -old_version_required_for = ''' -OCRmyPDF requires '{program}' {need_version} or higher when run with the -{required_for} arguments. If you omit these arguments, OCRmyPDF may be able to -proceed. For best results, install the program. -''' - -osx_install_advice = ''' -If you have homebrew installed, try these command to install the missing -package: - brew install {package} -''' - -linux_install_advice = ''' -On systems with the aptitude package manager (Debian, Ubuntu), try these -commands: - sudo apt-get update - sudo apt-get install {package} - -On RPM-based systems (Red Hat, Fedora), search for instructions on -installing the RPM for {program}. -''' - -windows_install_advice = ''' -If not already installed, install the Chocolatey package manager. Then use -a command prompt to install the missing package: - choco install {package} -''' - - -def _get_platform(): - if sys.platform.startswith('freebsd'): - return 'freebsd' - elif sys.platform.startswith('linux'): - return 'linux' - elif sys.platform.startswith('win'): - return 'windows' - return sys.platform - - -def _error_trailer(program, package, **kwargs): - if isinstance(package, Mapping): - package = package.get(_get_platform(), program) - - if _get_platform() == 'darwin': - log.info(osx_install_advice.format(**locals())) - elif _get_platform() == 'linux': - log.info(linux_install_advice.format(**locals())) - elif _get_platform() == 'windows': - log.info(windows_install_advice.format(**locals())) - - -def _error_missing_program(program, package, required_for, recommended): - if required_for: - log.error(missing_optional_program.format(**locals())) - elif recommended: - log.info(missing_recommend_program.format(**locals())) - else: - log.error(missing_program.format(**locals())) - _error_trailer(**locals()) - - -def _error_old_version(program, package, need_version, found_version, required_for): - if required_for: - log.error(old_version_required_for.format(**locals())) - else: - log.error(old_version.format(**locals())) - _error_trailer(**locals()) - - -def check_external_program( - *, - program, - package, - version_checker, - need_version, - required_for=None, - recommended=False, -): - try: - found_version = version_checker() - except (CalledProcessError, FileNotFoundError, MissingDependencyError): - _error_missing_program(program, package, required_for, recommended) - if not recommended: - raise MissingDependencyError() - return - - def remove_leading_v(s): - if s.startswith('v'): - return s[1:] - return s - - found_version = remove_leading_v(found_version) - need_version = remove_leading_v(need_version) - - if LooseVersion(found_version) < LooseVersion(need_version): - _error_old_version(program, package, need_version, found_version, required_for) - if not recommended: - raise MissingDependencyError() - - log.debug('Found %s %s', program, found_version) +from ocrmypdf.exec._support import ( + check_external_program, + get_version, + run, + shim_paths_with_program_files, +) diff --git a/src/ocrmypdf/exec/_support.py b/src/ocrmypdf/exec/_support.py new file mode 100644 index 00000000..eba2a472 --- /dev/null +++ b/src/ocrmypdf/exec/_support.py @@ -0,0 +1,302 @@ +# © 2020 James R. Barlow: github.com/jbarlow83 +# +# This file is part of OCRmyPDF. +# +# OCRmyPDF is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# OCRmyPDF is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with OCRmyPDF. If not, see . + +"""Wrappers to manage subprocess calls""" + +import logging +import os +import re +import shutil +import sys +from collections.abc import Mapping +from contextlib import suppress +from distutils.version import LooseVersion +from subprocess import PIPE, STDOUT, CalledProcessError +from subprocess import run as subprocess_run + +from ..exceptions import MissingDependencyError + +log = logging.getLogger(__name__) + + +def _get_program(args, env=None): + program = args[0] + test_path = env.get('_OCRMYPDF_TEST_PATH', '') + if test_path: + program = shutil.which(program, path=test_path) + return program + + +def run(args, *, env=None, **kwargs): + """Wrapper around subprocess.run() + + The main purpose of this wrapper is to allow us to substitute the main program + for a spoof in the test suite. The hidden variable _OCRMYPDF_TEST_PATH replaces + the main PATH as a location to check for programs to run. + + Secondly we have to account for behavioral differences in Windows in particular. + Creating symbolic links in Windows requires administrator privileges and + may not work if for some reason we're using a FAT file system or the temporary + folder is on a different drive from the working folder. The test suite + works around this by creating shim Python scripts that perform the same function + as a symbolic link, but those shims require support on this side, to ensure + we call them with Python. + + """ + if not env: + env = os.environ + + # Search in spoof path if necessary + program = _get_program(args, env) + args = [program] + args[1:] + + if os.name == 'nt': + args = fix_windows_args(program, args, env) + + log.debug("Running: %s", args) + process_log = log.getChild('subprocess.' + os.path.basename(program)) + if sys.version_info < (3, 7) and os.name == 'nt': + # Can't use close_fds=True on Windows with Python 3.6 or older + # https://bugs.python.org/issue19575, etc. + kwargs['close_fds'] = False + + stderr = None + try: + proc = subprocess_run(args, env=env, **kwargs) + except CalledProcessError as e: + stderr = getattr(e, 'stderr', None) + raise + else: + stderr = getattr(proc, 'stderr', None) + finally: + if process_log.isEnabledFor(logging.DEBUG) and stderr: + with suppress(AttributeError, UnicodeDecodeError): + stderr = stderr.decode('utf-8', 'replace') + process_log.debug("stderr = %s", stderr) + return proc + + +def fix_windows_args(program, args, env): + """Adjust our desired program and command line arguments for use on Windows""" + + if sys.version_info < (3, 8): + # bpo-33617 - Windows needs manual Path -> str conversion + args = [os.fspath(arg) for arg in args] + program = os.fspath(program) + + # If we are running a .py on Windows, ensure we call it with this Python + # (to support test suite shims) + if program.lower().endswith('.py'): + args = [sys.executable] + args + + paths = os.pathsep.join(os.get_exec_path(env)) + if not shutil.which(args[0], path=paths): + # If the program we want is not on the PATH, add some interesting + # locations in %PROGRAMFILES% to the PATH and try again + shimmed_path = shim_paths_with_program_files(env) + new_args0 = shutil.which(args[0], path=shimmed_path) + if new_args0: + args[0] = new_args0 + return args + + +def get_version(program, *, version_arg='--version', regex=r'(\d+(\.\d+)*)', env=None): + """Get the version of the specified program""" + args_prog = [program, version_arg] + try: + proc = run( + args_prog, + close_fds=True, + universal_newlines=True, + stdout=PIPE, + stderr=STDOUT, + check=True, + env=env, + ) + output = proc.stdout + except FileNotFoundError as e: + raise MissingDependencyError( + f"Could not find program '{program}' on the PATH" + ) from e + except CalledProcessError as e: + if e.returncode != 0: + raise MissingDependencyError( + f"Ran program '{program}' but it exited with an error:\n{e.output}" + ) from e + raise MissingDependencyError( + f"Could not find program '{program}' on the PATH" + ) from e + try: + version = re.match(regex, output.strip()).group(1) + except AttributeError as e: + raise MissingDependencyError( + f"The program '{program}' did not report its version. " + f"Message was:\n{output}" + ) + + return version + + +def shim_paths_with_program_files(env=None): + if not env: + env = os.environ + program_files = env.get('PROGRAMFILES', '') + if not program_files: + return env.get('PATH', '') + paths = [] + try: + for dirname in os.listdir(program_files): + if dirname.lower() == 'tesseract-ocr': + paths.append(os.path.join(program_files, dirname)) + elif dirname.lower() == 'gs': + try: + latest_gs = max( + os.listdir(os.path.join(program_files, dirname)), + key=lambda d: float(d[2:]), + ) + except (FileNotFoundError, NotADirectoryError): + continue + paths.append(os.path.join(program_files, dirname, latest_gs, 'bin')) + except EnvironmentError: + pass + paths.extend(path for path in os.get_exec_path(env) if path not in set(paths)) + return os.pathsep.join(paths) + + +missing_program = ''' +The program '{program}' could not be executed or was not found on your +system PATH. +''' + +missing_optional_program = ''' +The program '{program}' could not be executed or was not found on your +system PATH. This program is required when you use the +{required_for} arguments. You could try omitting these arguments, or install +the package. +''' + +missing_recommend_program = ''' +The program '{program}' could not be executed or was not found on your +system PATH. This program is recommended when using the {required_for} arguments, +but not required, so we will proceed. For best results, install the program. +''' + +old_version = ''' +OCRmyPDF requires '{program}' {need_version} or higher. Your system appears +to have {found_version}. Please update this program. +''' + +old_version_required_for = ''' +OCRmyPDF requires '{program}' {need_version} or higher when run with the +{required_for} arguments. If you omit these arguments, OCRmyPDF may be able to +proceed. For best results, install the program. +''' + +osx_install_advice = ''' +If you have homebrew installed, try these command to install the missing +package: + brew install {package} +''' + +linux_install_advice = ''' +On systems with the aptitude package manager (Debian, Ubuntu), try these +commands: + sudo apt-get update + sudo apt-get install {package} + +On RPM-based systems (Red Hat, Fedora), search for instructions on +installing the RPM for {program}. +''' + +windows_install_advice = ''' +If not already installed, install the Chocolatey package manager. Then use +a command prompt to install the missing package: + choco install {package} +''' + + +def _get_platform(): + if sys.platform.startswith('freebsd'): + return 'freebsd' + elif sys.platform.startswith('linux'): + return 'linux' + elif sys.platform.startswith('win'): + return 'windows' + return sys.platform + + +def _error_trailer(program, package, **kwargs): + if isinstance(package, Mapping): + package = package.get(_get_platform(), program) + + if _get_platform() == 'darwin': + log.info(osx_install_advice.format(**locals())) + elif _get_platform() == 'linux': + log.info(linux_install_advice.format(**locals())) + elif _get_platform() == 'windows': + log.info(windows_install_advice.format(**locals())) + + +def _error_missing_program(program, package, required_for, recommended): + if required_for: + log.error(missing_optional_program.format(**locals())) + elif recommended: + log.info(missing_recommend_program.format(**locals())) + else: + log.error(missing_program.format(**locals())) + _error_trailer(**locals()) + + +def _error_old_version(program, package, need_version, found_version, required_for): + if required_for: + log.error(old_version_required_for.format(**locals())) + else: + log.error(old_version.format(**locals())) + _error_trailer(**locals()) + + +def check_external_program( + *, + program, + package, + version_checker, + need_version, + required_for=None, + recommended=False, +): + try: + found_version = version_checker() + except (CalledProcessError, FileNotFoundError, MissingDependencyError): + _error_missing_program(program, package, required_for, recommended) + if not recommended: + raise MissingDependencyError() + return + + def remove_leading_v(s): + if s.startswith('v'): + return s[1:] + return s + + found_version = remove_leading_v(found_version) + need_version = remove_leading_v(need_version) + + if LooseVersion(found_version) < LooseVersion(need_version): + _error_old_version(program, package, need_version, found_version, required_for) + if not recommended: + raise MissingDependencyError() + + log.debug('Found %s %s', program, found_version) diff --git a/src/ocrmypdf/leptonica.py b/src/ocrmypdf/leptonica.py index 0a8f6cba..50e9b294 100644 --- a/src/ocrmypdf/leptonica.py +++ b/src/ocrmypdf/leptonica.py @@ -33,9 +33,9 @@ from io import BytesIO, UnsupportedOperation from os import fspath from tempfile import TemporaryFile -from .exceptions import MissingDependencyError -from .exec import shim_paths_with_program_files -from .lib._leptonica import ffi +from ocrmypdf.exceptions import MissingDependencyError +from ocrmypdf.exec import shim_paths_with_program_files +from ocrmypdf.lib._leptonica import ffi # pylint: disable=protected-access From 7f67556995568ddef50bef76c21266dc0c1201b4 Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Tue, 12 May 2020 01:35:45 -0700 Subject: [PATCH 62/94] ocrmypdf.__init__: Hide _HookimplMarker --- src/ocrmypdf/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ocrmypdf/__init__.py b/src/ocrmypdf/__init__.py index 2326efb2..08da4d6f 100644 --- a/src/ocrmypdf/__init__.py +++ b/src/ocrmypdf/__init__.py @@ -16,7 +16,7 @@ # along with OCRmyPDF. If not, see . -from pluggy import HookimplMarker +from pluggy import HookimplMarker as _HookimplMarker from ocrmypdf import helpers, hocrtransform, leptonica, pdfa, pdfinfo from ocrmypdf._version import PROGRAM_NAME, __version__ @@ -37,4 +37,4 @@ from ocrmypdf.exceptions import ( UnsupportedImageFormatError, ) -hookimpl = HookimplMarker('ocrmypdf') +hookimpl = _HookimplMarker('ocrmypdf') From a2d3e0b53ea8b4d5430788dbd9f72ff42fda1165 Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Tue, 12 May 2020 02:12:08 -0700 Subject: [PATCH 63/94] Convert remaining imports to absolute --- src/ocrmypdf/_validation.py | 13 +++++++++---- src/ocrmypdf/exec/_support.py | 2 +- src/ocrmypdf/exec/jbig2enc.py | 4 ++-- src/ocrmypdf/exec/pngquant.py | 4 ++-- src/ocrmypdf/exec/unpaper.py | 6 +++--- src/ocrmypdf/pdfinfo/__init__.py | 2 +- src/ocrmypdf/pdfinfo/layout.py | 2 +- 7 files changed, 19 insertions(+), 14 deletions(-) diff --git a/src/ocrmypdf/_validation.py b/src/ocrmypdf/_validation.py index d7acbf09..16096aa3 100644 --- a/src/ocrmypdf/_validation.py +++ b/src/ocrmypdf/_validation.py @@ -27,14 +27,14 @@ from shutil import copyfileobj import PIL -from ._unicodefun import verify_python3_env -from .exceptions import ( +from ocrmypdf._unicodefun import verify_python3_env +from ocrmypdf.exceptions import ( BadArgsError, InputFileError, MissingDependencyError, OutputFileAccessError, ) -from .exec import ( +from ocrmypdf.exec import ( check_external_program, ghostscript, jbig2enc, @@ -42,7 +42,12 @@ from .exec import ( tesseract, unpaper, ) -from .helpers import is_file_writable, is_iterable_notstr, monotonic, safe_symlink +from ocrmypdf.helpers import ( + is_file_writable, + is_iterable_notstr, + monotonic, + safe_symlink, +) # ------------- # External dependencies diff --git a/src/ocrmypdf/exec/_support.py b/src/ocrmypdf/exec/_support.py index eba2a472..bd8ed0e6 100644 --- a/src/ocrmypdf/exec/_support.py +++ b/src/ocrmypdf/exec/_support.py @@ -28,7 +28,7 @@ from distutils.version import LooseVersion from subprocess import PIPE, STDOUT, CalledProcessError from subprocess import run as subprocess_run -from ..exceptions import MissingDependencyError +from ocrmypdf.exceptions import MissingDependencyError log = logging.getLogger(__name__) diff --git a/src/ocrmypdf/exec/jbig2enc.py b/src/ocrmypdf/exec/jbig2enc.py index 5218edbd..979b8eb7 100644 --- a/src/ocrmypdf/exec/jbig2enc.py +++ b/src/ocrmypdf/exec/jbig2enc.py @@ -20,8 +20,8 @@ from functools import lru_cache from subprocess import PIPE -from ..exceptions import MissingDependencyError -from . import get_version, run +from ocrmypdf.exceptions import MissingDependencyError +from ocrmypdf.exec import get_version, run @lru_cache(maxsize=1) diff --git a/src/ocrmypdf/exec/pngquant.py b/src/ocrmypdf/exec/pngquant.py index 17721065..f99fd421 100644 --- a/src/ocrmypdf/exec/pngquant.py +++ b/src/ocrmypdf/exec/pngquant.py @@ -23,8 +23,8 @@ from tempfile import NamedTemporaryFile from PIL import Image -from ..exceptions import MissingDependencyError -from . import get_version +from ocrmypdf.exceptions import MissingDependencyError +from ocrmypdf.exec import get_version @lru_cache(maxsize=1) diff --git a/src/ocrmypdf/exec/unpaper.py b/src/ocrmypdf/exec/unpaper.py index 80243129..2f77701b 100644 --- a/src/ocrmypdf/exec/unpaper.py +++ b/src/ocrmypdf/exec/unpaper.py @@ -30,9 +30,9 @@ from tempfile import TemporaryDirectory from PIL import Image -from ..exceptions import MissingDependencyError, SubprocessOutputError -from . import get_version -from . import run as external_run +from ocrmypdf.exceptions import MissingDependencyError, SubprocessOutputError +from ocrmypdf.exec import get_version +from ocrmypdf.exec import run as external_run log = logging.getLogger(__name__) diff --git a/src/ocrmypdf/pdfinfo/__init__.py b/src/ocrmypdf/pdfinfo/__init__.py index 093fea5e..0e8b8750 100644 --- a/src/ocrmypdf/pdfinfo/__init__.py +++ b/src/ocrmypdf/pdfinfo/__init__.py @@ -16,4 +16,4 @@ # You should have received a copy of the GNU General Public License # along with OCRmyPDF. If not, see . -from .info import Colorspace, Encoding, PdfInfo +from ocrmypdf.pdfinfo.info import Colorspace, Encoding, PdfInfo diff --git a/src/ocrmypdf/pdfinfo/layout.py b/src/ocrmypdf/pdfinfo/layout.py index db159c2e..2c3d2b2a 100644 --- a/src/ocrmypdf/pdfinfo/layout.py +++ b/src/ocrmypdf/pdfinfo/layout.py @@ -31,7 +31,7 @@ from pdfminer.pdffont import PDFSimpleFont, PDFUnicodeNotDefined from pdfminer.pdfpage import PDFPage from pdfminer.utils import bbox2str, matrix2str -from ..exceptions import EncryptedPdfError +from ocrmypdf.exceptions import EncryptedPdfError STRIP_NAME = re.compile(r'[0-9]+') From 6f5b75bcd04040b262600f9eaff781f99aadb679 Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Tue, 12 May 2020 02:23:56 -0700 Subject: [PATCH 64/94] Remove lru_cache on get_version Does not play well with forking. --- src/ocrmypdf/exec/_support.py | 1 + src/ocrmypdf/exec/ghostscript.py | 2 -- src/ocrmypdf/exec/jbig2enc.py | 2 -- src/ocrmypdf/exec/pngquant.py | 2 -- src/ocrmypdf/exec/unpaper.py | 2 -- 5 files changed, 1 insertion(+), 8 deletions(-) diff --git a/src/ocrmypdf/exec/_support.py b/src/ocrmypdf/exec/_support.py index bd8ed0e6..60d22a83 100644 --- a/src/ocrmypdf/exec/_support.py +++ b/src/ocrmypdf/exec/_support.py @@ -25,6 +25,7 @@ import sys from collections.abc import Mapping from contextlib import suppress from distutils.version import LooseVersion +from functools import lru_cache from subprocess import PIPE, STDOUT, CalledProcessError from subprocess import run as subprocess_run diff --git a/src/ocrmypdf/exec/ghostscript.py b/src/ocrmypdf/exec/ghostscript.py index b58e30cb..d7407726 100644 --- a/src/ocrmypdf/exec/ghostscript.py +++ b/src/ocrmypdf/exec/ghostscript.py @@ -21,7 +21,6 @@ import logging import os import re import warnings -from functools import lru_cache from io import BytesIO from os import fspath from pathlib import Path @@ -57,7 +56,6 @@ if os.name == 'nt': GS = Path(GS).stem -@lru_cache(maxsize=1) def version(): return get_version(GS) diff --git a/src/ocrmypdf/exec/jbig2enc.py b/src/ocrmypdf/exec/jbig2enc.py index 979b8eb7..c027e28d 100644 --- a/src/ocrmypdf/exec/jbig2enc.py +++ b/src/ocrmypdf/exec/jbig2enc.py @@ -17,14 +17,12 @@ """Interface to jbig2 executable""" -from functools import lru_cache from subprocess import PIPE from ocrmypdf.exceptions import MissingDependencyError from ocrmypdf.exec import get_version, run -@lru_cache(maxsize=1) def version(): return get_version('jbig2', regex=r'jbig2enc (\d+(\.\d+)*).*') diff --git a/src/ocrmypdf/exec/pngquant.py b/src/ocrmypdf/exec/pngquant.py index f99fd421..ad00560f 100644 --- a/src/ocrmypdf/exec/pngquant.py +++ b/src/ocrmypdf/exec/pngquant.py @@ -17,7 +17,6 @@ """Interface to pngquant executable""" -from functools import lru_cache from subprocess import run from tempfile import NamedTemporaryFile @@ -27,7 +26,6 @@ from ocrmypdf.exceptions import MissingDependencyError from ocrmypdf.exec import get_version -@lru_cache(maxsize=1) def version(): return get_version('pngquant', regex=r'(\d+(\.\d+)*).*') diff --git a/src/ocrmypdf/exec/unpaper.py b/src/ocrmypdf/exec/unpaper.py index 2f77701b..a1aa749e 100644 --- a/src/ocrmypdf/exec/unpaper.py +++ b/src/ocrmypdf/exec/unpaper.py @@ -23,7 +23,6 @@ import logging import os import shlex -from functools import lru_cache from pathlib import Path from subprocess import PIPE, STDOUT, CalledProcessError from tempfile import TemporaryDirectory @@ -37,7 +36,6 @@ from ocrmypdf.exec import run as external_run log = logging.getLogger(__name__) -@lru_cache(maxsize=1) def version(): return get_version('unpaper') From d372f1f7fa70ce29b49cb29efa475d10243de5c9 Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Tue, 12 May 2020 04:09:29 -0700 Subject: [PATCH 65/94] Remove "skip page" from tesseract interface Breaks tests/test_main.py::test_tesseract_missing_tessdata because conftest.py does not update options.tesseract_env before testing options for some reason, and tesseract.has_textonly_pdf raises an exception instead of returning False as the test assumes. --- src/ocrmypdf/_pipeline.py | 2 -- src/ocrmypdf/exec/tesseract.py | 23 ++++++----------------- tests/test_tesseract.py | 4 +--- 3 files changed, 7 insertions(+), 22 deletions(-) diff --git a/src/ocrmypdf/_pipeline.py b/src/ocrmypdf/_pipeline.py index 477ea703..162677df 100644 --- a/src/ocrmypdf/_pipeline.py +++ b/src/ocrmypdf/_pipeline.py @@ -616,12 +616,10 @@ def ocr_tesseract_textonly_pdf(input_image, page_context): options = page_context.options tesseract.generate_pdf( input_image=input_image, - skip_pdf=None, output_pdf=output_pdf, output_text=output_text, language=options.language, engine_mode=options.tesseract_oem, - text_only=True, tessconfig=options.tesseract_config, timeout=options.tesseract_timeout, pagesegmode=options.tesseract_pagesegmode, diff --git a/src/ocrmypdf/exec/tesseract.py b/src/ocrmypdf/exec/tesseract.py index aea9a0ce..57450937 100644 --- a/src/ocrmypdf/exec/tesseract.py +++ b/src/ocrmypdf/exec/tesseract.py @@ -302,29 +302,20 @@ def generate_hocr( shutil.move(prefix.with_suffix('.txt'), output_sidecar) -def use_skip_page(text_only, skip_pdf, output_pdf, output_text): +def use_skip_page(output_pdf, output_text): output_text.write_text('[skipped page]', encoding='utf-8') - if skip_pdf and not text_only: - # Substitute a "skipped page" - with suppress(FileNotFoundError): - output_pdf.unlink() # In case it was partially created - safe_symlink(skip_pdf, output_pdf) - return - - # Or normally, just write a 0 byte file to the output to indicate a skip + # A 0 byte file to the output to indicate a skip output_pdf.write_bytes(b'') def generate_pdf( *, input_image: Path, - skip_pdf: Optional[Path] = None, output_pdf: Path, output_text: Path, language: List[str], engine_mode, - text_only: bool, tessconfig: List[str], timeout: float, pagesegmode: int, @@ -335,12 +326,10 @@ def generate_pdf( """Use Tesseract to render a PDF. input_image -- image to analyze - skip_pdf -- if we time out, use this file as output output_pdf -- file to generate output_text -- OCR text file language -- list of languages to consider engine_mode -- engine mode argument for tess v4 - text_only -- enable tesseract text only mode? tessconfig -- tesseract configuration timeout -- timeout (seconds) log -- logger object @@ -351,8 +340,8 @@ def generate_pdf( if pagesegmode is not None: args_tesseract.extend(['--psm', str(pagesegmode)]) - if text_only and has_textonly_pdf(tesseract_env, language): - args_tesseract.extend(['-c', 'textonly_pdf=1']) + # has_textonly_pdf(tesseract_env=tesseract_env, langs=language) + args_tesseract.extend(['-c', 'textonly_pdf=1']) if user_words: args_tesseract.extend(['--user-words', user_words]) @@ -380,11 +369,11 @@ def generate_pdf( shutil.move(prefix + '.txt', output_text) except TimeoutExpired: page_timedout(timeout) - use_skip_page(text_only, skip_pdf, output_pdf, output_text) + use_skip_page(output_pdf, output_text) except CalledProcessError as e: tesseract_log_output(e.output) if b'Image too large' in e.output: - use_skip_page(text_only, skip_pdf, output_pdf, output_text) + use_skip_page(output_pdf, output_text) return raise SubprocessOutputError() from e else: diff --git a/tests/test_tesseract.py b/tests/test_tesseract.py index 3f921de8..9c37dde1 100644 --- a/tests/test_tesseract.py +++ b/tests/test_tesseract.py @@ -108,12 +108,10 @@ def test_image_too_large_pdf(monkeypatch, resources, outdir): monkeypatch.setattr(tesseract, 'run', dummy_run) tesseract.generate_pdf( input_image=resources / 'crom.png', - skip_pdf=resources / 'blank.pdf', output_pdf=outdir / 'pdf.pdf', output_text=outdir / 'txt.txt', language=['eng'], engine_mode=None, - text_only=False, tessconfig=[], timeout=180.0, pagesegmode=None, @@ -123,7 +121,7 @@ def test_image_too_large_pdf(monkeypatch, resources, outdir): ) assert Path(outdir / 'txt.txt').read_text() == '[skipped page]' if os.name != 'nt': # different semantics - assert Path(outdir / 'pdf.pdf').samefile(resources / 'blank.pdf') + assert Path(outdir / 'pdf.pdf').stat().st_size == 0 def test_timeout(caplog): From 12a2f78c4dda83c50ba1b484a0f29f5adc8a0a6e Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Thu, 14 May 2020 03:19:22 -0700 Subject: [PATCH 66/94] Fix validation of languages not using tesseract_env And some related issues. --- src/ocrmypdf/_validation.py | 4 ++-- tests/conftest.py | 3 ++- tests/test_main.py | 8 ++++---- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/ocrmypdf/_validation.py b/src/ocrmypdf/_validation.py index 16096aa3..2fc85928 100644 --- a/src/ocrmypdf/_validation.py +++ b/src/ocrmypdf/_validation.py @@ -84,12 +84,12 @@ def check_options_languages(options): options.language = options.language[0].split('+') languages = set(options.language) - if not languages.issubset(tesseract.languages()): + if not languages.issubset(tesseract.languages(options.tesseract_env)): msg = ( "The installed version of tesseract does not have language " "data for the following requested languages: \n" ) - for lang in languages - tesseract.languages(): + for lang in languages - tesseract.languages(options.tesseract_env): msg += lang + '\n' raise MissingDependencyError(msg) diff --git a/tests/conftest.py b/tests/conftest.py index 9599369a..6ea95e96 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -241,7 +241,7 @@ def run_ocrmypdf_api(input_file, output_file, *args, env=None): [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) @@ -252,6 +252,7 @@ def run_ocrmypdf_api(input_file, output_file, *args, env=None): if options.tesseract_env: assert all(isinstance(v, (str, bytes)) for v in options.tesseract_env.values()) + api.check_options(options) return api.run_pipeline(options, plugin_manager=None, api=False) diff --git a/tests/test_main.py b/tests/test_main.py index 9fcb49d7..0fc11e76 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -186,10 +186,10 @@ def test_tesseract_missing_tessdata(resources, no_outpdf, tmpdir): env = os.environ.copy() env['TESSDATA_PREFIX'] = os.fspath(tmpdir) - returncode = run_ocrmypdf_api( - resources / 'graph.pdf', no_outpdf, '-v', '1', '--skip-text', env=env - ) - assert returncode == ExitCode.missing_dependency + with pytest.raises(MissingDependencyError): + run_ocrmypdf_api( + resources / 'graph.pdf', no_outpdf, '-v', '1', '--skip-text', env=env + ) def test_invalid_input_pdf(resources, no_outpdf): From 41eb54cc0a59855aaa2d5d03001f80507d2fb2b6 Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Thu, 14 May 2020 03:23:25 -0700 Subject: [PATCH 67/94] Standardize tesseract.generate_hocr and _pdf parameters --- src/ocrmypdf/_pipeline.py | 8 ++++---- src/ocrmypdf/_validation.py | 4 ++-- src/ocrmypdf/exec/tesseract.py | 35 +++++++++++++++++----------------- tests/conftest.py | 1 - tests/test_main.py | 2 +- tests/test_tesseract.py | 10 +++++----- 6 files changed, 29 insertions(+), 31 deletions(-) diff --git a/src/ocrmypdf/_pipeline.py b/src/ocrmypdf/_pipeline.py index 162677df..6350ea69 100644 --- a/src/ocrmypdf/_pipeline.py +++ b/src/ocrmypdf/_pipeline.py @@ -538,8 +538,8 @@ def ocr_tesseract_hocr(input_file, page_context): tesseract.generate_hocr( input_file=input_file, output_hocr=hocr_out, - output_sidecar=hocr_text_out, - language=options.language, + output_text=hocr_text_out, + languages=options.language, engine_mode=options.tesseract_oem, tessconfig=options.tesseract_config, timeout=options.tesseract_timeout, @@ -615,10 +615,10 @@ def ocr_tesseract_textonly_pdf(input_image, page_context): output_text = page_context.get_path('ocr_tess.txt') options = page_context.options tesseract.generate_pdf( - input_image=input_image, + input_file=input_image, output_pdf=output_pdf, output_text=output_text, - language=options.language, + languages=options.language, engine_mode=options.tesseract_oem, tessconfig=options.tesseract_config, timeout=options.tesseract_timeout, diff --git a/src/ocrmypdf/_validation.py b/src/ocrmypdf/_validation.py index 2fc85928..4ef6fcdf 100644 --- a/src/ocrmypdf/_validation.py +++ b/src/ocrmypdf/_validation.py @@ -84,12 +84,12 @@ def check_options_languages(options): options.language = options.language[0].split('+') languages = set(options.language) - if not languages.issubset(tesseract.languages(options.tesseract_env)): + if not languages.issubset(tesseract.get_languages(options.tesseract_env)): msg = ( "The installed version of tesseract does not have language " "data for the following requested languages: \n" ) - for lang in languages - tesseract.languages(options.tesseract_env): + for lang in languages - tesseract.get_languages(options.tesseract_env): msg += lang + '\n' raise MissingDependencyError(msg) diff --git a/src/ocrmypdf/exec/tesseract.py b/src/ocrmypdf/exec/tesseract.py index 57450937..39046ce5 100644 --- a/src/ocrmypdf/exec/tesseract.py +++ b/src/ocrmypdf/exec/tesseract.py @@ -89,7 +89,8 @@ def has_textonly_pdf(tesseract_env=None, langs=None): params = proc.stdout except CalledProcessError as e: raise MissingDependencyError( - "Could not --print-parameters from tesseract" + "Could not --print-parameters from tesseract. This can happen if the " + "TESSDATA_PREFIX environment is not set to a valid tessdata folder. " ) from e if 'textonly_pdf' in params: return True @@ -105,7 +106,7 @@ def has_user_words(tesseract_env=None): return version(tesseract_env) >= '4.1' -def languages(tesseract_env=None): +def get_languages(tesseract_env=None): def lang_error(output): msg = ( "Tesseract failed to report available languages.\n" @@ -232,21 +233,21 @@ def page_timedout(timeout): log.warning("[tesseract] took too long to OCR - skipping") -def _generate_null_hocr(output_hocr, output_sidecar, image): +def _generate_null_hocr(output_hocr, output_text, image): """Produce a .hocr file that reports no text detected on a page that is the same size as the input image.""" with Image.open(image) as im: w, h = im.size output_hocr.write_text(HOCR_TEMPLATE.format(w, h), encoding='utf-8') - output_sidecar.write_text('[skipped page]', encoding='utf-8') + output_text.write_text('[skipped page]', encoding='utf-8') def generate_hocr( input_file: Path, output_hocr: Path, - output_sidecar: Path, - language: list, + output_text: Path, + languages: list, engine_mode, tessconfig: list, timeout: float, @@ -257,7 +258,7 @@ def generate_hocr( ): prefix = output_hocr.with_suffix('') - args_tesseract = tess_base_args(language, engine_mode) + args_tesseract = tess_base_args(languages, engine_mode) if pagesegmode is not None: args_tesseract.extend(['--psm', str(pagesegmode)]) @@ -286,11 +287,11 @@ def generate_hocr( # Temporary workaround to hocrTransform not being able to function if # it does not have a valid hOCR file. page_timedout(timeout) - _generate_null_hocr(output_hocr, output_sidecar, input_file) + _generate_null_hocr(output_hocr, output_text, input_file) except CalledProcessError as e: tesseract_log_output(e.output) if b'Image too large' in e.output: - _generate_null_hocr(output_hocr, output_sidecar, input_file) + _generate_null_hocr(output_hocr, output_text, input_file) return raise SubprocessOutputError() from e @@ -299,7 +300,7 @@ def generate_hocr( # The sidecar text file will get the suffix .txt; rename it to # whatever caller wants it named if prefix.with_suffix('.txt').exists(): - shutil.move(prefix.with_suffix('.txt'), output_sidecar) + shutil.move(prefix.with_suffix('.txt'), output_text) def use_skip_page(output_pdf, output_text): @@ -311,10 +312,10 @@ def use_skip_page(output_pdf, output_text): def generate_pdf( *, - input_image: Path, + input_file: Path, output_pdf: Path, output_text: Path, - language: List[str], + languages: List[str], engine_mode, tessconfig: List[str], timeout: float, @@ -325,22 +326,20 @@ def generate_pdf( ): """Use Tesseract to render a PDF. - input_image -- image to analyze + input_file -- image to analyze output_pdf -- file to generate output_text -- OCR text file - language -- list of languages to consider + languages -- list of languages to consider engine_mode -- engine mode argument for tess v4 tessconfig -- tesseract configuration timeout -- timeout (seconds) - log -- logger object """ - args_tesseract = tess_base_args(language, engine_mode) + args_tesseract = tess_base_args(languages, engine_mode) if pagesegmode is not None: args_tesseract.extend(['--psm', str(pagesegmode)]) - # has_textonly_pdf(tesseract_env=tesseract_env, langs=language) args_tesseract.extend(['-c', 'textonly_pdf=1']) if user_words: @@ -354,7 +353,7 @@ def generate_pdf( # Reminder: test suite tesseract spoofers might break after any changes # to the number of order parameters here - args_tesseract.extend([input_image, prefix, 'pdf', 'txt'] + tessconfig) + args_tesseract.extend([input_file, prefix, 'pdf', 'txt'] + tessconfig) try: p = run( args_tesseract, diff --git a/tests/conftest.py b/tests/conftest.py index 6ea95e96..8eaede2a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -241,7 +241,6 @@ def run_ocrmypdf_api(input_file, output_file, *args, env=None): [str(input_file), str(output_file)] + [str(arg) for arg in args if arg is not None] ) - if env: options.tesseract_env = env.copy() options.tesseract_env['_OCRMYPDF_TEST_INFILE'] = os.fspath(input_file) diff --git a/tests/test_main.py b/tests/test_main.py index 0fc11e76..c3a715ce 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -233,7 +233,7 @@ def test_german(spoof_tesseract_cache, resources, outdir): env=spoof_tesseract_cache, ) except MissingDependencyError: - if 'deu' not in tesseract.languages(): + if 'deu' not in tesseract.get_languages(): pytest.xfail(reason="tesseract-deu language pack not installed") raise diff --git a/tests/test_tesseract.py b/tests/test_tesseract.py index 9c37dde1..ffe3fe31 100644 --- a/tests/test_tesseract.py +++ b/tests/test_tesseract.py @@ -77,7 +77,7 @@ def test_no_languages(tmp_path): env['TESSDATA_PREFIX'] = fspath(tmp_path) with pytest.raises(MissingDependencyError): - tesseract.languages(tesseract_env=env) + tesseract.get_languages(tesseract_env=env) def test_image_too_large_hocr(monkeypatch, resources, outdir): @@ -88,8 +88,8 @@ def test_image_too_large_hocr(monkeypatch, resources, outdir): tesseract.generate_hocr( input_file=resources / 'crom.png', output_hocr=outdir / 'out.hocr', - output_sidecar=outdir / 'out.txt', - language=['eng'], + output_text=outdir / 'out.txt', + languages=['eng'], engine_mode=None, tessconfig=[], timeout=180.0, @@ -107,10 +107,10 @@ def test_image_too_large_pdf(monkeypatch, resources, outdir): monkeypatch.setattr(tesseract, 'run', dummy_run) tesseract.generate_pdf( - input_image=resources / 'crom.png', + input_file=resources / 'crom.png', output_pdf=outdir / 'pdf.pdf', output_text=outdir / 'txt.txt', - language=['eng'], + languages=['eng'], engine_mode=None, tessconfig=[], timeout=180.0, From 8174089c8be254152b4f9b80f2833f108bdb0ff9 Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Thu, 14 May 2020 03:54:21 -0700 Subject: [PATCH 68/94] Begin transforming Tesseract into pluggable OCR engine --- src/ocrmypdf/_pipeline.py | 37 ++++------ src/ocrmypdf/_plugin_manager.py | 4 +- src/ocrmypdf/_sync.py | 10 ++- src/ocrmypdf/builtin_plugins/__init__.py | 18 +++++ src/ocrmypdf/builtin_plugins/tesseract_ocr.py | 68 +++++++++++++++++++ src/ocrmypdf/pluginspec.py | 36 +++++++++- 6 files changed, 140 insertions(+), 33 deletions(-) create mode 100644 src/ocrmypdf/builtin_plugins/__init__.py create mode 100644 src/ocrmypdf/builtin_plugins/tesseract_ocr.py diff --git a/src/ocrmypdf/_pipeline.py b/src/ocrmypdf/_pipeline.py index 6350ea69..21a92202 100644 --- a/src/ocrmypdf/_pipeline.py +++ b/src/ocrmypdf/_pipeline.py @@ -379,11 +379,8 @@ def get_orientation_correction(preview, page_context): """ - orient_conf = tesseract.get_orientation( - preview, - engine_mode=page_context.options.tesseract_oem, - timeout=page_context.options.tesseract_timeout, - tesseract_env=page_context.options.tesseract_env, + orient_conf = page_context.plugin_manager.hook.get_ocr_engine().get_orientation( + preview, page_context.options ) correction = orient_conf.angle % 360 @@ -531,22 +528,17 @@ def create_ocr_image(image, page_context): return output_file -def ocr_tesseract_hocr(input_file, page_context): +def ocr_engine_hocr(input_file, page_context): hocr_out = page_context.get_path('ocr_hocr.hocr') hocr_text_out = page_context.get_path('ocr_hocr.txt') options = page_context.options - tesseract.generate_hocr( + + ocr_engine = page_context.plugin_manager.hook.get_ocr_engine() + ocr_engine.generate_hocr( input_file=input_file, output_hocr=hocr_out, output_text=hocr_text_out, - languages=options.language, - engine_mode=options.tesseract_oem, - tessconfig=options.tesseract_config, - timeout=options.tesseract_timeout, - pagesegmode=options.tesseract_pagesegmode, - user_words=options.user_words, - user_patterns=options.user_patterns, - tesseract_env=options.tesseract_env, + options=options, ) return (hocr_out, hocr_text_out) @@ -610,22 +602,17 @@ def render_hocr_page(hocr, page_context): return output_file -def ocr_tesseract_textonly_pdf(input_image, page_context): +def ocr_engine_textonly_pdf(input_image, page_context): output_pdf = page_context.get_path('ocr_tess.pdf') output_text = page_context.get_path('ocr_tess.txt') options = page_context.options - tesseract.generate_pdf( + + ocr_engine = page_context.plugin_manager.hook.get_ocr_engine() + ocr_engine.generate_pdf( input_file=input_image, output_pdf=output_pdf, output_text=output_text, - languages=options.language, - engine_mode=options.tesseract_oem, - tessconfig=options.tesseract_config, - timeout=options.tesseract_timeout, - pagesegmode=options.tesseract_pagesegmode, - user_words=options.user_words, - user_patterns=options.user_patterns, - tesseract_env=options.tesseract_env, + options=options, ) return (output_pdf, output_text) diff --git a/src/ocrmypdf/_plugin_manager.py b/src/ocrmypdf/_plugin_manager.py index 9ccfc3b0..1edd2483 100644 --- a/src/ocrmypdf/_plugin_manager.py +++ b/src/ocrmypdf/_plugin_manager.py @@ -26,9 +26,11 @@ import pluggy from ocrmypdf import pluginspec -def get_plugin_manager(plugins: List[str]): +def get_plugin_manager(plugins: List[str], builtins=True): pm = pluggy.PluginManager('ocrmypdf') pm.add_hookspecs(pluginspec) + if builtins: + plugins.insert(0, 'ocrmypdf.builtin_plugins') for name in plugins: if name.endswith('.py'): # Import by filename diff --git a/src/ocrmypdf/_sync.py b/src/ocrmypdf/_sync.py index 710bda5c..3f97a301 100644 --- a/src/ocrmypdf/_sync.py +++ b/src/ocrmypdf/_sync.py @@ -42,8 +42,8 @@ from ocrmypdf._pipeline import ( is_ocr_required, merge_sidecars, metadata_fixup, - ocr_tesseract_hocr, - ocr_tesseract_textonly_pdf, + ocr_engine_hocr, + ocr_engine_textonly_pdf, optimize_pdf, preprocess_clean, preprocess_deskew, @@ -176,13 +176,11 @@ def exec_page_sync(page_context): ) if options.pdf_renderer == 'hocr': - (hocr_out, text_out) = ocr_tesseract_hocr(ocr_image_out, page_context) + (hocr_out, text_out) = ocr_engine_hocr(ocr_image_out, page_context) ocr_out = render_hocr_page(hocr_out, page_context) if options.pdf_renderer == 'sandwich': - (ocr_out, text_out) = ocr_tesseract_textonly_pdf( - ocr_image_out, page_context - ) + (ocr_out, text_out) = ocr_engine_textonly_pdf(ocr_image_out, page_context) return PageResult( pageno=page_context.pageno, diff --git a/src/ocrmypdf/builtin_plugins/__init__.py b/src/ocrmypdf/builtin_plugins/__init__.py new file mode 100644 index 00000000..e5fd494e --- /dev/null +++ b/src/ocrmypdf/builtin_plugins/__init__.py @@ -0,0 +1,18 @@ +# © 2020 James R. Barlow: github.com/jbarlow83 +# +# This file is part of OCRmyPDF. +# +# OCRmyPDF is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# OCRmyPDF is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with OCRmyPDF. If not, see . + +from ocrmypdf.builtin_plugins.tesseract_ocr import * diff --git a/src/ocrmypdf/builtin_plugins/tesseract_ocr.py b/src/ocrmypdf/builtin_plugins/tesseract_ocr.py new file mode 100644 index 00000000..12e8d667 --- /dev/null +++ b/src/ocrmypdf/builtin_plugins/tesseract_ocr.py @@ -0,0 +1,68 @@ +# © 2020 James R. Barlow: github.com/jbarlow83 +# +# This file is part of OCRmyPDF. +# +# OCRmyPDF is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# OCRmyPDF is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with OCRmyPDF. If not, see . + +from ocrmypdf import hookimpl +from ocrmypdf.exec import tesseract +from ocrmypdf.pluginspec import OcrEngine + + +class TesseractOcrEngine(OcrEngine): + def languages(self): + return tesseract.get_languages() + + def get_orientation(self, input_file, options): + return tesseract.get_orientation( + input_file, + engine_mode=options.tesseract_oem, + timeout=options.tesseract_timeout, + tesseract_env=options.tesseract_env, + ) + + def generate_hocr(self, input_file, output_hocr, output_text, options): + tesseract.generate_hocr( + input_file=input_file, + output_hocr=output_hocr, + output_text=output_text, + languages=options.language, + engine_mode=options.tesseract_oem, + tessconfig=options.tesseract_config, + timeout=options.tesseract_timeout, + pagesegmode=options.tesseract_pagesegmode, + user_words=options.user_words, + user_patterns=options.user_patterns, + tesseract_env=options.tesseract_env, + ) + + def generate_pdf(self, input_file, output_pdf, output_text, options): + tesseract.generate_pdf( + input_file=input_file, + output_pdf=output_pdf, + output_text=output_text, + languages=options.language, + engine_mode=options.tesseract_oem, + tessconfig=options.tesseract_config, + timeout=options.tesseract_timeout, + pagesegmode=options.tesseract_pagesegmode, + user_words=options.user_words, + user_patterns=options.user_patterns, + tesseract_env=options.tesseract_env, + ) + + +@hookimpl +def get_ocr_engine(): + return TesseractOcrEngine() diff --git a/src/ocrmypdf/pluginspec.py b/src/ocrmypdf/pluginspec.py index 6063ce54..0261ba75 100644 --- a/src/ocrmypdf/pluginspec.py +++ b/src/ocrmypdf/pluginspec.py @@ -15,9 +15,11 @@ # You should have received a copy of the GNU General Public License # along with OCRmyPDF. If not, see . +from abc import ABC, abstractmethod from argparse import ArgumentParser, Namespace +from collections import namedtuple from pathlib import Path -from typing import Optional +from typing import AbstractSet, Optional import pluggy from PIL import Image @@ -87,3 +89,35 @@ def filter_page_image(page: 'PageContext', image_filename: Path) -> Path: Note that the ocrmypdf image optimization stage may ultimately chose a different format. """ + + +OrientationConfidence = namedtuple('OrientationConfidence', ('angle', 'confidence')) + + +class OcrEngine(ABC): + @abstractmethod + def languages(self) -> AbstractSet[str]: + """Returns set of languages that are supported.""" + + @abstractmethod + def get_orientation( + self, input_file: Path, options: Namespace + ) -> OrientationConfidence: + """Returns the orientation of the image.""" + + @abstractmethod + def generate_hocr( + self, input_file: Path, output_hocr: Path, output_text: Path, options: Namespace + ) -> None: + pass + + @abstractmethod + def generate_pdf( + self, input_file: Path, output_pdf: Path, output_text: Path, options: Namespace + ) -> None: + pass + + +@hookspec(firstresult=True) +def get_ocr_engine() -> OcrEngine: + pass From 9af94ac9b7b75601689443f146052e9e45876998 Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Thu, 14 May 2020 04:23:23 -0700 Subject: [PATCH 69/94] pipeline: use OCR engine abstraction instead of Tesseract --- src/ocrmypdf/_pipeline.py | 36 ++++++++----------- src/ocrmypdf/_plugin_manager.py | 7 ++-- src/ocrmypdf/builtin_plugins/tesseract_ocr.py | 21 ++++++++--- src/ocrmypdf/pluginspec.py | 32 ++++++++++------- tests/test_metadata.py | 9 +++-- 5 files changed, 63 insertions(+), 42 deletions(-) diff --git a/src/ocrmypdf/_pipeline.py b/src/ocrmypdf/_pipeline.py index 21a92202..0806880e 100644 --- a/src/ocrmypdf/_pipeline.py +++ b/src/ocrmypdf/_pipeline.py @@ -37,7 +37,7 @@ from ocrmypdf.exceptions import ( PriorOcrFoundError, UnsupportedImageFormatError, ) -from ocrmypdf.exec import ghostscript, tesseract, unpaper +from ocrmypdf.exec import ghostscript, unpaper from ocrmypdf.helpers import Resolution, safe_symlink from ocrmypdf.hocrtransform import HocrTransform from ocrmypdf.optimize import optimize @@ -362,21 +362,19 @@ def describe_rotation(page_context, orient_conf, correction): def get_orientation_correction(preview, page_context): - """ - Work out orientation correct for each page. + """Work out orientation correct for each page. We ask Ghostscript to draw a preview page, which will rasterize with the - current /Rotate applied, and then ask Tesseract which way the page is + current /Rotate applied, and then ask OCR which way the page is oriented. If the value of /Rotate is correct (e.g., a user already - manually fixed rotation), then Tesseract will say the page is pointing + manually fixed rotation), then OCR will say the page is pointing up and the correction is zero. Otherwise, the orientation found by - Tesseract represents the clockwise rotation, or the counterclockwise + OCR represents the clockwise rotation, or the counterclockwise correction to rotation. When we draw the real page for OCR, we rotate it by the CCW correction, which points it (hopefully) upright. _graft.py takes care of the orienting the image and text layers. - """ orient_conf = page_context.plugin_manager.hook.get_ocr_engine().get_orientation( @@ -555,7 +553,7 @@ def create_visible_page_jpg(image, page_context): # might have removed the DPI information. In this case, fall back to # square DPI used to rasterize. When the preview image was # rasterized, it was also converted to square resolution, which is - # what we want to give tesseract, so keep it square. + # what we want to give to the OCR engine, so keep it square. if 'dpi' in im.info: dpi = Resolution(*im.info['dpi']) else: @@ -617,7 +615,9 @@ def ocr_engine_textonly_pdf(input_image, page_context): return (output_pdf, output_text) -def get_docinfo(base_pdf, options): +def get_docinfo(base_pdf, context): + options = context.options + def from_document_info(key): try: s = base_pdf.docinfo[key] @@ -629,7 +629,6 @@ def get_docinfo(base_pdf, options): k: from_document_info(k) for k in ('/Title', '/Author', '/Keywords', '/Subject', '/CreationDate') } - renderer_tag = 'OCR' if options is not None: if options.title: pdfmark['/Title'] = options.title @@ -640,12 +639,9 @@ def get_docinfo(base_pdf, options): if options.subject: pdfmark['/Subject'] = options.subject - if options.pdf_renderer == 'sandwich': - renderer_tag = 'OCR-PDF' + creator_tag = context.plugin_manager.hook.get_ocr_engine().creator_tag(options) - pdfmark['/Creator'] = ( - f'{PROGRAM_NAME} {VERSION} / ' f'Tesseract {renderer_tag} {tesseract.version()}' - ) + pdfmark['/Creator'] = f'{PROGRAM_NAME} {VERSION} / {creator_tag}' pdfmark['/Producer'] = f'pikepdf {pikepdf.__version__}' if 'OCRMYPDF_CREATOR' in os.environ: pdfmark['/Creator'] = os.environ['OCRMYPDF_CREATOR'] @@ -732,7 +728,7 @@ def metadata_fixup(working_file, context): log.info("The following metadata fields were not copied: %r", missing) with pikepdf.open(context.origin) as original, pikepdf.open(working_file) as pdf: - docinfo = get_docinfo(original, options) + docinfo = get_docinfo(original, context) with pdf.open_metadata() as meta: meta.load_from_docinfo(docinfo, delete_missing=False, raise_failure=False) # If xmp:CreateDate is missing, set it to the modify date to @@ -780,11 +776,9 @@ def merge_sidecars(txt_files, context): if txt_file: with open(txt_file, 'r', encoding="utf-8") as in_: txt = in_.read() - # Tesseract v4 alpha started adding form feeds in - # commit aa6eb6b - # No obvious way to detect what binaries will do this, so - # for consistency just ignore its form feeds and insert our - # own + # Some OCR engines (e.g. Tesseract v4 alpha) add form feeds + # between pages, and some do not. For consistency, we ignore + # any added by the OCR engine and them on our own. if txt.endswith('\f'): stream.write(txt[:-1]) else: diff --git a/src/ocrmypdf/_plugin_manager.py b/src/ocrmypdf/_plugin_manager.py index 1edd2483..e42ef94b 100644 --- a/src/ocrmypdf/_plugin_manager.py +++ b/src/ocrmypdf/_plugin_manager.py @@ -29,9 +29,12 @@ from ocrmypdf import pluginspec def get_plugin_manager(plugins: List[str], builtins=True): pm = pluggy.PluginManager('ocrmypdf') pm.add_hookspecs(pluginspec) + if builtins: - plugins.insert(0, 'ocrmypdf.builtin_plugins') - for name in plugins: + all_plugins = ['ocrmypdf.builtin_plugins'] + plugins + else: + all_plugins = plugins + for name in all_plugins: if name.endswith('.py'): # Import by filename module_name = Path(name).stem diff --git a/src/ocrmypdf/builtin_plugins/tesseract_ocr.py b/src/ocrmypdf/builtin_plugins/tesseract_ocr.py index 12e8d667..d68a2946 100644 --- a/src/ocrmypdf/builtin_plugins/tesseract_ocr.py +++ b/src/ocrmypdf/builtin_plugins/tesseract_ocr.py @@ -21,10 +21,21 @@ from ocrmypdf.pluginspec import OcrEngine class TesseractOcrEngine(OcrEngine): - def languages(self): + @staticmethod + def version(): + return tesseract.version() + + @staticmethod + def creator_tag(options): + tag = '-PDF' if options.pdf_renderer == 'sandwich' else '' + return f"Tesseract OCR{tag} {TesseractOcrEngine.version()}" + + @staticmethod + def languages(): return tesseract.get_languages() - def get_orientation(self, input_file, options): + @staticmethod + def get_orientation(input_file, options): return tesseract.get_orientation( input_file, engine_mode=options.tesseract_oem, @@ -32,7 +43,8 @@ class TesseractOcrEngine(OcrEngine): tesseract_env=options.tesseract_env, ) - def generate_hocr(self, input_file, output_hocr, output_text, options): + @staticmethod + def generate_hocr(input_file, output_hocr, output_text, options): tesseract.generate_hocr( input_file=input_file, output_hocr=output_hocr, @@ -47,7 +59,8 @@ class TesseractOcrEngine(OcrEngine): tesseract_env=options.tesseract_env, ) - def generate_pdf(self, input_file, output_pdf, output_text, options): + @staticmethod + def generate_pdf(input_file, output_pdf, output_text, options): tesseract.generate_pdf( input_file=input_file, output_pdf=output_pdf, diff --git a/src/ocrmypdf/pluginspec.py b/src/ocrmypdf/pluginspec.py index 0261ba75..366c51d3 100644 --- a/src/ocrmypdf/pluginspec.py +++ b/src/ocrmypdf/pluginspec.py @@ -15,7 +15,7 @@ # You should have received a copy of the GNU General Public License # along with OCRmyPDF. If not, see . -from abc import ABC, abstractmethod +from abc import ABC, abstractstaticmethod from argparse import ArgumentParser, Namespace from collections import namedtuple from pathlib import Path @@ -95,27 +95,33 @@ OrientationConfidence = namedtuple('OrientationConfidence', ('angle', 'confidenc class OcrEngine(ABC): - @abstractmethod - def languages(self) -> AbstractSet[str]: + @abstractstaticmethod + def version() -> str: + """Returns the version of the OCR engine.""" + + @abstractstaticmethod + def creator_tag(options) -> str: + """Returns the creator tag to identify this software's role in creating the PDF.""" + + @abstractstaticmethod + def languages() -> AbstractSet[str]: """Returns set of languages that are supported.""" - @abstractmethod - def get_orientation( - self, input_file: Path, options: Namespace - ) -> OrientationConfidence: + @abstractstaticmethod + def get_orientation(input_file: Path, options: Namespace) -> OrientationConfidence: """Returns the orientation of the image.""" - @abstractmethod + @abstractstaticmethod def generate_hocr( - self, input_file: Path, output_hocr: Path, output_text: Path, options: Namespace + input_file: Path, output_hocr: Path, output_text: Path, options: Namespace ) -> None: - pass + """Called to produce a hOCR file.""" - @abstractmethod + @abstractstaticmethod def generate_pdf( - self, input_file: Path, output_pdf: Path, output_text: Path, options: Namespace + input_file: Path, output_pdf: Path, output_text: Path, options: Namespace ) -> None: - pass + """Called to produce a text only PDF (no image, invisible text).""" @hookspec(firstresult=True) diff --git a/tests/test_metadata.py b/tests/test_metadata.py index acb8f31c..795dcc43 100644 --- a/tests/test_metadata.py +++ b/tests/test_metadata.py @@ -29,6 +29,7 @@ from pikepdf.models.metadata import decode_pdf_date from ocrmypdf._jobcontext import PdfContext from ocrmypdf._pipeline import convert_to_pdfa, metadata_fixup +from ocrmypdf._plugin_manager import get_plugin_manager from ocrmypdf.cli import get_parser from ocrmypdf.exceptions import ExitCode from ocrmypdf.pdfa import SRGB_ICC_PROFILE, file_claims_pdfa, generate_pdfa_ps @@ -291,7 +292,9 @@ def test_metadata_fixup_warning(resources, outdir, caplog): copyfile(resources / 'graph.pdf', outdir / 'graph.pdf') - context = PdfContext(options, outdir, outdir / 'graph.pdf', None, None) + context = PdfContext( + options, outdir, outdir / 'graph.pdf', None, get_plugin_manager([]) + ) metadata_fixup(working_file=outdir / 'graph.pdf', context=context) for record in caplog.records: assert record.levelname != 'WARNING' @@ -302,7 +305,9 @@ def test_metadata_fixup_warning(resources, outdir, caplog): meta['prism2:publicationName'] = 'OCRmyPDF Test' graph.save(outdir / 'graph_mod.pdf') - context = PdfContext(options, outdir, outdir / 'graph_mod.pdf', None, None) + context = PdfContext( + options, outdir, outdir / 'graph_mod.pdf', None, get_plugin_manager([]) + ) metadata_fixup(working_file=outdir / 'graph.pdf', context=context) assert any(record.levelname == 'WARNING' for record in caplog.records) From 2bd586e093a191fb6e9e939b56eb801de5069035 Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Sat, 16 May 2020 01:50:37 -0700 Subject: [PATCH 70/94] Compare requested languages to OCR engine instead of tesseract directly Also refactoring to facilitating validation needing the plugin manager. --- src/ocrmypdf/__main__.py | 2 +- src/ocrmypdf/_validation.py | 16 +++++++++------- src/ocrmypdf/api.py | 2 +- src/ocrmypdf/builtin_plugins/tesseract_ocr.py | 7 +++++-- src/ocrmypdf/pluginspec.py | 8 ++++++-- tests/conftest.py | 9 ++++++--- tests/test_unpaper.py | 6 ++++-- tests/test_validation.py | 9 ++++++--- 8 files changed, 38 insertions(+), 21 deletions(-) diff --git a/src/ocrmypdf/__main__.py b/src/ocrmypdf/__main__.py index 01f0a478..1cf84daf 100755 --- a/src/ocrmypdf/__main__.py +++ b/src/ocrmypdf/__main__.py @@ -58,7 +58,7 @@ def run(args=None): ) log.debug('ocrmypdf %s', __version__) try: - check_options(options) + check_options(options, plugin_manager) except ValueError as e: log.error(e) return ExitCode.bad_args diff --git a/src/ocrmypdf/_validation.py b/src/ocrmypdf/_validation.py index 4ef6fcdf..bfd52e69 100644 --- a/src/ocrmypdf/_validation.py +++ b/src/ocrmypdf/_validation.py @@ -27,6 +27,7 @@ from shutil import copyfileobj import PIL +from ocrmypdf._plugin_manager import get_plugin_manager from ocrmypdf._unicodefun import verify_python3_env from ocrmypdf.exceptions import ( BadArgsError, @@ -72,7 +73,7 @@ def check_platform(): ) -def check_options_languages(options): +def check_options_languages(options, plugin_manager): if not options.language: options.language = [DEFAULT_LANGUAGE] system_lang = locale.getlocale()[0] @@ -84,12 +85,13 @@ def check_options_languages(options): options.language = options.language[0].split('+') languages = set(options.language) - if not languages.issubset(tesseract.get_languages(options.tesseract_env)): + ocr_engine = plugin_manager.hook.get_ocr_engine() + if not languages.issubset(ocr_engine.languages(options)): msg = ( - "The installed version of tesseract does not have language " - "data for the following requested languages: \n" + f"{ocr_engine} does not have language data for the following " + "requested languages: \n" ) - for lang in languages - tesseract.get_languages(options.tesseract_env): + for lang in languages - ocr_engine.languages(options): msg += lang + '\n' raise MissingDependencyError(msg) @@ -308,9 +310,9 @@ def check_options_pillow(options): PIL.Image.MAX_IMAGE_PIXELS = None -def check_options(options): +def check_options(options, plugin_manager): check_platform() - check_options_languages(options) + check_options_languages(options, plugin_manager) check_options_metadata(options) check_options_output(options) check_options_sidecar(options) diff --git a/src/ocrmypdf/api.py b/src/ocrmypdf/api.py index f53107fa..7edce482 100644 --- a/src/ocrmypdf/api.py +++ b/src/ocrmypdf/api.py @@ -279,5 +279,5 @@ def ocr( # pylint: disable=unused-argument options = create_options( **{k: v for k, v in locals().items() if not k.startswith('_')} ) - check_options(options) + check_options(options, _plugin_manager) return run_pipeline(options=options, plugin_manager=_plugin_manager, api=True) diff --git a/src/ocrmypdf/builtin_plugins/tesseract_ocr.py b/src/ocrmypdf/builtin_plugins/tesseract_ocr.py index d68a2946..85b2e4c8 100644 --- a/src/ocrmypdf/builtin_plugins/tesseract_ocr.py +++ b/src/ocrmypdf/builtin_plugins/tesseract_ocr.py @@ -30,9 +30,12 @@ class TesseractOcrEngine(OcrEngine): tag = '-PDF' if options.pdf_renderer == 'sandwich' else '' return f"Tesseract OCR{tag} {TesseractOcrEngine.version()}" + def __str__(self): + return f"Tesseract OCR {TesseractOcrEngine.version()}" + @staticmethod - def languages(): - return tesseract.get_languages() + def languages(options): + return tesseract.get_languages(options.tesseract_env) @staticmethod def get_orientation(input_file, options): diff --git a/src/ocrmypdf/pluginspec.py b/src/ocrmypdf/pluginspec.py index 366c51d3..5e0a4f88 100644 --- a/src/ocrmypdf/pluginspec.py +++ b/src/ocrmypdf/pluginspec.py @@ -100,11 +100,15 @@ class OcrEngine(ABC): """Returns the version of the OCR engine.""" @abstractstaticmethod - def creator_tag(options) -> str: + def creator_tag(options: Namespace) -> str: """Returns the creator tag to identify this software's role in creating the PDF.""" @abstractstaticmethod - def languages() -> AbstractSet[str]: + def __str__(self): + """Returns name of OCR engine and version.""" + + @abstractstaticmethod + def languages(options: Namespace) -> AbstractSet[str]: """Returns set of languages that are supported.""" @abstractstaticmethod diff --git a/tests/conftest.py b/tests/conftest.py index 8eaede2a..55bd8376 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -25,6 +25,7 @@ from subprocess import PIPE, run import pytest from ocrmypdf import api, cli, pdfinfo +from ocrmypdf._plugin_manager import get_plugin_manager from ocrmypdf.exec import unpaper pytest_plugins = ['helpers_namespace'] @@ -217,11 +218,12 @@ def check_ocrmypdf(input_file, output_file, *args, env=None): [str(input_file), str(output_file)] + [str(arg) for arg in args if arg is not None] ) - api.check_options(options) + plugin_manager = get_plugin_manager(options.plugins) + api.check_options(options, plugin_manager) if env: options.tesseract_env = env options.tesseract_env['_OCRMYPDF_TEST_INFILE'] = os.fspath(input_file) - result = api.run_pipeline(options, plugin_manager=None, api=True) + result = api.run_pipeline(options, plugin_manager=plugin_manager, api=True) assert result == 0 assert output_file.exists(), "Output file not created" @@ -251,7 +253,8 @@ def run_ocrmypdf_api(input_file, output_file, *args, env=None): if options.tesseract_env: assert all(isinstance(v, (str, bytes)) for v in options.tesseract_env.values()) - api.check_options(options) + plugin_manager = get_plugin_manager(options.plugins) + api.check_options(options, plugin_manager) return api.run_pipeline(options, plugin_manager=None, api=False) diff --git a/tests/test_unpaper.py b/tests/test_unpaper.py index 836ef0a8..e87800c9 100644 --- a/tests/test_unpaper.py +++ b/tests/test_unpaper.py @@ -20,6 +20,7 @@ from unittest.mock import patch import pytest +from ocrmypdf._plugin_manager import get_plugin_manager from ocrmypdf._validation import check_options from ocrmypdf.cli import get_parser from ocrmypdf.exceptions import ExitCode, MissingDependencyError @@ -43,11 +44,12 @@ def test_no_unpaper(resources, no_outpdf): input_ = fspath(resources / "c02-22.pdf") output = fspath(no_outpdf) options = get_parser().parse_args(args=["--clean", input_, output]) - + plugin_manager = get_plugin_manager(options.plugins) with patch("ocrmypdf.exec.unpaper.version") as mock_unpaper_version: mock_unpaper_version.side_effect = FileNotFoundError("unpaper") + with pytest.raises(MissingDependencyError): - check_options(options) + check_options(options, plugin_manager) def test_old_unpaper(spoof_unpaper_oldversion, resources, no_outpdf): diff --git a/tests/test_validation.py b/tests/test_validation.py index 35c21fa0..3d3221a4 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -22,6 +22,7 @@ from unittest.mock import patch import pytest import ocrmypdf._validation as vd +from ocrmypdf._plugin_manager import get_plugin_manager from ocrmypdf.api import create_options from ocrmypdf.cli import get_parser from ocrmypdf.exceptions import BadArgsError, MissingDependencyError @@ -153,8 +154,9 @@ def test_false_action_store_true(): @pytest.mark.parametrize('progress_bar', [True, False]) def test_no_progress_bar(progress_bar, resources): opts = make_opts(progress_bar=progress_bar, input_file=(resources / 'trivial.pdf')) + plugin_manager = get_plugin_manager(opts.plugins) with patch('ocrmypdf._concurrent.tqdm', autospec=True) as tqdmpatch: - vd.check_options(opts) + vd.check_options(opts, plugin_manager) pdfinfo = PdfInfo(opts.input_file, progbar=opts.progress_bar) assert pdfinfo is not None assert tqdmpatch.called @@ -164,11 +166,12 @@ def test_no_progress_bar(progress_bar, resources): def test_language_warning(caplog): opts = make_opts(language=None) + plugin_manager = get_plugin_manager(opts.plugins) caplog.set_level(logging.DEBUG) with patch( 'ocrmypdf._validation.locale.getlocale', return_value=('en_US', 'UTF-8') ): - vd.check_options_languages(opts) + vd.check_options_languages(opts, plugin_manager) assert opts.language == ['eng'] assert '' in caplog.text @@ -176,7 +179,7 @@ def test_language_warning(caplog): with patch( 'ocrmypdf._validation.locale.getlocale', return_value=('fr_FR', 'UTF-8') ): - vd.check_options_languages(opts) + vd.check_options_languages(opts, plugin_manager) assert opts.language == ['eng'] assert 'assuming --language' in caplog.text From 9bccff4f885b4cbf3e38aa36437073c9003997d4 Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Sat, 16 May 2020 03:24:31 -0700 Subject: [PATCH 71/94] Move Tesseract specific arguments to plugin --- src/ocrmypdf/__main__.py | 10 +--- src/ocrmypdf/_plugin_manager.py | 12 ++++ src/ocrmypdf/_sync.py | 2 +- src/ocrmypdf/builtin_plugins/tesseract_ocr.py | 56 +++++++++++++++++++ src/ocrmypdf/cli.py | 55 +----------------- tests/conftest.py | 20 +++---- tests/test_validation.py | 5 +- 7 files changed, 87 insertions(+), 73 deletions(-) diff --git a/src/ocrmypdf/__main__.py b/src/ocrmypdf/__main__.py index 1cf84daf..69d68db4 100755 --- a/src/ocrmypdf/__main__.py +++ b/src/ocrmypdf/__main__.py @@ -22,7 +22,7 @@ import sys from multiprocessing import set_start_method from ocrmypdf import __version__ -from ocrmypdf._plugin_manager import get_plugin_manager +from ocrmypdf._plugin_manager import get_parser_options_plugins from ocrmypdf._sync import run_pipeline from ocrmypdf._validation import check_closed_streams, check_options from ocrmypdf.api import Verbosity, configure_logging @@ -33,13 +33,7 @@ log = logging.getLogger('ocrmypdf') def run(args=None): - pre_options, _unused = plugins_only_parser.parse_known_args(args=args) - plugin_manager = get_plugin_manager(pre_options.plugins) - - parser = get_parser() - plugin_manager.hook.add_options(parser=parser) - - options = parser.parse_args(args=args) + parser, options, plugin_manager = get_parser_options_plugins(args=args) if not check_closed_streams(options): return ExitCode.bad_args diff --git a/src/ocrmypdf/_plugin_manager.py b/src/ocrmypdf/_plugin_manager.py index e42ef94b..6216a44b 100644 --- a/src/ocrmypdf/_plugin_manager.py +++ b/src/ocrmypdf/_plugin_manager.py @@ -24,6 +24,7 @@ from typing import List import pluggy from ocrmypdf import pluginspec +from ocrmypdf.cli import get_parser, plugins_only_parser def get_plugin_manager(plugins: List[str], builtins=True): @@ -47,3 +48,14 @@ def get_plugin_manager(plugins: List[str], builtins=True): module = importlib.import_module(name) pm.register(module) return pm + + +def get_parser_options_plugins(args): + pre_options, _unused = plugins_only_parser.parse_known_args(args=args) + plugin_manager = get_plugin_manager(pre_options.plugins) + + parser = get_parser() + plugin_manager.hook.add_options(parser=parser) + + options = parser.parse_args(args=args) + return parser, options, plugin_manager diff --git a/src/ocrmypdf/_sync.py b/src/ocrmypdf/_sync.py index 3f97a301..c25bf8e8 100644 --- a/src/ocrmypdf/_sync.py +++ b/src/ocrmypdf/_sync.py @@ -305,7 +305,7 @@ def run_pipeline(options, *, plugin_manager, api=False): if not options.jobs: options.jobs = available_cpu_count() if not plugin_manager: - plugin_manager = get_plugin_manager([]) + plugin_manager = get_plugin_manager(options.plugins) work_folder = Path(mkdtemp(prefix="com.github.ocrmypdf.")) debug_log_handler = None diff --git a/src/ocrmypdf/builtin_plugins/tesseract_ocr.py b/src/ocrmypdf/builtin_plugins/tesseract_ocr.py index 85b2e4c8..ee97845b 100644 --- a/src/ocrmypdf/builtin_plugins/tesseract_ocr.py +++ b/src/ocrmypdf/builtin_plugins/tesseract_ocr.py @@ -16,10 +16,66 @@ # along with OCRmyPDF. If not, see . from ocrmypdf import hookimpl +from ocrmypdf.cli import numeric from ocrmypdf.exec import tesseract from ocrmypdf.pluginspec import OcrEngine +@hookimpl +def add_options(parser): + tess = parser.add_argument_group("Tesseract", "Advanced control of Tesseract OCR") + tess.add_argument( + '--tesseract-config', + action='append', + metavar='CFG', + default=[], + help="Additional Tesseract configuration files -- see documentation", + ) + tess.add_argument( + '--tesseract-pagesegmode', + action='store', + type=int, + metavar='PSM', + choices=range(0, 14), + help="Set Tesseract page segmentation mode (see tesseract --help)", + ) + tess.add_argument( + '--tesseract-oem', + action='store', + type=int, + metavar='MODE', + choices=range(0, 4), + help=( + "Set Tesseract 4.0 OCR engine mode: " + "0 - original Tesseract only; " + "1 - neural nets LSTM only; " + "2 - Tesseract + LSTM; " + "3 - default." + ), + ) + tess.add_argument( + '--tesseract-timeout', + default=180.0, + type=numeric(float, 0), + metavar='SECONDS', + help='Give up on OCR after the timeout, but copy the preprocessed page ' + 'into the final output', + ) + tess.add_argument( + '--user-words', + metavar='FILE', + help="Specify the location of the Tesseract user words file. This is a " + "list of words Tesseract should consider while performing OCR in " + "addition to its standard language dictionaries. This can improve " + "OCR quality especially for specialized and technical documents.", + ) + tess.add_argument( + '--user-patterns', + metavar='FILE', + help="Specify the location of the Tesseract user patterns file.", + ) + + class TesseractOcrEngine(OcrEngine): @staticmethod def version(): diff --git a/src/ocrmypdf/cli.py b/src/ocrmypdf/cli.py index cea8e351..0675300c 100644 --- a/src/ocrmypdf/cli.py +++ b/src/ocrmypdf/cli.py @@ -57,6 +57,7 @@ class ArgumentParser(argparse.ArgumentParser): def get_parser(): parser = ArgumentParser( prog=_PROGRAM_NAME, + allow_abbrev=True, fromfile_prefix_chars='@', formatter_class=argparse.RawDescriptionHelpFormatter, description="""\ @@ -382,14 +383,14 @@ Online documentation is located at: ) advanced = parser.add_argument_group( - "Advanced", "Advanced options to control Tesseract's OCR behavior" + "Advanced", "Advanced options to control OCRmyPDF" ) advanced.add_argument( '--pages', type=str, help=( "Limit OCR to the specified pages (ranges or comma separated), " - "skipping others", + "skipping others" ), ) advanced.add_argument( @@ -401,35 +402,6 @@ Online documentation is located at: "decompression bomb", default=128.0, ) - advanced.add_argument( - '--tesseract-config', - action='append', - metavar='CFG', - default=[], - help="Additional Tesseract configuration files -- see documentation", - ) - advanced.add_argument( - '--tesseract-pagesegmode', - action='store', - type=int, - metavar='PSM', - choices=range(0, 14), - help="Set Tesseract page segmentation mode (see tesseract --help)", - ) - advanced.add_argument( - '--tesseract-oem', - action='store', - type=int, - metavar='MODE', - choices=range(0, 4), - help=( - "Set Tesseract 4.0 OCR engine mode: " - "0 - original Tesseract only; " - "1 - neural nets LSTM only; " - "2 - Tesseract + LSTM; " - "3 - default." - ), - ) advanced.add_argument( '--pdf-renderer', choices=['auto', 'hocr', 'sandwich'], @@ -437,14 +409,6 @@ Online documentation is located at: help="Choose OCR PDF renderer - the default option is to let OCRmyPDF " "choose. See documentation for discussion.", ) - advanced.add_argument( - '--tesseract-timeout', - default=180.0, - type=numeric(float, 0), - metavar='SECONDS', - help='Give up on OCR after the timeout, but copy the preprocessed page ' - 'into the final output', - ) advanced.add_argument( '--rotate-pages-threshold', default=14.0, @@ -466,19 +430,6 @@ Online documentation is located at: "skipped. Not supported for --output-type=pdf ; that setting " "preserves the original compression of all images.", ) - advanced.add_argument( - '--user-words', - metavar='FILE', - help="Specify the location of the Tesseract user words file. This is a " - "list of words Tesseract should consider while performing OCR in " - "addition to its standard language dictionaries. This can improve " - "OCR quality especially for specialized and technical documents.", - ) - advanced.add_argument( - '--user-patterns', - metavar='FILE', - help="Specify the location of the Tesseract user patterns file.", - ) advanced.add_argument( '--fast-web-view', type=numeric(float, 0), diff --git a/tests/conftest.py b/tests/conftest.py index 55bd8376..d7d4b437 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -25,7 +25,7 @@ from subprocess import PIPE, run import pytest from ocrmypdf import api, cli, pdfinfo -from ocrmypdf._plugin_manager import get_plugin_manager +from ocrmypdf._plugin_manager import get_parser_options_plugins from ocrmypdf.exec import unpaper pytest_plugins = ['helpers_namespace'] @@ -213,12 +213,11 @@ def no_outpdf(tmp_path): @pytest.helpers.register def check_ocrmypdf(input_file, output_file, *args, env=None): """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.get_parser().parse_args( - [str(input_file), str(output_file)] - + [str(arg) for arg in args if arg is not None] - ) - plugin_manager = get_plugin_manager(options.plugins) + _parser, options, plugin_manager = get_parser_options_plugins(args=args) api.check_options(options, plugin_manager) if env: options.tesseract_env = env @@ -239,10 +238,10 @@ def run_ocrmypdf_api(input_file, output_file, *args, env=None): Does not currently have a way to manipulate the PATH except for Tesseract. """ - options = cli.get_parser().parse_args( - [str(input_file), str(output_file)] - + [str(arg) for arg in args if arg is not None] - ) + 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) if env: options.tesseract_env = env.copy() options.tesseract_env['_OCRMYPDF_TEST_INFILE'] = os.fspath(input_file) @@ -253,7 +252,6 @@ def run_ocrmypdf_api(input_file, output_file, *args, env=None): if options.tesseract_env: assert all(isinstance(v, (str, bytes)) for v in options.tesseract_env.values()) - plugin_manager = get_plugin_manager(options.plugins) api.check_options(options, plugin_manager) return api.run_pipeline(options, plugin_manager=None, api=False) diff --git a/tests/test_validation.py b/tests/test_validation.py index 3d3221a4..857dbda0 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -32,8 +32,11 @@ from ocrmypdf.pdfinfo import PdfInfo def make_opts(input_file='a.pdf', output_file='b.pdf', language='eng', **kwargs): if language is not None: kwargs['language'] = language + parser = get_parser() + pm = get_plugin_manager(kwargs.get('plugins', [])) + pm.hook.add_options(parser=parser) return create_options( - input_file=input_file, output_file=output_file, parser=get_parser(), **kwargs + input_file=input_file, output_file=output_file, parser=parser, **kwargs ) From a0f9ca3a30d3de8b3b4f555985f2fd5decee0d7f Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Mon, 25 May 2020 01:31:46 -0700 Subject: [PATCH 72/94] Move Tesseract options validation into plugin --- src/ocrmypdf/_sync.py | 2 - src/ocrmypdf/_validation.py | 31 +------------- src/ocrmypdf/builtin_plugins/tesseract_ocr.py | 41 ++++++++++++++++++- src/ocrmypdf/pluginspec.py | 2 +- tests/test_validation.py | 19 ++++++--- 5 files changed, 55 insertions(+), 40 deletions(-) diff --git a/src/ocrmypdf/_sync.py b/src/ocrmypdf/_sync.py index c25bf8e8..87302438 100644 --- a/src/ocrmypdf/_sync.py +++ b/src/ocrmypdf/_sync.py @@ -323,8 +323,6 @@ def run_pipeline(options, *, plugin_manager, api=False): original_filename, start_input_file, work_folder / 'origin.pdf', options ) - plugin_manager.hook.prepare(options=options) - # Gather pdfinfo and create context pdfinfo = get_pdfinfo( origin_pdf, diff --git a/src/ocrmypdf/_validation.py b/src/ocrmypdf/_validation.py index bfd52e69..785ca13a 100644 --- a/src/ocrmypdf/_validation.py +++ b/src/ocrmypdf/_validation.py @@ -123,18 +123,6 @@ def check_options_output(options): msg += f"Found Ghostscript {ghostscript.version()}" log.warning(msg) - # Decide on what renderer to use - if options.pdf_renderer == 'auto': - options.pdf_renderer = 'sandwich' - - if options.pdf_renderer == 'sandwich' and not tesseract.has_textonly_pdf( - options.tesseract_env, languages - ): - raise MissingDependencyError( - "You are using an alpha version of Tesseract 4.0 that does not support " - "the textonly_pdf parameter. We don't support versions this old." - ) - if options.output_type == 'pdfa': options.output_type = 'pdfa-2' @@ -277,18 +265,6 @@ def check_options_advanced(options): "--pdfa-image-compression argument has no effect when " "--output-type is not 'pdfa', 'pdfa-1', or 'pdfa-2'" ) - if not tesseract.has_user_words(options.tesseract_env) and ( - options.user_words or options.user_patterns - ): - log.warning( - "Tesseract 4.0 ignores --user-words and --user-patterns, so these " - "arguments have no effect." - ) - if options.tesseract_pagesegmode in (0, 2): - log.warning( - "The --tesseract-pagesegmode argument you select will disable OCR. " - "This may cause processing to fail." - ) def check_options_metadata(options): @@ -322,6 +298,7 @@ def check_options(options, plugin_manager): check_options_advanced(options) check_options_pillow(options) check_dependency_versions(options) + plugin_manager.hook.check_options(options=options) def check_closed_streams(options): # pragma: no cover @@ -464,12 +441,6 @@ def report_output_file_size(options, input_file, output_file): def check_dependency_versions(options): - check_external_program( - program='tesseract', - package={'linux': 'tesseract-ocr'}, - version_checker=tesseract.version, - need_version='4.0.0', # using backport for Travis CI - ) check_external_program( program='gs', package='ghostscript', diff --git a/src/ocrmypdf/builtin_plugins/tesseract_ocr.py b/src/ocrmypdf/builtin_plugins/tesseract_ocr.py index ee97845b..41cc830a 100644 --- a/src/ocrmypdf/builtin_plugins/tesseract_ocr.py +++ b/src/ocrmypdf/builtin_plugins/tesseract_ocr.py @@ -15,11 +15,16 @@ # You should have received a copy of the GNU General Public License # along with OCRmyPDF. If not, see . +import logging + from ocrmypdf import hookimpl from ocrmypdf.cli import numeric -from ocrmypdf.exec import tesseract +from ocrmypdf.exceptions import MissingDependencyError +from ocrmypdf.exec import check_external_program, tesseract from ocrmypdf.pluginspec import OcrEngine +log = logging.getLogger(__name__) + @hookimpl def add_options(parser): @@ -76,6 +81,40 @@ def add_options(parser): ) +@hookimpl +def check_options(options): + check_external_program( + program='tesseract', + package={'linux': 'tesseract-ocr'}, + version_checker=tesseract.version, + need_version='4.0.0', # using backport for Travis CI + ) + + # Decide on what renderer to use + if options.pdf_renderer == 'auto': + options.pdf_renderer = 'sandwich' + + if options.pdf_renderer == 'sandwich' and not tesseract.has_textonly_pdf( + options.tesseract_env, set(options.language) + ): + raise MissingDependencyError( + "You are using an alpha version of Tesseract 4.0 that does not support " + "the textonly_pdf parameter. We don't support versions this old." + ) + if not tesseract.has_user_words(options.tesseract_env) and ( + options.user_words or options.user_patterns + ): + log.warning( + "Tesseract 4.0 ignores --user-words and --user-patterns, so these " + "arguments have no effect." + ) + if options.tesseract_pagesegmode in (0, 2): + log.warning( + "The --tesseract-pagesegmode argument you select will disable OCR. " + "This may cause processing to fail." + ) + + class TesseractOcrEngine(OcrEngine): @staticmethod def version(): diff --git a/src/ocrmypdf/pluginspec.py b/src/ocrmypdf/pluginspec.py index 5e0a4f88..432b1946 100644 --- a/src/ocrmypdf/pluginspec.py +++ b/src/ocrmypdf/pluginspec.py @@ -39,7 +39,7 @@ def add_options(parser: ArgumentParser) -> None: @hookspec -def prepare(options: Namespace) -> None: +def check_options(options: Namespace) -> None: """Called to notify a plugin that a file will be processed. The plugin may modify the *options*. All objects that are in options must diff --git a/tests/test_validation.py b/tests/test_validation.py index 857dbda0..babc301d 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -69,7 +69,8 @@ def test_old_tesseract_error(): with patch('ocrmypdf.exec.tesseract.has_textonly_pdf', return_value=False): with pytest.raises(MissingDependencyError): opts = make_opts(pdf_renderer='sandwich', language='eng') - vd.check_options_output(opts) + plugin_manager = get_plugin_manager(opts.plugins) + vd.check_options(opts, plugin_manager) def test_lossless_redo(): @@ -96,12 +97,17 @@ def test_optimizing(caplog): def test_user_words(caplog): - with patch('ocrmypdf.exec.tesseract.version', return_value='4.0.0'): - vd.check_options_advanced(make_opts(user_words='foo')) + + with patch('ocrmypdf.exec.tesseract.has_user_words', return_value=False): + opts = make_opts(user_words='foo') + plugin_manager = get_plugin_manager(opts.plugins) + vd.check_options(opts, plugin_manager) assert '4.0 ignores --user-words' in caplog.text caplog.clear() - with patch('ocrmypdf.exec.tesseract.version', return_value='4.1.0'): - vd.check_options_advanced(make_opts(user_patterns='foo')) + with patch('ocrmypdf.exec.tesseract.has_user_words', return_value=True): + opts = make_opts(user_patterns='foo') + plugin_manager = get_plugin_manager(opts.plugins) + vd.check_options(opts, plugin_manager) assert '4.0 ignores --user-words' not in caplog.text @@ -223,5 +229,6 @@ def test_version_comparison(): def test_pagesegmode_warning(caplog): opts = make_opts(tesseract_pagesegmode='0') - vd.check_options_advanced(opts) + plugin_manager = get_plugin_manager(opts.plugins) + vd.check_options(opts, plugin_manager) assert 'disable OCR' in caplog.text From d43212d30b6e26dd272efffcbe4650c9b4c94970 Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Mon, 25 May 2020 03:20:10 -0700 Subject: [PATCH 73/94] Refactor --language argument into set --- src/ocrmypdf/_validation.py | 16 +++++----------- src/ocrmypdf/builtin_plugins/tesseract_ocr.py | 6 +++--- src/ocrmypdf/cli.py | 17 ++++++++++++++++- tests/test_validation.py | 4 ++-- 4 files changed, 26 insertions(+), 17 deletions(-) diff --git a/src/ocrmypdf/_validation.py b/src/ocrmypdf/_validation.py index 785ca13a..941e3522 100644 --- a/src/ocrmypdf/_validation.py +++ b/src/ocrmypdf/_validation.py @@ -74,24 +74,19 @@ def check_platform(): def check_options_languages(options, plugin_manager): - if not options.language: - options.language = [DEFAULT_LANGUAGE] + if not options.languages: + options.languages = {DEFAULT_LANGUAGE} system_lang = locale.getlocale()[0] if system_lang and not system_lang.startswith('en'): log.debug("No language specified; assuming --language %s", DEFAULT_LANGUAGE) - # Support v2.x "eng+deu" language syntax - if '+' in options.language[0]: - options.language = options.language[0].split('+') - - languages = set(options.language) ocr_engine = plugin_manager.hook.get_ocr_engine() - if not languages.issubset(ocr_engine.languages(options)): + if not options.languages.issubset(ocr_engine.languages(options)): msg = ( f"{ocr_engine} does not have language data for the following " "requested languages: \n" ) - for lang in languages - ocr_engine.languages(options): + for lang in options.languages - ocr_engine.languages(options): msg += lang + '\n' raise MissingDependencyError(msg) @@ -101,8 +96,7 @@ def check_options_output(options): # 1. Ghostscript < 9.20 mangles multibyte Unicode # 2. hocr doesn't work on non-Latin languages (so don't select it) - languages = set(options.language) - is_latin = languages.issubset(HOCR_OK_LANGS) + is_latin = options.languages.issubset(HOCR_OK_LANGS) if options.pdf_renderer == 'hocr' and not is_latin: msg = ( diff --git a/src/ocrmypdf/builtin_plugins/tesseract_ocr.py b/src/ocrmypdf/builtin_plugins/tesseract_ocr.py index 41cc830a..e2fcfb6d 100644 --- a/src/ocrmypdf/builtin_plugins/tesseract_ocr.py +++ b/src/ocrmypdf/builtin_plugins/tesseract_ocr.py @@ -95,7 +95,7 @@ def check_options(options): options.pdf_renderer = 'sandwich' if options.pdf_renderer == 'sandwich' and not tesseract.has_textonly_pdf( - options.tesseract_env, set(options.language) + options.tesseract_env, set(options.languages) ): raise MissingDependencyError( "You are using an alpha version of Tesseract 4.0 that does not support " @@ -147,7 +147,7 @@ class TesseractOcrEngine(OcrEngine): input_file=input_file, output_hocr=output_hocr, output_text=output_text, - languages=options.language, + languages=options.languages, engine_mode=options.tesseract_oem, tessconfig=options.tesseract_config, timeout=options.tesseract_timeout, @@ -163,7 +163,7 @@ class TesseractOcrEngine(OcrEngine): input_file=input_file, output_pdf=output_pdf, output_text=output_text, - languages=options.language, + languages=options.languages, engine_mode=options.tesseract_oem, tessconfig=options.tesseract_config, timeout=options.tesseract_timeout, diff --git a/src/ocrmypdf/cli.py b/src/ocrmypdf/cli.py index 0675300c..ccf66d99 100644 --- a/src/ocrmypdf/cli.py +++ b/src/ocrmypdf/cli.py @@ -54,6 +54,20 @@ class ArgumentParser(argparse.ArgumentParser): raise ValueError(message) +class LanguageSetAction(argparse.Action): + def __init__(self, option_strings, dest, default=None, **kwargs): + if default is None: + default = set() + super().__init__(option_strings, dest, default=default, **kwargs) + + def __call__(self, parser, namespace, values, option_string=None): + dest = getattr(namespace, self.dest) + if '+' in values: + dest.add(lang for lang in values.split('+')) + else: + dest.add(values) + + def get_parser(): parser = ArgumentParser( prog=_PROGRAM_NAME, @@ -126,7 +140,8 @@ Online documentation is located at: parser.add_argument( '-l', '--language', - action='append', + dest='languages', + action=LanguageSetAction, help="Language(s) of the file to be OCRed (see tesseract --list-langs for " "all language packs installed in your system). Use -l eng+deu for " "multiple languages.", diff --git a/tests/test_validation.py b/tests/test_validation.py index babc301d..f6fcc775 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -181,7 +181,7 @@ def test_language_warning(caplog): 'ocrmypdf._validation.locale.getlocale', return_value=('en_US', 'UTF-8') ): vd.check_options_languages(opts, plugin_manager) - assert opts.language == ['eng'] + assert opts.languages == {'eng'} assert '' in caplog.text opts = make_opts(language=None) @@ -189,7 +189,7 @@ def test_language_warning(caplog): 'ocrmypdf._validation.locale.getlocale', return_value=('fr_FR', 'UTF-8') ): vd.check_options_languages(opts, plugin_manager) - assert opts.language == ['eng'] + assert opts.languages == {'eng'} assert 'assuming --language' in caplog.text From aa060db5bc7f2c4017902360cc2b039bfca4d8bc Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Tue, 26 May 2020 02:13:17 -0700 Subject: [PATCH 74/94] Refactor tesseract_env variable into the plugin Removed all cases except one in api.py, which isn't worth solving because it should be removed anyway. This also fixes a logic error in the OMP_THREAD_LIMIT decision, api.py did not use pass kwargs correctly so they never worked before. --- src/ocrmypdf/_plugin_manager.py | 5 +++- src/ocrmypdf/_sync.py | 19 -------------- src/ocrmypdf/api.py | 19 ++++++-------- src/ocrmypdf/builtin_plugins/tesseract_ocr.py | 25 +++++++++++++++++++ src/ocrmypdf/cli.py | 1 - src/ocrmypdf/pluginspec.py | 2 +- tests/test_unpaper.py | 8 +++--- 7 files changed, 42 insertions(+), 37 deletions(-) diff --git a/src/ocrmypdf/_plugin_manager.py b/src/ocrmypdf/_plugin_manager.py index 6216a44b..1fbc1b76 100644 --- a/src/ocrmypdf/_plugin_manager.py +++ b/src/ocrmypdf/_plugin_manager.py @@ -15,6 +15,7 @@ # You should have received a copy of the GNU General Public License # along with OCRmyPDF. If not, see . +import argparse import importlib import importlib.util import sys @@ -50,7 +51,9 @@ def get_plugin_manager(plugins: List[str], builtins=True): return pm -def get_parser_options_plugins(args): +def get_parser_options_plugins( + args, +) -> (argparse.ArgumentParser, argparse.Namespace, pluggy.PluginManager): pre_options, _unused = plugins_only_parser.parse_known_args(args=args) plugin_manager = get_plugin_manager(pre_options.plugins) diff --git a/src/ocrmypdf/_sync.py b/src/ocrmypdf/_sync.py index 87302438..60eed5de 100644 --- a/src/ocrmypdf/_sync.py +++ b/src/ocrmypdf/_sync.py @@ -216,25 +216,6 @@ def exec_concurrent(context): if max_workers > 1: log.info("Start processing %d pages concurrently", max_workers) - # Tesseract 4.x can be multithreaded, and we also run multiple workers. We want - # to manage how many threads it uses to avoid creating total threads than cores. - # Performance testing shows we're better off - # parallelizing ocrmypdf and forcing Tesseract to be single threaded, which we - # get by setting the envvar OMP_THREAD_LIMIT to 1. But if the page count of the - # input file is small, then we allow Tesseract to use threads, subject to the - # constraint: (ocrmypdf workers) * (tesseract threads) <= max_workers. - # As of Tesseract 4.1, 3 threads is the most effective on a 4 core/8 thread system. - tess_threads = min(3, context.options.jobs // max_workers) - if context.options.tesseract_env is None: - context.options.tesseract_env = os.environ.copy() - context.options.tesseract_env.setdefault('OMP_THREAD_LIMIT', str(tess_threads)) - try: - tess_threads = int(context.options.tesseract_env['OMP_THREAD_LIMIT']) - except ValueError: # OMP_THREAD_LIMIT initialized to non-numeric - context.log.error("Environment variable OMP_THREAD_LIMIT is not numeric") - if tess_threads > 1: - log.info("Using Tesseract OpenMP thread limit %d", tess_threads) - sidecars = [None] * len(context.pdfinfo) ocrgraft = OcrGrafter(context) diff --git a/src/ocrmypdf/api.py b/src/ocrmypdf/api.py index 7edce482..68ede13a 100644 --- a/src/ocrmypdf/api.py +++ b/src/ocrmypdf/api.py @@ -15,6 +15,7 @@ # You should have received a copy of the GNU General Public License # along with OCRmyPDF. If not, see . +import inspect import logging import os import sys @@ -178,11 +179,6 @@ def create_options( options = parser.parse_args(cmdline) for keyword, val in deferred: setattr(options, keyword, val) - - # If we are running a Tesseract spoof, ensure it knows what the input file is - if os.environ.get('PYTEST_CURRENT_TEST') and options.tesseract_env: - options.tesseract_env['_OCRMYPDF_TEST_INFILE'] = os.fspath(input_file) - return options @@ -233,7 +229,6 @@ def ocr( # pylint: disable=unused-argument plugins: Iterable[str] = None, keep_temporary_files: bool = None, progress_bar: bool = None, - tesseract_env: Dict[str, str] = None, **kwargs, ): """Run OCRmyPDF on one PDF or image. @@ -245,7 +240,6 @@ def ocr( # pylint: disable=unused-argument use_threads (bool): Use worker threads instead of processes. This reduces performance but may make debugging easier since it is easier to set breakpoints. - tesseract_env (dict): Override environment variables for Tesseract Raises: ocrmypdf.PdfMergeFailedError: If the input PDF is malformed, preventing merging with the OCR layer. @@ -274,10 +268,13 @@ def ocr( # pylint: disable=unused-argument parser = get_parser() _plugin_manager = get_plugin_manager(plugins) - _plugin_manager.hook.add_options(parser=parser) + _plugin_manager.hook.add_options(parser=parser) # pylint: disable=no-member - options = create_options( - **{k: v for k, v in locals().items() if not k.startswith('_')} - ) + create_options_kwargs = { + k: v for k, v in locals().items() if not k.startswith('_') and k != 'kwargs' + } + create_options_kwargs.update(kwargs) + + options = create_options(**create_options_kwargs) check_options(options, _plugin_manager) return run_pipeline(options=options, plugin_manager=_plugin_manager, api=True) diff --git a/src/ocrmypdf/builtin_plugins/tesseract_ocr.py b/src/ocrmypdf/builtin_plugins/tesseract_ocr.py index e2fcfb6d..4991baff 100644 --- a/src/ocrmypdf/builtin_plugins/tesseract_ocr.py +++ b/src/ocrmypdf/builtin_plugins/tesseract_ocr.py @@ -15,7 +15,9 @@ # You should have received a copy of the GNU General Public License # along with OCRmyPDF. If not, see . +import argparse import logging +import os from ocrmypdf import hookimpl from ocrmypdf.cli import numeric @@ -79,6 +81,7 @@ def add_options(parser): metavar='FILE', help="Specify the location of the Tesseract user patterns file.", ) + tess.add_argument('--tesseract-env', type=str, help=argparse.SUPPRESS) @hookimpl @@ -115,6 +118,28 @@ def check_options(options): ) +@hookimpl +def validate(pdfinfo, options): + # If we are running a Tesseract spoof, ensure it knows what the input file is + if os.environ.get('PYTEST_CURRENT_TEST') and options.tesseract_env: + options.tesseract_env['_OCRMYPDF_TEST_INFILE'] = os.fspath(options.input_file) + + # Tesseract 4.x can be multithreaded, and we also run multiple workers. We want + # to manage how many threads it uses to avoid creating total threads than cores. + # Performance testing shows we're better off + # parallelizing ocrmypdf and forcing Tesseract to be single threaded, which we + # get by setting the envvar OMP_THREAD_LIMIT to 1. But if the page count of the + # input file is small, then we allow Tesseract to use threads, subject to the + # constraint: (ocrmypdf workers) * (tesseract threads) <= max_workers. + # As of Tesseract 4.1, 3 threads is the most effective on a 4 core/8 thread system. + if not options.tesseract_env.get('OMP_THREAD_LIMIT', '').isnumeric(): + tess_threads = min(3, options.jobs // len(pdfinfo), len(pdfinfo)) + options.tesseract_env['OMP_THREAD_LIMIT'] = str(tess_threads) + + if tess_threads > 1: + log.info("Using Tesseract OpenMP thread limit %d", tess_threads) + + class TesseractOcrEngine(OcrEngine): @staticmethod def version(): diff --git a/src/ocrmypdf/cli.py b/src/ocrmypdf/cli.py index ccf66d99..a34a2108 100644 --- a/src/ocrmypdf/cli.py +++ b/src/ocrmypdf/cli.py @@ -474,7 +474,6 @@ Online documentation is located at: action='store_true', help="Keep temporary files (helpful for debugging)", ) - debugging.add_argument('--tesseract-env', type=str, help=argparse.SUPPRESS) return parser diff --git a/src/ocrmypdf/pluginspec.py b/src/ocrmypdf/pluginspec.py index 432b1946..dd659458 100644 --- a/src/ocrmypdf/pluginspec.py +++ b/src/ocrmypdf/pluginspec.py @@ -40,7 +40,7 @@ def add_options(parser: ArgumentParser) -> None: @hookspec def check_options(options: Namespace) -> None: - """Called to notify a plugin that a file will be processed. + """Called to ask the plugin to check all of its options. The plugin may modify the *options*. All objects that are in options must be picklable so they can be marshalled to child worker processes. diff --git a/tests/test_unpaper.py b/tests/test_unpaper.py index e87800c9..bd04da2c 100644 --- a/tests/test_unpaper.py +++ b/tests/test_unpaper.py @@ -20,7 +20,7 @@ from unittest.mock import patch import pytest -from ocrmypdf._plugin_manager import get_plugin_manager +from ocrmypdf._plugin_manager import get_parser_options_plugins from ocrmypdf._validation import check_options from ocrmypdf.cli import get_parser from ocrmypdf.exceptions import ExitCode, MissingDependencyError @@ -43,13 +43,13 @@ def spoof_unpaper_oldversion(tmp_path_factory): def test_no_unpaper(resources, no_outpdf): input_ = fspath(resources / "c02-22.pdf") output = fspath(no_outpdf) - options = get_parser().parse_args(args=["--clean", input_, output]) - plugin_manager = get_plugin_manager(options.plugins) + + _parser, options, pm = get_parser_options_plugins(["--clean", input_, output]) with patch("ocrmypdf.exec.unpaper.version") as mock_unpaper_version: mock_unpaper_version.side_effect = FileNotFoundError("unpaper") with pytest.raises(MissingDependencyError): - check_options(options, plugin_manager) + check_options(options, pm) def test_old_unpaper(spoof_unpaper_oldversion, resources, no_outpdf): From 6528234608b66216843fe0ed17e78ef2b3e967dd Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Mon, 1 Jun 2020 02:27:27 -0700 Subject: [PATCH 75/94] Fix tesseract_ocr.py errors --- src/ocrmypdf/__init__.py | 1 + src/ocrmypdf/builtin_plugins/tesseract_ocr.py | 7 ++++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/ocrmypdf/__init__.py b/src/ocrmypdf/__init__.py index 08da4d6f..d64253b8 100644 --- a/src/ocrmypdf/__init__.py +++ b/src/ocrmypdf/__init__.py @@ -36,5 +36,6 @@ from ocrmypdf.exceptions import ( TesseractConfigError, UnsupportedImageFormatError, ) +from ocrmypdf.pluginspec import OcrEngine, OrientationConfidence hookimpl = _HookimplMarker('ocrmypdf') diff --git a/src/ocrmypdf/builtin_plugins/tesseract_ocr.py b/src/ocrmypdf/builtin_plugins/tesseract_ocr.py index 4991baff..d5f5eb7b 100644 --- a/src/ocrmypdf/builtin_plugins/tesseract_ocr.py +++ b/src/ocrmypdf/builtin_plugins/tesseract_ocr.py @@ -120,8 +120,11 @@ def check_options(options): @hookimpl def validate(pdfinfo, options): + if not options.tesseract_env: + return + # If we are running a Tesseract spoof, ensure it knows what the input file is - if os.environ.get('PYTEST_CURRENT_TEST') and options.tesseract_env: + if os.environ.get('PYTEST_CURRENT_TEST'): options.tesseract_env['_OCRMYPDF_TEST_INFILE'] = os.fspath(options.input_file) # Tesseract 4.x can be multithreaded, and we also run multiple workers. We want @@ -135,6 +138,8 @@ def validate(pdfinfo, options): if not options.tesseract_env.get('OMP_THREAD_LIMIT', '').isnumeric(): tess_threads = min(3, options.jobs // len(pdfinfo), len(pdfinfo)) options.tesseract_env['OMP_THREAD_LIMIT'] = str(tess_threads) + else: + tess_threads = int(options.tesseract_env['OMP_THREAD_LIMIT']) if tess_threads > 1: log.info("Using Tesseract OpenMP thread limit %d", tess_threads) From 2b23f7ec73121214c91496f32b8669538d911d94 Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Mon, 1 Jun 2020 02:45:49 -0700 Subject: [PATCH 76/94] tesseract_noop: begin implementing with plugin --- tests/conftest.py | 27 +++++--- tests/plugins/tesseract_noop.py | 111 ++++++++++++++++++++++++++++++++ tests/test_main.py | 10 +-- 3 files changed, 133 insertions(+), 15 deletions(-) create mode 100644 tests/plugins/tesseract_noop.py diff --git a/tests/conftest.py b/tests/conftest.py index d7d4b437..7be5f1f8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -220,8 +220,12 @@ def check_ocrmypdf(input_file, output_file, *args, env=None): _parser, options, plugin_manager = get_parser_options_plugins(args=args) api.check_options(options, plugin_manager) if env: - options.tesseract_env = env - options.tesseract_env['_OCRMYPDF_TEST_INFILE'] = os.fspath(input_file) + first = env['_OCRMYPDF_TEST_PATH'].split(os.pathsep)[0] + if 'tesseract_noop' in first: + options.plugins = ['tests/plugins/tesseract_noop.py'] + else: + options.tesseract_env = env + options.tesseract_env['_OCRMYPDF_TEST_INFILE'] = os.fspath(input_file) result = api.run_pipeline(options, plugin_manager=plugin_manager, api=True) assert result == 0 @@ -243,12 +247,19 @@ def run_ocrmypdf_api(input_file, output_file, *args, env=None): ] _parser, options, plugin_manager = get_parser_options_plugins(args=args) 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 + try: + first = env['_OCRMYPDF_TEST_PATH'].split(os.pathsep)[0] + if 'tesseract_noop' in first: + options.plugins = ['tests/plugins/tesseract_noop.py'] + else: + 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 + except KeyError: + pass if options.tesseract_env: assert all(isinstance(v, (str, bytes)) for v in options.tesseract_env.values()) diff --git a/tests/plugins/tesseract_noop.py b/tests/plugins/tesseract_noop.py new file mode 100644 index 00000000..1db91fe4 --- /dev/null +++ b/tests/plugins/tesseract_noop.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +# © 2016 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 no-op spoof + +To quickly run tests where getting OCR output is not necessary. + +In 'hocr' mode, create a .hocr file that specifies no text found. + +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 + +from ocrmypdf import OcrEngine, OrientationConfidence, hookimpl + +HOCR_TEMPLATE = ''' + + + + + + + + + +
+
+

+ + +

+
+
+ +''' + + +class NoopOcrEngine(OcrEngine): + @staticmethod + def version(): + return '4.0.0' + + @staticmethod + def creator_tag(options): + tag = '-PDF' if options.pdf_renderer == 'sandwich' else '' + return f"NO-OP {tag} {NoopOcrEngine.version()}" + + def __str__(self): + return f"NO-OP {NoopOcrEngine.version()}" + + @staticmethod + def languages(options): + return {'eng'} + + @staticmethod + def get_orientation(input_file, options): + return OrientationConfidence(angle=0, confidence=0.0) + + @staticmethod + def generate_hocr(input_file, output_hocr, output_text, options): + with Image.open(input_file) as im, open( + output_hocr, 'w', encoding='utf-8' + ) as f: + w, h = im.size + f.write(HOCR_TEMPLATE.format(str(w), str(h))) + with open(output_text, 'w') as f: + f.write('') + + @staticmethod + def generate_pdf(input_file, output_pdf, output_text, options): + with Image.open(input_file) as im: + dpi = im.info['dpi'] + pagesize = im.size[0] / dpi[0], im.size[1] / dpi[1] + ptsize = pagesize[0] * 72, pagesize[1] * 72 + pdf = pikepdf.new() + pdf.add_blank_page(page_size=ptsize) + pdf.save(output_pdf, static_id=True) + output_text.write_text('') + + +@hookimpl +def get_ocr_engine(): + return NoopOcrEngine() diff --git a/tests/test_main.py b/tests/test_main.py index c3a715ce..8c417d76 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -182,14 +182,10 @@ def test_maximum_options( ) -def test_tesseract_missing_tessdata(resources, no_outpdf, tmpdir): - env = os.environ.copy() - env['TESSDATA_PREFIX'] = os.fspath(tmpdir) - +def test_tesseract_missing_tessdata(monkeypatch, resources, no_outpdf, tmpdir): + monkeypatch.setenv("TESSDATA_PREFIX", os.fspath(tmpdir)) with pytest.raises(MissingDependencyError): - run_ocrmypdf_api( - resources / 'graph.pdf', no_outpdf, '-v', '1', '--skip-text', env=env - ) + run_ocrmypdf_api(resources / 'graph.pdf', no_outpdf, '-v', '1', '--skip-text') def test_invalid_input_pdf(resources, no_outpdf): From 1598f2f0e5f1a07f3af3c292adf0622925c8110d Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Mon, 1 Jun 2020 03:06:40 -0700 Subject: [PATCH 77/94] Abolish spoof_tesseract_noop --- src/ocrmypdf/api.py | 2 +- tests/conftest.py | 9 +-- tests/spoof/tesseract_noop.py | 134 ---------------------------------- tests/test_acroform.py | 4 +- tests/test_ghostscript.py | 58 +++++++++------ tests/test_image_input.py | 14 +++- tests/test_main.py | 131 +++++++++++++++++++++------------ tests/test_metadata.py | 51 ++++++++----- tests/test_optimize.py | 16 ++-- tests/test_preprocessing.py | 16 ++-- tests/test_stdio.py | 56 +++++++------- tests/test_unpaper.py | 25 +++++-- 12 files changed, 234 insertions(+), 282 deletions(-) delete mode 100755 tests/spoof/tesseract_noop.py diff --git a/src/ocrmypdf/api.py b/src/ocrmypdf/api.py index 68ede13a..008d284d 100644 --- a/src/ocrmypdf/api.py +++ b/src/ocrmypdf/api.py @@ -143,7 +143,7 @@ def create_options( # These arguments with special handling for which we bypass # argparse - if arg in {'tesseract_env', 'progress_bar'}: + if arg in {'tesseract_env', 'progress_bar', 'plugins'}: deferred.append((arg, val)) continue diff --git a/tests/conftest.py b/tests/conftest.py index 7be5f1f8..bc188bf9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -169,11 +169,6 @@ def spoof(tmp_path_factory, **kwargs): 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(): @@ -222,7 +217,7 @@ def check_ocrmypdf(input_file, output_file, *args, env=None): if env: first = env['_OCRMYPDF_TEST_PATH'].split(os.pathsep)[0] if 'tesseract_noop' in first: - options.plugins = ['tests/plugins/tesseract_noop.py'] + raise ValueError('noop') else: options.tesseract_env = env options.tesseract_env['_OCRMYPDF_TEST_INFILE'] = os.fspath(input_file) @@ -250,7 +245,7 @@ def run_ocrmypdf_api(input_file, output_file, *args, env=None): try: first = env['_OCRMYPDF_TEST_PATH'].split(os.pathsep)[0] if 'tesseract_noop' in first: - options.plugins = ['tests/plugins/tesseract_noop.py'] + raise ValueError('noop') else: options.tesseract_env = env.copy() options.tesseract_env['_OCRMYPDF_TEST_INFILE'] = os.fspath(input_file) diff --git a/tests/spoof/tesseract_noop.py b/tests/spoof/tesseract_noop.py deleted file mode 100755 index 30f97209..00000000 --- a/tests/spoof/tesseract_noop.py +++ /dev/null @@ -1,134 +0,0 @@ -#!/usr/bin/env python3 -# © 2016 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 no-op spoof - -To quickly run tests where getting OCR output is not necessary. - -In 'hocr' mode, create a .hocr file that specifies no text found. - -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 -''' - -HOCR_TEMPLATE = ''' - - - - - - - - - -
-
-

- - -

-
-
- -''' - - -def main(): - if sys.argv[1] == '--version': - print(VERSION_STRING, file=sys.stderr) - sys.exit(0) - elif sys.argv[1] == '--list-langs': - print('List of available languages (1):\neng', file=sys.stderr) - sys.exit(0) - elif sys.argv[-2] == '--print-parameters': - print("Some parameters", file=sys.stderr) - print("textonly_pdf\t1\tSome help text") - sys.exit(0) - elif sys.argv[-2] == 'hocr': - inputf = sys.argv[-4] - output = sys.argv[-3] - with Image.open(inputf) as im, open( - output + '.hocr', 'w', encoding='utf-8' - ) as f: - w, h = im.size - f.write(HOCR_TEMPLATE.format(str(w), str(h))) - with open(output + '.txt', 'w') as f: - f.write('') - elif sys.argv[-2] == 'pdf': - if 'textonly_pdf=1' in sys.argv: - inputf = sys.argv[-4] - output = sys.argv[-3] - with Image.open(inputf) as im: - dpi = im.info['dpi'] - pagesize = im.size[0] / dpi[0], im.size[1] / dpi[1] - ptsize = pagesize[0] * 72, pagesize[1] * 72 - - pdf_out = pikepdf.new() - pdf_out.add_blank_page(page_size=ptsize) - pdf_out.save(Path(output).with_suffix('.pdf'), static_id=True) - Path(output).with_suffix('.txt').write_text('') - else: - inputf = sys.argv[-4] - output = sys.argv[-3] - pdf_bytes = img2pdf.convert([inputf], dpi=300) - with open(output + '.pdf', 'wb') as f: - f.write(pdf_bytes) - with open(output + '.txt', 'w') as f: - f.write('') - elif sys.argv[-1] == 'stdout': - inputf = sys.argv[-2] - print( - """Orientation: 0 -Orientation in degrees: 0 -Orientation confidence: 100.00 -Script: 1 -Script confidence: 100.00""", - file=sys.stderr, - ) - else: - print("Spoof doesn't understand arguments", file=sys.stderr) - print(sys.argv, file=sys.stderr) - sys.exit(1) - - sys.exit(0) - - -if __name__ == '__main__': - main() diff --git a/tests/test_acroform.py b/tests/test_acroform.py index 44de63da..4ab52406 100644 --- a/tests/test_acroform.py +++ b/tests/test_acroform.py @@ -35,8 +35,8 @@ def test_acroform_and_redo(acroform, caplog, no_outpdf): assert '--redo-ocr is not currently possible' in caplog.text -def test_acroform_message(acroform, caplog, spoof_tesseract_noop, outpdf): +def test_acroform_message(acroform, caplog, outpdf): caplog.set_level(logging.INFO) - check_ocrmypdf(acroform, outpdf, env=spoof_tesseract_noop) + check_ocrmypdf(acroform, outpdf, '--plugin', 'tests/plugins/tesseract_noop.py') assert 'fillable form' in caplog.text assert '--force-ocr' in caplog.text diff --git a/tests/test_ghostscript.py b/tests/test_ghostscript.py index da04ea84..0e6931df 100644 --- a/tests/test_ghostscript.py +++ b/tests/test_ghostscript.py @@ -33,31 +33,23 @@ spoof = pytest.helpers.spoof @pytest.fixture -def spoof_no_tess_gs_render_fail(tmp_path_factory): - return spoof( - tmp_path_factory, tesseract='tesseract_noop.py', gs='gs_render_failure.py' - ) +def spoof_gs_render_fail(tmp_path_factory): + return spoof(tmp_path_factory, gs='gs_render_failure.py') @pytest.fixture -def spoof_no_tess_gs_raster_fail(tmp_path_factory): - return spoof( - tmp_path_factory, tesseract='tesseract_noop.py', gs='gs_raster_failure.py' - ) +def spoof_gs_raster_fail(tmp_path_factory): + return spoof(tmp_path_factory, gs='gs_raster_failure.py') @pytest.fixture -def spoof_no_tess_no_pdfa(tmp_path_factory): - return spoof( - tmp_path_factory, tesseract='tesseract_noop.py', gs='gs_pdfa_failure.py' - ) +def spoof_no_pdfa(tmp_path_factory): + return spoof(tmp_path_factory, gs='gs_pdfa_failure.py') @pytest.fixture -def spoof_no_tess_pdfa_warning(tmp_path_factory): - return spoof( - tmp_path_factory, tesseract='tesseract_noop.py', gs='gs_feature_elision.py' - ) +def spoof_pdfa_warning(tmp_path_factory): + return spoof(tmp_path_factory, gs='gs_feature_elision.py') @pytest.fixture @@ -114,30 +106,48 @@ def test_rasterize_rotated(francais, outdir, caplog): assert im.info['dpi'] == (forced_dpi[1], forced_dpi[0]) -def test_gs_render_failure(spoof_no_tess_gs_render_fail, resources, outpdf): +def test_gs_render_failure(spoof_gs_render_fail, resources, outpdf): p, out, err = run_ocrmypdf( - resources / 'blank.pdf', outpdf, env=spoof_no_tess_gs_render_fail + resources / 'blank.pdf', + outpdf, + '--plugin', + 'tests/plugins/tesseract_noop.py', + env=spoof_gs_render_fail, ) assert 'Casper is not a friendly ghost' in err assert p.returncode == ExitCode.child_process_error -def test_gs_raster_failure(spoof_no_tess_gs_raster_fail, resources, outpdf): +def test_gs_raster_failure(spoof_gs_raster_fail, resources, outpdf): p, out, err = run_ocrmypdf( - resources / 'francais.pdf', outpdf, env=spoof_no_tess_gs_raster_fail + resources / 'francais.pdf', + outpdf, + '--plugin', + 'tests/plugins/tesseract_noop.py', + env=spoof_gs_raster_fail, ) assert 'Ghost story archive not found' in err assert p.returncode == ExitCode.child_process_error -def test_ghostscript_pdfa_failure(spoof_no_tess_no_pdfa, resources, outpdf): +def test_ghostscript_pdfa_failure(spoof_no_pdfa, resources, outpdf): p, out, err = run_ocrmypdf( - resources / 'francais.pdf', outpdf, env=spoof_no_tess_no_pdfa + resources / 'francais.pdf', + outpdf, + '--plugin', + 'tests/plugins/tesseract_noop.py', + env=spoof_no_pdfa, ) assert ( p.returncode == ExitCode.pdfa_conversion_failed ), "Unexpected return when PDF/A fails" -def test_ghostscript_feature_elision(spoof_no_tess_pdfa_warning, resources, outpdf): - check_ocrmypdf(resources / 'francais.pdf', outpdf, env=spoof_no_tess_pdfa_warning) +def test_ghostscript_feature_elision(spoof_pdfa_warning, resources, outpdf): + check_ocrmypdf( + resources / 'francais.pdf', + outpdf, + '--plugin', + 'tests/plugins/tesseract_noop.py', + env=spoof_pdfa_warning, + ) diff --git a/tests/test_image_input.py b/tests/test_image_input.py index ceb94cbe..c3636952 100644 --- a/tests/test_image_input.py +++ b/tests/test_image_input.py @@ -33,9 +33,14 @@ def baiona(resources): return Image.open(resources / 'baiona_gray.png') -def test_image_to_pdf(spoof_tesseract_noop, resources, outpdf): +def test_image_to_pdf(resources, outpdf): check_ocrmypdf( - resources / 'crom.png', outpdf, '--image-dpi', '200', env=spoof_tesseract_noop + resources / 'crom.png', + outpdf, + '--image-dpi', + '200', + '--plugin', + 'tests/plugins/tesseract_noop.py', ) @@ -77,7 +82,7 @@ def test_img2pdf_fails(resources, no_outpdf): assert rc == ocrmypdf.ExitCode.input_file -def test_jpeg_in_jpeg_out(resources, outpdf, spoof_tesseract_noop): +def test_jpeg_in_jpeg_out(resources, outpdf): check_ocrmypdf( resources / 'congress.jpg', outpdf, @@ -86,7 +91,8 @@ def test_jpeg_in_jpeg_out(resources, outpdf, spoof_tesseract_noop): '--output-type', 'pdf', # specifically check pdf because Ghostscript may convert to JPEG '--remove-background', - env=spoof_tesseract_noop, + '--plugin', + 'tests/plugins/tesseract_noop.py', ) with pikepdf.open(outpdf) as pdf: assert next(pdf.pages[0].images.values()).Filter == pikepdf.Name.DCTDecode diff --git a/tests/test_main.py b/tests/test_main.py index 8c417d76..e161730e 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -111,7 +111,7 @@ def test_redo_ocr(resources, outpdf): ), "Expected text to be different after re-OCR" -def test_argsfile(spoof_tesseract_noop, resources, outdir): +def test_argsfile(resources, outdir): path_argsfile = outdir / 'test_argsfile.txt' with open(str(path_argsfile), 'w') as argsfile: print( @@ -119,15 +119,14 @@ def test_argsfile(spoof_tesseract_noop, resources, outdir): 'ArgsFile Test', '--author', 'Test Cases', + '--plugin', + 'tests/plugins/tesseract_noop.py', sep='\n', end='\n', file=argsfile, ) check_ocrmypdf( - resources / 'graph.pdf', - path_argsfile, - '@' + str(outdir / 'test_argsfile.txt'), - env=spoof_tesseract_noop, + resources / 'graph.pdf', path_argsfile, '@' + str(outdir / 'test_argsfile.txt') ) @@ -239,23 +238,27 @@ def test_klingon(resources, outpdf): assert p.returncode == ExitCode.missing_dependency -def test_missing_docinfo(spoof_tesseract_noop, resources, outpdf): +def test_missing_docinfo(resources, outpdf): result = run_ocrmypdf_api( resources / 'missing_docinfo.pdf', outpdf, '-l', 'eng', '--skip-text', - env=spoof_tesseract_noop, + '--plugin', + 'tests/plugins/tesseract_noop.py', ) assert result == ExitCode.ok -def test_uppercase_extension(spoof_tesseract_noop, resources, outdir): +def test_uppercase_extension(resources, outdir): shutil.copy(str(resources / "skew.pdf"), str(outdir / "UPPERCASE.PDF")) check_ocrmypdf( - outdir / "UPPERCASE.PDF", outdir / "UPPERCASE_OUT.PDF", env=spoof_tesseract_noop + outdir / "UPPERCASE.PDF", + outdir / "UPPERCASE_OUT.PDF", + '--plugin', + 'tests/plugins/tesseract_noop.py', ) @@ -349,9 +352,12 @@ def test_tesseract_image_too_big( ) -def test_algo4(resources, spoof_tesseract_noop, outpdf): +def test_algo4(resources, outpdf): p, _, _ = run_ocrmypdf( - resources / 'encrypted_algo4.pdf', outpdf, env=spoof_tesseract_noop + resources / 'encrypted_algo4.pdf', + outpdf, + '--plugin', + 'tests/plugins/tesseract_noop.py', ) assert p.returncode == ExitCode.encrypted_pdf @@ -370,17 +376,19 @@ def test_jbig2_passthrough(spoof_tesseract_cache, resources, outpdf): assert out_pageinfo[0].images[0].enc == Encoding.jbig2 -def test_masks(spoof_tesseract_noop, resources, outpdf): +def test_masks(resources, outpdf): assert ( ocrmypdf.ocr( - resources / 'masks.pdf', outpdf, tesseract_env=spoof_tesseract_noop + resources / 'masks.pdf', outpdf, plugins=['tests/plugins/tesseract_noop.py'] ) == ExitCode.ok ) -def test_linearized_pdf_and_indirect_object(spoof_tesseract_noop, resources, outpdf): - check_ocrmypdf(resources / 'epson.pdf', outpdf, env=spoof_tesseract_noop) +def test_linearized_pdf_and_indirect_object(resources, outpdf): + check_ocrmypdf( + resources / 'epson.pdf', outpdf, '--plugin', 'tests/plugins/tesseract_noop.py' + ) def test_very_high_dpi(spoof_tesseract_cache, resources, outpdf): @@ -393,20 +401,27 @@ def test_very_high_dpi(spoof_tesseract_cache, resources, outpdf): assert isclose(image.dpi.x, 2400) -def test_overlay(spoof_tesseract_noop, resources, outpdf): +def test_overlay(resources, outpdf): check_ocrmypdf( - resources / 'overlay.pdf', outpdf, '--skip-text', env=spoof_tesseract_noop + resources / 'overlay.pdf', + outpdf, + '--skip-text', + '--plugin', + 'tests/plugins/tesseract_noop.py', ) -def test_destination_not_writable(spoof_tesseract_noop, resources, outdir): +def test_destination_not_writable(resources, outdir): if os.name != 'nt' and (os.getuid() == 0 or os.geteuid() == 0): pytest.xfail(reason="root can write to anything") protected_file = outdir / 'protected.pdf' protected_file.touch() protected_file.chmod(0o400) # Read-only p, _out, _err = run_ocrmypdf( - resources / 'jbig2.pdf', protected_file, env=spoof_tesseract_noop + resources / 'jbig2.pdf', + protected_file, + '--plugin', + 'tests/plugins/tesseract_noop.py', ) assert p.returncode == ExitCode.file_access_error, "Expected error" @@ -479,9 +494,13 @@ def test_user_words_ocr(resources, outdir): ) -def test_form_xobject(spoof_tesseract_noop, resources, outpdf): +def test_form_xobject(resources, outpdf): check_ocrmypdf( - resources / 'formxobject.pdf', outpdf, '--force-ocr', env=spoof_tesseract_noop + resources / 'formxobject.pdf', + outpdf, + '--force-ocr', + '--plugin', + 'tests/plugins/tesseract_noop.py', ) @@ -513,14 +532,15 @@ def test_pagesize_consistency(renderer, resources, outpdf): assert isclose(before_dims[1], after_dims[1], rel_tol=1e-4) -def test_skip_big_with_no_images(spoof_tesseract_noop, resources, outpdf): +def test_skip_big_with_no_images(resources, outpdf): check_ocrmypdf( resources / 'blank.pdf', outpdf, '--skip-big', '5', '--force-ocr', - env=spoof_tesseract_noop, + '--plugin', + 'tests/plugins/tesseract_noop.py', ) @@ -528,18 +548,20 @@ def test_skip_big_with_no_images(spoof_tesseract_noop, resources, outpdf): '8.0.0' <= pikepdf.__libqpdf_version__ <= '8.0.1', reason="libqpdf regression on pages with no contents", ) -def test_no_contents(spoof_tesseract_noop, resources, outpdf): +def test_no_contents(resources, outpdf): check_ocrmypdf( - resources / 'no_contents.pdf', outpdf, '--force-ocr', env=spoof_tesseract_noop + resources / 'no_contents.pdf', + outpdf, + '--force-ocr', + '--plugin', + 'tests/plugins/tesseract_noop.py', ) @pytest.mark.parametrize( 'image', ['baiona.png', 'baiona_gray.png', 'baiona_alpha.png', 'congress.jpg'] ) -def test_compression_preserved( - spoof_tesseract_noop, ocrmypdf_exec, resources, image, outpdf -): +def test_compression_preserved(ocrmypdf_exec, resources, image, outpdf): input_file = str(resources / image) output_file = str(outpdf) @@ -553,6 +575,8 @@ def test_compression_preserved( '150', '--output-type', 'pdf', + '--plugin', + 'tests/plugins/tesseract_noop.py', '-', output_file, ] @@ -562,7 +586,6 @@ def test_compression_preserved( stderr=PIPE, stdin=input_stream, universal_newlines=True, - env=spoof_tesseract_noop, check=False, ) @@ -596,9 +619,7 @@ def test_compression_preserved( ('congress.jpg', 'lossless'), ], ) -def test_compression_changed( - spoof_tesseract_noop, ocrmypdf_exec, resources, image, compression, outpdf -): +def test_compression_changed(ocrmypdf_exec, resources, image, compression, outpdf): input_file = str(resources / image) output_file = str(outpdf) @@ -615,6 +636,8 @@ def test_compression_changed( '0', '--pdfa-image-compression', compression, + '--plugin', + 'tests/plugins/tesseract_noop.py', '-', output_file, ] @@ -624,7 +647,6 @@ def test_compression_changed( stderr=PIPE, stdin=input_stream, universal_newlines=True, - env=spoof_tesseract_noop, check=False, ) assert p.returncode == ExitCode.ok, p.stderr @@ -717,35 +739,52 @@ def test_decompression_bomb(resources, outpdf): assert p.returncode == 0 -def test_text_curves(spoof_tesseract_noop, resources, outpdf): +def test_text_curves(resources, outpdf): with patch('ocrmypdf._pipeline.VECTOR_PAGE_DPI', 100): - check_ocrmypdf(resources / 'vector.pdf', outpdf, env=spoof_tesseract_noop) + check_ocrmypdf( + resources / 'vector.pdf', + outpdf, + '--plugin', + 'tests/plugins/tesseract_noop.py', + ) info = PdfInfo(outpdf) assert len(info.pages[0].images) == 0, "added images to the vector PDF" check_ocrmypdf( - resources / 'vector.pdf', outpdf, '--force-ocr', env=spoof_tesseract_noop + resources / 'vector.pdf', + outpdf, + '--force-ocr', + '--plugin', + 'tests/plugins/tesseract_noop.py', ) info = PdfInfo(outpdf) assert len(info.pages[0].images) != 0, "force did not rasterize" -def test_output_is_dir(spoof_tesseract_noop, resources, outdir): +def test_output_is_dir(resources, outdir): p, _out, err = run_ocrmypdf( - resources / 'trivial.pdf', outdir, '--force-ocr', env=spoof_tesseract_noop + resources / 'trivial.pdf', + outdir, + '--force-ocr', + '--plugin', + 'tests/plugins/tesseract_noop.py', ) assert p.returncode == ExitCode.file_access_error assert 'is not a writable file' in err @pytest.mark.skipif(os.name == 'nt', reason="symlink needs admin permissions") -def test_output_is_symlink(spoof_tesseract_noop, resources, outdir): +def test_output_is_symlink(resources, outdir): sym = Path(outdir / 'this_is_a_symlink') sym.symlink_to(outdir / 'out.pdf') p, _out, err = run_ocrmypdf( - resources / 'trivial.pdf', sym, '--force-ocr', env=spoof_tesseract_noop + resources / 'trivial.pdf', + sym, + '--force-ocr', + '--plugin', + 'tests/plugins/tesseract_noop.py', ) assert p.returncode == ExitCode.ok, err assert (outdir / 'out.pdf').stat().st_size > 0, 'target file not created' @@ -781,9 +820,7 @@ def test_version_check(): [0.0, 1, 'pdf', True], ], ) -def test_fast_web_view( - spoof_tesseract_noop, resources, outpdf, threshold, optimize, output_type, expected -): +def test_fast_web_view(resources, outpdf, threshold, optimize, output_type, expected): check_ocrmypdf( resources / 'trivial.pdf', outpdf, @@ -793,18 +830,20 @@ def test_fast_web_view( optimize, '--output-type', output_type, - env=spoof_tesseract_noop, + '--plugin', + 'tests/plugins/tesseract_noop.py', ) with pikepdf.open(outpdf) as pdf: assert pdf.is_linearized == expected -def test_image_dpi_not_image(caplog, spoof_tesseract_noop, resources, outpdf): +def test_image_dpi_not_image(caplog, resources, outpdf): check_ocrmypdf( resources / 'trivial.pdf', outpdf, '--image-dpi', '100', - env=spoof_tesseract_noop, + '--plugin', + 'tests/plugins/tesseract_noop.py', ) assert '--image-dpi is being ignored' in caplog.text diff --git a/tests/test_metadata.py b/tests/test_metadata.py index 795dcc43..59250eac 100644 --- a/tests/test_metadata.py +++ b/tests/test_metadata.py @@ -51,7 +51,7 @@ spoof = pytest.helpers.spoof @pytest.mark.parametrize("output_type", ['pdfa', 'pdf']) -def test_preserve_metadata(spoof_tesseract_noop, output_type, resources, outpdf): +def test_preserve_metadata(output_type, resources, outpdf): pdf_before = pikepdf.open(resources / 'graph.pdf') output = check_ocrmypdf( @@ -59,7 +59,8 @@ def test_preserve_metadata(spoof_tesseract_noop, output_type, resources, outpdf) outpdf, '--output-type', output_type, - env=spoof_tesseract_noop, + '--plugin', + 'tests/plugins/tesseract_noop.py', ) pdf_after = pikepdf.open(output) @@ -72,7 +73,7 @@ def test_preserve_metadata(spoof_tesseract_noop, output_type, resources, outpdf) @pytest.mark.parametrize("output_type", ['pdfa', 'pdf']) -def test_override_metadata(spoof_tesseract_noop, output_type, resources, outpdf): +def test_override_metadata(output_type, resources, outpdf): input_file = resources / 'c02-22.pdf' german = 'Du siehst den Wald vor lauter Bäumen nicht.' chinese = '孔子' @@ -86,7 +87,8 @@ def test_override_metadata(spoof_tesseract_noop, output_type, resources, outpdf) chinese, '--output-type', output_type, - env=spoof_tesseract_noop, + '--plugin', + 'tests/plugins/tesseract_noop.py', ) assert p.returncode == ExitCode.ok, err @@ -106,7 +108,7 @@ def test_override_metadata(spoof_tesseract_noop, output_type, resources, outpdf) assert pdfa_info['output'] == output_type -def test_high_unicode(spoof_tesseract_noop, resources, no_outpdf): +def test_high_unicode(resources, no_outpdf): # Ghostscript doesn't support high Unicode, so neither do we, to be # safe @@ -120,7 +122,8 @@ def test_high_unicode(spoof_tesseract_noop, resources, no_outpdf): high_unicode, '--output-type', 'pdfa', - env=spoof_tesseract_noop, + '--plugin', + 'tests/plugins/tesseract_noop.py', ) assert p.returncode == ExitCode.bad_args, err @@ -129,9 +132,7 @@ def test_high_unicode(spoof_tesseract_noop, resources, no_outpdf): @pytest.mark.skipif(not fitz, reason="test uses fitz") @pytest.mark.parametrize('ocr_option', ['--skip-text', '--force-ocr']) @pytest.mark.parametrize('output_type', ['pdf', 'pdfa']) -def test_bookmarks_preserved( - spoof_tesseract_noop, output_type, ocr_option, resources, outpdf -): +def test_bookmarks_preserved(output_type, ocr_option, resources, outpdf): input_file = resources / 'toc.pdf' before_toc = fitz.Document(str(input_file)).getToC() @@ -141,7 +142,8 @@ def test_bookmarks_preserved( ocr_option, '--output-type', output_type, - env=spoof_tesseract_noop, + '--plugin', + 'tests/plugins/tesseract_noop.py', ) after_toc = fitz.Document(str(outpdf)).getToC() @@ -156,13 +158,16 @@ def seconds_between_dates(date1, date2): @pytest.mark.parametrize('infile', ['trivial.pdf', 'jbig2.pdf']) @pytest.mark.parametrize('output_type', ['pdf', 'pdfa']) -def test_creation_date_preserved( - spoof_tesseract_noop, output_type, resources, infile, outpdf -): +def test_creation_date_preserved(output_type, resources, infile, outpdf): input_file = resources / infile check_ocrmypdf( - input_file, outpdf, '--output-type', output_type, env=spoof_tesseract_noop + input_file, + outpdf, + '--output-type', + output_type, + '--plugin', + 'tests/plugins/tesseract_noop.py', ) pdf_before = pikepdf.open(input_file) @@ -185,7 +190,7 @@ def test_creation_date_preserved( @pytest.mark.parametrize('output_type', ['pdf', 'pdfa']) -def test_xml_metadata_preserved(spoof_tesseract_noop, output_type, resources, outpdf): +def test_xml_metadata_preserved(output_type, resources, outpdf): input_file = resources / 'graph.pdf' try: @@ -196,7 +201,12 @@ def test_xml_metadata_preserved(spoof_tesseract_noop, output_type, resources, ou before = file_to_dict(str(input_file)) check_ocrmypdf( - input_file, outpdf, '--output-type', output_type, env=spoof_tesseract_noop + input_file, + outpdf, + '--output-type', + output_type, + '--plugin', + 'tests/plugins/tesseract_noop.py', ) after = file_to_dict(str(outpdf)) @@ -274,9 +284,14 @@ def test_srgb_in_unicode_path(tmp_path): generate_pdfa_ps(dstdir / 'out.ps') -def test_kodak_toc(resources, outpdf, spoof_tesseract_noop): +def test_kodak_toc(resources, outpdf): _output = check_ocrmypdf( - resources / 'kcs.pdf', outpdf, '--output-type', 'pdf', env=spoof_tesseract_noop + resources / 'kcs.pdf', + outpdf, + '--output-type', + 'pdf', + '--plugin', + 'tests/plugins/tesseract_noop.py', ) p = pikepdf.open(outpdf) diff --git a/tests/test_optimize.py b/tests/test_optimize.py index f9f6747c..e8cf0717 100644 --- a/tests/test_optimize.py +++ b/tests/test_optimize.py @@ -54,7 +54,7 @@ def test_mono_not_inverted(resources, outdir): @pytest.mark.skipif(not pngquant.available(), reason='need pngquant') -def test_jpg_png_params(resources, outpdf, spoof_tesseract_noop): +def test_jpg_png_params(resources, outpdf): check_ocrmypdf( resources / 'crom.png', outpdf, @@ -66,13 +66,14 @@ def test_jpg_png_params(resources, outpdf, spoof_tesseract_noop): '50', '--png-quality', '20', - env=spoof_tesseract_noop, + '--plugin', + 'tests/plugins/tesseract_noop.py', ) @pytest.mark.skipif(not jbig2enc.available(), reason='need jbig2enc') @pytest.mark.parametrize('lossy', [False, True]) -def test_jbig2_lossy(lossy, resources, outpdf, spoof_tesseract_noop): +def test_jbig2_lossy(lossy, resources, outpdf): args = [ resources / 'ccitt.pdf', outpdf, @@ -84,11 +85,13 @@ def test_jbig2_lossy(lossy, resources, outpdf, spoof_tesseract_noop): '50', '--png-quality', '20', + '--plugin', + 'tests/plugins/tesseract_noop.py', ] if lossy: args.append('--jbig2-lossy') - check_ocrmypdf(*args, env=spoof_tesseract_noop) + check_ocrmypdf(*args) pdf = pikepdf.open(outpdf) pim = pikepdf.PdfImage(next(iter(pdf.pages[0].images.values()))) @@ -104,7 +107,7 @@ def test_jbig2_lossy(lossy, resources, outpdf, spoof_tesseract_noop): not jbig2enc.available() or not pngquant.available(), reason='need jbig2enc and pngquant', ) -def test_flate_to_jbig2(resources, outdir, spoof_tesseract_noop): +def test_flate_to_jbig2(resources, outdir): # This test requires an image that pngquant is capable of converting to # to 1bpp - so use an existing 1bpp image, convert up, confirm it can # convert down @@ -122,7 +125,8 @@ def test_flate_to_jbig2(resources, outdir, spoof_tesseract_noop): '50', '--optimize', '3', - env=spoof_tesseract_noop, + '--plugin', + 'tests/plugins/tesseract_noop.py', ) pdf = pikepdf.open(outdir / 'out.pdf') diff --git a/tests/test_preprocessing.py b/tests/test_preprocessing.py index b90517eb..08e34bec 100644 --- a/tests/test_preprocessing.py +++ b/tests/test_preprocessing.py @@ -15,7 +15,6 @@ # You should have received a copy of the GNU General Public License # along with OCRmyPDF. If not, see . -import logging from math import isclose import pytest @@ -38,16 +37,18 @@ spoof = pytest.helpers.spoof RENDERERS = ['hocr', 'sandwich'] -def test_deskew(spoof_tesseract_noop, resources, outdir): +def test_deskew(resources, outdir): # Run with deskew deskewed_pdf = check_ocrmypdf( - resources / 'skew.pdf', outdir / 'skew.pdf', '-d', env=spoof_tesseract_noop + resources / 'skew.pdf', + outdir / 'skew.pdf', + '-d', + '--plugin', + 'tests/plugins/tesseract_noop.py', ) # Now render as an image again and use Leptonica to find the skew angle # to confirm that it was deskewed - log = logging.getLogger() - deskewed_png = outdir / 'deskewed.png' ghostscript.rasterize_pdf( @@ -65,7 +66,7 @@ def test_deskew(spoof_tesseract_noop, resources, outdir): assert -0.5 < skew_angle < 0.5, "Deskewing failed" -def test_remove_background(spoof_tesseract_noop, resources, outdir): +def test_remove_background(resources, outdir): # Ensure the input image does not contain pure white/black with Image.open(resources / 'congress.jpg') as im: assert im.getextrema() != ((0, 255), (0, 255), (0, 255)) @@ -76,7 +77,8 @@ def test_remove_background(spoof_tesseract_noop, resources, outdir): '--remove-background', '--image-dpi', '150', - env=spoof_tesseract_noop, + '--plugin', + 'tests/plugins/tesseract_noop.py', ) output_png = outdir / 'remove_bg.png' diff --git a/tests/test_stdio.py b/tests/test_stdio.py index e57c11f0..d78b1644 100644 --- a/tests/test_stdio.py +++ b/tests/test_stdio.py @@ -38,25 +38,24 @@ def spoof_tess_bad_utf8(tmp_path_factory): return spoof(tmp_path_factory, tesseract='tesseract_badutf8.py') -def test_stdin(spoof_tesseract_noop, ocrmypdf_exec, resources, outpdf): +def test_stdin(ocrmypdf_exec, resources, outpdf): input_file = str(resources / 'francais.pdf') output_file = str(outpdf) # Runs: ocrmypdf - output.pdf < testfile.pdf with open(input_file, 'rb') as input_stream: - p_args = ocrmypdf_exec + ['-', output_file] - p = run( - p_args, - stdout=PIPE, - stderr=PIPE, - stdin=input_stream, - env=spoof_tesseract_noop, - ) + p_args = ocrmypdf_exec + [ + '-', + output_file, + '--plugin', + 'tests/plugins/tesseract_noop.py', + ] + p = run(p_args, stdout=PIPE, stderr=PIPE, stdin=input_stream) assert p.returncode == ExitCode.ok -def test_stdout(spoof_tesseract_noop, ocrmypdf_exec, resources, outpdf): - if 'COV_CORE_DATAFILE' in spoof_tesseract_noop: +def test_stdout(ocrmypdf_exec, resources, outpdf): + if 'COV_CORE_DATAFILE' in os.environ: pytest.skip(msg="Coverage uses stdout") input_file = str(resources / 'francais.pdf') @@ -64,14 +63,13 @@ def test_stdout(spoof_tesseract_noop, ocrmypdf_exec, resources, outpdf): # Runs: ocrmypdf francais.pdf - > test_stdout.pdf with open(output_file, 'wb') as output_stream: - p_args = ocrmypdf_exec + [input_file, '-'] - p = run( - p_args, - stdout=output_stream, - stderr=PIPE, - stdin=DEVNULL, - env=spoof_tesseract_noop, - ) + p_args = ocrmypdf_exec + [ + input_file, + '-', + '--plugin', + 'tests/plugins/tesseract_noop.py', + ] + p = run(p_args, stdout=output_stream, stderr=PIPE, stdin=DEVNULL) assert p.returncode == ExitCode.ok assert check_pdf(output_file) @@ -81,7 +79,7 @@ def test_stdout(spoof_tesseract_noop, ocrmypdf_exec, resources, outpdf): sys.version_info[0:3] >= (3, 6, 4), reason="issue fixed in Python 3.6.4" ) @pytest.mark.skipif(os.name == 'nt', reason="POSIX problem") -def test_closed_streams(spoof_tesseract_noop, ocrmypdf_exec, resources, outpdf): +def test_closed_streams(ocrmypdf_exec, resources, outpdf): input_file = str(resources / 'francais.pdf') output_file = str(outpdf) @@ -89,14 +87,18 @@ def test_closed_streams(spoof_tesseract_noop, ocrmypdf_exec, resources, outpdf): os.close(0) os.close(1) - p_args = ocrmypdf_exec + [input_file, output_file] + p_args = ocrmypdf_exec + [ + input_file, + output_file, + '--plugin', + 'tests/plugins/tesseract_noop.py', + ] p = Popen( # pylint: disable=subprocess-popen-preexec-fn p_args, close_fds=True, stdout=None, stderr=PIPE, stdin=None, - env=spoof_tesseract_noop, preexec_fn=evil_closer, ) out, err = p.communicate() @@ -123,12 +125,16 @@ def test_bad_locale(): os.name == 'nt' and sys.version_info < (3, 8), reason="Windows does not like this; not sure how to fix", ) -def test_dev_null(spoof_tesseract_noop, resources): - if 'COV_CORE_DATAFILE' in spoof_tesseract_noop: +def test_dev_null(resources): + if 'COV_CORE_DATAFILE' in os.environ: pytest.skip(msg="Coverage uses stdout") p, out, err = run_ocrmypdf( - resources / 'trivial.pdf', os.devnull, '--force-ocr', env=spoof_tesseract_noop + resources / 'trivial.pdf', + os.devnull, + '--force-ocr', + '--plugin', + 'tests/plugins/tesseract_noop.py', ) assert p.returncode == 0, "could not send output to /dev/null" assert len(out) == 0, "wrote to stdout" diff --git a/tests/test_unpaper.py b/tests/test_unpaper.py index bd04da2c..5ef9fac3 100644 --- a/tests/test_unpaper.py +++ b/tests/test_unpaper.py @@ -60,45 +60,54 @@ def test_old_unpaper(spoof_unpaper_oldversion, resources, no_outpdf): @pytest.mark.skipif(not have_unpaper(), reason="requires unpaper") -def test_clean(spoof_tesseract_noop, resources, outpdf): - check_ocrmypdf(resources / "skew.pdf", outpdf, "-c", env=spoof_tesseract_noop) +def test_clean(resources, outpdf): + check_ocrmypdf( + resources / "skew.pdf", + outpdf, + "-c", + '--plugin', + 'tests/plugins/tesseract_noop.py', + ) @pytest.mark.skipif(not have_unpaper(), reason="requires unpaper") -def test_unpaper_args_valid(spoof_tesseract_noop, resources, outpdf): +def test_unpaper_args_valid(resources, outpdf): check_ocrmypdf( resources / "skew.pdf", outpdf, "-c", "--unpaper-args", "--layout double", # Spaces required here - env=spoof_tesseract_noop, + '--plugin', + 'tests/plugins/tesseract_noop.py', ) @pytest.mark.skipif(not have_unpaper(), reason="requires unpaper") -def test_unpaper_args_invalid_filename(spoof_tesseract_noop, resources, outpdf): +def test_unpaper_args_invalid_filename(resources, outpdf): p, out, err = run_ocrmypdf( resources / "skew.pdf", outpdf, "-c", "--unpaper-args", "/etc/passwd", - env=spoof_tesseract_noop, + '--plugin', + 'tests/plugins/tesseract_noop.py', ) assert "No filenames allowed" in err assert p.returncode == ExitCode.bad_args @pytest.mark.skipif(not have_unpaper(), reason="requires unpaper") -def test_unpaper_args_invalid(spoof_tesseract_noop, resources, outpdf): +def test_unpaper_args_invalid(resources, outpdf): p, out, err = run_ocrmypdf( resources / "skew.pdf", outpdf, "-c", "--unpaper-args", "unpaper is not going to like these arguments", - env=spoof_tesseract_noop, + '--plugin', + 'tests/plugins/tesseract_noop.py', ) # Can't tell difference between unpaper choking on bad arguments or some # other unpaper failure From daca9197755e2872b26d010ebf4b27a5b2661b6f Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Thu, 28 May 2020 15:01:51 -0700 Subject: [PATCH 78/94] Mark pdfminer.six 20200517 as supported --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 45e0ae1e..b91e182d 100644 --- a/setup.py +++ b/setup.py @@ -82,7 +82,7 @@ setup( 'cffi >= 1.9.1', # must be a setup and install requirement 'coloredlogs >= 14.0', # strictly optional 'img2pdf >= 0.3.0, < 0.4', # pure Python, so track HEAD closely - 'pdfminer.six >= 20191110, <= 20200402', + 'pdfminer.six >= 20191110, <= 20200517', 'pikepdf >= 1.8.1, < 2', 'Pillow >= 6.2.0', 'reportlab >= 3.3.0', # oldest released version with sane image handling From 4f4ad0fb7602f4c10e9acf17ae67e2d0b778fe2c Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Tue, 2 Jun 2020 01:49:47 -0700 Subject: [PATCH 79/94] Convert tesseract_big_image_error to plugin --- tests/plugins/tesseract_big_image_error.py | 61 +++++++++++++++++ tests/spoof/tesseract_big_image_error.py | 77 ---------------------- tests/test_main.py | 12 +--- 3 files changed, 64 insertions(+), 86 deletions(-) create mode 100644 tests/plugins/tesseract_big_image_error.py delete mode 100755 tests/spoof/tesseract_big_image_error.py diff --git a/tests/plugins/tesseract_big_image_error.py b/tests/plugins/tesseract_big_image_error.py new file mode 100644 index 00000000..f040855c --- /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_big_image_error.py b/tests/spoof/tesseract_big_image_error.py deleted file mode 100755 index 8b710bee..00000000 --- a/tests/spoof/tesseract_big_image_error.py +++ /dev/null @@ -1,77 +0,0 @@ -#!/usr/bin/env python3 -# © 2016 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 sys - -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: return error claiming image too big -''' - -"""Simulates an error of Tesseract failing on attempts to process large images - -""" - - -def main(): - if sys.argv[1] == '--version': - print(VERSION_STRING, file=sys.stderr) - sys.exit(0) - elif sys.argv[1] == '--list-langs': - print('List of available languages (1):\neng\n', file=sys.stderr) - sys.exit(0) - elif sys.argv[-2] == '--print-parameters': - print('A parameter list would go here\ntextonly_pdf 0\n', file=sys.stderr) - sys.exit(0) - elif sys.argv[-2] == 'hocr': - print( - "Image too large: (33830, 14959)\n" "Error during processing.", - file=sys.stderr, - ) - sys.exit(1) - elif sys.argv[-2] == 'pdf': - print( - "Image too large: (33830, 14959)\n" "Error during processing.", - file=sys.stderr, - ) - sys.exit(1) - elif sys.argv[-1] == 'stdout': - print( - "Image too large: (33830, 14959)\n" "Error during processing.", - file=sys.stderr, - ) - sys.exit(1) - else: - print("Spoof doesn't understand arguments", file=sys.stderr) - print(sys.argv, file=sys.stderr) - sys.exit(1) - - sys.exit(0) - - -if __name__ == '__main__': - main() diff --git a/tests/test_main.py b/tests/test_main.py index e161730e..bf95bd76 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -50,11 +50,6 @@ def spoof_tesseract_crash(tmp_path_factory): return spoof(tmp_path_factory, tesseract='tesseract_crash.py') -@pytest.fixture -def spoof_tesseract_big_image_error(tmp_path_factory): - return spoof(tmp_path_factory, tesseract='tesseract_big_image_error.py') - - def test_quick(spoof_tesseract_cache, resources, outpdf): check_ocrmypdf(resources / 'ccitt.pdf', outpdf, env=spoof_tesseract_cache) @@ -337,9 +332,7 @@ def test_tesseract_crash_autorotate(spoof_tesseract_crash, resources, no_outpdf) @pytest.mark.parametrize('renderer', RENDERERS) @pytest.mark.slow -def test_tesseract_image_too_big( - renderer, spoof_tesseract_big_image_error, resources, outpdf -): +def test_tesseract_image_too_big(renderer, resources, outpdf): check_ocrmypdf( resources / 'hugemono.pdf', outpdf, @@ -348,7 +341,8 @@ def test_tesseract_image_too_big( renderer, '--max-image-mpixels', '0', - env=spoof_tesseract_big_image_error, + '--plugin', + 'tests/plugins/tesseract_big_image_error.py', ) From 82e7eb91d2d3f56fd627bc3ac48699c17b09e771 Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Tue, 2 Jun 2020 01:50:02 -0700 Subject: [PATCH 80/94] Tidy tesseract_noop --- tests/plugins/tesseract_noop.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/tests/plugins/tesseract_noop.py b/tests/plugins/tesseract_noop.py index 1db91fe4..7f78d3e0 100644 --- a/tests/plugins/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 @@ -31,10 +30,6 @@ 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 From 1b92f447c3c44440641e87acd1af2bc13bd13b37 Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Tue, 2 Jun 2020 02:36:41 -0700 Subject: [PATCH 81/94] Convert tesseract_crash to plugin --- src/ocrmypdf/exec/tesseract.py | 4 +- tests/plugins/tesseract_crash.py | 64 ++++++++++++++++++++++++++ tests/spoof/tesseract_crash.py | 78 -------------------------------- tests/test_main.py | 26 ++++++----- 4 files changed, 82 insertions(+), 90 deletions(-) create mode 100755 tests/plugins/tesseract_crash.py delete mode 100755 tests/spoof/tesseract_crash.py diff --git a/src/ocrmypdf/exec/tesseract.py b/src/ocrmypdf/exec/tesseract.py index 39046ce5..b1ae3c3c 100644 --- a/src/ocrmypdf/exec/tesseract.py +++ b/src/ocrmypdf/exec/tesseract.py @@ -167,7 +167,9 @@ def get_orientation(input_file: Path, engine_mode, timeout: float, tesseract_env except TimeoutExpired: return OrientationConfidence(angle=0, confidence=0.0) except CalledProcessError as e: - tesseract_log_output(e.output) + # breakpoint() + tesseract_log_output(e.stdout) + tesseract_log_output(e.stderr) if ( b'Too few characters. Skipping this page' in e.output or b'Image too large' in e.output diff --git a/tests/plugins/tesseract_crash.py b/tests/plugins/tesseract_crash.py new file mode 100755 index 00000000..806af41b --- /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_crash.py b/tests/spoof/tesseract_crash.py deleted file mode 100755 index 03c7dbde..00000000 --- a/tests/spoof/tesseract_crash.py +++ /dev/null @@ -1,78 +0,0 @@ -#!/usr/bin/env python3 -# © 2016 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 - -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: CRASH ON OCR or --psm 0 -''' - -"""Simulates a Tesseract crash when asked to run OCR - -It isn't strictly necessary to crash the process and that has unwanted -side effects like triggering core dumps or error reporting, logging and such. -It's enough to dump some text to stderr and return an error code. - -Follows the POSIX(?) convention of returning 128 + signal number. - -""" - - -def main(): - if sys.argv[1] == '--version': - print(VERSION_STRING, file=sys.stderr) - sys.exit(0) - elif sys.argv[1] == '--list-langs': - print('List of available languages (1):\neng', file=sys.stderr) - sys.exit(0) - elif sys.argv[-2] == '--print-parameters': - print('A parameter list would go here\ntextonly_pdf 0\n', file=sys.stderr) - sys.exit(0) - elif sys.argv[-2] == 'hocr': - print("KABOOM! Tesseract failed for some reason", file=sys.stderr) - sys.exit(128 + signal.SIGSEGV) - elif sys.argv[-2] == 'pdf': - print("KABOOM! Tesseract failed for some reason", file=sys.stderr) - sys.exit(128 + signal.SIGSEGV) - elif sys.argv[-1] == 'stdout': - print( - "libc++abi.dylib: terminating with uncaught exception of type " - "std::bad_alloc: std::bad_alloc", - file=sys.stderr, - ) - sys.exit(128 + signal.SIGABRT) - else: - print("Spoof doesn't understand arguments", file=sys.stderr) - print(sys.argv, file=sys.stderr) - sys.exit(1) - - sys.exit(0) - - -if __name__ == '__main__': - main() diff --git a/tests/test_main.py b/tests/test_main.py index bf95bd76..b16b31d8 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -45,11 +45,6 @@ spoof = pytest.helpers.spoof RENDERERS = ['hocr', 'sandwich'] -@pytest.fixture -def spoof_tesseract_crash(tmp_path_factory): - return spoof(tmp_path_factory, tesseract='tesseract_crash.py') - - def test_quick(spoof_tesseract_cache, resources, outpdf): check_ocrmypdf(resources / 'ccitt.pdf', outpdf, env=spoof_tesseract_cache) @@ -192,12 +187,16 @@ def test_blank_input_pdf(resources, outpdf): assert result == ExitCode.ok -def test_force_ocr_on_pdf_with_no_images(spoof_tesseract_crash, resources, no_outpdf): +def test_force_ocr_on_pdf_with_no_images(resources, no_outpdf): # As a correctness test, make sure that --force-ocr on a PDF with no # content still triggers tesseract. If tesseract crashes, then it was # called. p, _, _ = run_ocrmypdf( - resources / 'blank.pdf', no_outpdf, '--force-ocr', env=spoof_tesseract_crash + resources / 'blank.pdf', + no_outpdf, + '--force-ocr', + '--plugin', + 'tests/plugins/tesseract_crash.py', ) assert p.returncode == ExitCode.child_process_error assert not no_outpdf.exists() @@ -304,7 +303,7 @@ def test_pagesegmode(renderer, spoof_tesseract_cache, resources, outpdf): @pytest.mark.parametrize('renderer', RENDERERS) -def test_tesseract_crash(renderer, spoof_tesseract_crash, resources, no_outpdf): +def test_tesseract_crash(renderer, resources, no_outpdf): p, _, err = run_ocrmypdf( resources / 'ccitt.pdf', no_outpdf, @@ -312,16 +311,21 @@ def test_tesseract_crash(renderer, spoof_tesseract_crash, resources, no_outpdf): '1', '--pdf-renderer', renderer, - env=spoof_tesseract_crash, + '--plugin', + 'tests/plugins/tesseract_crash.py', ) assert p.returncode == ExitCode.child_process_error assert not no_outpdf.exists() assert "SubprocessOutputError" in err -def test_tesseract_crash_autorotate(spoof_tesseract_crash, resources, no_outpdf): +def test_tesseract_crash_autorotate(resources, no_outpdf): p, out, err = run_ocrmypdf( - resources / 'ccitt.pdf', no_outpdf, '-r', env=spoof_tesseract_crash + resources / 'ccitt.pdf', + no_outpdf, + '-r', + '--plugin', + 'tests/plugins/tesseract_crash.py', ) assert p.returncode == ExitCode.child_process_error assert not no_outpdf.exists() From c6b2fa8851df3c1b23c3c2f2c525700d76ddb0e3 Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Tue, 2 Jun 2020 02:42:14 -0700 Subject: [PATCH 82/94] Remove unpaper spoof; no plugin needed --- tests/spoof/unpaper_oldversion.py | 37 ------------------------------- tests/test_unpaper.py | 20 ++++++++--------- 2 files changed, 10 insertions(+), 47 deletions(-) delete mode 100755 tests/spoof/unpaper_oldversion.py diff --git a/tests/spoof/unpaper_oldversion.py b/tests/spoof/unpaper_oldversion.py deleted file mode 100755 index ff2e27ea..00000000 --- a/tests/spoof/unpaper_oldversion.py +++ /dev/null @@ -1,37 +0,0 @@ -#!/usr/bin/env python3 -# © 2016 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 sys - - -def main(): - if sys.argv[1] == '--version': - print('0.5') - sys.exit(0) - - print("Only supports --version") - sys.exit(1) - - -if __name__ == '__main__': - main() diff --git a/tests/test_unpaper.py b/tests/test_unpaper.py index 5ef9fac3..3b9f155e 100644 --- a/tests/test_unpaper.py +++ b/tests/test_unpaper.py @@ -35,11 +35,6 @@ spoof = pytest.helpers.spoof have_unpaper = pytest.helpers.have_unpaper -@pytest.fixture -def spoof_unpaper_oldversion(tmp_path_factory): - return spoof(tmp_path_factory, unpaper="unpaper_oldversion.py") - - def test_no_unpaper(resources, no_outpdf): input_ = fspath(resources / "c02-22.pdf") output = fspath(no_outpdf) @@ -52,11 +47,16 @@ def test_no_unpaper(resources, no_outpdf): check_options(options, pm) -def test_old_unpaper(spoof_unpaper_oldversion, resources, no_outpdf): - p, out, err = run_ocrmypdf( - resources / "c02-22.pdf", no_outpdf, "--clean", env=spoof_unpaper_oldversion - ) - assert p.returncode == ExitCode.missing_dependency +def test_old_unpaper(resources, no_outpdf): + input_ = fspath(resources / "c02-22.pdf") + output = fspath(no_outpdf) + + _parser, options, pm = get_parser_options_plugins(["--clean", input_, output]) + with patch("ocrmypdf.exec.unpaper.version") as mock_unpaper_version: + mock_unpaper_version.return_value = '0.5' + + with pytest.raises(MissingDependencyError): + check_options(options, pm) @pytest.mark.skipif(not have_unpaper(), reason="requires unpaper") From ec3f506500175b37cb76154e4a6c4dc33edd58bd Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Fri, 5 Jun 2020 16:36:11 -0700 Subject: [PATCH 83/94] Convert tesseract_badutf8 to plugin --- tests/plugins/tesseract_badutf8.py | 63 +++++++++++++++++++++++ tests/spoof/tesseract_badutf8.py | 80 ------------------------------ tests/test_stdio.py | 5 -- 3 files changed, 63 insertions(+), 85 deletions(-) create mode 100644 tests/plugins/tesseract_badutf8.py delete mode 100755 tests/spoof/tesseract_badutf8.py diff --git a/tests/plugins/tesseract_badutf8.py b/tests/plugins/tesseract_badutf8.py new file mode 100644 index 00000000..b87a3dac --- /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/spoof/tesseract_badutf8.py b/tests/spoof/tesseract_badutf8.py deleted file mode 100755 index 3cbb6625..00000000 --- a/tests/spoof/tesseract_badutf8.py +++ /dev/null @@ -1,80 +0,0 @@ -#!/usr/bin/env python3 -# © 2017 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 sys - -"""Tesseract bad utf8 spoof - -In 'hocr' mode or 'pdf' mode, return error code 1 and some non-Unicode -text because tesseract seems to do that in some cases related to -language pack version mismatches - -""" - - -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 -''' - -# Japanese "Invalid UTF-8" encoded in Shift JIS -BAD_UTF8 = b'\x96\xb3\x8c\xf8\x82\xc8UTF-8\x0a' - - -def main(): - if sys.argv[1] == '--version': - print(VERSION_STRING, file=sys.stderr) - sys.exit(0) - elif sys.argv[1] == '--list-langs': - print('List of available languages (1):\neng', file=sys.stderr) - sys.exit(0) - elif sys.argv[-2] == '--print-parameters': - print("Some parameters", file=sys.stderr) - print("textonly_pdf\t1\tSome help text") - sys.exit(0) - elif sys.argv[-2] in ('hocr', 'pdf'): - sys.stdout.buffer.write(BAD_UTF8) - sys.exit(1) - elif sys.argv[-1] == 'stdout': - # input file is at sys.argv[-2] but we don't look at it - print( - """Orientation: 0 -Orientation in degrees: 0 -Orientation confidence: 100.00 -Script: 1 -Script confidence: 100.00""", - file=sys.stderr, - ) - else: - print("Spoof doesn't understand arguments", file=sys.stderr) - print(sys.argv, file=sys.stderr) - sys.exit(1) - - sys.exit(0) - - -if __name__ == '__main__': - main() diff --git a/tests/test_stdio.py b/tests/test_stdio.py index d78b1644..2f58306a 100644 --- a/tests/test_stdio.py +++ b/tests/test_stdio.py @@ -33,11 +33,6 @@ run_ocrmypdf_api = pytest.helpers.run_ocrmypdf spoof = pytest.helpers.spoof -@pytest.fixture -def spoof_tess_bad_utf8(tmp_path_factory): - return spoof(tmp_path_factory, tesseract='tesseract_badutf8.py') - - def test_stdin(ocrmypdf_exec, resources, outpdf): input_file = str(resources / 'francais.pdf') output_file = str(outpdf) From 6268e2fafff02469381f29550626b059e4eb9256 Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Fri, 5 Jun 2020 17:27:10 -0700 Subject: [PATCH 84/94] Begin replacing tests/spoof/tesseract_cache with plugin --- tests/plugins/tesseract_cache.py | 175 +++++++++++++++++++++++++++++++ tests/test_main.py | 6 +- 2 files changed, 179 insertions(+), 2 deletions(-) create mode 100644 tests/plugins/tesseract_cache.py diff --git a/tests/plugins/tesseract_cache.py b/tests/plugins/tesseract_cache.py new file mode 100644 index 00000000..37b865f1 --- /dev/null +++ b/tests/plugins/tesseract_cache.py @@ -0,0 +1,175 @@ +# © 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 argparse +import json +import logging +import platform +import re +import shutil +from functools import partial +from pathlib import Path +from subprocess import PIPE, CalledProcessError, CompletedProcess +from unittest.mock import patch + +from ocrmypdf import hookimpl +from ocrmypdf.builtin_plugins.tesseract_ocr import TesseractOcrEngine +from ocrmypdf.exec import run + +log = logging.getLogger(__name__) + +TESTS_ROOT = Path(__file__).resolve().parent.parent +CACHE_ROOT = TESTS_ROOT / 'cache' + + +parser = argparse.ArgumentParser( + prog='tesseract-cache', description='cache output of tesseract' +) +parser.add_argument('-l', '--language', action='append') +parser.add_argument('imagename') +parser.add_argument('outputbase') +parser.add_argument('configfiles', nargs='*') +parser.add_argument('--user-words', type=str) +parser.add_argument('--user-patterns', type=str) +parser.add_argument('-c', action='append') +parser.add_argument('--psm', type=int) +parser.add_argument('--oem', type=int) + + +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 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: + yield arg + + argv_slug = '__'.join(slugs()) + argv_slug = argv_slug.replace('/', '___') + + 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) + + log.debug("Using Tesseract cache {cache_folder}") + + if (cache_folder / 'stderr.bin').exists(): + log.debug("Cache HIT") + + # Replicate stdout/err + if args.outputbase != 'stdout': + if not args.configfiles: + args.configfiles.append('txt') + for configfile in args.configfiles: + # cp cache -> output + tessfile = args.outputbase + '.' + configfile + shutil.copy(str(cache_folder / configfile) + '.bin', tessfile) + return CompletedProcess( + args=run_args, + returncode=0, + stdout=(cache_folder / 'stdout.bin').read_bytes(), + stderr=(cache_folder / 'stderr.bin').read_bytes(), + ) + + log.debug("Cache MISS") + + 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: + args.configfiles.append('txt') + + for configfile in args.configfiles: + if configfile not in ('hocr', 'pdf', 'txt'): + continue + # cp pwd/{outputbase}.{configfile} -> {cache}/{configfile} + tessfile = args.outputbase + '.' + configfile + shutil.copy(tessfile, str(cache_folder / configfile) + '.bin') + + manifest = {} + manifest['tesseract_version'] = TesseractOcrEngine.version().replace('\n', ' ') + manifest['platform'] = platform.platform() + manifest['python'] = platform.python_version() + manifest['argv_slug'] = cache_folder.name + manifest['sourcefile'] = str(Path(source_file).relative_to(TESTS_ROOT)) + + def clean_sys_argv(): + for arg in run_args[1:]: + yield re.sub(r'.*/com.github.ocrmypdf[^/]+[/](.*)', r'$TMPDIR/\1', arg) + + manifest['args'] = list(clean_sys_argv()) + with (Path(CACHE_ROOT) / 'manifest.jsonl').open('a') as f: + json.dump(manifest, f) + f.write('\n') + f.flush() + + +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/test_main.py b/tests/test_main.py index b16b31d8..ceead7de 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -45,8 +45,10 @@ spoof = pytest.helpers.spoof RENDERERS = ['hocr', 'sandwich'] -def test_quick(spoof_tesseract_cache, resources, outpdf): - check_ocrmypdf(resources / 'ccitt.pdf', outpdf, env=spoof_tesseract_cache) +def test_quick(resources, outpdf): + check_ocrmypdf( + resources / 'ccitt.pdf', outpdf, '--plugin', 'tests/plugins/tesseract_cache.py' + ) @pytest.mark.parametrize('renderer', RENDERERS) From a9a473f2e5d544082f70946f53b26b75f4a3496e Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Fri, 5 Jun 2020 17:45:11 -0700 Subject: [PATCH 85/94] Convert all tesseract cache usages to plugin --- src/ocrmypdf/exec/tesseract.py | 9 +- tests/conftest.py | 7 -- tests/plugins/tesseract_cache.py | 26 ++++ tests/plugins/tesseract_noop.py | 2 +- tests/spoof/tesseract_cache.py | 198 ------------------------------- tests/test_main.py | 80 +++++++++---- tests/test_page_numbers.py | 4 +- tests/test_preprocessing.py | 19 ++- tests/test_rotation.py | 12 +- tests/test_tesseract.py | 1 - tests/test_unpaper.py | 1 - tests/test_userunit.py | 20 +++- 12 files changed, 118 insertions(+), 261 deletions(-) delete mode 100755 tests/spoof/tesseract_cache.py diff --git a/src/ocrmypdf/exec/tesseract.py b/src/ocrmypdf/exec/tesseract.py index b1ae3c3c..8e0f4608 100644 --- a/src/ocrmypdf/exec/tesseract.py +++ b/src/ocrmypdf/exec/tesseract.py @@ -167,7 +167,6 @@ def get_orientation(input_file: Path, engine_mode, timeout: float, tesseract_env except TimeoutExpired: return OrientationConfidence(angle=0, confidence=0.0) except CalledProcessError as e: - # breakpoint() tesseract_log_output(e.stdout) tesseract_log_output(e.stderr) if ( @@ -191,15 +190,17 @@ def get_orientation(input_file: Path, engine_mode, timeout: float, tesseract_env return oc -def tesseract_log_output(stdout): +def tesseract_log_output(stream): tlog = TesseractLoggerAdapter( log, extra=log.extra if hasattr(log, 'extra') else None ) + if not stream: + return try: - text = stdout.decode() + text = stream.decode() except UnicodeDecodeError: - text = stdout.decode('utf-8', 'ignore') + text = stream.decode('utf-8', 'ignore') lines = text.splitlines() for line in lines: diff --git a/tests/conftest.py b/tests/conftest.py index bc188bf9..0b88a070 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -169,13 +169,6 @@ def spoof(tmp_path_factory, **kwargs): return env -@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' diff --git a/tests/plugins/tesseract_cache.py b/tests/plugins/tesseract_cache.py index 37b865f1..807d65b0 100644 --- a/tests/plugins/tesseract_cache.py +++ b/tests/plugins/tesseract_cache.py @@ -19,6 +19,31 @@ # TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +"""Cache output of tesseract to speed up test suite + +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 +differences (e.g. JPEG decoders are allowed to produce differing outputs, +and in practice they do) will still be a cache hit. By design, an +invocation of tesseract with the same parameters from a different test case +will be a hit. It's fragile. + +The tests/cache/manifest.jsonl is a JSON lines file that contains +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. + +Certain operations are not cached and routed to Tesseract OCR directly. + +Assumes Tesseract 4.0.0-alpha or higher. + +""" + import argparse import json import logging @@ -147,6 +172,7 @@ def cached_run(options, run_args, **run_kwargs): json.dump(manifest, f) f.write('\n') f.flush() + return p class CacheOcrEngine(TesseractOcrEngine): diff --git a/tests/plugins/tesseract_noop.py b/tests/plugins/tesseract_noop.py index 7f78d3e0..26bfe1df 100644 --- a/tests/plugins/tesseract_noop.py +++ b/tests/plugins/tesseract_noop.py @@ -19,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. diff --git a/tests/spoof/tesseract_cache.py b/tests/spoof/tesseract_cache.py deleted file mode 100755 index adf3e257..00000000 --- a/tests/spoof/tesseract_cache.py +++ /dev/null @@ -1,198 +0,0 @@ -#!/usr/bin/env python3 -# © 2016 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. - -"""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. - -Changes to tests/resources/ or image processing algorithms don't trigger a -cache miss. By design, an input image that varies according to platform -differences (e.g. JPEG decoders are allowed to produce differing outputs, -and in practice they do) will still be a cache hit. By design, an -invocation of tesseract with the same parameters from a different test case -will be a hit. It's fragile. - -The tests/cache/manifest.jsonl is a JSON lines file that contains -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. - -Assumes Tesseract 4.0.0-alpha or higher. - -""" - -import argparse -import json -import os -import platform -import re -import shutil -import subprocess -import sys -from pathlib import Path - -__version__ = subprocess.check_output( - ['tesseract', '--version'], stderr=subprocess.STDOUT -).decode() - - -parser = argparse.ArgumentParser( - prog='tesseract-cache', description='cache output of tesseract' -) -parser.add_argument('-l', '--language', action='append') -parser.add_argument('imagename') -parser.add_argument('outputbase') -parser.add_argument('configfiles', nargs='*') -parser.add_argument('--user-words', type=str) -parser.add_argument('--user-patterns', type=str) -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 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 - elif arg == '-c' or arg.startswith('textonly'): - pass - else: - yield arg - - argv_slug = '__'.join(slugs()) - argv_slug = argv_slug.replace('/', '___') - - cache_folder = Path(CACHE_ROOT) / Path(source).stem / argv_slug - cache_folder.mkdir(parents=True, exist_ok=True) - - print(f"Tesseract cache folder {cache_folder} - ", end='', file=sys.stderr) - - if (cache_folder / 'stderr.bin').exists() and not cache_disabled: - # Cache hit - print("HIT", file=sys.stderr) - - # 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') - for configfile in args.configfiles: - # cp cache -> output - tessfile = args.outputbase + '.' + configfile - shutil.copy(str(cache_folder / configfile) + '.bin', tessfile) - sys.exit(0) - - # Cache miss - print("MISS", file=sys.stderr) - - # 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_folder / 'stdout.bin').write_bytes(p.stdout) - - if args.outputbase != 'stdout': - if not args.configfiles: - args.configfiles.append('txt') - - for configfile in args.configfiles: - if configfile not in ('hocr', 'pdf', 'txt'): - continue - # cp pwd/{outputbase}.{configfile} -> {cache}/{configfile} - 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['platform'] = platform.platform() - manifest['python'] = platform.python_version() - manifest['argv_slug'] = argv_slug - manifest['sourcefile'] = str(Path(source).relative_to(TESTS_ROOT)) - - def clean_sys_argv(): - for arg in sys.argv[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() - - -if __name__ == '__main__': - main() diff --git a/tests/test_main.py b/tests/test_main.py index ceead7de..6f9d7b50 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -52,7 +52,7 @@ def test_quick(resources, outpdf): @pytest.mark.parametrize('renderer', RENDERERS) -def test_oversample(spoof_tesseract_cache, renderer, resources, outpdf): +def test_oversample(renderer, resources, outpdf): oversampled_pdf = check_ocrmypdf( resources / 'skew.pdf', outpdf, @@ -61,7 +61,8 @@ def test_oversample(spoof_tesseract_cache, renderer, resources, outpdf): '-f', '--pdf-renderer', renderer, - env=spoof_tesseract_cache, + '--plugin', + 'tests/plugins/tesseract_cache.py', ) pdfinfo = PdfInfo(oversampled_pdf) @@ -75,17 +76,25 @@ def test_repeat_ocr(resources, no_outpdf): assert result == ExitCode.already_done_ocr -def test_force_ocr(spoof_tesseract_cache, resources, outpdf): +def test_force_ocr(resources, outpdf): out = check_ocrmypdf( - resources / 'graph_ocred.pdf', outpdf, '-f', env=spoof_tesseract_cache + resources / 'graph_ocred.pdf', + outpdf, + '-f', + '--plugin', + 'tests/plugins/tesseract_cache.py', ) pdfinfo = PdfInfo(out) assert pdfinfo[0].has_text -def test_skip_ocr(spoof_tesseract_cache, resources, outpdf): +def test_skip_ocr(resources, outpdf): out = check_ocrmypdf( - resources / 'graph_ocred.pdf', outpdf, '-s', env=spoof_tesseract_cache + resources / 'graph_ocred.pdf', + outpdf, + '-s', + '--plugin', + 'tests/plugins/tesseract_cache.py', ) pdfinfo = PdfInfo(out) assert pdfinfo[0].has_text @@ -136,9 +145,14 @@ def test_ocr_timeout(renderer, resources, outpdf): assert not pdfinfo[0].has_text -def test_skip_big(spoof_tesseract_cache, resources, outpdf): +def test_skip_big(resources, outpdf): out = check_ocrmypdf( - resources / 'jbig2.pdf', outpdf, '--skip-big', '1', env=spoof_tesseract_cache + resources / 'jbig2.pdf', + outpdf, + '--skip-big', + '1', + '--plugin', + 'tests/plugins/tesseract_cache.py', ) pdfinfo = PdfInfo(out) assert not pdfinfo[0].has_text @@ -146,9 +160,7 @@ def test_skip_big(spoof_tesseract_cache, resources, outpdf): @pytest.mark.parametrize('renderer', RENDERERS) @pytest.mark.parametrize('output_type', ['pdf', 'pdfa']) -def test_maximum_options( - spoof_tesseract_cache, renderer, output_type, resources, outpdf -): +def test_maximum_options(renderer, output_type, resources, outpdf): check_ocrmypdf( resources / 'multipage.pdf', outpdf, @@ -169,7 +181,8 @@ def test_maximum_options( renderer, '--output-type', output_type, - env=spoof_tesseract_cache, + '--plugin', + 'tests/plugins/tesseract_cache.py', ) @@ -208,7 +221,7 @@ def test_force_ocr_on_pdf_with_no_images(resources, no_outpdf): pytest.helpers.is_macos() and pytest.helpers.running_in_travis(), reason="takes too long to install language packs in Travis macOS homebrew", ) -def test_german(spoof_tesseract_cache, resources, outdir): +def test_german(resources, outdir): # Produce a sidecar too - implicit test that system locale is set up # properly. It is fine that we are testing -l deu on a French file because # we are exercising the functionality not going for accuracy. @@ -221,7 +234,8 @@ def test_german(spoof_tesseract_cache, resources, outdir): 'deu', # more commonly installed '--sidecar', sidecar, - env=spoof_tesseract_cache, + '--plugin', + 'tests/plugins/tesseract_cache.py', ) except MissingDependencyError: if 'deu' not in tesseract.get_languages(): @@ -290,7 +304,7 @@ def test_encrypted(resources, caplog, no_outpdf): @pytest.mark.parametrize('renderer', RENDERERS) -def test_pagesegmode(renderer, spoof_tesseract_cache, resources, outpdf): +def test_pagesegmode(renderer, resources, outpdf): check_ocrmypdf( resources / 'skew.pdf', outpdf, @@ -300,7 +314,8 @@ def test_pagesegmode(renderer, spoof_tesseract_cache, resources, outpdf): '1', '--pdf-renderer', renderer, - env=spoof_tesseract_cache, + '--plugin', + 'tests/plugins/tesseract_cache.py', ) @@ -362,7 +377,7 @@ def test_algo4(resources, outpdf): assert p.returncode == ExitCode.encrypted_pdf -def test_jbig2_passthrough(spoof_tesseract_cache, resources, outpdf): +def test_jbig2_passthrough(resources, outpdf): out = check_ocrmypdf( resources / 'jbig2.pdf', outpdf, @@ -370,7 +385,8 @@ def test_jbig2_passthrough(spoof_tesseract_cache, resources, outpdf): 'pdf', '--pdf-renderer', 'hocr', - env=spoof_tesseract_cache, + '--plugin', + 'tests/plugins/tesseract_cache.py', ) out_pageinfo = PdfInfo(out) assert out_pageinfo[0].images[0].enc == Encoding.jbig2 @@ -391,9 +407,14 @@ def test_linearized_pdf_and_indirect_object(resources, outpdf): ) -def test_very_high_dpi(spoof_tesseract_cache, resources, outpdf): +def test_very_high_dpi(resources, outpdf): "Checks for a Decimal quantize error with high DPI, etc" - check_ocrmypdf(resources / '2400dpi.pdf', outpdf, env=spoof_tesseract_cache) + check_ocrmypdf( + resources / '2400dpi.pdf', + outpdf, + '--plugin', + 'tests/plugins/tesseract_cache.py', + ) pdfinfo = PdfInfo(outpdf) image = pdfinfo[0].images[0] @@ -673,7 +694,7 @@ def test_compression_changed(ocrmypdf_exec, resources, image, compression, outpd im.close() -def test_sidecar_pagecount(spoof_tesseract_cache, resources, outpdf): +def test_sidecar_pagecount(resources, outpdf): sidecar = outpdf.with_suffix('.txt') check_ocrmypdf( resources / '3small.pdf', @@ -681,7 +702,8 @@ def test_sidecar_pagecount(spoof_tesseract_cache, resources, outpdf): '--skip-text', '--sidecar', sidecar, - env=spoof_tesseract_cache, + '--plugin', + 'tests/plugins/tesseract_cache.py', ) pdfinfo = PdfInfo(resources / '3small.pdf') @@ -697,10 +719,15 @@ def test_sidecar_pagecount(spoof_tesseract_cache, resources, outpdf): ), "Sidecar page count does not match PDF page count" -def test_sidecar_nonempty(spoof_tesseract_cache, resources, outpdf): +def test_sidecar_nonempty(resources, outpdf): sidecar = outpdf.with_suffix('.txt') check_ocrmypdf( - resources / 'ccitt.pdf', outpdf, '--sidecar', sidecar, env=spoof_tesseract_cache + resources / 'ccitt.pdf', + outpdf, + '--sidecar', + sidecar, + '--plugin', + 'tests/plugins/tesseract_cache.py', ) with open(sidecar, 'r', encoding='utf-8') as f: @@ -709,7 +736,7 @@ def test_sidecar_nonempty(spoof_tesseract_cache, resources, outpdf): @pytest.mark.parametrize('pdfa_level', ['1', '2', '3']) -def test_pdfa_n(spoof_tesseract_cache, pdfa_level, resources, outpdf): +def test_pdfa_n(pdfa_level, resources, outpdf): if pdfa_level == '3' and ghostscript.version() < '9.19': pytest.xfail(reason='Ghostscript >= 9.19 required') @@ -718,7 +745,8 @@ def test_pdfa_n(spoof_tesseract_cache, pdfa_level, resources, outpdf): outpdf, '--output-type', 'pdfa-' + pdfa_level, - env=spoof_tesseract_cache, + '--plugin', + 'tests/plugins/tesseract_cache.py', ) pdfa_info = file_claims_pdfa(outpdf) diff --git a/tests/test_page_numbers.py b/tests/test_page_numbers.py index 1fb494c4..733153fc 100644 --- a/tests/test_page_numbers.py +++ b/tests/test_page_numbers.py @@ -58,7 +58,7 @@ def test_list_range(): assert _pages_from_ranges([0, 1, 2]) == {0, 1, 2} -def test_limited_pages(resources, outpdf, spoof_tesseract_cache): +def test_limited_pages(resources, outpdf): multi = resources / 'multipage.pdf' ocrmypdf.ocr( multi, @@ -66,7 +66,7 @@ def test_limited_pages(resources, outpdf, spoof_tesseract_cache): pages='5-6', optimize=0, output_type='pdf', - tesseract_env=spoof_tesseract_cache, + plugins=['tests/plugins/tesseract_cache.py'], ) pi = PdfInfo(outpdf) assert not pi.pages[0].has_text diff --git a/tests/test_preprocessing.py b/tests/test_preprocessing.py index 08e34bec..e24c3b7d 100644 --- a/tests/test_preprocessing.py +++ b/tests/test_preprocessing.py @@ -102,9 +102,7 @@ def test_remove_background(resources, outdir): ) @pytest.mark.parametrize("renderer", ['sandwich', 'hocr']) @pytest.mark.parametrize("output_type", ['pdf', 'pdfa']) -def test_exotic_image( - spoof_tesseract_cache, pdf, renderer, output_type, resources, outdir -): +def test_exotic_image(pdf, renderer, output_type, resources, outdir): outfile = outdir / f'test_{pdf}_{renderer}.pdf' check_ocrmypdf( resources / pdf, @@ -118,14 +116,15 @@ def test_exotic_image( '--skip-text', '--pdf-renderer', renderer, - env=spoof_tesseract_cache, + '--plugin', + 'tests/plugins/tesseract_cache.py', ) assert outfile.with_suffix('.pdf.txt').exists() @pytest.mark.parametrize('renderer', RENDERERS) -def test_non_square_resolution(renderer, spoof_tesseract_cache, resources, outpdf): +def test_non_square_resolution(renderer, resources, outpdf): # Confirm input image is non-square resolution in_pageinfo = PdfInfo(resources / 'aspect.pdf') assert in_pageinfo[0].dpi.x != in_pageinfo[0].dpi.y @@ -135,7 +134,8 @@ def test_non_square_resolution(renderer, spoof_tesseract_cache, resources, outpd outpdf, '--pdf-renderer', renderer, - env=spoof_tesseract_cache, + '--plugin', + 'tests/plugins/tesseract_cache.py', ) out_pageinfo = PdfInfo(outpdf) @@ -145,9 +145,7 @@ def test_non_square_resolution(renderer, spoof_tesseract_cache, resources, outpd @pytest.mark.parametrize('renderer', RENDERERS) -def test_convert_to_square_resolution( - renderer, spoof_tesseract_cache, resources, outpdf -): +def test_convert_to_square_resolution(renderer, resources, outpdf): # Confirm input image is non-square resolution in_pageinfo = PdfInfo(resources / 'aspect.pdf') assert in_pageinfo[0].dpi.x != in_pageinfo[0].dpi.y @@ -159,7 +157,8 @@ def test_convert_to_square_resolution( '--force-ocr', '--pdf-renderer', renderer, - env=spoof_tesseract_cache, + '--plugin', + 'tests/plugins/tesseract_cache.py', ) out_pageinfo = PdfInfo(outpdf) diff --git a/tests/test_rotation.py b/tests/test_rotation.py index 4ffa59f8..0c8b5dd3 100644 --- a/tests/test_rotation.py +++ b/tests/test_rotation.py @@ -97,7 +97,7 @@ def test_monochrome_correlation(resources, outdir): @pytest.mark.slow @pytest.mark.parametrize('renderer', RENDERERS) -def test_autorotate(spoof_tesseract_cache, renderer, resources, outdir): +def test_autorotate(renderer, resources, outdir): # cardinal.pdf contains four copies of an image rotated in each cardinal # direction - these ones are "burned in" not tagged with /Rotate out = check_ocrmypdf( @@ -108,7 +108,8 @@ def test_autorotate(spoof_tesseract_cache, renderer, resources, outdir): '1', '--pdf-renderer', renderer, - env=spoof_tesseract_cache, + '--plugin', + 'tests/plugins/tesseract_cache.py', ) for n in range(1, 4 + 1): correlation = check_monochrome_correlation( @@ -128,9 +129,7 @@ def test_autorotate(spoof_tesseract_cache, renderer, resources, outdir): ('99', 'correlation < 0.10'), # High thres -> never rotate -> low corr ], ) -def test_autorotate_threshold( - spoof_tesseract_cache, threshold, correlation_test, resources, outdir -): +def test_autorotate_threshold(threshold, correlation_test, resources, outdir): out = check_ocrmypdf( resources / 'cardinal.pdf', outdir / 'out.pdf', @@ -139,7 +138,8 @@ def test_autorotate_threshold( '-r', # '-v', # '1', - env=spoof_tesseract_cache, + '--plugin', + 'tests/plugins/tesseract_cache.py', ) correlation = check_monochrome_correlation( diff --git a/tests/test_tesseract.py b/tests/test_tesseract.py index ffe3fe31..99222d5a 100644 --- a/tests/test_tesseract.py +++ b/tests/test_tesseract.py @@ -31,7 +31,6 @@ from ocrmypdf.exec import tesseract check_ocrmypdf = pytest.helpers.check_ocrmypdf run_ocrmypdf = pytest.helpers.run_ocrmypdf -spoof = pytest.helpers.spoof @pytest.mark.parametrize('basename', ['graph_ocred.pdf', 'cardinal.pdf']) diff --git a/tests/test_unpaper.py b/tests/test_unpaper.py index 3b9f155e..9a6dc248 100644 --- a/tests/test_unpaper.py +++ b/tests/test_unpaper.py @@ -31,7 +31,6 @@ from ocrmypdf.exceptions import ExitCode, MissingDependencyError check_ocrmypdf = pytest.helpers.check_ocrmypdf run_ocrmypdf = pytest.helpers.run_ocrmypdf -spoof = pytest.helpers.spoof have_unpaper = pytest.helpers.have_unpaper diff --git a/tests/test_userunit.py b/tests/test_userunit.py index 41d21aa2..60f97d08 100644 --- a/tests/test_userunit.py +++ b/tests/test_userunit.py @@ -25,7 +25,6 @@ from ocrmypdf.pdfinfo import PdfInfo check_ocrmypdf = pytest.helpers.check_ocrmypdf run_ocrmypdf = pytest.helpers.run_ocrmypdf run_ocrmypdf_api = pytest.helpers.run_ocrmypdf_api -spoof = pytest.helpers.spoof @pytest.fixture @@ -39,15 +38,26 @@ def test_userunit_ghostscript_fails(poster, no_outpdf, caplog): assert 'not supported by Ghostscript' in caplog.text -def test_userunit_pdf_passes(spoof_tesseract_cache, poster, outpdf): +def test_userunit_pdf_passes(poster, outpdf): before = PdfInfo(poster) - check_ocrmypdf(poster, outpdf, '--output-type=pdf', env=spoof_tesseract_cache) + check_ocrmypdf( + poster, + outpdf, + '--output-type=pdf', + '--plugin', + 'tests/plugins/tesseract_cache.py', + ) after = PdfInfo(outpdf) assert isclose(before[0].width_inches, after[0].width_inches) -def test_rotate_interaction(spoof_tesseract_cache, poster, outpdf): +def test_rotate_interaction(poster, outpdf): check_ocrmypdf( - poster, outpdf, '--output-type=pdf', '--rotate-pages', env=spoof_tesseract_cache + poster, + outpdf, + '--output-type=pdf', + '--rotate-pages', + '--plugin', + 'tests/plugins/tesseract_cache.py', ) From b109445215155fdd5d8707f96266885ca4706bbb Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Mon, 8 Jun 2020 17:10:27 -0700 Subject: [PATCH 86/94] Move Ghostscript rasterize_pdf to plugin --- src/ocrmypdf/_pipeline.py | 12 +-- src/ocrmypdf/_plugin_manager.py | 5 +- src/ocrmypdf/_validation.py | 50 +----------- src/ocrmypdf/builtin_plugins/__init__.py | 2 - src/ocrmypdf/builtin_plugins/ghostscript.py | 90 +++++++++++++++++++++ src/ocrmypdf/exec/_support.py | 5 +- src/ocrmypdf/exec/ghostscript.py | 18 +---- src/ocrmypdf/pluginspec.py | 31 +++++++ tests/test_main.py | 1 - tests/test_metadata.py | 1 - tests/test_validation.py | 25 ++++-- 11 files changed, 154 insertions(+), 86 deletions(-) create mode 100644 src/ocrmypdf/builtin_plugins/ghostscript.py diff --git a/src/ocrmypdf/_pipeline.py b/src/ocrmypdf/_pipeline.py index 0806880e..6e67d808 100644 --- a/src/ocrmypdf/_pipeline.py +++ b/src/ocrmypdf/_pipeline.py @@ -321,9 +321,9 @@ def rasterize_preview(input_file, page_context): output_file = page_context.get_path('rasterize_preview.jpg') canvas_dpi = get_canvas_square_dpi(page_context.pageinfo, page_context.options) page_dpi = get_page_square_dpi(page_context.pageinfo, page_context.options) - ghostscript.rasterize_pdf( - input_file, - output_file, + page_context.plugin_manager.hook.rasterize_pdf_page( + input_file=input_file, + output_file=output_file, raster_device='jpeggray', raster_dpi=canvas_dpi, page_dpi=page_dpi, @@ -430,9 +430,9 @@ def rasterize( canvas_dpi = get_canvas_square_dpi(pageinfo, page_context.options) page_dpi = get_page_square_dpi(pageinfo, page_context.options) - ghostscript.rasterize_pdf( - input_file, - output_file, + page_context.plugin_manager.hook.rasterize_pdf_page( + input_file=input_file, + output_file=output_file, raster_device=device, raster_dpi=canvas_dpi, page_dpi=page_dpi, diff --git a/src/ocrmypdf/_plugin_manager.py b/src/ocrmypdf/_plugin_manager.py index 1fbc1b76..9f328c69 100644 --- a/src/ocrmypdf/_plugin_manager.py +++ b/src/ocrmypdf/_plugin_manager.py @@ -33,7 +33,10 @@ def get_plugin_manager(plugins: List[str], builtins=True): pm.add_hookspecs(pluginspec) if builtins: - all_plugins = ['ocrmypdf.builtin_plugins'] + plugins + all_plugins = [ + 'ocrmypdf.builtin_plugins.ghostscript', + 'ocrmypdf.builtin_plugins.tesseract_ocr', + ] + plugins else: all_plugins = plugins for name in all_plugins: diff --git a/src/ocrmypdf/_validation.py b/src/ocrmypdf/_validation.py index 941e3522..3a92d34c 100644 --- a/src/ocrmypdf/_validation.py +++ b/src/ocrmypdf/_validation.py @@ -27,7 +27,6 @@ from shutil import copyfileobj import PIL -from ocrmypdf._plugin_manager import get_plugin_manager from ocrmypdf._unicodefun import verify_python3_env from ocrmypdf.exceptions import ( BadArgsError, @@ -35,14 +34,7 @@ from ocrmypdf.exceptions import ( MissingDependencyError, OutputFileAccessError, ) -from ocrmypdf.exec import ( - check_external_program, - ghostscript, - jbig2enc, - pngquant, - tesseract, - unpaper, -) +from ocrmypdf.exec import check_external_program, jbig2enc, pngquant, unpaper from ocrmypdf.helpers import ( is_file_writable, is_iterable_notstr, @@ -92,10 +84,6 @@ def check_options_languages(options, plugin_manager): def check_options_output(options): - # We have these constraints to check for. - # 1. Ghostscript < 9.20 mangles multibyte Unicode - # 2. hocr doesn't work on non-Latin languages (so don't select it) - is_latin = options.languages.issubset(HOCR_OK_LANGS) if options.pdf_renderer == 'hocr' and not is_latin: @@ -106,25 +94,6 @@ def check_options_output(options): ) log.warning(msg) - if ghostscript.version() < '9.20' and options.output_type != 'pdf' and not is_latin: - # https://bugs.ghostscript.com/show_bug.cgi?id=696874 - # Ghostscript < 9.20 fails to encode multibyte characters properly - msg = ( - "The installed version of Ghostscript does not work correctly " - "with the OCR languages you specified. Use --output-type pdf or " - "upgrade to Ghostscript 9.20 or later to avoid this issue." - ) - msg += f"Found Ghostscript {ghostscript.version()}" - log.warning(msg) - - if options.output_type == 'pdfa': - options.output_type = 'pdfa-2' - - if options.output_type == 'pdfa-3' and ghostscript.version() < '9.19': - raise MissingDependencyError( - "--output-type pdfa-3 requires Ghostscript 9.19 or later" - ) - lossless_reconstruction = False if not any( ( @@ -291,7 +260,6 @@ def check_options(options, plugin_manager): check_options_optimizing(options) check_options_advanced(options) check_options_pillow(options) - check_dependency_versions(options) plugin_manager.hook.check_options(options=options) @@ -432,19 +400,3 @@ def report_output_file_size(options, input_file, output_file): f"The output file size is {ratio:.2f}× larger than the input file.\n" f"{explanation}" ) - - -def check_dependency_versions(options): - check_external_program( - program='gs', - package='ghostscript', - version_checker=ghostscript.version, - need_version='9.15', # limited by Travis CI / Ubuntu 14.04 backports - ) - gs_version = ghostscript.version() - if gs_version in ('9.24', '9.51'): - raise MissingDependencyError( - f"Ghostscript {gs_version} contains serious regressions and is not " - "supported. Please upgrade to a newer version, or downgrade to the " - "previous version." - ) diff --git a/src/ocrmypdf/builtin_plugins/__init__.py b/src/ocrmypdf/builtin_plugins/__init__.py index e5fd494e..0ed32bc2 100644 --- a/src/ocrmypdf/builtin_plugins/__init__.py +++ b/src/ocrmypdf/builtin_plugins/__init__.py @@ -14,5 +14,3 @@ # # You should have received a copy of the GNU General Public License # along with OCRmyPDF. If not, see . - -from ocrmypdf.builtin_plugins.tesseract_ocr import * diff --git a/src/ocrmypdf/builtin_plugins/ghostscript.py b/src/ocrmypdf/builtin_plugins/ghostscript.py new file mode 100644 index 00000000..92f39008 --- /dev/null +++ b/src/ocrmypdf/builtin_plugins/ghostscript.py @@ -0,0 +1,90 @@ +# © 2020 James R. Barlow: github.com/jbarlow83 +# +# This file is part of OCRmyPDF. +# +# OCRmyPDF is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# OCRmyPDF is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with OCRmyPDF. If not, see . + +import logging +from pathlib import Path + +from ocrmypdf import hookimpl +from ocrmypdf._validation import HOCR_OK_LANGS +from ocrmypdf.exceptions import MissingDependencyError +from ocrmypdf.exec import check_external_program, ghostscript +from ocrmypdf.helpers import Resolution + +log = logging.getLogger(__name__) + + +@hookimpl +def check_options(options): + gs_version = ghostscript.version() + check_external_program( + program='gs', + package='ghostscript', + version_checker=gs_version, + need_version='9.15', # limited by Travis CI / Ubuntu 14.04 backports + ) + if gs_version in ('9.24', '9.51'): + raise MissingDependencyError( + f"Ghostscript {gs_version} contains serious regressions and is not " + "supported. Please upgrade to a newer version, or downgrade to the " + "previous version." + ) + + # We have these constraints to check for. + # 1. Ghostscript < 9.20 mangles multibyte Unicode + # 2. hocr doesn't work on non-Latin languages (so don't select it) + is_latin = options.languages.issubset(HOCR_OK_LANGS) + if gs_version < '9.20' and options.output_type != 'pdf' and not is_latin: + # https://bugs.ghostscript.com/show_bug.cgi?id=696874 + # Ghostscript < 9.20 fails to encode multibyte characters properly + msg = ( + "The installed version of Ghostscript does not work correctly " + "with the OCR languages you specified. Use --output-type pdf or " + "upgrade to Ghostscript 9.20 or later to avoid this issue." + ) + msg += f"Found Ghostscript {gs_version}" + log.warning(msg) + + if options.output_type == 'pdfa': + options.output_type = 'pdfa-2' + + if options.output_type == 'pdfa-3' and ghostscript.version() < '9.19': + raise MissingDependencyError( + "--output-type pdfa-3 requires Ghostscript 9.19 or later" + ) + + +@hookimpl +def rasterize_pdf_page( + input_file: Path, + output_file: Path, + raster_device: str, + raster_dpi: Resolution, + pageno: int, + page_dpi: Resolution = None, + rotation: int = None, + filter_vector: bool = False, +): + return ghostscript.rasterize_pdf( + input_file, + output_file, + raster_device=raster_device, + raster_dpi=raster_dpi, + pageno=pageno, + page_dpi=page_dpi, + rotation=rotation, + filter_vector=filter_vector, + ) diff --git a/src/ocrmypdf/exec/_support.py b/src/ocrmypdf/exec/_support.py index 60d22a83..5f52ac87 100644 --- a/src/ocrmypdf/exec/_support.py +++ b/src/ocrmypdf/exec/_support.py @@ -280,7 +280,10 @@ def check_external_program( recommended=False, ): try: - found_version = version_checker() + if callable(version_checker): + found_version = version_checker() + else: + found_version = version_checker except (CalledProcessError, FileNotFoundError, MissingDependencyError): _error_missing_program(program, package, required_for, recommended) if not recommended: diff --git a/src/ocrmypdf/exec/ghostscript.py b/src/ocrmypdf/exec/ghostscript.py index d7407726..a43b972b 100644 --- a/src/ocrmypdf/exec/ghostscript.py +++ b/src/ocrmypdf/exec/ghostscript.py @@ -20,7 +20,6 @@ import logging import os import re -import warnings from io import BytesIO from os import fspath from pathlib import Path @@ -92,22 +91,7 @@ def rasterize_pdf( rotation: int = None, filter_vector: bool = False, ): - """Rasterize one page of a PDF at resolution raster_dpi in canvas units. - - The image is sized to match the integer pixels dimensions implied by - raster_dpi even if those numbers are noninteger. The image's DPI will - be overridden with the values in page_dpi. - - :param input_file: pathlike - :param output_file: pathlike - :param raster_device: - :param raster_dpi: resolution at which to rasterize page - :param pageno: page number to rasterize (beginning at page 1) - :param page_dpi: resolution tuple (x, y) overriding output image DPI - :param rotation: 0, 90, 180, 270: clockwise angle to rotate page - :param filter_vector: if True, remove vector graphics objects - :return: - """ + """Rasterize one page of a PDF at resolution raster_dpi in canvas units.""" raster_dpi = raster_dpi.round(6) if not page_dpi: page_dpi = raster_dpi diff --git a/src/ocrmypdf/pluginspec.py b/src/ocrmypdf/pluginspec.py index dd659458..fd2498b2 100644 --- a/src/ocrmypdf/pluginspec.py +++ b/src/ocrmypdf/pluginspec.py @@ -24,6 +24,8 @@ from typing import AbstractSet, Optional import pluggy from PIL import Image +from ocrmypdf.helpers import Resolution + hookspec = pluggy.HookspecMarker('ocrmypdf') # pylint: disable=unused-argument @@ -66,6 +68,35 @@ def validate(pdfinfo: 'PdfInfo', options: Namespace) -> None: """ +@hookspec(firstresult=True) +def rasterize_pdf_page( + input_file: Path, + output_file: Path, + raster_device: str, + raster_dpi: Resolution, + pageno: int, + page_dpi: Resolution = None, + rotation: int = None, + filter_vector: bool = False, +) -> None: + """Rasterize one page of a PDF at resolution raster_dpi in canvas units. + + The image is sized to match the integer pixels dimensions implied by + raster_dpi even if those numbers are noninteger. The image's DPI will + be overridden with the values in page_dpi. + + Args: + raster_device: type of image to produce at output_file + raster_dpi: resolution at which to rasterize page + pageno: page number to rasterize (beginning at page 1) + page_dpi: resolution, overriding output image DPI + rotation: cardinal angle, clockwise, to rotate page + filter_vector: if True, remove vector graphics objects + Returns: + None + """ + + @hookspec(firstresult=True) def filter_ocr_image(page: 'PageContext', image: Image) -> Image: """Called to filter the image before it is sent to OCR. diff --git a/tests/test_main.py b/tests/test_main.py index 6f9d7b50..0b913a14 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -39,7 +39,6 @@ from ocrmypdf.pdfinfo import Colorspace, Encoding, PdfInfo check_ocrmypdf = pytest.helpers.check_ocrmypdf run_ocrmypdf = pytest.helpers.run_ocrmypdf run_ocrmypdf_api = pytest.helpers.run_ocrmypdf_api -spoof = pytest.helpers.spoof RENDERERS = ['hocr', 'sandwich'] diff --git a/tests/test_metadata.py b/tests/test_metadata.py index 59250eac..16175140 100644 --- a/tests/test_metadata.py +++ b/tests/test_metadata.py @@ -47,7 +47,6 @@ pytestmark = pytest.mark.filterwarnings('ignore:.*XMLParser.*:DeprecationWarning check_ocrmypdf = pytest.helpers.check_ocrmypdf run_ocrmypdf = pytest.helpers.run_ocrmypdf -spoof = pytest.helpers.spoof @pytest.mark.parametrize("output_type", ['pdfa', 'pdf']) diff --git a/tests/test_validation.py b/tests/test_validation.py index f6fcc775..3e6272af 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -29,19 +29,29 @@ from ocrmypdf.exceptions import BadArgsError, MissingDependencyError from ocrmypdf.pdfinfo import PdfInfo -def make_opts(input_file='a.pdf', output_file='b.pdf', language='eng', **kwargs): +def make_opts_pm(input_file='a.pdf', output_file='b.pdf', language='eng', **kwargs): if language is not None: kwargs['language'] = language parser = get_parser() pm = get_plugin_manager(kwargs.get('plugins', [])) pm.hook.add_options(parser=parser) - return create_options( - input_file=input_file, output_file=output_file, parser=parser, **kwargs + return ( + create_options( + input_file=input_file, output_file=output_file, parser=parser, **kwargs + ), + pm, ) +def make_opts(*args, **kwargs): + opts, _pm = make_opts_pm(*args, **kwargs) + return opts + + def test_hocr_notlatin_warning(caplog): - vd.check_options_output(make_opts(language='chi_sim', pdf_renderer='hocr')) + vd.check_options( + *make_opts_pm(language='chi_sim', pdf_renderer='hocr', output_type='pdfa') + ) assert 'PDF renderer is known to cause' in caplog.text @@ -49,20 +59,20 @@ def test_old_ghostscript(caplog): with patch('ocrmypdf.exec.ghostscript.version', return_value='9.19'), patch( 'ocrmypdf.exec.tesseract.has_textonly_pdf', return_value=True ): - vd.check_options_output(make_opts(language='chi_sim', output_type='pdfa')) + vd.check_options(*make_opts_pm(language='chi_sim', output_type='pdfa')) assert 'Ghostscript does not work correctly' in caplog.text with patch('ocrmypdf.exec.ghostscript.version', return_value='9.18'), patch( 'ocrmypdf.exec.tesseract.has_textonly_pdf', return_value=True ): with pytest.raises(MissingDependencyError): - vd.check_options_output(make_opts(output_type='pdfa-3')) + vd.check_options(*make_opts_pm(output_type='pdfa-3')) with patch('ocrmypdf.exec.ghostscript.version', return_value='9.24'), patch( 'ocrmypdf.exec.tesseract.has_textonly_pdf', return_value=True ): with pytest.raises(MissingDependencyError): - vd.check_dependency_versions(make_opts()) + vd.check_options(*make_opts_pm()) def test_old_tesseract_error(): @@ -97,7 +107,6 @@ def test_optimizing(caplog): def test_user_words(caplog): - with patch('ocrmypdf.exec.tesseract.has_user_words', return_value=False): opts = make_opts(user_words='foo') plugin_manager = get_plugin_manager(opts.plugins) From 7b9025f3977bf7dc423ff4bc1eb374f06eb6bb6b Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Mon, 8 Jun 2020 22:28:38 -0700 Subject: [PATCH 87/94] Convert generate_pdfa to plugin --- src/ocrmypdf/_pipeline.py | 5 +-- src/ocrmypdf/builtin_plugins/ghostscript.py | 27 +++++++++++----- src/ocrmypdf/exec/ghostscript.py | 18 ----------- src/ocrmypdf/pluginspec.py | 34 +++++++++++++++++++-- tests/test_metadata.py | 8 +++-- tests/test_preprocessing.py | 1 - tests/test_stdio.py | 1 - 7 files changed, 59 insertions(+), 35 deletions(-) diff --git a/src/ocrmypdf/_pipeline.py b/src/ocrmypdf/_pipeline.py index 6e67d808..094b0858 100644 --- a/src/ocrmypdf/_pipeline.py +++ b/src/ocrmypdf/_pipeline.py @@ -688,9 +688,10 @@ def convert_to_pdfa(input_pdf, input_ps_stub, context): else: safe_symlink(input_pdf, fix_docinfo_file) - ghostscript.generate_pdfa( + context.plugin_manager.hook.generate_pdfa( pdf_version=input_pdfinfo.min_version, - pdf_pages=[fix_docinfo_file, input_ps_stub], + pdf_pages=[fix_docinfo_file], + pdfmark=input_ps_stub, output_file=output_file, compression=options.pdfa_image_compression, pdfa_part=options.output_type[-1], # is pdfa-1, pdfa-2, or pdfa-3 diff --git a/src/ocrmypdf/builtin_plugins/ghostscript.py b/src/ocrmypdf/builtin_plugins/ghostscript.py index 92f39008..90cf41a3 100644 --- a/src/ocrmypdf/builtin_plugins/ghostscript.py +++ b/src/ocrmypdf/builtin_plugins/ghostscript.py @@ -69,14 +69,14 @@ def check_options(options): @hookimpl def rasterize_pdf_page( - input_file: Path, - output_file: Path, - raster_device: str, - raster_dpi: Resolution, - pageno: int, - page_dpi: Resolution = None, - rotation: int = None, - filter_vector: bool = False, + input_file, + output_file, + raster_device, + raster_dpi, + pageno, + page_dpi=None, + rotation=None, + filter_vector=False, ): return ghostscript.rasterize_pdf( input_file, @@ -88,3 +88,14 @@ def rasterize_pdf_page( rotation=rotation, filter_vector=filter_vector, ) + + +@hookimpl +def generate_pdfa(pdf_pages, pdfmark, output_file, compression, pdf_version, pdfa_part): + return ghostscript.generate_pdfa( + pdf_pages=[*pdf_pages, pdfmark], + output_file=output_file, + compression=compression, + pdf_version=pdf_version, + pdfa_part=pdfa_part, + ) diff --git a/src/ocrmypdf/exec/ghostscript.py b/src/ocrmypdf/exec/ghostscript.py index a43b972b..9fe690b9 100644 --- a/src/ocrmypdf/exec/ghostscript.py +++ b/src/ocrmypdf/exec/ghostscript.py @@ -154,24 +154,6 @@ def generate_pdfa( pdf_version: str = '1.5', pdfa_part: str = '2', ): - """Generate a PDF/A. - - The pdf_pages, a list files, will be merged into output_file. One or more - PDF files may be merged. One of the files in this list must be a pdfmark - file that provides Ghostscript with details on how to perform the PDF/A - conversion. By default with we pick PDF/A-2b, but this works for 1 or 3. - - compression can be 'jpeg', 'lossless', or an empty string. In 'jpeg', - Ghostscript is instructed to convert color and grayscale images to DCT - (JPEG encoding). In 'lossless' Ghostscript is told to convert images to - Flate (lossless/PNG). If the parameter is omitted Ghostscript is left to - make its own decisions about how to encode images; it appears to use a - heuristic to decide how to encode images. As of Ghostscript 9.25, we - support passthrough JPEG which allows Ghostscript to avoid transcoding - images entirely. (The feature was added in 9.23 but broken, and the 9.24 - release of Ghostscript had regressions, so we don't support it until 9.25.) - """ - compression_args = [] if compression == 'jpeg': compression_args = [ diff --git a/src/ocrmypdf/pluginspec.py b/src/ocrmypdf/pluginspec.py index fd2498b2..b83aaaa2 100644 --- a/src/ocrmypdf/pluginspec.py +++ b/src/ocrmypdf/pluginspec.py @@ -19,7 +19,7 @@ from abc import ABC, abstractstaticmethod from argparse import ArgumentParser, Namespace from collections import namedtuple from pathlib import Path -from typing import AbstractSet, Optional +from typing import AbstractSet, List, Optional import pluggy from PIL import Image @@ -75,8 +75,8 @@ def rasterize_pdf_page( raster_device: str, raster_dpi: Resolution, pageno: int, - page_dpi: Resolution = None, - rotation: int = None, + page_dpi: Optional[Resolution] = None, + rotation: Optional[int] = None, filter_vector: bool = False, ) -> None: """Rasterize one page of a PDF at resolution raster_dpi in canvas units. @@ -162,3 +162,31 @@ class OcrEngine(ABC): @hookspec(firstresult=True) def get_ocr_engine() -> OcrEngine: pass + + +@hookspec(firstresult=True) +def generate_pdfa( + pdf_pages: List[Path], + pdfmark: Path, + output_file: Path, + compression: str, + pdf_version: str, + pdfa_part: str, +): + """Generate a PDF/A. + + The pdf_pages, a list of files, will be merged into output_file. One or more + PDF files may be merged. The pdfmark file is a PostScript.ps file that + provides Ghostscript with details on how to perform the PDF/A + conversion. By default with we pick PDF/A-2b, but this works for 1 or 3. + + compression can be 'jpeg', 'lossless', or an empty string. In 'jpeg', + Ghostscript is instructed to convert color and grayscale images to DCT + (JPEG encoding). In 'lossless' Ghostscript is told to convert images to + Flate (lossless/PNG). If the parameter is omitted Ghostscript is left to + make its own decisions about how to encode images; it appears to use a + heuristic to decide how to encode images. As of Ghostscript 9.25, we + support passthrough JPEG which allows Ghostscript to avoid transcoding + images entirely. (The feature was added in 9.23 but broken, and the 9.24 + release of Ghostscript had regressions, so we don't support it until 9.25.) + """ diff --git a/tests/test_metadata.py b/tests/test_metadata.py index 16175140..1d310107 100644 --- a/tests/test_metadata.py +++ b/tests/test_metadata.py @@ -341,7 +341,9 @@ def test_prevent_gs_invalid_xml(resources, outdir): args=['-j', '1', '--output-type', 'pdfa-2', 'a.pdf', 'b.pdf'] ) pdfinfo = PdfInfo(outdir / 'layers.rendered.pdf') - context = PdfContext(options, outdir, outdir / 'layers.rendered.pdf', pdfinfo, None) + context = PdfContext( + options, outdir, outdir / 'layers.rendered.pdf', pdfinfo, get_plugin_manager([]) + ) convert_to_pdfa( str(outdir / 'layers.rendered.pdf'), str(outdir / 'pdfa.ps'), context @@ -372,7 +374,9 @@ def test_malformed_docinfo(caplog, resources, outdir): args=['-j', '1', '--output-type', 'pdfa-2', 'a.pdf', 'b.pdf'] ) pdfinfo = PdfInfo(outdir / 'layers.rendered.pdf') - context = PdfContext(options, outdir, outdir / 'layers.rendered.pdf', pdfinfo, None) + context = PdfContext( + options, outdir, outdir / 'layers.rendered.pdf', pdfinfo, get_plugin_manager([]) + ) convert_to_pdfa( str(outdir / 'layers.rendered.pdf'), str(outdir / 'pdfa.ps'), context diff --git a/tests/test_preprocessing.py b/tests/test_preprocessing.py index e24c3b7d..865b5e58 100644 --- a/tests/test_preprocessing.py +++ b/tests/test_preprocessing.py @@ -31,7 +31,6 @@ from ocrmypdf.pdfinfo import PdfInfo check_ocrmypdf = pytest.helpers.check_ocrmypdf run_ocrmypdf = pytest.helpers.run_ocrmypdf run_ocrmypdf_api = pytest.helpers.run_ocrmypdf_api -spoof = pytest.helpers.spoof RENDERERS = ['hocr', 'sandwich'] diff --git a/tests/test_stdio.py b/tests/test_stdio.py index 2f58306a..883d48de 100644 --- a/tests/test_stdio.py +++ b/tests/test_stdio.py @@ -30,7 +30,6 @@ from ocrmypdf.helpers import check_pdf run_ocrmypdf = pytest.helpers.run_ocrmypdf run_ocrmypdf_api = pytest.helpers.run_ocrmypdf -spoof = pytest.helpers.spoof def test_stdin(ocrmypdf_exec, resources, outpdf): From c22f2456064d70cbae6e25c9ac799fd6e76c47a8 Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Mon, 8 Jun 2020 23:48:45 -0700 Subject: [PATCH 88/94] Plugins must return not-None if they intend to stop builtin --- src/ocrmypdf/builtin_plugins/ghostscript.py | 6 ++++-- src/ocrmypdf/pluginspec.py | 9 ++++++--- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/ocrmypdf/builtin_plugins/ghostscript.py b/src/ocrmypdf/builtin_plugins/ghostscript.py index 90cf41a3..c74bb360 100644 --- a/src/ocrmypdf/builtin_plugins/ghostscript.py +++ b/src/ocrmypdf/builtin_plugins/ghostscript.py @@ -78,7 +78,7 @@ def rasterize_pdf_page( rotation=None, filter_vector=False, ): - return ghostscript.rasterize_pdf( + ghostscript.rasterize_pdf( input_file, output_file, raster_device=raster_device, @@ -88,14 +88,16 @@ def rasterize_pdf_page( rotation=rotation, filter_vector=filter_vector, ) + return output_file @hookimpl def generate_pdfa(pdf_pages, pdfmark, output_file, compression, pdf_version, pdfa_part): - return ghostscript.generate_pdfa( + ghostscript.generate_pdfa( pdf_pages=[*pdf_pages, pdfmark], output_file=output_file, compression=compression, pdf_version=pdf_version, pdfa_part=pdfa_part, ) + return output_file diff --git a/src/ocrmypdf/pluginspec.py b/src/ocrmypdf/pluginspec.py index b83aaaa2..e3961fbf 100644 --- a/src/ocrmypdf/pluginspec.py +++ b/src/ocrmypdf/pluginspec.py @@ -78,7 +78,7 @@ def rasterize_pdf_page( page_dpi: Optional[Resolution] = None, rotation: Optional[int] = None, filter_vector: bool = False, -) -> None: +) -> Path: """Rasterize one page of a PDF at resolution raster_dpi in canvas units. The image is sized to match the integer pixels dimensions implied by @@ -93,7 +93,7 @@ def rasterize_pdf_page( rotation: cardinal angle, clockwise, to rotate page filter_vector: if True, remove vector graphics objects Returns: - None + output_file """ @@ -172,7 +172,7 @@ def generate_pdfa( compression: str, pdf_version: str, pdfa_part: str, -): +) -> Path: """Generate a PDF/A. The pdf_pages, a list of files, will be merged into output_file. One or more @@ -189,4 +189,7 @@ def generate_pdfa( support passthrough JPEG which allows Ghostscript to avoid transcoding images entirely. (The feature was added in 9.23 but broken, and the 9.24 release of Ghostscript had regressions, so we don't support it until 9.25.) + + Returns: + output_file """ From 2059e916da89bbfc2fc45b41707a40ac08c166d7 Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Tue, 9 Jun 2020 00:00:25 -0700 Subject: [PATCH 89/94] Convert all ghostscript spoofs to test plugins --- setup.cfg | 2 +- .../{spoof => plugins}/gs_feature_elision.py | 46 +++++++-------- tests/{spoof => plugins}/gs_pdfa_failure.py | 55 ++++++++---------- tests/{spoof => plugins}/gs_raster_failure.py | 58 +++++++++++-------- tests/{spoof => plugins}/gs_render_failure.py | 46 +++++++-------- tests/test_ghostscript.py | 40 ++++--------- 6 files changed, 114 insertions(+), 133 deletions(-) rename tests/{spoof => plugins}/gs_feature_elision.py (59%) mode change 100755 => 100644 rename tests/{spoof => plugins}/gs_pdfa_failure.py (59%) mode change 100755 => 100644 rename tests/{spoof => plugins}/gs_raster_failure.py (51%) mode change 100755 => 100644 rename tests/{spoof => plugins}/gs_render_failure.py (55%) mode change 100755 => 100644 diff --git a/setup.cfg b/setup.cfg index 3cb3db9d..487ed30d 100644 --- a/setup.cfg +++ b/setup.cfg @@ -23,7 +23,7 @@ force_grid_wrap=0 use_parentheses=True line_length=88 known_first_party = ocrmypdf -known_third_party = PIL,_cffi_backend,cffi,flask,gs,img2pdf,pdfminer,pikepdf,pkg_resources,pluggy,pytest,reportlab,setuptools,sphinx_rtd_theme,tqdm,watchdog,werkzeug +known_third_party = PIL,_cffi_backend,cffi,flask,img2pdf,pdfminer,pikepdf,pkg_resources,pluggy,pytest,reportlab,setuptools,sphinx_rtd_theme,tqdm,watchdog,werkzeug [metadata] license_file = LICENSE 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..84f5e6e1 --- 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.exec 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..f9093224 --- 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.exec 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..cab3268e --- 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.exec 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..e1d934ce --- 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.exec 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/test_ghostscript.py b/tests/test_ghostscript.py index 0e6931df..a58a3c04 100644 --- a/tests/test_ghostscript.py +++ b/tests/test_ghostscript.py @@ -32,26 +32,6 @@ run_ocrmypdf_api = pytest.helpers.run_ocrmypdf_api spoof = pytest.helpers.spoof -@pytest.fixture -def spoof_gs_render_fail(tmp_path_factory): - return spoof(tmp_path_factory, gs='gs_render_failure.py') - - -@pytest.fixture -def spoof_gs_raster_fail(tmp_path_factory): - return spoof(tmp_path_factory, gs='gs_raster_failure.py') - - -@pytest.fixture -def spoof_no_pdfa(tmp_path_factory): - return spoof(tmp_path_factory, gs='gs_pdfa_failure.py') - - -@pytest.fixture -def spoof_pdfa_warning(tmp_path_factory): - return spoof(tmp_path_factory, gs='gs_feature_elision.py') - - @pytest.fixture def francais(resources): path = resources / 'francais.pdf' @@ -106,48 +86,52 @@ def test_rasterize_rotated(francais, outdir, caplog): assert im.info['dpi'] == (forced_dpi[1], forced_dpi[0]) -def test_gs_render_failure(spoof_gs_render_fail, resources, outpdf): +def test_gs_render_failure(resources, outpdf): p, out, err = run_ocrmypdf( resources / 'blank.pdf', outpdf, '--plugin', 'tests/plugins/tesseract_noop.py', - env=spoof_gs_render_fail, + '--plugin', + 'tests/plugins/gs_render_failure.py', ) assert 'Casper is not a friendly ghost' in err assert p.returncode == ExitCode.child_process_error -def test_gs_raster_failure(spoof_gs_raster_fail, resources, outpdf): +def test_gs_raster_failure(resources, outpdf): p, out, err = run_ocrmypdf( resources / 'francais.pdf', outpdf, '--plugin', 'tests/plugins/tesseract_noop.py', - env=spoof_gs_raster_fail, + '--plugin', + 'tests/plugins/gs_raster_failure.py', ) assert 'Ghost story archive not found' in err assert p.returncode == ExitCode.child_process_error -def test_ghostscript_pdfa_failure(spoof_no_pdfa, resources, outpdf): +def test_ghostscript_pdfa_failure(resources, outpdf): p, out, err = run_ocrmypdf( resources / 'francais.pdf', outpdf, '--plugin', 'tests/plugins/tesseract_noop.py', - env=spoof_no_pdfa, + '--plugin', + 'tests/plugins/gs_pdfa_failure.py', ) assert ( p.returncode == ExitCode.pdfa_conversion_failed ), "Unexpected return when PDF/A fails" -def test_ghostscript_feature_elision(spoof_pdfa_warning, resources, outpdf): +def test_ghostscript_feature_elision(resources, outpdf): check_ocrmypdf( resources / 'francais.pdf', outpdf, '--plugin', 'tests/plugins/tesseract_noop.py', - env=spoof_pdfa_warning, + '--plugin', + 'tests/plugins/gs_feature_elision.py', ) From ebbf68bd08dd25ea2afd5f674f9a7f3bb2903685 Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Tue, 9 Jun 2020 00:08:20 -0700 Subject: [PATCH 90/94] The big payoff: abolishing spoofing machinery --- src/ocrmypdf/exec/tesseract.py | 4 +- tests/conftest.py | 115 +-------------------------------- tests/test_ghostscript.py | 1 - 3 files changed, 4 insertions(+), 116 deletions(-) diff --git a/src/ocrmypdf/exec/tesseract.py b/src/ocrmypdf/exec/tesseract.py index 8e0f4608..591445b7 100644 --- a/src/ocrmypdf/exec/tesseract.py +++ b/src/ocrmypdf/exec/tesseract.py @@ -272,7 +272,7 @@ def generate_hocr( if user_patterns: args_tesseract.extend(['--user-patterns', user_patterns]) - # Reminder: test suite tesseract spoofers will break after any changes + # Reminder: test suite tesseract test plugins will break after any changes # to the number of order parameters here args_tesseract.extend([input_file, prefix, 'hocr', 'txt'] + tessconfig) try: @@ -353,7 +353,7 @@ def generate_pdf( prefix = os.path.splitext(output_pdf)[0] # Tesseract appends suffixes - # Reminder: test suite tesseract spoofers might break after any changes + # Reminder: test suite tesseract test plugins might break after any changes # to the number of order parameters here args_tesseract.extend([input_file, prefix, 'pdf', 'txt'] + tessconfig) diff --git a/tests/conftest.py b/tests/conftest.py index 0b88a070..75789771 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -71,104 +71,10 @@ def have_unpaper(): TESTS_ROOT = Path(__file__).parent.resolve() -SPOOF_PATH = TESTS_ROOT / 'spoof' 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): - r"""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 = 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 resources(): return Path(TESTS_ROOT) / 'resources' @@ -208,12 +114,7 @@ def check_ocrmypdf(input_file, output_file, *args, env=None): _parser, options, plugin_manager = get_parser_options_plugins(args=args) api.check_options(options, plugin_manager) if env: - first = env['_OCRMYPDF_TEST_PATH'].split(os.pathsep)[0] - if 'tesseract_noop' in first: - raise ValueError('noop') - else: - options.tesseract_env = env - options.tesseract_env['_OCRMYPDF_TEST_INFILE'] = os.fspath(input_file) + assert False, 'env set' result = api.run_pipeline(options, plugin_manager=plugin_manager, api=True) assert result == 0 @@ -235,19 +136,7 @@ def run_ocrmypdf_api(input_file, output_file, *args, env=None): ] _parser, options, plugin_manager = get_parser_options_plugins(args=args) if env: - try: - first = env['_OCRMYPDF_TEST_PATH'].split(os.pathsep)[0] - if 'tesseract_noop' in first: - raise ValueError('noop') - else: - 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 - except KeyError: - pass + assert False, 'env set' if options.tesseract_env: assert all(isinstance(v, (str, bytes)) for v in options.tesseract_env.values()) diff --git a/tests/test_ghostscript.py b/tests/test_ghostscript.py index a58a3c04..34472e91 100644 --- a/tests/test_ghostscript.py +++ b/tests/test_ghostscript.py @@ -29,7 +29,6 @@ from ocrmypdf.helpers import Resolution check_ocrmypdf = pytest.helpers.check_ocrmypdf run_ocrmypdf = pytest.helpers.run_ocrmypdf run_ocrmypdf_api = pytest.helpers.run_ocrmypdf_api -spoof = pytest.helpers.spoof @pytest.fixture From 21c0e045cbdd2a7a7253532ff34085a248239f03 Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Tue, 9 Jun 2020 00:30:13 -0700 Subject: [PATCH 91/94] Remove _OCRMYPDF_TEST_PATH environment variable --- src/ocrmypdf/exec/_support.py | 15 ++------------- tests/conftest.py | 14 ++++---------- tests/test_stdio.py | 8 +++----- 3 files changed, 9 insertions(+), 28 deletions(-) diff --git a/src/ocrmypdf/exec/_support.py b/src/ocrmypdf/exec/_support.py index 5f52ac87..3ff10558 100644 --- a/src/ocrmypdf/exec/_support.py +++ b/src/ocrmypdf/exec/_support.py @@ -34,20 +34,10 @@ from ocrmypdf.exceptions import MissingDependencyError log = logging.getLogger(__name__) -def _get_program(args, env=None): - program = args[0] - test_path = env.get('_OCRMYPDF_TEST_PATH', '') - if test_path: - program = shutil.which(program, path=test_path) - return program - - def run(args, *, env=None, **kwargs): """Wrapper around subprocess.run() - The main purpose of this wrapper is to allow us to substitute the main program - for a spoof in the test suite. The hidden variable _OCRMYPDF_TEST_PATH replaces - the main PATH as a location to check for programs to run. + The main purpose of this wrapper is to log subprocess output. Secondly we have to account for behavioral differences in Windows in particular. Creating symbolic links in Windows requires administrator privileges and @@ -62,8 +52,7 @@ def run(args, *, env=None, **kwargs): env = os.environ # Search in spoof path if necessary - program = _get_program(args, env) - args = [program] + args[1:] + program = args[0] if os.name == 'nt': args = fix_windows_args(program, args, env) diff --git a/tests/conftest.py b/tests/conftest.py index 75789771..b520d73b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -105,7 +105,7 @@ 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 @@ -113,8 +113,6 @@ def check_ocrmypdf(input_file, output_file, *args, env=None): _parser, options, plugin_manager = get_parser_options_plugins(args=args) api.check_options(options, plugin_manager) - if env: - assert False, 'env set' result = api.run_pipeline(options, plugin_manager=plugin_manager, api=True) assert result == 0 @@ -125,7 +123,7 @@ def check_ocrmypdf(input_file, output_file, *args, env=None): @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. @@ -135,8 +133,6 @@ def run_ocrmypdf_api(input_file, output_file, *args, env=None): str(arg) for arg in args if arg is not None ] _parser, options, plugin_manager = get_parser_options_plugins(args=args) - if env: - assert False, 'env set' if options.tesseract_env: assert all(isinstance(v, (str, bytes)) for v in options.tesseract_env.values()) @@ -145,12 +141,9 @@ def run_ocrmypdf_api(input_file, output_file, *args, env=None): @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] @@ -162,6 +155,7 @@ 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( diff --git a/tests/test_stdio.py b/tests/test_stdio.py index 883d48de..478661c2 100644 --- a/tests/test_stdio.py +++ b/tests/test_stdio.py @@ -105,11 +105,9 @@ def test_closed_streams(ocrmypdf_exec, resources, outpdf): Path('/etc/alpine-release').exists(), reason="invalid test on alpine" ) @pytest.mark.skipif(os.name == 'nt', reason="invalid test on Windows") -def test_bad_locale(): - env = os.environ.copy() - env['LC_ALL'] = 'C' - - p, out, err = run_ocrmypdf('a', 'b', env=env) +def test_bad_locale(monkeypatch): + monkeypatch.setenv('LC_ALL', 'C') + p, out, err = run_ocrmypdf('a', 'b') assert out == '', "stdout not clean" assert p.returncode != 0 assert 'configured to use ASCII as encoding' in err, "should whine" From 3b6f6782f0a3950d0c0f4e6f19f16ec2f2acbd9a Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Tue, 9 Jun 2020 00:39:53 -0700 Subject: [PATCH 92/94] Remove tesseract_env, --tesseract-env --- src/ocrmypdf/api.py | 2 +- src/ocrmypdf/builtin_plugins/tesseract_ocr.py | 25 ++------ src/ocrmypdf/exec/tesseract.py | 57 ++++--------------- tests/conftest.py | 2 - tests/test_tesseract.py | 10 +--- 5 files changed, 22 insertions(+), 74 deletions(-) diff --git a/src/ocrmypdf/api.py b/src/ocrmypdf/api.py index 008d284d..7e389d88 100644 --- a/src/ocrmypdf/api.py +++ b/src/ocrmypdf/api.py @@ -143,7 +143,7 @@ def create_options( # These arguments with special handling for which we bypass # argparse - if arg in {'tesseract_env', 'progress_bar', 'plugins'}: + if arg in {'progress_bar', 'plugins'}: deferred.append((arg, val)) continue diff --git a/src/ocrmypdf/builtin_plugins/tesseract_ocr.py b/src/ocrmypdf/builtin_plugins/tesseract_ocr.py index d5f5eb7b..150fcedf 100644 --- a/src/ocrmypdf/builtin_plugins/tesseract_ocr.py +++ b/src/ocrmypdf/builtin_plugins/tesseract_ocr.py @@ -81,7 +81,6 @@ def add_options(parser): metavar='FILE', help="Specify the location of the Tesseract user patterns file.", ) - tess.add_argument('--tesseract-env', type=str, help=argparse.SUPPRESS) @hookimpl @@ -98,15 +97,13 @@ def check_options(options): options.pdf_renderer = 'sandwich' if options.pdf_renderer == 'sandwich' and not tesseract.has_textonly_pdf( - options.tesseract_env, set(options.languages) + set(options.languages) ): raise MissingDependencyError( "You are using an alpha version of Tesseract 4.0 that does not support " "the textonly_pdf parameter. We don't support versions this old." ) - if not tesseract.has_user_words(options.tesseract_env) and ( - options.user_words or options.user_patterns - ): + if not tesseract.has_user_words() and (options.user_words or options.user_patterns): log.warning( "Tesseract 4.0 ignores --user-words and --user-patterns, so these " "arguments have no effect." @@ -120,13 +117,6 @@ def check_options(options): @hookimpl def validate(pdfinfo, options): - if not options.tesseract_env: - return - - # If we are running a Tesseract spoof, ensure it knows what the input file is - if os.environ.get('PYTEST_CURRENT_TEST'): - options.tesseract_env['_OCRMYPDF_TEST_INFILE'] = os.fspath(options.input_file) - # Tesseract 4.x can be multithreaded, and we also run multiple workers. We want # to manage how many threads it uses to avoid creating total threads than cores. # Performance testing shows we're better off @@ -135,11 +125,11 @@ def validate(pdfinfo, options): # input file is small, then we allow Tesseract to use threads, subject to the # constraint: (ocrmypdf workers) * (tesseract threads) <= max_workers. # As of Tesseract 4.1, 3 threads is the most effective on a 4 core/8 thread system. - if not options.tesseract_env.get('OMP_THREAD_LIMIT', '').isnumeric(): + if not os.environ.get('OMP_THREAD_LIMIT', '').isnumeric(): tess_threads = min(3, options.jobs // len(pdfinfo), len(pdfinfo)) - options.tesseract_env['OMP_THREAD_LIMIT'] = str(tess_threads) + os.environ['OMP_THREAD_LIMIT'] = str(tess_threads) else: - tess_threads = int(options.tesseract_env['OMP_THREAD_LIMIT']) + tess_threads = int(os.environ['OMP_THREAD_LIMIT']) if tess_threads > 1: log.info("Using Tesseract OpenMP thread limit %d", tess_threads) @@ -160,7 +150,7 @@ class TesseractOcrEngine(OcrEngine): @staticmethod def languages(options): - return tesseract.get_languages(options.tesseract_env) + return tesseract.get_languages() @staticmethod def get_orientation(input_file, options): @@ -168,7 +158,6 @@ class TesseractOcrEngine(OcrEngine): input_file, engine_mode=options.tesseract_oem, timeout=options.tesseract_timeout, - tesseract_env=options.tesseract_env, ) @staticmethod @@ -184,7 +173,6 @@ class TesseractOcrEngine(OcrEngine): pagesegmode=options.tesseract_pagesegmode, user_words=options.user_words, user_patterns=options.user_patterns, - tesseract_env=options.tesseract_env, ) @staticmethod @@ -200,7 +188,6 @@ class TesseractOcrEngine(OcrEngine): pagesegmode=options.tesseract_pagesegmode, user_words=options.user_words, user_patterns=options.user_patterns, - tesseract_env=options.tesseract_env, ) diff --git a/src/ocrmypdf/exec/tesseract.py b/src/ocrmypdf/exec/tesseract.py index 591445b7..d163d305 100644 --- a/src/ocrmypdf/exec/tesseract.py +++ b/src/ocrmypdf/exec/tesseract.py @@ -65,11 +65,11 @@ class TesseractLoggerAdapter(logging.LoggerAdapter): return '[tesseract] %s' % (msg), kwargs -def version(tesseract_env=None): - return get_version('tesseract', regex=r'tesseract\s(.+)', env=tesseract_env) +def version(): + return get_version('tesseract', regex=r'tesseract\s(.+)') -def has_textonly_pdf(tesseract_env=None, langs=None): +def has_textonly_pdf(langs=None): """Does Tesseract have textonly_pdf capability? Available in v4.00.00alpha since January 2017. Best to @@ -79,12 +79,7 @@ def has_textonly_pdf(tesseract_env=None, langs=None): params = '' try: proc = run( - args_tess, - check=True, - universal_newlines=True, - stdout=PIPE, - stderr=STDOUT, - env=tesseract_env, + args_tess, check=True, universal_newlines=True, stdout=PIPE, stderr=STDOUT ) params = proc.stdout except CalledProcessError as e: @@ -97,16 +92,16 @@ def has_textonly_pdf(tesseract_env=None, langs=None): return False -def has_user_words(tesseract_env=None): +def has_user_words(): """Does Tesseract have --user-words capability? Not available in 4.0, but available in 4.1. Also available in 3.x, but we no longer support 3.x. """ - return version(tesseract_env) >= '4.1' + return version() >= '4.1' -def get_languages(tesseract_env=None): +def get_languages(): def lang_error(output): msg = ( "Tesseract failed to report available languages.\n" @@ -119,12 +114,7 @@ def get_languages(tesseract_env=None): args_tess = ['tesseract', '--list-langs'] try: proc = run( - args_tess, - universal_newlines=True, - stdout=PIPE, - stderr=STDOUT, - check=True, - env=tesseract_env, + args_tess, universal_newlines=True, stdout=PIPE, stderr=STDOUT, check=True ) output = proc.stdout except CalledProcessError as e: @@ -146,7 +136,7 @@ def tess_base_args(langs: List[str], engine_mode) -> List[str]: return args -def get_orientation(input_file: Path, engine_mode, timeout: float, tesseract_env=None): +def get_orientation(input_file: Path, engine_mode, timeout: float): args_tesseract = tess_base_args(['osd'], engine_mode) + [ '--psm', '0', @@ -155,14 +145,7 @@ def get_orientation(input_file: Path, engine_mode, timeout: float, tesseract_env ] try: - p = run( - args_tesseract, - stdout=PIPE, - stderr=STDOUT, - timeout=timeout, - check=True, - env=tesseract_env, - ) + p = run(args_tesseract, stdout=PIPE, stderr=STDOUT, timeout=timeout, check=True) stdout = p.stdout except TimeoutExpired: return OrientationConfidence(angle=0, confidence=0.0) @@ -257,7 +240,6 @@ def generate_hocr( pagesegmode: int, user_words, user_patterns, - tesseract_env, ): prefix = output_hocr.with_suffix('') @@ -276,14 +258,7 @@ def generate_hocr( # to the number of order parameters here args_tesseract.extend([input_file, prefix, 'hocr', 'txt'] + tessconfig) try: - p = run( - args_tesseract, - stdout=PIPE, - stderr=STDOUT, - timeout=timeout, - check=True, - env=tesseract_env, - ) + p = run(args_tesseract, stdout=PIPE, stderr=STDOUT, timeout=timeout, check=True) stdout = p.stdout except TimeoutExpired: # Generate a HOCR file with no recognized text if tesseract times out @@ -325,7 +300,6 @@ def generate_pdf( pagesegmode: int, user_words, user_patterns, - tesseract_env, ): """Use Tesseract to render a PDF. @@ -358,14 +332,7 @@ def generate_pdf( args_tesseract.extend([input_file, prefix, 'pdf', 'txt'] + tessconfig) try: - p = run( - args_tesseract, - stdout=PIPE, - stderr=STDOUT, - timeout=timeout, - check=True, - env=tesseract_env, - ) + p = run(args_tesseract, stdout=PIPE, stderr=STDOUT, timeout=timeout, check=True) stdout = p.stdout if os.path.exists(prefix + '.txt'): shutil.move(prefix + '.txt', output_text) diff --git a/tests/conftest.py b/tests/conftest.py index b520d73b..34bbba86 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -133,8 +133,6 @@ def run_ocrmypdf_api(input_file, output_file, *args): str(arg) for arg in args if arg is not None ] _parser, options, plugin_manager = get_parser_options_plugins(args=args) - if options.tesseract_env: - assert all(isinstance(v, (str, bytes)) for v in options.tesseract_env.values()) api.check_options(options, plugin_manager) return api.run_pipeline(options, plugin_manager=None, api=False) diff --git a/tests/test_tesseract.py b/tests/test_tesseract.py index 99222d5a..a8a28dc6 100644 --- a/tests/test_tesseract.py +++ b/tests/test_tesseract.py @@ -70,13 +70,11 @@ def test_content_preservation(resources, outpdf): assert len(page.images) > 1, "masks were rasterized" -def test_no_languages(tmp_path): - env = os.environ.copy() +def test_no_languages(tmp_path, monkeypatch): (tmp_path / 'tessdata').mkdir() - env['TESSDATA_PREFIX'] = fspath(tmp_path) - + monkeypatch.setenv('TESSDATA_PREFIX', fspath(tmp_path)) with pytest.raises(MissingDependencyError): - tesseract.get_languages(tesseract_env=env) + tesseract.get_languages() def test_image_too_large_hocr(monkeypatch, resources, outdir): @@ -95,7 +93,6 @@ def test_image_too_large_hocr(monkeypatch, resources, outdir): pagesegmode=None, user_words=None, user_patterns=None, - tesseract_env=None, ) assert "name='ocr-capabilities'" in Path(outdir / 'out.hocr').read_text() @@ -116,7 +113,6 @@ def test_image_too_large_pdf(monkeypatch, resources, outdir): pagesegmode=None, user_words=None, user_patterns=None, - tesseract_env=None, ) assert Path(outdir / 'txt.txt').read_text() == '[skipped page]' if os.name != 'nt': # different semantics From be8ca589d42a980e0b3d3c2c9fdfb871adb0c764 Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Tue, 9 Jun 2020 14:53:10 -0700 Subject: [PATCH 93/94] Move ocrmypdf.exec.run and friends to ocrmypdf.subprocess --- src/ocrmypdf/_validation.py | 3 ++- src/ocrmypdf/builtin_plugins/ghostscript.py | 3 ++- src/ocrmypdf/builtin_plugins/tesseract_ocr.py | 3 ++- src/ocrmypdf/exec/__init__.py | 9 +-------- src/ocrmypdf/exec/ghostscript.py | 2 +- src/ocrmypdf/exec/jbig2enc.py | 2 +- src/ocrmypdf/exec/pngquant.py | 3 +-- src/ocrmypdf/exec/tesseract.py | 2 +- src/ocrmypdf/exec/unpaper.py | 4 ++-- src/ocrmypdf/leptonica.py | 2 +- src/ocrmypdf/{exec/_support.py => subprocess.py} | 4 ++-- tests/plugins/gs_feature_elision.py | 2 +- tests/plugins/gs_pdfa_failure.py | 2 +- tests/plugins/gs_raster_failure.py | 2 +- tests/plugins/gs_render_failure.py | 2 +- tests/plugins/tesseract_cache.py | 2 +- tests/test_main.py | 3 ++- 17 files changed, 23 insertions(+), 27 deletions(-) rename src/ocrmypdf/{exec/_support.py => subprocess.py} (99%) diff --git a/src/ocrmypdf/_validation.py b/src/ocrmypdf/_validation.py index 3a92d34c..ca5dedda 100644 --- a/src/ocrmypdf/_validation.py +++ b/src/ocrmypdf/_validation.py @@ -34,13 +34,14 @@ from ocrmypdf.exceptions import ( MissingDependencyError, OutputFileAccessError, ) -from ocrmypdf.exec import check_external_program, jbig2enc, pngquant, unpaper +from ocrmypdf.exec import jbig2enc, pngquant, unpaper from ocrmypdf.helpers import ( is_file_writable, is_iterable_notstr, monotonic, safe_symlink, ) +from ocrmypdf.subprocess import check_external_program # ------------- # External dependencies diff --git a/src/ocrmypdf/builtin_plugins/ghostscript.py b/src/ocrmypdf/builtin_plugins/ghostscript.py index c74bb360..76b86d94 100644 --- a/src/ocrmypdf/builtin_plugins/ghostscript.py +++ b/src/ocrmypdf/builtin_plugins/ghostscript.py @@ -21,8 +21,9 @@ from pathlib import Path from ocrmypdf import hookimpl from ocrmypdf._validation import HOCR_OK_LANGS from ocrmypdf.exceptions import MissingDependencyError -from ocrmypdf.exec import check_external_program, ghostscript +from ocrmypdf.exec import ghostscript from ocrmypdf.helpers import Resolution +from ocrmypdf.subprocess import check_external_program log = logging.getLogger(__name__) diff --git a/src/ocrmypdf/builtin_plugins/tesseract_ocr.py b/src/ocrmypdf/builtin_plugins/tesseract_ocr.py index 150fcedf..2d7ae3e2 100644 --- a/src/ocrmypdf/builtin_plugins/tesseract_ocr.py +++ b/src/ocrmypdf/builtin_plugins/tesseract_ocr.py @@ -22,8 +22,9 @@ import os from ocrmypdf import hookimpl from ocrmypdf.cli import numeric from ocrmypdf.exceptions import MissingDependencyError -from ocrmypdf.exec import check_external_program, tesseract +from ocrmypdf.exec import tesseract from ocrmypdf.pluginspec import OcrEngine +from ocrmypdf.subprocess import check_external_program log = logging.getLogger(__name__) diff --git a/src/ocrmypdf/exec/__init__.py b/src/ocrmypdf/exec/__init__.py index 13b4e48c..8c6d0bb3 100644 --- a/src/ocrmypdf/exec/__init__.py +++ b/src/ocrmypdf/exec/__init__.py @@ -15,11 +15,4 @@ # You should have received a copy of the GNU General Public License # along with OCRmyPDF. If not, see . -"""Wrappers to manage subprocess calls""" - -from ocrmypdf.exec._support import ( - check_external_program, - get_version, - run, - shim_paths_with_program_files, -) +"""Manage third party executables""" diff --git a/src/ocrmypdf/exec/ghostscript.py b/src/ocrmypdf/exec/ghostscript.py index 9fe690b9..0fb65b1b 100644 --- a/src/ocrmypdf/exec/ghostscript.py +++ b/src/ocrmypdf/exec/ghostscript.py @@ -29,8 +29,8 @@ from subprocess import PIPE, CalledProcessError from PIL import Image from ocrmypdf.exceptions import MissingDependencyError, SubprocessOutputError -from ocrmypdf.exec import get_version, run from ocrmypdf.helpers import Resolution +from ocrmypdf.subprocess import get_version, run log = logging.getLogger(__name__) diff --git a/src/ocrmypdf/exec/jbig2enc.py b/src/ocrmypdf/exec/jbig2enc.py index c027e28d..deced89a 100644 --- a/src/ocrmypdf/exec/jbig2enc.py +++ b/src/ocrmypdf/exec/jbig2enc.py @@ -20,7 +20,7 @@ from subprocess import PIPE from ocrmypdf.exceptions import MissingDependencyError -from ocrmypdf.exec import get_version, run +from ocrmypdf.subprocess import get_version, run def version(): diff --git a/src/ocrmypdf/exec/pngquant.py b/src/ocrmypdf/exec/pngquant.py index ad00560f..61f197fe 100644 --- a/src/ocrmypdf/exec/pngquant.py +++ b/src/ocrmypdf/exec/pngquant.py @@ -17,13 +17,12 @@ """Interface to pngquant executable""" -from subprocess import run from tempfile import NamedTemporaryFile from PIL import Image from ocrmypdf.exceptions import MissingDependencyError -from ocrmypdf.exec import get_version +from ocrmypdf.subprocess import get_version, run def version(): diff --git a/src/ocrmypdf/exec/tesseract.py b/src/ocrmypdf/exec/tesseract.py index d163d305..3beb6ff2 100644 --- a/src/ocrmypdf/exec/tesseract.py +++ b/src/ocrmypdf/exec/tesseract.py @@ -34,8 +34,8 @@ from ocrmypdf.exceptions import ( SubprocessOutputError, TesseractConfigError, ) -from ocrmypdf.exec import get_version, run from ocrmypdf.helpers import safe_symlink +from ocrmypdf.subprocess import get_version, run log = logging.getLogger(__name__) diff --git a/src/ocrmypdf/exec/unpaper.py b/src/ocrmypdf/exec/unpaper.py index a1aa749e..e1a58746 100644 --- a/src/ocrmypdf/exec/unpaper.py +++ b/src/ocrmypdf/exec/unpaper.py @@ -30,8 +30,8 @@ from tempfile import TemporaryDirectory from PIL import Image from ocrmypdf.exceptions import MissingDependencyError, SubprocessOutputError -from ocrmypdf.exec import get_version -from ocrmypdf.exec import run as external_run +from ocrmypdf.subprocess import get_version +from ocrmypdf.subprocess import run as external_run log = logging.getLogger(__name__) diff --git a/src/ocrmypdf/leptonica.py b/src/ocrmypdf/leptonica.py index 50e9b294..6af38089 100644 --- a/src/ocrmypdf/leptonica.py +++ b/src/ocrmypdf/leptonica.py @@ -34,8 +34,8 @@ from os import fspath from tempfile import TemporaryFile from ocrmypdf.exceptions import MissingDependencyError -from ocrmypdf.exec import shim_paths_with_program_files from ocrmypdf.lib._leptonica import ffi +from ocrmypdf.subprocess import shim_paths_with_program_files # pylint: disable=protected-access diff --git a/src/ocrmypdf/exec/_support.py b/src/ocrmypdf/subprocess.py similarity index 99% rename from src/ocrmypdf/exec/_support.py rename to src/ocrmypdf/subprocess.py index 3ff10558..36c36cae 100644 --- a/src/ocrmypdf/exec/_support.py +++ b/src/ocrmypdf/subprocess.py @@ -55,7 +55,7 @@ def run(args, *, env=None, **kwargs): program = args[0] if os.name == 'nt': - args = fix_windows_args(program, args, env) + args = _fix_windows_args(program, args, env) log.debug("Running: %s", args) process_log = log.getChild('subprocess.' + os.path.basename(program)) @@ -80,7 +80,7 @@ def run(args, *, env=None, **kwargs): return proc -def fix_windows_args(program, args, env): +def _fix_windows_args(program, args, env): """Adjust our desired program and command line arguments for use on Windows""" if sys.version_info < (3, 8): diff --git a/tests/plugins/gs_feature_elision.py b/tests/plugins/gs_feature_elision.py index 84f5e6e1..329eecf1 100644 --- a/tests/plugins/gs_feature_elision.py +++ b/tests/plugins/gs_feature_elision.py @@ -23,7 +23,7 @@ from unittest.mock import patch from ocrmypdf import hookimpl from ocrmypdf.builtin_plugins import ghostscript -from ocrmypdf.exec import run +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""" diff --git a/tests/plugins/gs_pdfa_failure.py b/tests/plugins/gs_pdfa_failure.py index f9093224..b14c5ea3 100644 --- a/tests/plugins/gs_pdfa_failure.py +++ b/tests/plugins/gs_pdfa_failure.py @@ -23,7 +23,7 @@ from unittest.mock import patch from ocrmypdf import hookimpl from ocrmypdf.builtin_plugins import ghostscript -from ocrmypdf.exec import run +from ocrmypdf.subprocess import run def run_rig_args(args, **kwargs): diff --git a/tests/plugins/gs_raster_failure.py b/tests/plugins/gs_raster_failure.py index cab3268e..aa032970 100644 --- a/tests/plugins/gs_raster_failure.py +++ b/tests/plugins/gs_raster_failure.py @@ -25,7 +25,7 @@ from unittest.mock import patch from ocrmypdf import hookimpl from ocrmypdf.builtin_plugins import ghostscript -from ocrmypdf.exec import run +from ocrmypdf.subprocess import run def raise_gs_fail(*args, **kwargs): diff --git a/tests/plugins/gs_render_failure.py b/tests/plugins/gs_render_failure.py index e1d934ce..eadee7b3 100644 --- a/tests/plugins/gs_render_failure.py +++ b/tests/plugins/gs_render_failure.py @@ -25,7 +25,7 @@ from unittest.mock import patch from ocrmypdf import hookimpl from ocrmypdf.builtin_plugins import ghostscript -from ocrmypdf.exec import run +from ocrmypdf.subprocess import run def raise_gs_fail(*args, **kwargs): diff --git a/tests/plugins/tesseract_cache.py b/tests/plugins/tesseract_cache.py index 807d65b0..74d3ab03 100644 --- a/tests/plugins/tesseract_cache.py +++ b/tests/plugins/tesseract_cache.py @@ -57,7 +57,7 @@ from unittest.mock import patch from ocrmypdf import hookimpl from ocrmypdf.builtin_plugins.tesseract_ocr import TesseractOcrEngine -from ocrmypdf.exec import run +from ocrmypdf.subprocess import run log = logging.getLogger(__name__) diff --git a/tests/test_main.py b/tests/test_main.py index 0b913a14..b2cd735f 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -29,9 +29,10 @@ from PIL import Image import ocrmypdf from ocrmypdf.exceptions import ExitCode, MissingDependencyError -from ocrmypdf.exec import get_version, ghostscript, tesseract +from ocrmypdf.exec import ghostscript, tesseract from ocrmypdf.pdfa import file_claims_pdfa from ocrmypdf.pdfinfo import Colorspace, Encoding, PdfInfo +from ocrmypdf.subprocess import get_version # pytest.helpers is dynamic # pylint: disable=no-member,redefined-outer-name From 0f942fb714d5a5b7d8ea868f1221899ce85b4626 Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Tue, 9 Jun 2020 14:55:54 -0700 Subject: [PATCH 94/94] Rename ocrmypdf.exec -> ocrmypdf._exec --- src/ocrmypdf/{exec => _exec}/__init__.py | 0 src/ocrmypdf/{exec => _exec}/ghostscript.py | 0 src/ocrmypdf/{exec => _exec}/jbig2enc.py | 0 src/ocrmypdf/{exec => _exec}/pngquant.py | 0 src/ocrmypdf/{exec => _exec}/tesseract.py | 0 src/ocrmypdf/{exec => _exec}/unpaper.py | 0 src/ocrmypdf/_pipeline.py | 2 +- src/ocrmypdf/_validation.py | 2 +- src/ocrmypdf/builtin_plugins/ghostscript.py | 2 +- src/ocrmypdf/builtin_plugins/tesseract_ocr.py | 2 +- src/ocrmypdf/optimize.py | 2 +- tests/conftest.py | 2 +- tests/plugins/gs_feature_elision.py | 2 +- tests/plugins/gs_pdfa_failure.py | 2 +- tests/plugins/gs_raster_failure.py | 2 +- tests/plugins/gs_render_failure.py | 2 +- tests/plugins/tesseract_badutf8.py | 4 ++-- tests/plugins/tesseract_big_image_error.py | 6 +++--- tests/plugins/tesseract_cache.py | 6 +++--- tests/plugins/tesseract_crash.py | 6 +++--- tests/test_ghostscript.py | 2 +- tests/test_hocrtransform.py | 2 +- tests/test_main.py | 2 +- tests/test_optimize.py | 4 ++-- tests/test_pdfinfo.py | 2 +- tests/test_preprocessing.py | 2 +- tests/test_rotation.py | 2 +- tests/test_tesseract.py | 2 +- tests/test_unpaper.py | 4 ++-- tests/test_validation.py | 18 +++++++++--------- 30 files changed, 41 insertions(+), 41 deletions(-) rename src/ocrmypdf/{exec => _exec}/__init__.py (100%) rename src/ocrmypdf/{exec => _exec}/ghostscript.py (100%) rename src/ocrmypdf/{exec => _exec}/jbig2enc.py (100%) rename src/ocrmypdf/{exec => _exec}/pngquant.py (100%) rename src/ocrmypdf/{exec => _exec}/tesseract.py (100%) rename src/ocrmypdf/{exec => _exec}/unpaper.py (100%) diff --git a/src/ocrmypdf/exec/__init__.py b/src/ocrmypdf/_exec/__init__.py similarity index 100% rename from src/ocrmypdf/exec/__init__.py rename to src/ocrmypdf/_exec/__init__.py diff --git a/src/ocrmypdf/exec/ghostscript.py b/src/ocrmypdf/_exec/ghostscript.py similarity index 100% rename from src/ocrmypdf/exec/ghostscript.py rename to src/ocrmypdf/_exec/ghostscript.py diff --git a/src/ocrmypdf/exec/jbig2enc.py b/src/ocrmypdf/_exec/jbig2enc.py similarity index 100% rename from src/ocrmypdf/exec/jbig2enc.py rename to src/ocrmypdf/_exec/jbig2enc.py diff --git a/src/ocrmypdf/exec/pngquant.py b/src/ocrmypdf/_exec/pngquant.py similarity index 100% rename from src/ocrmypdf/exec/pngquant.py rename to src/ocrmypdf/_exec/pngquant.py diff --git a/src/ocrmypdf/exec/tesseract.py b/src/ocrmypdf/_exec/tesseract.py similarity index 100% rename from src/ocrmypdf/exec/tesseract.py rename to src/ocrmypdf/_exec/tesseract.py diff --git a/src/ocrmypdf/exec/unpaper.py b/src/ocrmypdf/_exec/unpaper.py similarity index 100% rename from src/ocrmypdf/exec/unpaper.py rename to src/ocrmypdf/_exec/unpaper.py diff --git a/src/ocrmypdf/_pipeline.py b/src/ocrmypdf/_pipeline.py index 094b0858..20bc3dfb 100644 --- a/src/ocrmypdf/_pipeline.py +++ b/src/ocrmypdf/_pipeline.py @@ -28,6 +28,7 @@ from pikepdf.models.metadata import encode_pdf_date from PIL import Image, ImageColor, ImageDraw from ocrmypdf import leptonica +from ocrmypdf._exec import ghostscript, unpaper from ocrmypdf._version import PROGRAM_NAME from ocrmypdf._version import __version__ as VERSION from ocrmypdf.exceptions import ( @@ -37,7 +38,6 @@ from ocrmypdf.exceptions import ( PriorOcrFoundError, UnsupportedImageFormatError, ) -from ocrmypdf.exec import ghostscript, unpaper from ocrmypdf.helpers import Resolution, safe_symlink from ocrmypdf.hocrtransform import HocrTransform from ocrmypdf.optimize import optimize diff --git a/src/ocrmypdf/_validation.py b/src/ocrmypdf/_validation.py index ca5dedda..02483fb1 100644 --- a/src/ocrmypdf/_validation.py +++ b/src/ocrmypdf/_validation.py @@ -27,6 +27,7 @@ from shutil import copyfileobj import PIL +from ocrmypdf._exec import jbig2enc, pngquant, unpaper from ocrmypdf._unicodefun import verify_python3_env from ocrmypdf.exceptions import ( BadArgsError, @@ -34,7 +35,6 @@ from ocrmypdf.exceptions import ( MissingDependencyError, OutputFileAccessError, ) -from ocrmypdf.exec import jbig2enc, pngquant, unpaper from ocrmypdf.helpers import ( is_file_writable, is_iterable_notstr, diff --git a/src/ocrmypdf/builtin_plugins/ghostscript.py b/src/ocrmypdf/builtin_plugins/ghostscript.py index 76b86d94..e451c771 100644 --- a/src/ocrmypdf/builtin_plugins/ghostscript.py +++ b/src/ocrmypdf/builtin_plugins/ghostscript.py @@ -19,9 +19,9 @@ import logging from pathlib import Path from ocrmypdf import hookimpl +from ocrmypdf._exec import ghostscript from ocrmypdf._validation import HOCR_OK_LANGS from ocrmypdf.exceptions import MissingDependencyError -from ocrmypdf.exec import ghostscript from ocrmypdf.helpers import Resolution from ocrmypdf.subprocess import check_external_program diff --git a/src/ocrmypdf/builtin_plugins/tesseract_ocr.py b/src/ocrmypdf/builtin_plugins/tesseract_ocr.py index 2d7ae3e2..bd15ddbe 100644 --- a/src/ocrmypdf/builtin_plugins/tesseract_ocr.py +++ b/src/ocrmypdf/builtin_plugins/tesseract_ocr.py @@ -20,9 +20,9 @@ import logging import os from ocrmypdf import hookimpl +from ocrmypdf._exec import tesseract from ocrmypdf.cli import numeric from ocrmypdf.exceptions import MissingDependencyError -from ocrmypdf.exec import tesseract from ocrmypdf.pluginspec import OcrEngine from ocrmypdf.subprocess import check_external_program diff --git a/src/ocrmypdf/optimize.py b/src/ocrmypdf/optimize.py index 41bf7c60..35e1d731 100644 --- a/src/ocrmypdf/optimize.py +++ b/src/ocrmypdf/optimize.py @@ -30,9 +30,9 @@ from tqdm import tqdm from ocrmypdf import leptonica from ocrmypdf._concurrent import exec_progress_pool +from ocrmypdf._exec import jbig2enc, pngquant from ocrmypdf._jobcontext import PdfContext from ocrmypdf.exceptions import OutputFileAccessError -from ocrmypdf.exec import jbig2enc, pngquant from ocrmypdf.helpers import safe_symlink log = logging.getLogger(__name__) diff --git a/tests/conftest.py b/tests/conftest.py index 34bbba86..adcd1354 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -25,8 +25,8 @@ from subprocess import PIPE, run import pytest from ocrmypdf import api, cli, pdfinfo +from ocrmypdf._exec import unpaper from ocrmypdf._plugin_manager import get_parser_options_plugins -from ocrmypdf.exec import unpaper pytest_plugins = ['helpers_namespace'] diff --git a/tests/plugins/gs_feature_elision.py b/tests/plugins/gs_feature_elision.py index 329eecf1..419855cb 100644 --- a/tests/plugins/gs_feature_elision.py +++ b/tests/plugins/gs_feature_elision.py @@ -37,7 +37,7 @@ def run_append_stderr(*args, **kwargs): @hookimpl def generate_pdfa(pdf_pages, pdfmark, output_file, compression, pdf_version, pdfa_part): - with patch('ocrmypdf.exec.ghostscript.run', new=run_append_stderr): + with patch('ocrmypdf._exec.ghostscript.run', new=run_append_stderr): ghostscript.generate_pdfa( pdf_pages=pdf_pages, pdfmark=pdfmark, diff --git a/tests/plugins/gs_pdfa_failure.py b/tests/plugins/gs_pdfa_failure.py index b14c5ea3..dcad94f6 100644 --- a/tests/plugins/gs_pdfa_failure.py +++ b/tests/plugins/gs_pdfa_failure.py @@ -39,7 +39,7 @@ def run_rig_args(args, **kwargs): @hookimpl def generate_pdfa(pdf_pages, pdfmark, output_file, compression, pdf_version, pdfa_part): - with patch('ocrmypdf.exec.ghostscript.run', new=run_rig_args): + with patch('ocrmypdf._exec.ghostscript.run', new=run_rig_args): ghostscript.generate_pdfa( pdf_pages=pdf_pages, pdfmark=pdfmark, diff --git a/tests/plugins/gs_raster_failure.py b/tests/plugins/gs_raster_failure.py index aa032970..98b1984c 100644 --- a/tests/plugins/gs_raster_failure.py +++ b/tests/plugins/gs_raster_failure.py @@ -45,7 +45,7 @@ def rasterize_pdf_page( rotation=None, filter_vector=False, ) -> Path: - with patch('ocrmypdf.exec.ghostscript.run', new=raise_gs_fail): + with patch('ocrmypdf._exec.ghostscript.run', new=raise_gs_fail): ghostscript.rasterize_pdf_page( input_file=input_file, output_file=output_file, diff --git a/tests/plugins/gs_render_failure.py b/tests/plugins/gs_render_failure.py index eadee7b3..c27a5801 100644 --- a/tests/plugins/gs_render_failure.py +++ b/tests/plugins/gs_render_failure.py @@ -36,7 +36,7 @@ def raise_gs_fail(*args, **kwargs): @hookimpl def generate_pdfa(pdf_pages, pdfmark, output_file, compression, pdf_version, pdfa_part): - with patch('ocrmypdf.exec.ghostscript.run', new=raise_gs_fail): + with patch('ocrmypdf._exec.ghostscript.run', new=raise_gs_fail): ghostscript.generate_pdfa( pdf_pages=pdf_pages, pdfmark=pdfmark, diff --git a/tests/plugins/tesseract_badutf8.py b/tests/plugins/tesseract_badutf8.py index b87a3dac..3511938d 100644 --- a/tests/plugins/tesseract_badutf8.py +++ b/tests/plugins/tesseract_badutf8.py @@ -45,14 +45,14 @@ def bad_utf8(*args, **kwargs): class BadUtf8OcrEngine(TesseractOcrEngine): @staticmethod def generate_hocr(input_file, output_hocr, output_text, options): - with patch('ocrmypdf.exec.tesseract.run', new=bad_utf8): + 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): + with patch('ocrmypdf._exec.tesseract.run', new=bad_utf8): TesseractOcrEngine.generate_pdf( input_file, output_pdf, output_text, options ) diff --git a/tests/plugins/tesseract_big_image_error.py b/tests/plugins/tesseract_big_image_error.py index f040855c..04d0e0cd 100644 --- a/tests/plugins/tesseract_big_image_error.py +++ b/tests/plugins/tesseract_big_image_error.py @@ -38,19 +38,19 @@ def raise_size_exception(*args, **kwargs): class BigImageErrorOcrEngine(TesseractOcrEngine): @staticmethod def get_orientation(input_file, options): - with patch('ocrmypdf.exec.tesseract.run', new=raise_size_exception): + 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): + 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): + with patch('ocrmypdf._exec.tesseract.run', new=raise_size_exception): TesseractOcrEngine.generate_pdf( input_file, output_pdf, output_text, options ) diff --git a/tests/plugins/tesseract_cache.py b/tests/plugins/tesseract_cache.py index 74d3ab03..1df3fd98 100644 --- a/tests/plugins/tesseract_cache.py +++ b/tests/plugins/tesseract_cache.py @@ -178,19 +178,19 @@ def cached_run(options, run_args, **run_kwargs): class CacheOcrEngine(TesseractOcrEngine): @staticmethod def get_orientation(input_file, options): - with patch('ocrmypdf.exec.tesseract.run', new=partial(cached_run, 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)): + 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)): + with patch('ocrmypdf._exec.tesseract.run', new=partial(cached_run, options)): TesseractOcrEngine.generate_pdf( input_file, output_pdf, output_text, options ) diff --git a/tests/plugins/tesseract_crash.py b/tests/plugins/tesseract_crash.py index 806af41b..74c3970a 100755 --- a/tests/plugins/tesseract_crash.py +++ b/tests/plugins/tesseract_crash.py @@ -41,19 +41,19 @@ def raise_crash(*args, **kwargs): class CrashOcrEngine(TesseractOcrEngine): @staticmethod def get_orientation(input_file, options): - with patch('ocrmypdf.exec.tesseract.run', new=raise_crash): + 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): + 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): + with patch('ocrmypdf._exec.tesseract.run', new=raise_crash): TesseractOcrEngine.generate_pdf( input_file, output_pdf, output_text, options ) diff --git a/tests/test_ghostscript.py b/tests/test_ghostscript.py index 34472e91..a2dd90d7 100644 --- a/tests/test_ghostscript.py +++ b/tests/test_ghostscript.py @@ -22,8 +22,8 @@ import pikepdf import pytest from PIL import Image +from ocrmypdf._exec.ghostscript import rasterize_pdf from ocrmypdf.exceptions import ExitCode -from ocrmypdf.exec.ghostscript import rasterize_pdf from ocrmypdf.helpers import Resolution check_ocrmypdf = pytest.helpers.check_ocrmypdf diff --git a/tests/test_hocrtransform.py b/tests/test_hocrtransform.py index e00f8365..1a1f817a 100644 --- a/tests/test_hocrtransform.py +++ b/tests/test_hocrtransform.py @@ -21,7 +21,7 @@ import pytest from PIL import Image from ocrmypdf import hocrtransform -from ocrmypdf.exec.tesseract import HOCR_TEMPLATE +from ocrmypdf._exec.tesseract import HOCR_TEMPLATE from ocrmypdf.helpers import check_pdf # pylint: disable=redefined-outer-name diff --git a/tests/test_main.py b/tests/test_main.py index b2cd735f..289cbbcc 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -28,8 +28,8 @@ import pytest from PIL import Image import ocrmypdf +from ocrmypdf._exec import ghostscript, tesseract from ocrmypdf.exceptions import ExitCode, MissingDependencyError -from ocrmypdf.exec import ghostscript, tesseract from ocrmypdf.pdfa import file_claims_pdfa from ocrmypdf.pdfinfo import Colorspace, Encoding, PdfInfo from ocrmypdf.subprocess import get_version diff --git a/tests/test_optimize.py b/tests/test_optimize.py index e8cf0717..a957af5f 100644 --- a/tests/test_optimize.py +++ b/tests/test_optimize.py @@ -23,8 +23,8 @@ import pytest from PIL import Image from ocrmypdf import optimize as opt -from ocrmypdf.exec import jbig2enc, pngquant -from ocrmypdf.exec.ghostscript import rasterize_pdf +from ocrmypdf._exec import jbig2enc, pngquant +from ocrmypdf._exec.ghostscript import rasterize_pdf from ocrmypdf.helpers import Resolution check_ocrmypdf = pytest.helpers.check_ocrmypdf # pylint: disable=e1101 diff --git a/tests/test_pdfinfo.py b/tests/test_pdfinfo.py index cfa90d94..558995fc 100644 --- a/tests/test_pdfinfo.py +++ b/tests/test_pdfinfo.py @@ -26,7 +26,7 @@ from PIL import Image from reportlab.pdfgen.canvas import Canvas from ocrmypdf import pdfinfo -from ocrmypdf.exec import ghostscript +from ocrmypdf._exec import ghostscript from ocrmypdf.pdfinfo import Colorspace, Encoding # pylint: disable=protected-access diff --git a/tests/test_preprocessing.py b/tests/test_preprocessing.py index 865b5e58..7cabe827 100644 --- a/tests/test_preprocessing.py +++ b/tests/test_preprocessing.py @@ -20,7 +20,7 @@ from math import isclose import pytest from PIL import Image -from ocrmypdf.exec import ghostscript +from ocrmypdf._exec import ghostscript from ocrmypdf.helpers import Resolution from ocrmypdf.leptonica import Pix from ocrmypdf.pdfinfo import PdfInfo diff --git a/tests/test_rotation.py b/tests/test_rotation.py index 0c8b5dd3..f0e7fcfd 100644 --- a/tests/test_rotation.py +++ b/tests/test_rotation.py @@ -26,7 +26,7 @@ import pytest from PIL import Image from ocrmypdf import leptonica -from ocrmypdf.exec import ghostscript, tesseract +from ocrmypdf._exec import ghostscript, tesseract from ocrmypdf.helpers import Resolution from ocrmypdf.pdfinfo import PdfInfo diff --git a/tests/test_tesseract.py b/tests/test_tesseract.py index a8a28dc6..0db09110 100644 --- a/tests/test_tesseract.py +++ b/tests/test_tesseract.py @@ -24,8 +24,8 @@ from pathlib import Path import pytest from ocrmypdf import pdfinfo +from ocrmypdf._exec import tesseract from ocrmypdf.exceptions import MissingDependencyError -from ocrmypdf.exec import tesseract # pylint: disable=no-member,redefined-outer-name diff --git a/tests/test_unpaper.py b/tests/test_unpaper.py index 9a6dc248..6e28235a 100644 --- a/tests/test_unpaper.py +++ b/tests/test_unpaper.py @@ -39,7 +39,7 @@ def test_no_unpaper(resources, no_outpdf): output = fspath(no_outpdf) _parser, options, pm = get_parser_options_plugins(["--clean", input_, output]) - with patch("ocrmypdf.exec.unpaper.version") as mock_unpaper_version: + with patch("ocrmypdf._exec.unpaper.version") as mock_unpaper_version: mock_unpaper_version.side_effect = FileNotFoundError("unpaper") with pytest.raises(MissingDependencyError): @@ -51,7 +51,7 @@ def test_old_unpaper(resources, no_outpdf): output = fspath(no_outpdf) _parser, options, pm = get_parser_options_plugins(["--clean", input_, output]) - with patch("ocrmypdf.exec.unpaper.version") as mock_unpaper_version: + with patch("ocrmypdf._exec.unpaper.version") as mock_unpaper_version: mock_unpaper_version.return_value = '0.5' with pytest.raises(MissingDependencyError): diff --git a/tests/test_validation.py b/tests/test_validation.py index 3e6272af..3c5ebac6 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -56,27 +56,27 @@ def test_hocr_notlatin_warning(caplog): def test_old_ghostscript(caplog): - with patch('ocrmypdf.exec.ghostscript.version', return_value='9.19'), patch( - 'ocrmypdf.exec.tesseract.has_textonly_pdf', return_value=True + with patch('ocrmypdf._exec.ghostscript.version', return_value='9.19'), patch( + 'ocrmypdf._exec.tesseract.has_textonly_pdf', return_value=True ): vd.check_options(*make_opts_pm(language='chi_sim', output_type='pdfa')) assert 'Ghostscript does not work correctly' in caplog.text - with patch('ocrmypdf.exec.ghostscript.version', return_value='9.18'), patch( - 'ocrmypdf.exec.tesseract.has_textonly_pdf', return_value=True + with patch('ocrmypdf._exec.ghostscript.version', return_value='9.18'), patch( + 'ocrmypdf._exec.tesseract.has_textonly_pdf', return_value=True ): with pytest.raises(MissingDependencyError): vd.check_options(*make_opts_pm(output_type='pdfa-3')) - with patch('ocrmypdf.exec.ghostscript.version', return_value='9.24'), patch( - 'ocrmypdf.exec.tesseract.has_textonly_pdf', return_value=True + with patch('ocrmypdf._exec.ghostscript.version', return_value='9.24'), patch( + 'ocrmypdf._exec.tesseract.has_textonly_pdf', return_value=True ): with pytest.raises(MissingDependencyError): vd.check_options(*make_opts_pm()) def test_old_tesseract_error(): - with patch('ocrmypdf.exec.tesseract.has_textonly_pdf', return_value=False): + with patch('ocrmypdf._exec.tesseract.has_textonly_pdf', return_value=False): with pytest.raises(MissingDependencyError): opts = make_opts(pdf_renderer='sandwich', language='eng') plugin_manager = get_plugin_manager(opts.plugins) @@ -107,13 +107,13 @@ def test_optimizing(caplog): def test_user_words(caplog): - with patch('ocrmypdf.exec.tesseract.has_user_words', return_value=False): + with patch('ocrmypdf._exec.tesseract.has_user_words', return_value=False): opts = make_opts(user_words='foo') plugin_manager = get_plugin_manager(opts.plugins) vd.check_options(opts, plugin_manager) assert '4.0 ignores --user-words' in caplog.text caplog.clear() - with patch('ocrmypdf.exec.tesseract.has_user_words', return_value=True): + with patch('ocrmypdf._exec.tesseract.has_user_words', return_value=True): opts = make_opts(user_patterns='foo') plugin_manager = get_plugin_manager(opts.plugins) vd.check_options(opts, plugin_manager)