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')