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.
Files changed (61) hide show
  1. ocrmypdf/RELEASE.md +38 -0
  2. ocrmypdf/__init__.py +70 -0
  3. ocrmypdf/__main__.py +83 -0
  4. ocrmypdf/_concurrent.py +129 -0
  5. ocrmypdf/_exec/__init__.py +6 -0
  6. ocrmypdf/_exec/ghostscript.py +296 -0
  7. ocrmypdf/_exec/jbig2enc.py +51 -0
  8. ocrmypdf/_exec/pngquant.py +52 -0
  9. ocrmypdf/_exec/tesseract.py +399 -0
  10. ocrmypdf/_exec/unpaper.py +138 -0
  11. ocrmypdf/_graft.py +319 -0
  12. ocrmypdf/_jobcontext.py +100 -0
  13. ocrmypdf/_logging.py +30 -0
  14. ocrmypdf/_metadata.py +184 -0
  15. ocrmypdf/_pipeline.py +994 -0
  16. ocrmypdf/_pipelines/__init__.py +5 -0
  17. ocrmypdf/_pipelines/_common.py +454 -0
  18. ocrmypdf/_pipelines/hocr_to_ocr_pdf.py +134 -0
  19. ocrmypdf/_pipelines/ocr.py +225 -0
  20. ocrmypdf/_pipelines/pdf_to_hocr.py +116 -0
  21. ocrmypdf/_plugin_manager.py +129 -0
  22. ocrmypdf/_progressbar.py +142 -0
  23. ocrmypdf/_validation.py +368 -0
  24. ocrmypdf/_version.py +16 -0
  25. ocrmypdf/api.py +552 -0
  26. ocrmypdf/builtin_plugins/__init__.py +6 -0
  27. ocrmypdf/builtin_plugins/concurrency.py +185 -0
  28. ocrmypdf/builtin_plugins/default_filters.py +14 -0
  29. ocrmypdf/builtin_plugins/ghostscript.py +138 -0
  30. ocrmypdf/builtin_plugins/optimize.py +165 -0
  31. ocrmypdf/builtin_plugins/tesseract_ocr.py +281 -0
  32. ocrmypdf/cli.py +466 -0
  33. ocrmypdf/data/__init__.py +5 -0
  34. ocrmypdf/data/pdf.ttf +0 -0
  35. ocrmypdf/data/sRGB.icc +0 -0
  36. ocrmypdf/exceptions.py +152 -0
  37. ocrmypdf/extra_plugins/__init__.py +9 -0
  38. ocrmypdf/extra_plugins/semfree.py +206 -0
  39. ocrmypdf/helpers.py +334 -0
  40. ocrmypdf/hocrtransform/__init__.py +18 -0
  41. ocrmypdf/hocrtransform/__main__.py +40 -0
  42. ocrmypdf/hocrtransform/_font.py +112 -0
  43. ocrmypdf/hocrtransform/_hocr.py +473 -0
  44. ocrmypdf/imageops.py +152 -0
  45. ocrmypdf/languages.py +849 -0
  46. ocrmypdf/optimize.py +774 -0
  47. ocrmypdf/pdfa.py +130 -0
  48. ocrmypdf/pdfinfo/__init__.py +11 -0
  49. ocrmypdf/pdfinfo/info.py +1202 -0
  50. ocrmypdf/pdfinfo/layout.py +329 -0
  51. ocrmypdf/pluginspec.py +573 -0
  52. ocrmypdf/py.typed +3 -0
  53. ocrmypdf/quality.py +48 -0
  54. ocrmypdf/subprocess/__init__.py +343 -0
  55. ocrmypdf/subprocess/_windows.py +214 -0
  56. ocrmypdf-16.0.0.dist-info/LICENSE +373 -0
  57. ocrmypdf-16.0.0.dist-info/METADATA +201 -0
  58. ocrmypdf-16.0.0.dist-info/RECORD +61 -0
  59. ocrmypdf-16.0.0.dist-info/WHEEL +5 -0
  60. ocrmypdf-16.0.0.dist-info/entry_points.txt +2 -0
  61. 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())
@@ -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,6 @@
1
+ # SPDX-FileCopyrightText: 2022 James R. Barlow
2
+ # SPDX-License-Identifier: MPL-2.0
3
+
4
+ """Manage third party executables."""
5
+
6
+ from __future__ import annotations
@@ -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