ocrmypdf 16.0.0__py310-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ocrmypdf/RELEASE.md +38 -0
- ocrmypdf/__init__.py +70 -0
- ocrmypdf/__main__.py +83 -0
- ocrmypdf/_concurrent.py +129 -0
- ocrmypdf/_exec/__init__.py +6 -0
- ocrmypdf/_exec/ghostscript.py +296 -0
- ocrmypdf/_exec/jbig2enc.py +51 -0
- ocrmypdf/_exec/pngquant.py +52 -0
- ocrmypdf/_exec/tesseract.py +399 -0
- ocrmypdf/_exec/unpaper.py +138 -0
- ocrmypdf/_graft.py +319 -0
- ocrmypdf/_jobcontext.py +100 -0
- ocrmypdf/_logging.py +30 -0
- ocrmypdf/_metadata.py +184 -0
- ocrmypdf/_pipeline.py +994 -0
- ocrmypdf/_pipelines/__init__.py +5 -0
- ocrmypdf/_pipelines/_common.py +454 -0
- ocrmypdf/_pipelines/hocr_to_ocr_pdf.py +134 -0
- ocrmypdf/_pipelines/ocr.py +225 -0
- ocrmypdf/_pipelines/pdf_to_hocr.py +116 -0
- ocrmypdf/_plugin_manager.py +129 -0
- ocrmypdf/_progressbar.py +142 -0
- ocrmypdf/_validation.py +368 -0
- ocrmypdf/_version.py +16 -0
- ocrmypdf/api.py +552 -0
- ocrmypdf/builtin_plugins/__init__.py +6 -0
- ocrmypdf/builtin_plugins/concurrency.py +185 -0
- ocrmypdf/builtin_plugins/default_filters.py +14 -0
- ocrmypdf/builtin_plugins/ghostscript.py +138 -0
- ocrmypdf/builtin_plugins/optimize.py +165 -0
- ocrmypdf/builtin_plugins/tesseract_ocr.py +281 -0
- ocrmypdf/cli.py +466 -0
- ocrmypdf/data/__init__.py +5 -0
- ocrmypdf/data/pdf.ttf +0 -0
- ocrmypdf/data/sRGB.icc +0 -0
- ocrmypdf/exceptions.py +152 -0
- ocrmypdf/extra_plugins/__init__.py +9 -0
- ocrmypdf/extra_plugins/semfree.py +206 -0
- ocrmypdf/helpers.py +334 -0
- ocrmypdf/hocrtransform/__init__.py +18 -0
- ocrmypdf/hocrtransform/__main__.py +40 -0
- ocrmypdf/hocrtransform/_font.py +112 -0
- ocrmypdf/hocrtransform/_hocr.py +473 -0
- ocrmypdf/imageops.py +152 -0
- ocrmypdf/languages.py +849 -0
- ocrmypdf/optimize.py +774 -0
- ocrmypdf/pdfa.py +130 -0
- ocrmypdf/pdfinfo/__init__.py +11 -0
- ocrmypdf/pdfinfo/info.py +1202 -0
- ocrmypdf/pdfinfo/layout.py +329 -0
- ocrmypdf/pluginspec.py +573 -0
- ocrmypdf/py.typed +3 -0
- ocrmypdf/quality.py +48 -0
- ocrmypdf/subprocess/__init__.py +343 -0
- ocrmypdf/subprocess/_windows.py +214 -0
- ocrmypdf-16.0.0.dist-info/LICENSE +373 -0
- ocrmypdf-16.0.0.dist-info/METADATA +201 -0
- ocrmypdf-16.0.0.dist-info/RECORD +61 -0
- ocrmypdf-16.0.0.dist-info/WHEEL +5 -0
- ocrmypdf-16.0.0.dist-info/entry_points.txt +2 -0
- ocrmypdf-16.0.0.dist-info/top_level.txt +1 -0
ocrmypdf/RELEASE.md
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
<!-- SPDX-FileCopyrightText: 2022 James R. Barlow -->
|
|
2
|
+
<!-- SPDX-License-Identifier: CC-BY-SA-4.0 -->
|
|
3
|
+
|
|
4
|
+
# Release checklist
|
|
5
|
+
|
|
6
|
+
## Patch release
|
|
7
|
+
|
|
8
|
+
- Check `pytest`
|
|
9
|
+
|
|
10
|
+
- Update release notes
|
|
11
|
+
|
|
12
|
+
## Minor release
|
|
13
|
+
|
|
14
|
+
## Major release
|
|
15
|
+
|
|
16
|
+
- Run `pre-commit autoupdate`
|
|
17
|
+
|
|
18
|
+
- Check README.md
|
|
19
|
+
|
|
20
|
+
- Check pyproject.toml
|
|
21
|
+
|
|
22
|
+
- Are classifiers up to date?
|
|
23
|
+
- Is `python_requires` correct?
|
|
24
|
+
- Is it to drop support for older Pythons?
|
|
25
|
+
- Can we tighten any `install_requires` dependencies?
|
|
26
|
+
|
|
27
|
+
- Search for old version shims we can remove
|
|
28
|
+
|
|
29
|
+
- "shim"
|
|
30
|
+
- ` pikepdf.__version__`
|
|
31
|
+
|
|
32
|
+
- Search for deprecation: search all files for deprec*, etc.
|
|
33
|
+
|
|
34
|
+
- Check requirements in setup.cfg
|
|
35
|
+
|
|
36
|
+
- Delete `tests/cache`, do `pytest --runslow`, and update cache.
|
|
37
|
+
|
|
38
|
+
- Do `pytest --cov-report html`
|
ocrmypdf/__init__.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2022 James R. Barlow
|
|
2
|
+
# SPDX-License-Identifier: MPL-2.0
|
|
3
|
+
|
|
4
|
+
"""Adds OCR layer to PDFs."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from pluggy import HookimplMarker as _HookimplMarker
|
|
9
|
+
|
|
10
|
+
from ocrmypdf import helpers, hocrtransform, pdfa, pdfinfo
|
|
11
|
+
from ocrmypdf._concurrent import Executor
|
|
12
|
+
from ocrmypdf._jobcontext import PageContext, PdfContext
|
|
13
|
+
from ocrmypdf._pipelines._common import (
|
|
14
|
+
configure_debug_logging,
|
|
15
|
+
)
|
|
16
|
+
from ocrmypdf._version import PROGRAM_NAME, __version__
|
|
17
|
+
from ocrmypdf.api import (
|
|
18
|
+
Verbosity,
|
|
19
|
+
configure_logging,
|
|
20
|
+
ocr,
|
|
21
|
+
)
|
|
22
|
+
from ocrmypdf.exceptions import (
|
|
23
|
+
BadArgsError,
|
|
24
|
+
DpiError,
|
|
25
|
+
EncryptedPdfError,
|
|
26
|
+
ExitCode,
|
|
27
|
+
ExitCodeException,
|
|
28
|
+
InputFileError,
|
|
29
|
+
MissingDependencyError,
|
|
30
|
+
OutputFileAccessError,
|
|
31
|
+
PriorOcrFoundError,
|
|
32
|
+
SubprocessOutputError,
|
|
33
|
+
TesseractConfigError,
|
|
34
|
+
UnsupportedImageFormatError,
|
|
35
|
+
)
|
|
36
|
+
from ocrmypdf.pluginspec import OcrEngine, OrientationConfidence
|
|
37
|
+
|
|
38
|
+
hookimpl = _HookimplMarker('ocrmypdf')
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
__all__ = [
|
|
42
|
+
'__version__',
|
|
43
|
+
'BadArgsError',
|
|
44
|
+
'configure_debug_logging',
|
|
45
|
+
'configure_logging',
|
|
46
|
+
'DpiError',
|
|
47
|
+
'EncryptedPdfError',
|
|
48
|
+
'Executor',
|
|
49
|
+
'ExitCode',
|
|
50
|
+
'ExitCodeException',
|
|
51
|
+
'helpers',
|
|
52
|
+
'hocrtransform',
|
|
53
|
+
'hookimpl',
|
|
54
|
+
'InputFileError',
|
|
55
|
+
'MissingDependencyError',
|
|
56
|
+
'ocr',
|
|
57
|
+
'OcrEngine',
|
|
58
|
+
'OrientationConfidence',
|
|
59
|
+
'OutputFileAccessError',
|
|
60
|
+
'PageContext',
|
|
61
|
+
'pdfa',
|
|
62
|
+
'PdfContext',
|
|
63
|
+
'pdfinfo',
|
|
64
|
+
'PriorOcrFoundError',
|
|
65
|
+
'PROGRAM_NAME',
|
|
66
|
+
'SubprocessOutputError',
|
|
67
|
+
'TesseractConfigError',
|
|
68
|
+
'UnsupportedImageFormatError',
|
|
69
|
+
'Verbosity',
|
|
70
|
+
]
|
ocrmypdf/__main__.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# SPDX-FileCopyrightText: 2022 James R. Barlow
|
|
3
|
+
# SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
|
|
5
|
+
"""ocrmypdf command line entrypoint."""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import multiprocessing
|
|
11
|
+
import os
|
|
12
|
+
import signal
|
|
13
|
+
import sys
|
|
14
|
+
from contextlib import suppress
|
|
15
|
+
|
|
16
|
+
from ocrmypdf import __version__
|
|
17
|
+
from ocrmypdf._pipelines.ocr import run_pipeline_cli
|
|
18
|
+
from ocrmypdf._plugin_manager import get_parser_options_plugins
|
|
19
|
+
from ocrmypdf._validation import check_options
|
|
20
|
+
from ocrmypdf.api import Verbosity, configure_logging
|
|
21
|
+
from ocrmypdf.exceptions import (
|
|
22
|
+
BadArgsError,
|
|
23
|
+
ExitCode,
|
|
24
|
+
InputFileError,
|
|
25
|
+
MissingDependencyError,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
log = logging.getLogger('ocrmypdf')
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def sigbus(*args):
|
|
32
|
+
"""Handle SIGBUS signals.
|
|
33
|
+
|
|
34
|
+
pikepdf, depending on configuration, may use mmap so SIGBUS is a
|
|
35
|
+
possibility.
|
|
36
|
+
"""
|
|
37
|
+
raise InputFileError("Lost access to the input file")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def run(args=None):
|
|
41
|
+
"""Run the ocrmypdf command line interface."""
|
|
42
|
+
_parser, options, plugin_manager = get_parser_options_plugins(args=args)
|
|
43
|
+
|
|
44
|
+
with suppress(AttributeError, PermissionError):
|
|
45
|
+
os.nice(5)
|
|
46
|
+
|
|
47
|
+
verbosity = options.verbose
|
|
48
|
+
if not os.isatty(sys.stderr.fileno()):
|
|
49
|
+
options.progress_bar = False
|
|
50
|
+
if options.quiet:
|
|
51
|
+
verbosity = Verbosity.quiet
|
|
52
|
+
options.progress_bar = False
|
|
53
|
+
configure_logging(
|
|
54
|
+
verbosity,
|
|
55
|
+
progress_bar_friendly=options.progress_bar,
|
|
56
|
+
manage_root_logger=True,
|
|
57
|
+
plugin_manager=plugin_manager,
|
|
58
|
+
)
|
|
59
|
+
log.debug('ocrmypdf %s', __version__)
|
|
60
|
+
try:
|
|
61
|
+
check_options(options, plugin_manager)
|
|
62
|
+
except ValueError as e:
|
|
63
|
+
log.error(e)
|
|
64
|
+
return ExitCode.bad_args
|
|
65
|
+
except BadArgsError as e:
|
|
66
|
+
log.error(e)
|
|
67
|
+
return e.exit_code
|
|
68
|
+
except MissingDependencyError as e:
|
|
69
|
+
log.error(e)
|
|
70
|
+
return ExitCode.missing_dependency
|
|
71
|
+
|
|
72
|
+
with suppress(AttributeError, OSError):
|
|
73
|
+
signal.signal(signal.SIGBUS, sigbus)
|
|
74
|
+
|
|
75
|
+
result = run_pipeline_cli(options=options, plugin_manager=plugin_manager)
|
|
76
|
+
return result
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
if __name__ == '__main__':
|
|
80
|
+
multiprocessing.freeze_support()
|
|
81
|
+
if os.name == 'posix':
|
|
82
|
+
multiprocessing.set_start_method('forkserver')
|
|
83
|
+
sys.exit(run())
|
ocrmypdf/_concurrent.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2022 James R. Barlow
|
|
2
|
+
# SPDX-License-Identifier: MPL-2.0
|
|
3
|
+
|
|
4
|
+
"""OCRmyPDF concurrency abstractions."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import threading
|
|
9
|
+
from abc import ABC, abstractmethod
|
|
10
|
+
from collections.abc import Iterable
|
|
11
|
+
from typing import Any, Callable, TypeVar
|
|
12
|
+
|
|
13
|
+
from ocrmypdf._progressbar import NullProgressBar, ProgressBar
|
|
14
|
+
|
|
15
|
+
T = TypeVar('T')
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _task_noop(*_args, **_kwargs):
|
|
19
|
+
return
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _task_finished_noop(_result: Any, pbar: ProgressBar):
|
|
23
|
+
pbar.update()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Executor(ABC):
|
|
27
|
+
"""Abstract concurrent executor."""
|
|
28
|
+
|
|
29
|
+
pool_lock = threading.Lock()
|
|
30
|
+
pbar_class = NullProgressBar
|
|
31
|
+
|
|
32
|
+
def __init__(self, *, pbar_class=None):
|
|
33
|
+
if pbar_class:
|
|
34
|
+
self.pbar_class = pbar_class
|
|
35
|
+
|
|
36
|
+
def __call__(
|
|
37
|
+
self,
|
|
38
|
+
*,
|
|
39
|
+
use_threads: bool,
|
|
40
|
+
max_workers: int,
|
|
41
|
+
progress_kwargs: dict,
|
|
42
|
+
worker_initializer: Callable | None = None,
|
|
43
|
+
task: Callable[..., T] | None = None,
|
|
44
|
+
task_arguments: Iterable | None = None,
|
|
45
|
+
task_finished: Callable[[T, ProgressBar], None] | None = None,
|
|
46
|
+
) -> None:
|
|
47
|
+
"""Set up parallel execution and progress reporting.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
use_threads: If ``False``, the workload is the sort that will benefit from
|
|
51
|
+
running in a multiprocessing context (for example, it uses Python
|
|
52
|
+
heavily, and parallelizing it with threads is not expected to be
|
|
53
|
+
performant).
|
|
54
|
+
max_workers: The maximum number of workers that should be run.
|
|
55
|
+
progress_kwargs: Arguments to set up the progress bar.
|
|
56
|
+
worker_initializer: Called when a worker is initialized, in the worker's
|
|
57
|
+
execution context. If the child workers are processes, it must be
|
|
58
|
+
possible to marshall/pickle the worker initializer.
|
|
59
|
+
``functools.partial`` can be used to bind parameters.
|
|
60
|
+
task: Called when the worker starts a new task, in the worker's execution
|
|
61
|
+
context. Must be possible to marshall to the worker.
|
|
62
|
+
task_finished: Called when a worker finishes a task, in the parent's
|
|
63
|
+
context.
|
|
64
|
+
task_arguments: An iterable that generates a group of parameters for each
|
|
65
|
+
task. This runs in the parent's context, but the parameters must be
|
|
66
|
+
marshallable to the worker.
|
|
67
|
+
"""
|
|
68
|
+
if not task_arguments:
|
|
69
|
+
return # Nothing to do!
|
|
70
|
+
if not worker_initializer:
|
|
71
|
+
worker_initializer = _task_noop
|
|
72
|
+
if not task_finished:
|
|
73
|
+
task_finished = _task_finished_noop
|
|
74
|
+
if not task:
|
|
75
|
+
task = _task_noop
|
|
76
|
+
|
|
77
|
+
with self.pool_lock:
|
|
78
|
+
self._execute(
|
|
79
|
+
use_threads=use_threads,
|
|
80
|
+
max_workers=max_workers,
|
|
81
|
+
progress_kwargs=progress_kwargs,
|
|
82
|
+
worker_initializer=worker_initializer,
|
|
83
|
+
task=task,
|
|
84
|
+
task_arguments=task_arguments,
|
|
85
|
+
task_finished=task_finished,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
@abstractmethod
|
|
89
|
+
def _execute(
|
|
90
|
+
self,
|
|
91
|
+
*,
|
|
92
|
+
use_threads: bool,
|
|
93
|
+
max_workers: int,
|
|
94
|
+
progress_kwargs: dict,
|
|
95
|
+
worker_initializer: Callable,
|
|
96
|
+
task: Callable,
|
|
97
|
+
task_arguments: Iterable,
|
|
98
|
+
task_finished: Callable,
|
|
99
|
+
):
|
|
100
|
+
"""Custom executors should override this method."""
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def setup_executor(plugin_manager) -> Executor:
|
|
104
|
+
pbar_class = plugin_manager.hook.get_progressbar_class()
|
|
105
|
+
return plugin_manager.hook.get_executor(progressbar_class=pbar_class)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class SerialExecutor(Executor):
|
|
109
|
+
"""Implements a purely sequential executor using the parallel protocol.
|
|
110
|
+
|
|
111
|
+
The current process/thread will be the worker that executes all tasks
|
|
112
|
+
in order. As such, ``worker_initializer`` will never be called.
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
def _execute(
|
|
116
|
+
self,
|
|
117
|
+
*,
|
|
118
|
+
use_threads: bool,
|
|
119
|
+
max_workers: int,
|
|
120
|
+
progress_kwargs: dict,
|
|
121
|
+
worker_initializer: Callable,
|
|
122
|
+
task: Callable,
|
|
123
|
+
task_arguments: Iterable,
|
|
124
|
+
task_finished: Callable,
|
|
125
|
+
): # pylint: disable=unused-argument
|
|
126
|
+
with self.pbar_class(**progress_kwargs) as pbar:
|
|
127
|
+
for args in task_arguments:
|
|
128
|
+
result = task(*args)
|
|
129
|
+
task_finished(result, pbar)
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2022 James R. Barlow
|
|
2
|
+
# SPDX-License-Identifier: MPL-2.0
|
|
3
|
+
|
|
4
|
+
"""Interface to Ghostscript executable."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
from collections import deque
|
|
12
|
+
from io import BytesIO
|
|
13
|
+
from os import fspath
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from subprocess import PIPE, CalledProcessError
|
|
16
|
+
|
|
17
|
+
from packaging.version import Version
|
|
18
|
+
from PIL import Image, UnidentifiedImageError
|
|
19
|
+
|
|
20
|
+
from ocrmypdf.exceptions import ColorConversionNeededError, SubprocessOutputError
|
|
21
|
+
from ocrmypdf.helpers import Resolution
|
|
22
|
+
from ocrmypdf.subprocess import get_version, run, run_polling_stderr
|
|
23
|
+
|
|
24
|
+
COLOR_CONVERSION_STRATEGIES = frozenset(
|
|
25
|
+
[
|
|
26
|
+
'CMYK',
|
|
27
|
+
'Gray',
|
|
28
|
+
'LeaveColorUnchanged',
|
|
29
|
+
'RGB',
|
|
30
|
+
'UseDeviceIndependentColor',
|
|
31
|
+
]
|
|
32
|
+
)
|
|
33
|
+
# Ghostscript executable - gswin32c is not supported
|
|
34
|
+
GS = 'gswin64c' if os.name == 'nt' else 'gs'
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
log = logging.getLogger(__name__)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class DuplicateFilter(logging.Filter):
|
|
41
|
+
"""Filter out duplicate log messages.
|
|
42
|
+
|
|
43
|
+
A context window of default 5 messages is used to determine if a message is a
|
|
44
|
+
duplicate. This is because some Ghostscript messages are word wrapped.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(self, logger: logging.Logger, context_window=5):
|
|
48
|
+
self.window: deque[str] = deque([], maxlen=context_window)
|
|
49
|
+
self.logger = logger
|
|
50
|
+
self.levelno = logging.DEBUG
|
|
51
|
+
self.count = 0
|
|
52
|
+
|
|
53
|
+
def filter(self, record):
|
|
54
|
+
if record.msg in self.window:
|
|
55
|
+
self.count += 1
|
|
56
|
+
self.levelno = record.levelno
|
|
57
|
+
return False
|
|
58
|
+
else:
|
|
59
|
+
if self.count >= 1:
|
|
60
|
+
rep_msg = f"(suppressed {self.count} repeated lines)"
|
|
61
|
+
self.count = 0 # Avoid infinite recursion
|
|
62
|
+
self.logger.log(self.levelno, rep_msg)
|
|
63
|
+
self.window.clear()
|
|
64
|
+
self.window.append(record.msg)
|
|
65
|
+
return True
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
log.addFilter(DuplicateFilter(log))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def version() -> Version:
|
|
72
|
+
return Version(get_version(GS))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _gs_error_reported(stream) -> bool:
|
|
76
|
+
match = re.search(r'error', stream, flags=re.IGNORECASE)
|
|
77
|
+
return bool(match)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _gs_devicen_reported(stream) -> bool:
|
|
81
|
+
"""Did Ghostscript warn about a DeviceN with inappropriate alternate?
|
|
82
|
+
|
|
83
|
+
If so, we need the user to select a color conversion, or the resulting PDF will
|
|
84
|
+
not present correctly in some PDF viewers.
|
|
85
|
+
"""
|
|
86
|
+
match = re.search(
|
|
87
|
+
r'DeviceN.*inappropriate alternate',
|
|
88
|
+
stream,
|
|
89
|
+
flags=re.IGNORECASE | re.MULTILINE,
|
|
90
|
+
)
|
|
91
|
+
return bool(match)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def rasterize_pdf(
|
|
95
|
+
input_file: os.PathLike,
|
|
96
|
+
output_file: os.PathLike,
|
|
97
|
+
*,
|
|
98
|
+
raster_device: str,
|
|
99
|
+
raster_dpi: Resolution,
|
|
100
|
+
pageno: int = 1,
|
|
101
|
+
page_dpi: Resolution | None = None,
|
|
102
|
+
rotation: int | None = None,
|
|
103
|
+
filter_vector: bool = False,
|
|
104
|
+
stop_on_error: bool = False,
|
|
105
|
+
):
|
|
106
|
+
"""Rasterize one page of a PDF at resolution raster_dpi in canvas units."""
|
|
107
|
+
raster_dpi = raster_dpi.round(6)
|
|
108
|
+
if not page_dpi:
|
|
109
|
+
page_dpi = raster_dpi
|
|
110
|
+
|
|
111
|
+
args_gs = (
|
|
112
|
+
[
|
|
113
|
+
GS,
|
|
114
|
+
'-dQUIET',
|
|
115
|
+
'-dSAFER',
|
|
116
|
+
'-dBATCH',
|
|
117
|
+
'-dNOPAUSE',
|
|
118
|
+
'-dInterpolateControl=-1',
|
|
119
|
+
f'-sDEVICE={raster_device}',
|
|
120
|
+
f'-dFirstPage={pageno}',
|
|
121
|
+
f'-dLastPage={pageno}',
|
|
122
|
+
f'-r{raster_dpi.x:f}x{raster_dpi.y:f}',
|
|
123
|
+
]
|
|
124
|
+
+ (['-dFILTERVECTOR'] if filter_vector else [])
|
|
125
|
+
+ (['-dPDFSTOPONERROR'] if stop_on_error else [])
|
|
126
|
+
+ [
|
|
127
|
+
'-o',
|
|
128
|
+
'-',
|
|
129
|
+
'-sstdout=%stderr', # Literal %s, not string interpolation
|
|
130
|
+
'-dAutoRotatePages=/None', # Probably has no effect on raster
|
|
131
|
+
'-f',
|
|
132
|
+
fspath(input_file),
|
|
133
|
+
]
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
try:
|
|
137
|
+
p = run(args_gs, stdout=PIPE, stderr=PIPE, check=True)
|
|
138
|
+
except CalledProcessError as e:
|
|
139
|
+
log.error(e.stderr.decode(errors='replace'))
|
|
140
|
+
raise SubprocessOutputError('Ghostscript rasterizing failed') from e
|
|
141
|
+
else:
|
|
142
|
+
stderr = p.stderr.decode(errors='replace')
|
|
143
|
+
if _gs_error_reported(stderr):
|
|
144
|
+
log.error(stderr)
|
|
145
|
+
|
|
146
|
+
try:
|
|
147
|
+
with Image.open(BytesIO(p.stdout)) as im:
|
|
148
|
+
if rotation is not None:
|
|
149
|
+
log.debug("Rotating output by %i", rotation)
|
|
150
|
+
# rotation is a clockwise angle and Image.ROTATE_* is
|
|
151
|
+
# counterclockwise so this cancels out the rotation
|
|
152
|
+
if rotation == 90:
|
|
153
|
+
im = im.transpose(Image.Transpose.ROTATE_90)
|
|
154
|
+
elif rotation == 180:
|
|
155
|
+
im = im.transpose(Image.Transpose.ROTATE_180)
|
|
156
|
+
elif rotation == 270:
|
|
157
|
+
im = im.transpose(Image.Transpose.ROTATE_270)
|
|
158
|
+
if rotation % 180 == 90:
|
|
159
|
+
page_dpi = page_dpi.flip_axis()
|
|
160
|
+
im.save(fspath(output_file), dpi=page_dpi)
|
|
161
|
+
except UnidentifiedImageError:
|
|
162
|
+
log.error(
|
|
163
|
+
f"Ghostscript (using {raster_device} at {raster_dpi} dpi) produced "
|
|
164
|
+
"an invalid page image file."
|
|
165
|
+
)
|
|
166
|
+
raise
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class GhostscriptFollower:
|
|
170
|
+
"""Parses the output of Ghostscript and uses it to update the progress bar."""
|
|
171
|
+
|
|
172
|
+
re_process = re.compile(r"Processing pages \d+ through (\d+).")
|
|
173
|
+
re_page = re.compile(r"Page (\d+)")
|
|
174
|
+
|
|
175
|
+
def __init__(self, progressbar_class):
|
|
176
|
+
self.count = 0
|
|
177
|
+
self.progressbar_class = progressbar_class
|
|
178
|
+
self.progressbar = None
|
|
179
|
+
|
|
180
|
+
def __call__(self, line):
|
|
181
|
+
if not self.progressbar_class:
|
|
182
|
+
return
|
|
183
|
+
if not self.progressbar:
|
|
184
|
+
m = self.re_process.match(line.strip())
|
|
185
|
+
if m:
|
|
186
|
+
self.count = int(m.group(1))
|
|
187
|
+
self.progressbar = self.progressbar_class(
|
|
188
|
+
total=self.count, desc="PDF/A conversion", unit='page'
|
|
189
|
+
)
|
|
190
|
+
return
|
|
191
|
+
else:
|
|
192
|
+
if self.re_page.match(line.strip()):
|
|
193
|
+
self.progressbar.update()
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def generate_pdfa(
|
|
197
|
+
pdf_pages,
|
|
198
|
+
output_file: os.PathLike,
|
|
199
|
+
*,
|
|
200
|
+
compression: str,
|
|
201
|
+
color_conversion_strategy: str,
|
|
202
|
+
pdf_version: str = '1.5',
|
|
203
|
+
pdfa_part: str = '2',
|
|
204
|
+
progressbar_class=None,
|
|
205
|
+
stop_on_error: bool = False,
|
|
206
|
+
):
|
|
207
|
+
# Ghostscript's compression is all or nothing. We can either force all images
|
|
208
|
+
# to JPEG, force all to Flate/PNG, or let it decide how to encode the images.
|
|
209
|
+
# In most case it's best to let it decide.
|
|
210
|
+
compression_args = []
|
|
211
|
+
if compression == 'jpeg':
|
|
212
|
+
compression_args = [
|
|
213
|
+
"-dAutoFilterColorImages=false",
|
|
214
|
+
"-dColorImageFilter=/DCTEncode",
|
|
215
|
+
"-dAutoFilterGrayImages=false",
|
|
216
|
+
"-dGrayImageFilter=/DCTEncode",
|
|
217
|
+
]
|
|
218
|
+
elif compression == 'lossless':
|
|
219
|
+
compression_args = [
|
|
220
|
+
"-dAutoFilterColorImages=false",
|
|
221
|
+
"-dColorImageFilter=/FlateEncode",
|
|
222
|
+
"-dAutoFilterGrayImages=false",
|
|
223
|
+
"-dGrayImageFilter=/FlateEncode",
|
|
224
|
+
]
|
|
225
|
+
else:
|
|
226
|
+
compression_args = [
|
|
227
|
+
"-dAutoFilterColorImages=true",
|
|
228
|
+
"-dAutoFilterGrayImages=true",
|
|
229
|
+
]
|
|
230
|
+
|
|
231
|
+
gs_version = version()
|
|
232
|
+
if gs_version == Version('9.56.0'):
|
|
233
|
+
# 9.56.0 breaks our OCR, should be fixed in 9.56.1
|
|
234
|
+
# https://bugs.ghostscript.com/show_bug.cgi?id=705187
|
|
235
|
+
compression_args.append('-dNEWPDF=false')
|
|
236
|
+
|
|
237
|
+
if os.name == 'nt':
|
|
238
|
+
# Windows has lots of fatal "permission denied" errors
|
|
239
|
+
stop_on_error = False
|
|
240
|
+
|
|
241
|
+
# nb no need to specify ProcessColorModel when ColorConversionStrategy
|
|
242
|
+
# is set; see:
|
|
243
|
+
# https://bugs.ghostscript.com/show_bug.cgi?id=699392
|
|
244
|
+
args_gs = (
|
|
245
|
+
[
|
|
246
|
+
GS,
|
|
247
|
+
"-dBATCH",
|
|
248
|
+
"-dNOPAUSE",
|
|
249
|
+
"-dSAFER",
|
|
250
|
+
f"-dCompatibilityLevel={str(pdf_version)}",
|
|
251
|
+
"-sDEVICE=pdfwrite",
|
|
252
|
+
"-dAutoRotatePages=/None",
|
|
253
|
+
f"-sColorConversionStrategy={color_conversion_strategy}",
|
|
254
|
+
]
|
|
255
|
+
+ (['-dPDFSTOPONERROR'] if stop_on_error else [])
|
|
256
|
+
+ compression_args
|
|
257
|
+
+ [
|
|
258
|
+
"-dJPEGQ=95",
|
|
259
|
+
f"-dPDFA={pdfa_part}",
|
|
260
|
+
"-dPDFACompatibilityPolicy=1",
|
|
261
|
+
"-o",
|
|
262
|
+
"-",
|
|
263
|
+
"-sstdout=%stderr", # Literal %s, not string interpolation
|
|
264
|
+
]
|
|
265
|
+
)
|
|
266
|
+
args_gs.extend(fspath(s) for s in pdf_pages) # Stringify Path objs
|
|
267
|
+
try:
|
|
268
|
+
with Path(output_file).open('wb') as output:
|
|
269
|
+
p = run_polling_stderr(
|
|
270
|
+
args_gs,
|
|
271
|
+
stdout=output,
|
|
272
|
+
stderr=PIPE,
|
|
273
|
+
check=True,
|
|
274
|
+
text=True,
|
|
275
|
+
encoding='utf-8',
|
|
276
|
+
errors='replace',
|
|
277
|
+
callback=GhostscriptFollower(progressbar_class),
|
|
278
|
+
)
|
|
279
|
+
except CalledProcessError as e:
|
|
280
|
+
# Ghostscript does not change return code when it fails to create
|
|
281
|
+
# PDF/A - check PDF/A status elsewhere
|
|
282
|
+
log.error(e.stderr)
|
|
283
|
+
raise SubprocessOutputError('Ghostscript PDF/A rendering failed') from e
|
|
284
|
+
else:
|
|
285
|
+
stderr = p.stderr
|
|
286
|
+
# If there is an error we log the whole stderr, except for filtering
|
|
287
|
+
# duplicates.
|
|
288
|
+
if _gs_error_reported(stderr):
|
|
289
|
+
# Ghostscript outputs the pattern **** Error: .... frequently.
|
|
290
|
+
# Occasionally the error message is spammed many times. We filter
|
|
291
|
+
# out duplicates of this message using the filter above. We use
|
|
292
|
+
# the **** pattern to split the stderr into parts.
|
|
293
|
+
for part in stderr.split('****'):
|
|
294
|
+
log.error(part)
|
|
295
|
+
if _gs_devicen_reported(stderr):
|
|
296
|
+
raise ColorConversionNeededError()
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2022 James R. Barlow
|
|
2
|
+
# SPDX-License-Identifier: MPL-2.0
|
|
3
|
+
|
|
4
|
+
"""Interface to jbig2 executable."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from subprocess import PIPE
|
|
9
|
+
|
|
10
|
+
from packaging.version import Version
|
|
11
|
+
|
|
12
|
+
from ocrmypdf.exceptions import MissingDependencyError
|
|
13
|
+
from ocrmypdf.subprocess import get_version, run
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def version() -> Version:
|
|
17
|
+
return Version(get_version('jbig2', regex=r'jbig2enc (\d+(\.\d+)*).*'))
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def available():
|
|
21
|
+
try:
|
|
22
|
+
version()
|
|
23
|
+
except MissingDependencyError:
|
|
24
|
+
return False
|
|
25
|
+
return True
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def convert_group(cwd, infiles, out_prefix, threshold):
|
|
29
|
+
args = [
|
|
30
|
+
'jbig2',
|
|
31
|
+
'-b',
|
|
32
|
+
out_prefix,
|
|
33
|
+
'--symbol-mode', # symbol mode (lossy)
|
|
34
|
+
'-t',
|
|
35
|
+
str(threshold), # threshold
|
|
36
|
+
# '-r', # refinement mode (lossless symbol mode, currently disabled in
|
|
37
|
+
# jbig2)
|
|
38
|
+
'--pdf',
|
|
39
|
+
]
|
|
40
|
+
args.extend(infiles)
|
|
41
|
+
proc = run(args, cwd=cwd, stdout=PIPE, stderr=PIPE)
|
|
42
|
+
proc.check_returncode()
|
|
43
|
+
return proc
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def convert_single(cwd, infile, outfile, threshold):
|
|
47
|
+
args = ['jbig2', '--pdf', '-t', str(threshold), infile]
|
|
48
|
+
with open(outfile, 'wb') as fstdout:
|
|
49
|
+
proc = run(args, cwd=cwd, stdout=fstdout, stderr=PIPE)
|
|
50
|
+
proc.check_returncode()
|
|
51
|
+
return proc
|