ocrmypdf 16.5.0__py3-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/_defaults.py +10 -0
  6. ocrmypdf/_exec/__init__.py +6 -0
  7. ocrmypdf/_exec/ghostscript.py +311 -0
  8. ocrmypdf/_exec/jbig2enc.py +57 -0
  9. ocrmypdf/_exec/pngquant.py +52 -0
  10. ocrmypdf/_exec/tesseract.py +400 -0
  11. ocrmypdf/_exec/unpaper.py +136 -0
  12. ocrmypdf/_graft.py +326 -0
  13. ocrmypdf/_jobcontext.py +100 -0
  14. ocrmypdf/_logging.py +30 -0
  15. ocrmypdf/_metadata.py +214 -0
  16. ocrmypdf/_pipeline.py +1079 -0
  17. ocrmypdf/_pipelines/__init__.py +5 -0
  18. ocrmypdf/_pipelines/_common.py +455 -0
  19. ocrmypdf/_pipelines/hocr_to_ocr_pdf.py +133 -0
  20. ocrmypdf/_pipelines/ocr.py +223 -0
  21. ocrmypdf/_pipelines/pdf_to_hocr.py +115 -0
  22. ocrmypdf/_plugin_manager.py +129 -0
  23. ocrmypdf/_progressbar.py +148 -0
  24. ocrmypdf/_validation.py +367 -0
  25. ocrmypdf/_version.py +16 -0
  26. ocrmypdf/api.py +557 -0
  27. ocrmypdf/builtin_plugins/__init__.py +6 -0
  28. ocrmypdf/builtin_plugins/concurrency.py +190 -0
  29. ocrmypdf/builtin_plugins/default_filters.py +12 -0
  30. ocrmypdf/builtin_plugins/ghostscript.py +141 -0
  31. ocrmypdf/builtin_plugins/optimize.py +165 -0
  32. ocrmypdf/builtin_plugins/tesseract_ocr.py +301 -0
  33. ocrmypdf/cli.py +467 -0
  34. ocrmypdf/data/__init__.py +5 -0
  35. ocrmypdf/data/pdf.ttf +0 -0
  36. ocrmypdf/data/sRGB.icc +0 -0
  37. ocrmypdf/exceptions.py +152 -0
  38. ocrmypdf/extra_plugins/__init__.py +9 -0
  39. ocrmypdf/extra_plugins/semfree.py +205 -0
  40. ocrmypdf/helpers.py +337 -0
  41. ocrmypdf/hocrtransform/__init__.py +18 -0
  42. ocrmypdf/hocrtransform/__main__.py +40 -0
  43. ocrmypdf/hocrtransform/_font.py +141 -0
  44. ocrmypdf/hocrtransform/_hocr.py +501 -0
  45. ocrmypdf/imageops.py +152 -0
  46. ocrmypdf/languages.py +851 -0
  47. ocrmypdf/optimize.py +783 -0
  48. ocrmypdf/pdfa.py +130 -0
  49. ocrmypdf/pdfinfo/__init__.py +11 -0
  50. ocrmypdf/pdfinfo/info.py +1252 -0
  51. ocrmypdf/pdfinfo/layout.py +331 -0
  52. ocrmypdf/pluginspec.py +573 -0
  53. ocrmypdf/py.typed +3 -0
  54. ocrmypdf/quality.py +47 -0
  55. ocrmypdf/subprocess/__init__.py +343 -0
  56. ocrmypdf/subprocess/_windows.py +209 -0
  57. ocrmypdf-16.5.0.dist-info/METADATA +204 -0
  58. ocrmypdf-16.5.0.dist-info/RECORD +61 -0
  59. ocrmypdf-16.5.0.dist-info/WHEEL +4 -0
  60. ocrmypdf-16.5.0.dist-info/entry_points.txt +2 -0
  61. ocrmypdf-16.5.0.dist-info/licenses/LICENSE +373 -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._defaults import PROGRAM_NAME
13
+ from ocrmypdf._jobcontext import PageContext, PdfContext
14
+ from ocrmypdf._pipelines._common import (
15
+ configure_debug_logging,
16
+ )
17
+ from ocrmypdf._version import __version__
18
+ from ocrmypdf.api import (
19
+ Verbosity,
20
+ configure_logging,
21
+ ocr,
22
+ )
23
+ from ocrmypdf.exceptions import (
24
+ BadArgsError,
25
+ DpiError,
26
+ EncryptedPdfError,
27
+ ExitCode,
28
+ ExitCodeException,
29
+ InputFileError,
30
+ MissingDependencyError,
31
+ OutputFileAccessError,
32
+ PriorOcrFoundError,
33
+ SubprocessOutputError,
34
+ TesseractConfigError,
35
+ UnsupportedImageFormatError,
36
+ )
37
+ from ocrmypdf.pluginspec import OcrEngine, OrientationConfidence
38
+
39
+ hookimpl = _HookimplMarker('ocrmypdf')
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 Callable, Iterable
11
+ from typing import Any, 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)
ocrmypdf/_defaults.py ADDED
@@ -0,0 +1,10 @@
1
+ # SPDX-FileCopyrightText: 2024 James R. Barlow
2
+ # SPDX-License-Identifier: MPL-2.0
3
+
4
+ # Enforce English hegemony
5
+ DEFAULT_LANGUAGE = 'eng'
6
+
7
+ # Default rotation threshold
8
+ DEFAULT_ROTATE_PAGES_THRESHOLD = 14.0
9
+
10
+ PROGRAM_NAME = 'OCRmyPDF'
@@ -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,311 @@
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 __enter__(self):
181
+ # We can't actually set up the progressbar here, because we don't know
182
+ # how many pages there are until the first __call__() happens. So we
183
+ # do it in __call__().
184
+ return self
185
+
186
+ def __exit__(self, exc_type, exc_value, traceback):
187
+ if self.progressbar:
188
+ return self.progressbar.__exit__(exc_type, exc_value, traceback)
189
+ return False
190
+
191
+ def __call__(self, line):
192
+ if not self.progressbar_class:
193
+ return
194
+ if not self.progressbar:
195
+ m = self.re_process.match(line.strip())
196
+ if m:
197
+ self.count = int(m.group(1))
198
+ self.progressbar = self.progressbar_class(
199
+ total=self.count, desc="PDF/A conversion", unit='page'
200
+ )
201
+ # Now that we know the count, we can set up the progressbar.
202
+ self.progressbar.__enter__()
203
+ else:
204
+ if self.re_page.match(line.strip()):
205
+ self.progressbar.update()
206
+
207
+
208
+ def generate_pdfa(
209
+ pdf_pages,
210
+ output_file: os.PathLike,
211
+ *,
212
+ compression: str,
213
+ color_conversion_strategy: str,
214
+ pdf_version: str = '1.5',
215
+ pdfa_part: str = '2',
216
+ progressbar_class=None,
217
+ stop_on_error: bool = False,
218
+ ):
219
+ # Ghostscript's compression is all or nothing. We can either force all images
220
+ # to JPEG, force all to Flate/PNG, or let it decide how to encode the images.
221
+ # In most case it's best to let it decide.
222
+ compression_args = []
223
+ if compression == 'jpeg':
224
+ compression_args = [
225
+ "-dAutoFilterColorImages=false",
226
+ "-dColorImageFilter=/DCTEncode",
227
+ "-dAutoFilterGrayImages=false",
228
+ "-dGrayImageFilter=/DCTEncode",
229
+ ]
230
+ elif compression == 'lossless':
231
+ compression_args = [
232
+ "-dAutoFilterColorImages=false",
233
+ "-dColorImageFilter=/FlateEncode",
234
+ "-dAutoFilterGrayImages=false",
235
+ "-dGrayImageFilter=/FlateEncode",
236
+ ]
237
+ else:
238
+ compression_args = [
239
+ "-dAutoFilterColorImages=true",
240
+ "-dAutoFilterGrayImages=true",
241
+ ]
242
+
243
+ gs_version = version()
244
+ if gs_version == Version('9.56.0'):
245
+ # 9.56.0 breaks our OCR, should be fixed in 9.56.1
246
+ # https://bugs.ghostscript.com/show_bug.cgi?id=705187
247
+ compression_args.append('-dNEWPDF=false')
248
+
249
+ if os.name == 'nt':
250
+ # Windows has lots of fatal "permission denied" errors
251
+ stop_on_error = False
252
+
253
+ # nb no need to specify ProcessColorModel when ColorConversionStrategy
254
+ # is set; see:
255
+ # https://bugs.ghostscript.com/show_bug.cgi?id=699392
256
+ args_gs = (
257
+ [
258
+ GS,
259
+ "-dBATCH",
260
+ "-dNOPAUSE",
261
+ "-dSAFER",
262
+ f"-dCompatibilityLevel={str(pdf_version)}",
263
+ "-sDEVICE=pdfwrite",
264
+ "-dAutoRotatePages=/None",
265
+ f"-sColorConversionStrategy={color_conversion_strategy}",
266
+ ]
267
+ + (['-dPDFSTOPONERROR'] if stop_on_error else [])
268
+ + compression_args
269
+ + [
270
+ "-dJPEGQ=95",
271
+ f"-dPDFA={pdfa_part}",
272
+ "-dPDFACompatibilityPolicy=1",
273
+ "-o",
274
+ "-",
275
+ "-sstdout=%stderr", # Literal %s, not string interpolation
276
+ ]
277
+ )
278
+ args_gs.extend(fspath(s) for s in pdf_pages) # Stringify Path objs
279
+ try:
280
+ with (
281
+ Path(output_file).open('wb') as output,
282
+ GhostscriptFollower(progressbar_class) as pbar,
283
+ ):
284
+ p = run_polling_stderr(
285
+ args_gs,
286
+ stdout=output,
287
+ stderr=PIPE,
288
+ check=True,
289
+ text=True,
290
+ encoding='utf-8',
291
+ errors='replace',
292
+ callback=pbar,
293
+ )
294
+ except CalledProcessError as e:
295
+ # Ghostscript does not change return code when it fails to create
296
+ # PDF/A - check PDF/A status elsewhere
297
+ log.error(e.stderr)
298
+ raise SubprocessOutputError('Ghostscript PDF/A rendering failed') from e
299
+ else:
300
+ stderr = p.stderr
301
+ # If there is an error we log the whole stderr, except for filtering
302
+ # duplicates.
303
+ if _gs_error_reported(stderr):
304
+ # Ghostscript outputs the pattern **** Error: .... frequently.
305
+ # Occasionally the error message is spammed many times. We filter
306
+ # out duplicates of this message using the filter above. We use
307
+ # the **** pattern to split the stderr into parts.
308
+ for part in stderr.split('****'):
309
+ log.error(part)
310
+ if _gs_devicen_reported(stderr):
311
+ raise ColorConversionNeededError()