Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b8ccbe8cb | ||
|
|
ab1ff3331b | ||
|
|
3675ae918c | ||
|
|
0ba32b96b7 | ||
|
|
add64e4fa2 | ||
|
|
7fe2954ede | ||
|
|
ad202693b3 | ||
|
|
594ef83551 | ||
|
|
78b71618c1 | ||
|
|
b8aa89e1ec | ||
|
|
b4c1f66bc1 | ||
|
|
5172dbde8d | ||
|
|
d2908640c6 | ||
|
|
997bf7578d | ||
|
|
043258242c | ||
|
|
156d5d9a9c | ||
|
|
0b7e52fb5e | ||
|
|
a5feef07d0 | ||
|
|
f11bb53e61 | ||
|
|
68a57a7839 | ||
|
|
4194430dc1 | ||
|
|
a707c56fae | ||
|
|
3cba50bfbd | ||
|
|
ed5e17d0a4 | ||
|
|
ce0e0ecd4d | ||
|
|
7e1223c12c | ||
|
|
b83d7f6d1a | ||
|
|
80e957908a | ||
|
|
f0e7bea8ba | ||
|
|
0cdb9bd04a |
+2
-3
@@ -125,8 +125,7 @@ include:
|
|||||||
.. envvar:: OMP_THREAD_LIMIT
|
.. envvar:: OMP_THREAD_LIMIT
|
||||||
|
|
||||||
Controls the number of threads Tesseract will use. OCRmyPDF will
|
Controls the number of threads Tesseract will use. OCRmyPDF will
|
||||||
manage this environment if it is not already set. (Currently, it will
|
manage this environment variable if it is not already set.
|
||||||
set it to 1 because this gives the best results in testing.)
|
|
||||||
|
|
||||||
For example, if you have a development build of Tesseract don't wish to
|
For example, if you have a development build of Tesseract don't wish to
|
||||||
use the system installation, you can launch OCRmyPDF as follows:
|
use the system installation, you can launch OCRmyPDF as follows:
|
||||||
@@ -315,7 +314,7 @@ message is:
|
|||||||
.. code-block:: none
|
.. code-block:: none
|
||||||
|
|
||||||
Temporary working files retained at:
|
Temporary working files retained at:
|
||||||
/tmp/com.github.ocrmypdf.u20wpz07
|
/tmp/ocrmypdf.io.u20wpz07
|
||||||
|
|
||||||
The organization of this folder is an implementation detail and subject
|
The organization of this folder is an implementation detail and subject
|
||||||
to change between releases. However the general organization is that
|
to change between releases. However the general organization is that
|
||||||
|
|||||||
+2
-3
@@ -20,7 +20,7 @@ and largely have the same functions.
|
|||||||
|
|
||||||
import ocrmypdf
|
import ocrmypdf
|
||||||
|
|
||||||
if __name__ == '__main__': # To ensure correct behavior on Windows
|
if __name__ == '__main__': # To ensure correct behavior on Windows and macOS
|
||||||
ocrmypdf.ocr('input.pdf', 'output.pdf', deskew=True)
|
ocrmypdf.ocr('input.pdf', 'output.pdf', deskew=True)
|
||||||
|
|
||||||
With a few exceptions, all of the command line arguments are available
|
With a few exceptions, all of the command line arguments are available
|
||||||
@@ -42,8 +42,7 @@ execution. To do this, it will:
|
|||||||
- execute other subprocesses (forking and executing other programs)
|
- execute other subprocesses (forking and executing other programs)
|
||||||
|
|
||||||
The Python process that calls ``ocrmypdf.ocr()`` must be sufficiently
|
The Python process that calls ``ocrmypdf.ocr()`` must be sufficiently
|
||||||
privileged to perform these actions. If it is not, ``ocrmypdf()`` will
|
privileged to perform these actions.
|
||||||
fail.
|
|
||||||
|
|
||||||
There is no currently no option to manage how jobs are scheduled other
|
There is no currently no option to manage how jobs are scheduled other
|
||||||
than the argument ``jobs=`` which will limit the number of worker
|
than the argument ``jobs=`` which will limit the number of worker
|
||||||
|
|||||||
+18
-19
@@ -495,10 +495,6 @@ Installing on Windows
|
|||||||
Native Windows
|
Native Windows
|
||||||
--------------
|
--------------
|
||||||
|
|
||||||
.. note::
|
|
||||||
|
|
||||||
It is easier to install OCRmyPDF on Windows Subsystem for Linux.
|
|
||||||
|
|
||||||
.. note::
|
.. note::
|
||||||
|
|
||||||
Administrator privileges will be required for some of these steps.
|
Administrator privileges will be required for some of these steps.
|
||||||
@@ -509,30 +505,33 @@ You must install the following for Windows:
|
|||||||
* Tesseract 4.0 or later
|
* Tesseract 4.0 or later
|
||||||
* Ghostscript 9.50 or later
|
* Ghostscript 9.50 or later
|
||||||
|
|
||||||
You can install these with the Chocolatey package manager:
|
Using the `Chocolatey <https://chocolatey.org/>`_ package manager, install the
|
||||||
|
following when running in an Administrator command prompt:
|
||||||
|
|
||||||
* ``choco install python3``
|
* ``choco install python3``
|
||||||
* ``choco install --pre tesseract``
|
* ``choco install --pre tesseract``
|
||||||
* ``choco install ghostscript``
|
* ``choco install ghostscript``
|
||||||
|
* ``choco install pngquant`` (optional)
|
||||||
|
|
||||||
Also consider adding:
|
The commands above will install Python 3.x (latest version), Tesseract, Ghostscript
|
||||||
|
and pngquant. Chocolatey may also need to install the Windows Visual C++ Runtime
|
||||||
|
DLLs or other Windows patches, and may require a reboot.
|
||||||
|
|
||||||
* ``choco install pngquant``
|
You may then use ``pip`` to install ocrmypdf. (This can performed by a user or
|
||||||
|
Administrator.):
|
||||||
|
|
||||||
Windows 10 64-bit and 64-bit versions of applications are recommended. Earlier
|
* ``pip install ocrmypdf
|
||||||
versions of Windows and 32-bit versions of these programs are not tested, and not
|
|
||||||
supported at this time.
|
|
||||||
|
|
||||||
OCRmyPDF will check for Tesseract-OCR and Ghostscript in your Program Files folder.
|
Chocolatey automatically selects appropriate versions of these applications. If you
|
||||||
If they are in some other location, you may need to modify the ``PATH``
|
are installing them manually, please install 64-bit versions of all applications for
|
||||||
environment variable so Tesseract, Ghostscript, and other any optional executables can
|
64-bit Windows, or 32-bit versions of all applications for 32-bit Windows. Mixing
|
||||||
be found. You can enter it in the command line or
|
the "bitness" of these programs will lead to errors.
|
||||||
`follow these directions <https://www.computerhope.com/issues/ch000549.htm#dospath>`_
|
|
||||||
to make the change persistent and system-wide.
|
|
||||||
|
|
||||||
You may then use pip to install ocrmypdf:
|
OCRmyPDF will check the Windows Registry and standard locations in your Program Files
|
||||||
|
for third party software it needs (specifically, Tesseract and Ghostscript). To
|
||||||
* ``pip install ocrmypdf``
|
override the versions OCRmyPDF selects, you can modify the ``PATH`` environment
|
||||||
|
variable. `Follow these directions <https://www.computerhope.com/issues/ch000549.htm#dospath>`_
|
||||||
|
to change the PATH.
|
||||||
|
|
||||||
Windows Subsystem for Linux
|
Windows Subsystem for Linux
|
||||||
---------------------------
|
---------------------------
|
||||||
|
|||||||
@@ -12,6 +12,35 @@ may be unreliable. Use the API to depend on precise behavior.
|
|||||||
The public API may be useful in scripts that launch OCRmyPDF processes or that
|
The public API may be useful in scripts that launch OCRmyPDF processes or that
|
||||||
wish to use some of its features for working with PDFs.
|
wish to use some of its features for working with PDFs.
|
||||||
|
|
||||||
|
v11.4.1
|
||||||
|
=======
|
||||||
|
|
||||||
|
- Fixed an issue where invalid pages ranges passed using the ``pages`` argument,
|
||||||
|
such as "1-0" would cause unhandled exceptions.
|
||||||
|
- Accepted a user-contributed to the Synology demo script in misc/synology.py.
|
||||||
|
- Clarified documentation about change of temporary file location ``ocrmypdf.io``.
|
||||||
|
- Fixed Python wheel tag which was incorrectly set to py35 even though we long
|
||||||
|
since dropped support for Python 3.5.
|
||||||
|
|
||||||
|
v11.4.0
|
||||||
|
=======
|
||||||
|
|
||||||
|
- When looking for Tesseract and Ghostscript, we now check the Windows Registry to
|
||||||
|
see if their installers registered the location of their executables. This should
|
||||||
|
help Windows users who have installed these programs to non-standard
|
||||||
|
locations.
|
||||||
|
- We now report on the progress of PDF/A conversion, since this operation is
|
||||||
|
sometimes slow.
|
||||||
|
- Improved command line completions.
|
||||||
|
- The prefix of the temporary folder OCRmyPDF creates has been changed from
|
||||||
|
``com.github.ocrmypdf`` to ``ocrmypdf.io``. Scripts that chose to depend on this
|
||||||
|
prefix may need to be adjusted. (This has always been an implementation detail so is
|
||||||
|
not considered part of the semantic versioning "contract".)
|
||||||
|
- Fixed issue #692, where a particular file with malformed fonts would flood an
|
||||||
|
internal message cue by generating so many debug messages.
|
||||||
|
- Fixed an exception on processing hOCR files with no page record. Tesseract
|
||||||
|
is not known to generate such files.
|
||||||
|
|
||||||
v11.3.4
|
v11.3.4
|
||||||
=======
|
=======
|
||||||
|
|
||||||
|
|||||||
@@ -59,7 +59,8 @@ complete -c ocrmypdf -x -l output-type -a '(__fish_ocrmypdf_output_type)' -d "se
|
|||||||
|
|
||||||
function __fish_ocrmypdf_pdf_renderer
|
function __fish_ocrmypdf_pdf_renderer
|
||||||
echo -e "auto\t"(_ "auto select PDF renderer")
|
echo -e "auto\t"(_ "auto select PDF renderer")
|
||||||
echo -e "hocr\t"(_ "use hocr renderer")
|
echo -e "hocr\t"(_ "use hOCR renderer")
|
||||||
|
echo -e "hocrdebug\t"(_ "uses hOCR renderer in debug mode, showing recognized text")
|
||||||
echo -e "sandwich\t"(_ "use sandwich renderer")
|
echo -e "sandwich\t"(_ "use sandwich renderer")
|
||||||
end
|
end
|
||||||
complete -c ocrmypdf -x -l pdf-renderer -a '(__fish_ocrmypdf_pdf_renderer)' -d "select PDF renderer options"
|
complete -c ocrmypdf -x -l pdf-renderer -a '(__fish_ocrmypdf_pdf_renderer)' -d "select PDF renderer options"
|
||||||
@@ -135,4 +136,4 @@ complete -c ocrmypdf -r -l user-words -d "specify location of user words file"
|
|||||||
complete -c ocrmypdf -r -l user-patterns -d "specify location of user patterns file"
|
complete -c ocrmypdf -r -l user-patterns -d "specify location of user patterns file"
|
||||||
complete -c ocrmypdf -x -l fast-web-view -d "if file size if above this amount in MB, linearize PDF"
|
complete -c ocrmypdf -x -l fast-web-view -d "if file size if above this amount in MB, linearize PDF"
|
||||||
|
|
||||||
complete -c ocrmypdf -x -a "(__fish_complete_suffix .pdf)"
|
complete -c ocrmypdf -x -a "(__fish_complete_suffix .pdf; __fish_complete_suffix .PDF; __fish_complete_suffix .jpg; __fish_complete_suffix .png)"
|
||||||
|
|||||||
+3
-1
@@ -79,8 +79,10 @@ for dir_name, subdirs, file_list in os.walk(start_dir):
|
|||||||
stdout=output_file,
|
stdout=output_file,
|
||||||
stderr=subprocess.PIPE,
|
stderr=subprocess.PIPE,
|
||||||
check=False,
|
check=False,
|
||||||
|
text=True,
|
||||||
|
errors='ignore',
|
||||||
)
|
)
|
||||||
logging.info(proc.stderr.read())
|
logging.info(proc.stderr)
|
||||||
os.chmod(full_path_ocr, 0o664)
|
os.chmod(full_path_ocr, 0o664)
|
||||||
os.chmod(full_path, 0o664)
|
os.chmod(full_path, 0o664)
|
||||||
full_path_ocr_archive = sys.argv[2]
|
full_path_ocr_archive = sys.argv[2]
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
[bdist_wheel]
|
[bdist_wheel]
|
||||||
python-tag = py35
|
python-tag = py36
|
||||||
|
|
||||||
[aliases]
|
[aliases]
|
||||||
test=pytest
|
test=pytest
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ setup(
|
|||||||
],
|
],
|
||||||
tests_require=tests_require,
|
tests_require=tests_require,
|
||||||
entry_points={'console_scripts': ['ocrmypdf = ocrmypdf.__main__:run']},
|
entry_points={'console_scripts': ['ocrmypdf = ocrmypdf.__main__:run']},
|
||||||
package_data={'ocrmypdf': ['data/sRGB.icc']},
|
package_data={'ocrmypdf': ['data/sRGB.icc', 'py.typed']},
|
||||||
include_package_data=True,
|
include_package_data=True,
|
||||||
zip_safe=False,
|
zip_safe=False,
|
||||||
project_urls={
|
project_urls={
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ from PIL import Image
|
|||||||
|
|
||||||
from ocrmypdf.exceptions import MissingDependencyError, SubprocessOutputError
|
from ocrmypdf.exceptions import MissingDependencyError, SubprocessOutputError
|
||||||
from ocrmypdf.helpers import Resolution
|
from ocrmypdf.helpers import Resolution
|
||||||
from ocrmypdf.subprocess import get_version, run
|
from ocrmypdf.subprocess import get_version, run, run_polling_stderr
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -139,12 +139,39 @@ def rasterize_pdf(
|
|||||||
im.save(fspath(output_file), dpi=page_dpi)
|
im.save(fspath(output_file), dpi=page_dpi)
|
||||||
|
|
||||||
|
|
||||||
|
class GhostscriptFollower:
|
||||||
|
re_process = re.compile(r"Processing pages \d+ through (\d+).")
|
||||||
|
re_page = re.compile(r"Page (\d+)")
|
||||||
|
|
||||||
|
def __init__(self, progressbar_class):
|
||||||
|
self.count = 0
|
||||||
|
self.progressbar_class = progressbar_class
|
||||||
|
self.progressbar = None
|
||||||
|
|
||||||
|
def __call__(self, line):
|
||||||
|
if not self.progressbar_class:
|
||||||
|
return
|
||||||
|
if not self.progressbar:
|
||||||
|
m = self.re_process.match(line.strip())
|
||||||
|
if m:
|
||||||
|
self.count = int(m.group(1))
|
||||||
|
self.progressbar = self.progressbar_class(
|
||||||
|
total=self.count, desc="PDF/A conversion", unit='page'
|
||||||
|
)
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
m = self.re_page.match(line.strip())
|
||||||
|
if m:
|
||||||
|
self.progressbar.update()
|
||||||
|
|
||||||
|
|
||||||
def generate_pdfa(
|
def generate_pdfa(
|
||||||
pdf_pages,
|
pdf_pages,
|
||||||
output_file: os.PathLike,
|
output_file: os.PathLike,
|
||||||
compression: str,
|
compression: str,
|
||||||
pdf_version: str = '1.5',
|
pdf_version: str = '1.5',
|
||||||
pdfa_part: str = '2',
|
pdfa_part: str = '2',
|
||||||
|
progressbar_class=None,
|
||||||
):
|
):
|
||||||
# Ghostscript's compression is all or nothing. We can either force all images
|
# Ghostscript's compression is all or nothing. We can either force all images
|
||||||
# to JPEG, force all to Flate/PNG, or let it decide how to encode the images.
|
# to JPEG, force all to Flate/PNG, or let it decide how to encode the images.
|
||||||
@@ -188,7 +215,6 @@ def generate_pdfa(
|
|||||||
args_gs = (
|
args_gs = (
|
||||||
[
|
[
|
||||||
GS,
|
GS,
|
||||||
"-dQUIET",
|
|
||||||
"-dBATCH",
|
"-dBATCH",
|
||||||
"-dNOPAUSE",
|
"-dNOPAUSE",
|
||||||
"-dSAFER",
|
"-dSAFER",
|
||||||
@@ -208,16 +234,26 @@ def generate_pdfa(
|
|||||||
]
|
]
|
||||||
)
|
)
|
||||||
args_gs.extend(fspath(s) for s in pdf_pages) # Stringify Path objs
|
args_gs.extend(fspath(s) for s in pdf_pages) # Stringify Path objs
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with Path(output_file).open('wb') as output:
|
with Path(output_file).open('wb') as output:
|
||||||
p = run(args_gs, stdout=output, stderr=PIPE, check=True)
|
p = run_polling_stderr(
|
||||||
|
args_gs,
|
||||||
|
stdout=output,
|
||||||
|
stderr=PIPE,
|
||||||
|
check=True,
|
||||||
|
text=True,
|
||||||
|
encoding='utf-8',
|
||||||
|
errors='replace',
|
||||||
|
callback=GhostscriptFollower(progressbar_class),
|
||||||
|
)
|
||||||
except CalledProcessError as e:
|
except CalledProcessError as e:
|
||||||
# Ghostscript does not change return code when it fails to create
|
# Ghostscript does not change return code when it fails to create
|
||||||
# PDF/A - check PDF/A status elsewhere
|
# PDF/A - check PDF/A status elsewhere
|
||||||
log.error(e.stderr.decode(errors='replace'))
|
log.error(e.stderr)
|
||||||
raise SubprocessOutputError('Ghostscript PDF/A rendering failed')
|
raise SubprocessOutputError('Ghostscript PDF/A rendering failed') from e
|
||||||
else:
|
else:
|
||||||
stderr = p.stderr.decode('utf-8', errors='replace')
|
stderr = p.stderr
|
||||||
if _gs_error_reported(stderr):
|
if _gs_error_reported(stderr):
|
||||||
last_part = None
|
last_part = None
|
||||||
repcount = 0
|
repcount = 0
|
||||||
|
|||||||
@@ -99,7 +99,14 @@ def get_languages():
|
|||||||
|
|
||||||
args_tess = ['tesseract', '--list-langs']
|
args_tess = ['tesseract', '--list-langs']
|
||||||
try:
|
try:
|
||||||
proc = run(args_tess, text=True, stdout=PIPE, stderr=STDOUT, check=True)
|
proc = run(
|
||||||
|
args_tess,
|
||||||
|
text=True,
|
||||||
|
stdout=PIPE,
|
||||||
|
stderr=STDOUT,
|
||||||
|
logs_errors_to_stdout=True,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
output = proc.stdout
|
output = proc.stdout
|
||||||
except CalledProcessError as e:
|
except CalledProcessError as e:
|
||||||
raise MissingDependencyError(lang_error(e.output)) from e
|
raise MissingDependencyError(lang_error(e.output)) from e
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import img2pdf
|
|||||||
import pikepdf
|
import pikepdf
|
||||||
from pikepdf.models.metadata import encode_pdf_date
|
from pikepdf.models.metadata import encode_pdf_date
|
||||||
from PIL import Image, ImageColor, ImageDraw
|
from PIL import Image, ImageColor, ImageDraw
|
||||||
|
from tqdm import tqdm
|
||||||
|
|
||||||
from ocrmypdf import leptonica
|
from ocrmypdf import leptonica
|
||||||
from ocrmypdf._exec import unpaper
|
from ocrmypdf._exec import unpaper
|
||||||
@@ -601,14 +602,17 @@ def create_pdf_page_from_image(image: Path, page_context: PageContext):
|
|||||||
|
|
||||||
|
|
||||||
def render_hocr_page(hocr: Path, page_context: PageContext):
|
def render_hocr_page(hocr: Path, page_context: PageContext):
|
||||||
|
options = page_context.options
|
||||||
output_file = page_context.get_path('ocr_hocr.pdf')
|
output_file = page_context.get_path('ocr_hocr.pdf')
|
||||||
dpi = get_page_square_dpi(page_context.pageinfo, page_context.options)
|
dpi = get_page_square_dpi(page_context.pageinfo, options)
|
||||||
|
debug_mode = options.pdf_renderer == 'hocrdebug'
|
||||||
|
|
||||||
hocrtransform = HocrTransform(hocr, dpi.x) # square
|
hocrtransform = HocrTransform(hocr, dpi.x) # square
|
||||||
hocrtransform.to_pdf(
|
hocrtransform.to_pdf(
|
||||||
output_file,
|
output_file,
|
||||||
image_filename=None,
|
image_filename=None,
|
||||||
show_bounding_boxes=False,
|
show_bounding_boxes=False if not debug_mode else True,
|
||||||
invisible_text=True,
|
invisible_text=True if not debug_mode else False,
|
||||||
interword_spaces=True,
|
interword_spaces=True,
|
||||||
)
|
)
|
||||||
return output_file
|
return output_file
|
||||||
@@ -709,6 +713,7 @@ def convert_to_pdfa(input_pdf: Path, input_ps_stub: Path, context: PdfContext):
|
|||||||
output_file=output_file,
|
output_file=output_file,
|
||||||
compression=options.pdfa_image_compression,
|
compression=options.pdfa_image_compression,
|
||||||
pdfa_part=options.output_type[-1], # is pdfa-1, pdfa-2, or pdfa-3
|
pdfa_part=options.output_type[-1], # is pdfa-1, pdfa-2, or pdfa-3
|
||||||
|
progressbar_class=tqdm if options.progress_bar else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
return output_file
|
return output_file
|
||||||
|
|||||||
@@ -207,7 +207,7 @@ def exec_page_sync(page_context: PageContext):
|
|||||||
visible_image_out, page_context
|
visible_image_out, page_context
|
||||||
)
|
)
|
||||||
|
|
||||||
if options.pdf_renderer == 'hocr':
|
if options.pdf_renderer.startswith('hocr'):
|
||||||
(hocr_out, text_out) = ocr_engine_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)
|
ocr_out = render_hocr_page(hocr_out, page_context)
|
||||||
elif options.pdf_renderer == 'sandwich':
|
elif options.pdf_renderer == 'sandwich':
|
||||||
@@ -330,7 +330,7 @@ def run_pipeline(options, *, plugin_manager, api=False):
|
|||||||
if not plugin_manager:
|
if not plugin_manager:
|
||||||
plugin_manager = get_plugin_manager(options.plugins)
|
plugin_manager = get_plugin_manager(options.plugins)
|
||||||
|
|
||||||
work_folder = Path(mkdtemp(prefix="com.github.ocrmypdf."))
|
work_folder = Path(mkdtemp(prefix="ocrmypdf.io."))
|
||||||
debug_log_handler = None
|
debug_log_handler = None
|
||||||
if (
|
if (
|
||||||
(options.keep_temporary_files or options.verbose >= 1)
|
(options.keep_temporary_files or options.verbose >= 1)
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import sys
|
|||||||
import unicodedata
|
import unicodedata
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from shutil import copyfileobj
|
from shutil import copyfileobj
|
||||||
from typing import Tuple
|
from typing import List, Set, Tuple, Union
|
||||||
|
|
||||||
import pikepdf
|
import pikepdf
|
||||||
import PIL
|
import PIL
|
||||||
@@ -78,7 +78,7 @@ def check_options_languages(options, ocr_engine_languages):
|
|||||||
def check_options_output(options):
|
def check_options_output(options):
|
||||||
is_latin = options.languages.issubset(HOCR_OK_LANGS)
|
is_latin = options.languages.issubset(HOCR_OK_LANGS)
|
||||||
|
|
||||||
if options.pdf_renderer == 'hocr' and not is_latin:
|
if options.pdf_renderer.startswith('hocr') and not is_latin:
|
||||||
msg = (
|
msg = (
|
||||||
"The 'hocr' PDF renderer is known to cause problems with one "
|
"The 'hocr' PDF renderer is known to cause problems with one "
|
||||||
"or more of the languages in your document. Use "
|
"or more of the languages in your document. Use "
|
||||||
@@ -136,10 +136,10 @@ def check_options_preprocessing(options):
|
|||||||
raise BadArgsError(str(e))
|
raise BadArgsError(str(e))
|
||||||
|
|
||||||
|
|
||||||
def _pages_from_ranges(ranges):
|
def _pages_from_ranges(ranges: str) -> Set[int]:
|
||||||
if is_iterable_notstr(ranges):
|
if is_iterable_notstr(ranges):
|
||||||
return set(ranges)
|
return set(ranges)
|
||||||
pages = []
|
pages: List[int] = []
|
||||||
page_groups = ranges.replace(' ', '').split(',')
|
page_groups = ranges.replace(' ', '').split(',')
|
||||||
for g in page_groups:
|
for g in page_groups:
|
||||||
if not g:
|
if not g:
|
||||||
@@ -150,9 +150,18 @@ def _pages_from_ranges(ranges):
|
|||||||
pages.append(int(g) - 1)
|
pages.append(int(g) - 1)
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
pages.extend(range(int(start) - 1, int(end)))
|
new_pages = list(range(int(start) - 1, int(end)))
|
||||||
|
if not new_pages:
|
||||||
|
raise BadArgsError(f"invalid page subrange '{start}-{end}'")
|
||||||
|
pages.extend(new_pages)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise BadArgsError("invalid page range")
|
raise BadArgsError("invalid page range") from None
|
||||||
|
|
||||||
|
if not pages:
|
||||||
|
raise BadArgsError(
|
||||||
|
f"The string of page ranges '{ranges}' did not contain any recognizable "
|
||||||
|
f"page ranges."
|
||||||
|
)
|
||||||
|
|
||||||
if not monotonic(pages):
|
if not monotonic(pages):
|
||||||
log.warning(
|
log.warning(
|
||||||
|
|||||||
+7
-6
@@ -9,6 +9,7 @@ import logging
|
|||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
from enum import IntEnum
|
from enum import IntEnum
|
||||||
|
from io import IOBase
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import AnyStr, BinaryIO, Iterable, Optional, Union
|
from typing import AnyStr, BinaryIO, Iterable, Optional, Union
|
||||||
from warnings import warn
|
from warnings import warn
|
||||||
@@ -174,14 +175,14 @@ def create_options(
|
|||||||
else:
|
else:
|
||||||
raise TypeError(f"{arg}: {val} ({type(val)})")
|
raise TypeError(f"{arg}: {val} ({type(val)})")
|
||||||
|
|
||||||
try:
|
if isinstance(input_file, (BinaryIO, IOBase)):
|
||||||
cmdline.append(os.fspath(input_file))
|
|
||||||
except TypeError:
|
|
||||||
cmdline.append('stream://input_file')
|
cmdline.append('stream://input_file')
|
||||||
try:
|
else:
|
||||||
cmdline.append(os.fspath(output_file))
|
cmdline.append(os.fspath(input_file))
|
||||||
except TypeError:
|
if isinstance(output_file, (BinaryIO, IOBase)):
|
||||||
cmdline.append('stream://output_file')
|
cmdline.append('stream://output_file')
|
||||||
|
else:
|
||||||
|
cmdline.append(os.fspath(output_file))
|
||||||
|
|
||||||
parser._api_mode = True
|
parser._api_mode = True
|
||||||
options = parser.parse_args(cmdline)
|
options = parser.parse_args(cmdline)
|
||||||
|
|||||||
@@ -79,12 +79,21 @@ def rasterize_pdf_page(
|
|||||||
|
|
||||||
|
|
||||||
@hookimpl
|
@hookimpl
|
||||||
def generate_pdfa(pdf_pages, pdfmark, output_file, compression, pdf_version, pdfa_part):
|
def generate_pdfa(
|
||||||
|
pdf_pages,
|
||||||
|
pdfmark,
|
||||||
|
output_file,
|
||||||
|
compression,
|
||||||
|
pdf_version,
|
||||||
|
pdfa_part,
|
||||||
|
progressbar_class,
|
||||||
|
):
|
||||||
ghostscript.generate_pdfa(
|
ghostscript.generate_pdfa(
|
||||||
pdf_pages=[*pdf_pages, pdfmark],
|
pdf_pages=[*pdf_pages, pdfmark],
|
||||||
output_file=output_file,
|
output_file=output_file,
|
||||||
compression=compression,
|
compression=compression,
|
||||||
pdf_version=pdf_version,
|
pdf_version=pdf_version,
|
||||||
pdfa_part=pdfa_part,
|
pdfa_part=pdfa_part,
|
||||||
|
progressbar_class=progressbar_class,
|
||||||
)
|
)
|
||||||
return output_file
|
return output_file
|
||||||
|
|||||||
+5
-2
@@ -6,12 +6,15 @@
|
|||||||
|
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
from typing import Optional, Type, TypeVar
|
||||||
|
|
||||||
from ocrmypdf._version import PROGRAM_NAME as _PROGRAM_NAME
|
from ocrmypdf._version import PROGRAM_NAME as _PROGRAM_NAME
|
||||||
from ocrmypdf._version import __version__ as _VERSION
|
from ocrmypdf._version import __version__ as _VERSION
|
||||||
|
|
||||||
|
T = TypeVar('T')
|
||||||
|
|
||||||
def numeric(basetype, min_=None, max_=None):
|
|
||||||
|
def numeric(basetype: Type[T], min_: Optional[T] = None, max_: Optional[T] = None):
|
||||||
"""Validator for numeric params"""
|
"""Validator for numeric params"""
|
||||||
min_ = basetype(min_) if min_ is not None else None
|
min_ = basetype(min_) if min_ is not None else None
|
||||||
max_ = basetype(max_) if max_ is not None else None
|
max_ = basetype(max_) if max_ is not None else None
|
||||||
@@ -407,7 +410,7 @@ Online documentation is located at:
|
|||||||
)
|
)
|
||||||
advanced.add_argument(
|
advanced.add_argument(
|
||||||
'--pdf-renderer',
|
'--pdf-renderer',
|
||||||
choices=['auto', 'hocr', 'sandwich'],
|
choices=['auto', 'hocr', 'sandwich', 'hocrdebug'],
|
||||||
default='auto',
|
default='auto',
|
||||||
help="Choose OCR PDF renderer - the default option is to let OCRmyPDF "
|
help="Choose OCR PDF renderer - the default option is to let OCRmyPDF "
|
||||||
"choose. See documentation for discussion.",
|
"choose. See documentation for discussion.",
|
||||||
|
|||||||
@@ -216,10 +216,7 @@ def check_pdf(input_file: Path) -> bool:
|
|||||||
pdf.close()
|
pdf.close()
|
||||||
|
|
||||||
|
|
||||||
T = TypeVar('T')
|
def clamp(n, smallest, largest): # mypy doesn't understand types for this
|
||||||
|
|
||||||
|
|
||||||
def clamp(n: T, smallest: T, largest: T) -> T:
|
|
||||||
"""Clamps the value of n to between smallest and largest."""
|
"""Clamps the value of n to between smallest and largest."""
|
||||||
return max(smallest, min(n, largest))
|
return max(smallest, min(n, largest))
|
||||||
|
|
||||||
@@ -232,6 +229,7 @@ def pikepdf_enable_mmap():
|
|||||||
# log.debug("pikepdf mmap not available")
|
# log.debug("pikepdf mmap not available")
|
||||||
# We found a race condition probably related to pybind issue #2252 that can
|
# We found a race condition probably related to pybind issue #2252 that can
|
||||||
# cause a crash. For now, disable pikepdf mmap to be on the safe side.
|
# cause a crash. For now, disable pikepdf mmap to be on the safe side.
|
||||||
|
# Fix is not in pybind11 2.6.0
|
||||||
log.debug("pikepdf mmap disabled")
|
log.debug("pikepdf mmap disabled")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ from reportlab.lib.colors import black, cyan, magenta, red
|
|||||||
from reportlab.lib.units import inch
|
from reportlab.lib.units import inch
|
||||||
from reportlab.pdfgen.canvas import Canvas
|
from reportlab.pdfgen.canvas import Canvas
|
||||||
|
|
||||||
|
Element = ElementTree.Element
|
||||||
|
|
||||||
Rect = namedtuple('Rect', ['x1', 'y1', 'x2', 'y2'])
|
Rect = namedtuple('Rect', ['x1', 'y1', 'x2', 'y2'])
|
||||||
|
|
||||||
|
|
||||||
@@ -105,7 +107,7 @@ class HocrTransform:
|
|||||||
else:
|
else:
|
||||||
return ''
|
return ''
|
||||||
|
|
||||||
def _get_element_text(self, element):
|
def _get_element_text(self, element: Element):
|
||||||
"""
|
"""
|
||||||
Return the textual content of the element and its children
|
Return the textual content of the element and its children
|
||||||
"""
|
"""
|
||||||
@@ -119,7 +121,7 @@ class HocrTransform:
|
|||||||
return text
|
return text
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def element_coordinates(cls, element) -> Rect:
|
def element_coordinates(cls, element: Element) -> Rect:
|
||||||
"""
|
"""
|
||||||
Returns a tuple containing the coordinates of the bounding box around
|
Returns a tuple containing the coordinates of the bounding box around
|
||||||
an element
|
an element
|
||||||
@@ -133,7 +135,7 @@ class HocrTransform:
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def baseline(cls, element) -> Tuple[float, float]:
|
def baseline(cls, element: Element) -> Tuple[float, float]:
|
||||||
"""
|
"""
|
||||||
Returns a tuple containing the baseline slope and intercept.
|
Returns a tuple containing the baseline slope and intercept.
|
||||||
"""
|
"""
|
||||||
@@ -149,7 +151,7 @@ class HocrTransform:
|
|||||||
"""
|
"""
|
||||||
return Rect._make((c / self.dpi * inch) for c in pxl)
|
return Rect._make((c / self.dpi * inch) for c in pxl)
|
||||||
|
|
||||||
def _child_xpath(self, html_tag, html_class=None):
|
def _child_xpath(self, html_tag: str, html_class: Optional[str] = None) -> str:
|
||||||
xpath = f".//{self.xmlns}{html_tag}"
|
xpath = f".//{self.xmlns}{html_tag}"
|
||||||
if html_class:
|
if html_class:
|
||||||
xpath += f"[@class='{html_class}']"
|
xpath += f"[@class='{html_class}']"
|
||||||
@@ -280,13 +282,15 @@ class HocrTransform:
|
|||||||
def _do_line(
|
def _do_line(
|
||||||
self,
|
self,
|
||||||
pdf: Canvas,
|
pdf: Canvas,
|
||||||
line,
|
line: Optional[Element],
|
||||||
elemclass: str,
|
elemclass: str,
|
||||||
fontname: str,
|
fontname: str,
|
||||||
invisible_text: bool,
|
invisible_text: bool,
|
||||||
interword_spaces: bool,
|
interword_spaces: bool,
|
||||||
show_bounding_boxes: bool,
|
show_bounding_boxes: bool,
|
||||||
):
|
):
|
||||||
|
if not line:
|
||||||
|
return
|
||||||
pxl_line_coords = self.element_coordinates(line)
|
pxl_line_coords = self.element_coordinates(line)
|
||||||
line_box = self.pt_from_pixel(pxl_line_coords)
|
line_box = self.pt_from_pixel(pxl_line_coords)
|
||||||
line_height = line_box.y2 - line_box.y1
|
line_height = line_box.y2 - line_box.y1
|
||||||
|
|||||||
@@ -27,15 +27,16 @@ from tempfile import TemporaryFile
|
|||||||
|
|
||||||
from ocrmypdf.exceptions import MissingDependencyError
|
from ocrmypdf.exceptions import MissingDependencyError
|
||||||
from ocrmypdf.lib._leptonica import ffi
|
from ocrmypdf.lib._leptonica import ffi
|
||||||
from ocrmypdf.subprocess import shim_paths_with_program_files
|
|
||||||
|
|
||||||
# pylint: disable=protected-access
|
# pylint: disable=protected-access
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
if os.name == 'nt':
|
if os.name == 'nt':
|
||||||
|
from ocrmypdf.subprocess._windows import shim_env_path
|
||||||
|
|
||||||
libname = 'liblept-5'
|
libname = 'liblept-5'
|
||||||
os.environ['PATH'] = shim_paths_with_program_files()
|
os.environ['PATH'] = shim_env_path()
|
||||||
else:
|
else:
|
||||||
libname = 'lept'
|
libname = 'lept'
|
||||||
_libpath = find_library(libname)
|
_libpath = find_library(libname)
|
||||||
@@ -58,9 +59,9 @@ if not _libpath:
|
|||||||
---------------------------------------------------------------------
|
---------------------------------------------------------------------
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
if os.name == 'nt':
|
if os.name == 'nt':
|
||||||
# On Windows, recent versions of libpng require zlib. We have to make sure
|
# On Windows, recent versions of libpng require zlib. We have to make sure
|
||||||
# the zlib version being loaded is the same one that libpng was built with.
|
# the zlib version being loaded is the same one that libpng was built with.
|
||||||
# This tries to import zlib from Tesseract's installation folder, falling back
|
# This tries to import zlib from Tesseract's installation folder, falling back
|
||||||
# to find_library() if liblept is being loaded from somewhere else.
|
# to find_library() if liblept is being loaded from somewhere else.
|
||||||
# Loading zlib from other places could cause a version mismatch
|
# Loading zlib from other places could cause a version mismatch
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ from typing import (
|
|||||||
Optional,
|
Optional,
|
||||||
Sequence,
|
Sequence,
|
||||||
Tuple,
|
Tuple,
|
||||||
Union,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
import img2pdf
|
import img2pdf
|
||||||
@@ -294,10 +293,6 @@ def extract_images_jbig2(pike: Pdf, root: Path, options) -> Dict[int, List[XrefE
|
|||||||
group = pageno // options.jbig2_page_group_size
|
group = pageno // options.jbig2_page_group_size
|
||||||
jbig2_groups[group].append(xref_ext)
|
jbig2_groups[group].append(xref_ext)
|
||||||
|
|
||||||
# Elide empty groups
|
|
||||||
jbig2_groups = {
|
|
||||||
group: xrefs for group, xrefs in jbig2_groups.items() if len(xrefs) > 0
|
|
||||||
}
|
|
||||||
log.debug("Optimizable images: JBIG2 groups: %s", (len(jbig2_groups),))
|
log.debug("Optimizable images: JBIG2 groups: %s", (len(jbig2_groups),))
|
||||||
return jbig2_groups
|
return jbig2_groups
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ def _postscript_objdef(
|
|||||||
objtype = '/stream' if stream_name else '/dict'
|
objtype = '/stream' if stream_name else '/dict'
|
||||||
|
|
||||||
if stream_name:
|
if stream_name:
|
||||||
|
assert stream_data is not None
|
||||||
a85_data = base64.a85encode(stream_data, adobe=True).decode('ascii')
|
a85_data = base64.a85encode(stream_data, adobe=True).decode('ascii')
|
||||||
yield f'{stream_name} ' + a85_data
|
yield f'{stream_name} ' + a85_data
|
||||||
yield 'def'
|
yield 'def'
|
||||||
|
|||||||
@@ -627,9 +627,12 @@ def _pdf_get_pageinfo(
|
|||||||
worker_pdf = None
|
worker_pdf = None
|
||||||
|
|
||||||
|
|
||||||
def _pdf_pageinfo_sync_init(infile):
|
def _pdf_pageinfo_sync_init(infile: Path, pdfminer_loglevel):
|
||||||
global worker_pdf # pylint: disable=global-statement
|
global worker_pdf # pylint: disable=global-statement
|
||||||
pikepdf_enable_mmap()
|
pikepdf_enable_mmap()
|
||||||
|
|
||||||
|
logging.getLogger('pdfminer').setLevel(pdfminer_loglevel)
|
||||||
|
|
||||||
# If this function is called as a thread initializer, we need a messy hack
|
# If this function is called as a thread initializer, we need a messy hack
|
||||||
# to close worker_pdf. If called as a process, it will be released when the
|
# to close worker_pdf. If called as a process, it will be released when the
|
||||||
# process is terminated.
|
# process is terminated.
|
||||||
@@ -674,7 +677,9 @@ def _pdf_pageinfo_concurrent(
|
|||||||
tqdm_kwargs=dict(
|
tqdm_kwargs=dict(
|
||||||
total=total, desc="Scanning contents", unit='page', disable=not progbar
|
total=total, desc="Scanning contents", unit='page', disable=not progbar
|
||||||
),
|
),
|
||||||
task_initializer=partial(_pdf_pageinfo_sync_init, infile),
|
task_initializer=partial(
|
||||||
|
_pdf_pageinfo_sync_init, infile, logging.getLogger('pdfminer').level
|
||||||
|
),
|
||||||
task=_pdf_pageinfo_sync,
|
task=_pdf_pageinfo_sync,
|
||||||
task_arguments=contexts,
|
task_arguments=contexts,
|
||||||
task_finished=update_pageinfo,
|
task_finished=update_pageinfo,
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ def check_options(options: Namespace) -> None:
|
|||||||
Note:
|
Note:
|
||||||
This hook will be called from the main process, and may modify global state
|
This hook will be called from the main process, and may modify global state
|
||||||
before child worker processes are forked.
|
before child worker processes are forked.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
@hookspec
|
@hookspec
|
||||||
@@ -280,6 +280,7 @@ def generate_pdfa(
|
|||||||
compression: str,
|
compression: str,
|
||||||
pdf_version: str,
|
pdf_version: str,
|
||||||
pdfa_part: str,
|
pdfa_part: str,
|
||||||
|
progressbar_class,
|
||||||
) -> Path:
|
) -> Path:
|
||||||
"""Generate a PDF/A.
|
"""Generate a PDF/A.
|
||||||
|
|
||||||
@@ -302,10 +303,21 @@ def generate_pdfa(
|
|||||||
At its own discretion, the PDF/A generator may raise the version,
|
At its own discretion, the PDF/A generator may raise the version,
|
||||||
but should not lower it.
|
but should not lower it.
|
||||||
pdfa_part: The desired PDF/A compliance level, such as ``'2B'``.
|
pdfa_part: The desired PDF/A compliance level, such as ``'2B'``.
|
||||||
|
progressbar_class: The class of a progress bar with a tqdm-like API. An
|
||||||
|
instance of this class will be initialized when PDF/A conversion
|
||||||
|
begins, using
|
||||||
|
``instance = progressbar_class(total: int, desc: str, unit:str)``,
|
||||||
|
defining the number of work units, a user-visible description,
|
||||||
|
and the name of the work units ("page"). Then ``instance.update()``
|
||||||
|
will be called when a work unit is completed. If ``None``, no
|
||||||
|
progress information is reported.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Path: If successful, the hook should return ``output_file``.
|
Path: If successful, the hook should return ``output_file``.
|
||||||
|
|
||||||
Note:
|
Note:
|
||||||
This is a :ref:`firstresult hook<firstresult>`.
|
This is a :ref:`firstresult hook<firstresult>`.
|
||||||
|
|
||||||
|
See also:
|
||||||
|
https://github.com/tqdm/tqdm
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
# ocrmypdf is typed
|
||||||
@@ -10,14 +10,13 @@
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import shutil
|
|
||||||
import sys
|
import sys
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from distutils.version import LooseVersion
|
from distutils.version import LooseVersion
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from subprocess import PIPE, STDOUT, CalledProcessError
|
from subprocess import PIPE, STDOUT, CalledProcessError, CompletedProcess, Popen
|
||||||
from subprocess import run as subprocess_run
|
from subprocess import run as subprocess_run
|
||||||
|
|
||||||
from ocrmypdf.exceptions import MissingDependencyError
|
from ocrmypdf.exceptions import MissingDependencyError
|
||||||
@@ -41,26 +40,7 @@ def run(args, *, env=None, logs_errors_to_stdout=False, **kwargs):
|
|||||||
if there is an error. If False, stderr is logged. Could be used with
|
if there is an error. If False, stderr is logged. Could be used with
|
||||||
stderr=STDOUT, stdout=PIPE for example.
|
stderr=STDOUT, stdout=PIPE for example.
|
||||||
"""
|
"""
|
||||||
if not env:
|
args, env, process_log, _text = _fix_process_args(args, env, kwargs)
|
||||||
env = os.environ
|
|
||||||
|
|
||||||
# Search in spoof path if necessary
|
|
||||||
program = args[0]
|
|
||||||
|
|
||||||
if os.name == 'nt':
|
|
||||||
args = _fix_windows_args(program, args, env)
|
|
||||||
|
|
||||||
log.debug("Running: %s", args)
|
|
||||||
process_log = log.getChild(os.path.basename(program))
|
|
||||||
if sys.version_info < (3, 7):
|
|
||||||
if 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
|
|
||||||
if 'text' in kwargs:
|
|
||||||
# Convert run(...text=) to run(...universal_newlines=) for Python 3.6
|
|
||||||
kwargs['universal_newlines'] = kwargs['text']
|
|
||||||
del kwargs['text']
|
|
||||||
|
|
||||||
stderr = None
|
stderr = None
|
||||||
stderr_name = 'stderr' if not logs_errors_to_stdout else 'stdout'
|
stderr_name = 'stderr' if not logs_errors_to_stdout else 'stdout'
|
||||||
@@ -82,28 +62,64 @@ def run(args, *, env=None, logs_errors_to_stdout=False, **kwargs):
|
|||||||
return proc
|
return proc
|
||||||
|
|
||||||
|
|
||||||
def _fix_windows_args(program, args, env):
|
def run_polling_stderr(args, *, callback, check=False, env=None, **kwargs):
|
||||||
"""Adjust our desired program and command line arguments for use on Windows"""
|
"""Run a process like ``ocrmypdf.subprocess.run``, and poll stderr.
|
||||||
|
|
||||||
if sys.version_info < (3, 8):
|
Every line of produced by stderr will be forwarded to the callback function.
|
||||||
# bpo-33617 - Windows needs manual Path -> str conversion
|
The intended use is monitoring progress of subprocesses that output their
|
||||||
args = [os.fspath(arg) for arg in args]
|
own progress indicators. In addition, each line will be logged if debug
|
||||||
program = os.fspath(program)
|
logging is enabled.
|
||||||
|
|
||||||
# If we are running a .py on Windows, ensure we call it with this Python
|
Requires stderr to be opened in text mode for ease of handling errors. In
|
||||||
# (to support test suite shims)
|
addition the expected encoding= and errors= arguments should be set. Note
|
||||||
if program.lower().endswith('.py'):
|
that if stdout is already set up, it need not be binary.
|
||||||
args = [sys.executable] + args
|
"""
|
||||||
|
args, env, process_log, text = _fix_process_args(args, env, kwargs)
|
||||||
|
assert text, "Must use text=True"
|
||||||
|
|
||||||
paths = os.pathsep.join(os.get_exec_path(env))
|
proc = Popen(args, env=env, **kwargs)
|
||||||
if not shutil.which(args[0], path=paths):
|
|
||||||
# If the program we want is not on the PATH, add some interesting
|
lines = []
|
||||||
# locations in %PROGRAMFILES% to the PATH and try again
|
while proc.poll() is None:
|
||||||
shimmed_path = shim_paths_with_program_files(env)
|
for msg in iter(proc.stderr.readline, ''):
|
||||||
new_args0 = shutil.which(args[0], path=shimmed_path)
|
if process_log.isEnabledFor(logging.DEBUG):
|
||||||
if new_args0:
|
process_log.debug(msg.strip())
|
||||||
args[0] = new_args0
|
callback(msg)
|
||||||
return args
|
lines.append(msg)
|
||||||
|
stderr = ''.join(lines)
|
||||||
|
|
||||||
|
if check and proc.returncode != 0:
|
||||||
|
raise CalledProcessError(proc.returncode, args, output=None, stderr=stderr)
|
||||||
|
return CompletedProcess(args, proc.returncode, None, stderr=stderr)
|
||||||
|
|
||||||
|
|
||||||
|
def _fix_process_args(args, env, kwargs):
|
||||||
|
assert 'universal_newlines' not in kwargs, "Use text= instead of universal_newlines"
|
||||||
|
|
||||||
|
if not env:
|
||||||
|
env = os.environ
|
||||||
|
|
||||||
|
# Search in spoof path if necessary
|
||||||
|
program = args[0]
|
||||||
|
|
||||||
|
if os.name == 'nt':
|
||||||
|
from ocrmypdf.subprocess._windows import fix_windows_args
|
||||||
|
|
||||||
|
args = fix_windows_args(program, args, env)
|
||||||
|
|
||||||
|
log.debug("Running: %s", args)
|
||||||
|
process_log = log.getChild(os.path.basename(program))
|
||||||
|
text = kwargs.get('text', False)
|
||||||
|
if sys.version_info < (3, 7):
|
||||||
|
if 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
|
||||||
|
if 'text' in kwargs:
|
||||||
|
# Convert run(...text=) to run(...universal_newlines=) for Python 3.6
|
||||||
|
kwargs['universal_newlines'] = kwargs['text']
|
||||||
|
del kwargs['text']
|
||||||
|
return args, env, process_log, text
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=None)
|
@lru_cache(maxsize=None)
|
||||||
@@ -143,44 +159,18 @@ def get_version(
|
|||||||
raise MissingDependencyError(
|
raise MissingDependencyError(
|
||||||
f"Could not find program '{program}' on the PATH"
|
f"Could not find program '{program}' on the PATH"
|
||||||
) from e
|
) from e
|
||||||
try:
|
|
||||||
version = re.match(regex, output.strip()).group(1)
|
match = re.match(regex, output.strip())
|
||||||
except AttributeError as e:
|
if not match:
|
||||||
raise MissingDependencyError(
|
raise MissingDependencyError(
|
||||||
f"The program '{program}' did not report its version. "
|
f"The program '{program}' did not report its version. "
|
||||||
f"Message was:\n{output}"
|
f"Message was:\n{output}"
|
||||||
)
|
)
|
||||||
|
version = match.group(1)
|
||||||
|
|
||||||
return version
|
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', '')
|
|
||||||
|
|
||||||
def path_walker():
|
|
||||||
for path in Path(program_files).iterdir():
|
|
||||||
if not path.is_dir():
|
|
||||||
continue
|
|
||||||
if path.name.lower() == 'tesseract-ocr':
|
|
||||||
yield path
|
|
||||||
elif path.name.lower() == 'gs':
|
|
||||||
yield from (p for p in path.glob('**/bin') if p.is_dir())
|
|
||||||
|
|
||||||
paths = sorted(
|
|
||||||
(p for p in path_walker()), key=lambda p: (p.name, p.parent.name), reverse=True
|
|
||||||
)
|
|
||||||
paths.extend(
|
|
||||||
Path(str_path)
|
|
||||||
for str_path in os.get_exec_path(env)
|
|
||||||
if Path(str_path) not in set(paths)
|
|
||||||
)
|
|
||||||
return os.pathsep.join(str(p) for p in paths)
|
|
||||||
|
|
||||||
|
|
||||||
missing_program = '''
|
missing_program = '''
|
||||||
The program '{program}' could not be executed or was not found on your
|
The program '{program}' could not be executed or was not found on your
|
||||||
system PATH.
|
system PATH.
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
# © 2020 James R. Barlow: github.com/jbarlow83
|
||||||
|
#
|
||||||
|
# This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
from distutils.version import LooseVersion
|
||||||
|
from itertools import chain, filterfalse
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Callable, Iterator, Optional, Tuple, TypeVar, cast
|
||||||
|
|
||||||
|
try:
|
||||||
|
import winreg
|
||||||
|
except ModuleNotFoundError as e:
|
||||||
|
raise ModuleNotFoundError("This module is for Windows only") from e
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
T = TypeVar('T')
|
||||||
|
|
||||||
|
|
||||||
|
def registry_enum(
|
||||||
|
key: winreg.HKEYType, enum_fn: Callable[[winreg.HKEYType, int], T]
|
||||||
|
) -> Iterator[T]:
|
||||||
|
LIMIT = 999
|
||||||
|
n = 0
|
||||||
|
while n < LIMIT:
|
||||||
|
try:
|
||||||
|
yield enum_fn(key, n)
|
||||||
|
n += 1
|
||||||
|
except OSError:
|
||||||
|
break
|
||||||
|
if n == LIMIT:
|
||||||
|
raise ValueError(f"Too many registry keys under {key}")
|
||||||
|
|
||||||
|
|
||||||
|
def registry_subkeys(key: winreg.HKEYType) -> Iterator[str]:
|
||||||
|
return registry_enum(key, winreg.EnumKey)
|
||||||
|
|
||||||
|
|
||||||
|
def registry_values(key: winreg.HKEYType) -> Iterator[Tuple[str, Any, int]]:
|
||||||
|
return registry_enum(key, winreg.EnumValue)
|
||||||
|
|
||||||
|
|
||||||
|
def registry_path_ghostscript(env=None) -> Iterator[Path]:
|
||||||
|
try:
|
||||||
|
with winreg.OpenKey(
|
||||||
|
winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\Artifex\GPL Ghostscript"
|
||||||
|
) as k:
|
||||||
|
latest_gs = max(registry_subkeys(k), key=LooseVersion)
|
||||||
|
with winreg.OpenKey(
|
||||||
|
winreg.HKEY_LOCAL_MACHINE, fr"SOFTWARE\Artifex\GPL Ghostscript\{latest_gs}"
|
||||||
|
) as k:
|
||||||
|
_, gs_path, _ = next(registry_values(k))
|
||||||
|
yield Path(gs_path) / 'bin'
|
||||||
|
except OSError as e:
|
||||||
|
log.warning(e)
|
||||||
|
|
||||||
|
|
||||||
|
def registry_path_tesseract(env=None) -> Iterator[Path]:
|
||||||
|
try:
|
||||||
|
with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\Tesseract-OCR") as k:
|
||||||
|
for subkey, val, _valtype in registry_values(k):
|
||||||
|
if subkey == 'InstallDir':
|
||||||
|
tesseract_path = Path(val)
|
||||||
|
yield tesseract_path
|
||||||
|
except OSError as e:
|
||||||
|
log.warning(e)
|
||||||
|
|
||||||
|
|
||||||
|
def program_files_paths(env=None) -> Iterator[Path]:
|
||||||
|
if not env:
|
||||||
|
env = os.environ
|
||||||
|
program_files = env.get('PROGRAMFILES', '')
|
||||||
|
|
||||||
|
def path_walker() -> Iterator[Path]:
|
||||||
|
for path in Path(program_files).iterdir():
|
||||||
|
if not path.is_dir():
|
||||||
|
continue
|
||||||
|
if path.name.lower() == 'tesseract-ocr':
|
||||||
|
yield path
|
||||||
|
elif path.name.lower() == 'gs':
|
||||||
|
yield from (p for p in path.glob('**/bin') if p.is_dir())
|
||||||
|
|
||||||
|
return iter(
|
||||||
|
sorted(
|
||||||
|
(p for p in path_walker()),
|
||||||
|
key=lambda p: (p.name, p.parent.name),
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def paths_from_env(env=None) -> Iterator[Path]:
|
||||||
|
return (Path(p) for p in os.get_exec_path(env) if p)
|
||||||
|
|
||||||
|
|
||||||
|
def shim_path(new_paths: Callable[[Any], Iterator[Path]], env=None) -> str:
|
||||||
|
if not env:
|
||||||
|
env = os.environ
|
||||||
|
return os.pathsep.join(str(p) for p in new_paths(env) if p)
|
||||||
|
|
||||||
|
|
||||||
|
SHIMS = [
|
||||||
|
paths_from_env,
|
||||||
|
registry_path_ghostscript,
|
||||||
|
registry_path_tesseract,
|
||||||
|
program_files_paths,
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
# If the program we want is not on the PATH, check elsewhere
|
||||||
|
for shim in SHIMS:
|
||||||
|
shimmed_path = shim_path(shim, env)
|
||||||
|
new_args0 = shutil.which(args[0], path=shimmed_path)
|
||||||
|
if new_args0:
|
||||||
|
args[0] = new_args0
|
||||||
|
break
|
||||||
|
|
||||||
|
return args
|
||||||
|
|
||||||
|
|
||||||
|
def unique_everseen(iterable, key=None):
|
||||||
|
"List unique elements, preserving order. Remember all elements ever seen."
|
||||||
|
# unique_everseen('AAAABBBCCDAABBB') --> A B C D
|
||||||
|
# unique_everseen('ABBCcAD', str.lower) --> A B C D
|
||||||
|
seen = set()
|
||||||
|
seen_add = seen.add
|
||||||
|
if key is None:
|
||||||
|
key = lambda x: x
|
||||||
|
for element in iterable:
|
||||||
|
k = key(element)
|
||||||
|
if k not in seen:
|
||||||
|
seen_add(k)
|
||||||
|
yield element
|
||||||
|
|
||||||
|
|
||||||
|
def shim_env_path(env=None):
|
||||||
|
if env is None:
|
||||||
|
env = os.environ
|
||||||
|
|
||||||
|
shim_paths = chain.from_iterable(shim(env) for shim in SHIMS)
|
||||||
|
return os.pathsep.join(
|
||||||
|
str(p) for p in unique_everseen(shim_paths, key=lambda p: str.casefold(str(p)))
|
||||||
|
)
|
||||||
@@ -45,5 +45,6 @@ def generate_pdfa(pdf_pages, pdfmark, output_file, compression, pdf_version, pdf
|
|||||||
compression=compression,
|
compression=compression,
|
||||||
pdf_version=pdf_version,
|
pdf_version=pdf_version,
|
||||||
pdfa_part=pdfa_part,
|
pdfa_part=pdfa_part,
|
||||||
|
progressbar_class=None,
|
||||||
)
|
)
|
||||||
return output_file
|
return output_file
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ from unittest.mock import patch
|
|||||||
|
|
||||||
from ocrmypdf import hookimpl
|
from ocrmypdf import hookimpl
|
||||||
from ocrmypdf.builtin_plugins import ghostscript
|
from ocrmypdf.builtin_plugins import ghostscript
|
||||||
from ocrmypdf.subprocess import run
|
from ocrmypdf.subprocess import run_polling_stderr
|
||||||
|
|
||||||
|
|
||||||
def run_rig_args(args, **kwargs):
|
def run_rig_args(args, **kwargs):
|
||||||
@@ -33,13 +33,13 @@ def run_rig_args(args, **kwargs):
|
|||||||
new_args = [
|
new_args = [
|
||||||
arg for arg in args if not arg.startswith('-dPDFA') and not arg.endswith('.ps')
|
arg for arg in args if not arg.startswith('-dPDFA') and not arg.endswith('.ps')
|
||||||
]
|
]
|
||||||
proc = run(new_args, **kwargs)
|
proc = run_polling_stderr(new_args, **kwargs)
|
||||||
return proc
|
return proc
|
||||||
|
|
||||||
|
|
||||||
@hookimpl
|
@hookimpl
|
||||||
def generate_pdfa(pdf_pages, pdfmark, output_file, compression, pdf_version, pdfa_part):
|
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_polling_stderr', new=run_rig_args):
|
||||||
ghostscript.generate_pdfa(
|
ghostscript.generate_pdfa(
|
||||||
pdf_pages=pdf_pages,
|
pdf_pages=pdf_pages,
|
||||||
pdfmark=pdfmark,
|
pdfmark=pdfmark,
|
||||||
@@ -47,5 +47,6 @@ def generate_pdfa(pdf_pages, pdfmark, output_file, compression, pdf_version, pdf
|
|||||||
compression=compression,
|
compression=compression,
|
||||||
pdf_version=pdf_version,
|
pdf_version=pdf_version,
|
||||||
pdfa_part=pdfa_part,
|
pdfa_part=pdfa_part,
|
||||||
|
progressbar_class=None,
|
||||||
)
|
)
|
||||||
return output_file
|
return output_file
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ def raise_gs_fail(*args, **kwargs):
|
|||||||
|
|
||||||
@hookimpl
|
@hookimpl
|
||||||
def generate_pdfa(pdf_pages, pdfmark, output_file, compression, pdf_version, pdfa_part):
|
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_polling_stderr', new=raise_gs_fail):
|
||||||
ghostscript.generate_pdfa(
|
ghostscript.generate_pdfa(
|
||||||
pdf_pages=pdf_pages,
|
pdf_pages=pdf_pages,
|
||||||
pdfmark=pdfmark,
|
pdfmark=pdfmark,
|
||||||
@@ -42,5 +42,6 @@ def generate_pdfa(pdf_pages, pdfmark, output_file, compression, pdf_version, pdf
|
|||||||
compression=compression,
|
compression=compression,
|
||||||
pdf_version=pdf_version,
|
pdf_version=pdf_version,
|
||||||
pdfa_part=pdfa_part,
|
pdfa_part=pdfa_part,
|
||||||
|
progressbar_class=None,
|
||||||
)
|
)
|
||||||
return output_file
|
return output_file
|
||||||
|
|||||||
@@ -165,7 +165,7 @@ def cached_run(options, run_args, **run_kwargs):
|
|||||||
|
|
||||||
def clean_sys_argv():
|
def clean_sys_argv():
|
||||||
for arg in run_args[1:]:
|
for arg in run_args[1:]:
|
||||||
yield re.sub(r'.*/com.github.ocrmypdf[^/]+[/](.*)', r'$TMPDIR/\1', arg)
|
yield re.sub(r'.*/ocrmypdf[.]io[.][^/]+[/](.*)', r'$TMPDIR/\1', arg)
|
||||||
|
|
||||||
manifest['args'] = list(clean_sys_argv())
|
manifest['args'] = list(clean_sys_argv())
|
||||||
with (Path(CACHE_ROOT) / 'manifest.jsonl').open('a') as f:
|
with (Path(CACHE_ROOT) / 'manifest.jsonl').open('a') as f:
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ from unittest.mock import MagicMock
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from ocrmypdf import helpers as helpers
|
from ocrmypdf import helpers as helpers
|
||||||
from ocrmypdf.subprocess import shim_paths_with_program_files
|
|
||||||
|
|
||||||
|
|
||||||
class TestSafeSymlink:
|
class TestSafeSymlink:
|
||||||
@@ -94,7 +93,10 @@ class TestFileIsWritable:
|
|||||||
assert not helpers.is_file_writable(pathmock)
|
assert not helpers.is_file_writable(pathmock)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(os.name != 'nt', reason="Windows test")
|
||||||
def test_shim_paths(tmp_path):
|
def test_shim_paths(tmp_path):
|
||||||
|
from ocrmypdf.subprocess._windows import shim_env_path
|
||||||
|
|
||||||
progfiles = tmp_path / 'Program Files'
|
progfiles = tmp_path / 'Program Files'
|
||||||
progfiles.mkdir()
|
progfiles.mkdir()
|
||||||
(progfiles / 'tesseract-ocr').mkdir()
|
(progfiles / 'tesseract-ocr').mkdir()
|
||||||
@@ -103,9 +105,9 @@ def test_shim_paths(tmp_path):
|
|||||||
syspath = tmp_path / 'bin'
|
syspath = tmp_path / 'bin'
|
||||||
env = {'PROGRAMFILES': str(progfiles), 'PATH': str(syspath)}
|
env = {'PROGRAMFILES': str(progfiles), 'PATH': str(syspath)}
|
||||||
|
|
||||||
result_str = shim_paths_with_program_files(env=env)
|
result_str = shim_env_path(env=env)
|
||||||
results = result_str.split(os.pathsep)
|
results = result_str.split(os.pathsep)
|
||||||
assert results[0].endswith('tesseract-ocr')
|
assert results[0] == str(syspath), results
|
||||||
assert results[1].endswith(os.path.join('gs', '9.52', 'bin'))
|
assert results[-3].endswith('tesseract-ocr'), results
|
||||||
assert results[2].endswith(os.path.join('gs', '9.51', 'bin'))
|
assert results[-2].endswith(os.path.join('gs', '9.52', 'bin')), results
|
||||||
assert results[3] == str(syspath)
|
assert results[-1].endswith(os.path.join('gs', '9.51', 'bin')), results
|
||||||
|
|||||||
@@ -28,6 +28,12 @@ from ocrmypdf.pdfinfo import PdfInfo
|
|||||||
['1,3,-11', BadArgsError],
|
['1,3,-11', BadArgsError],
|
||||||
['1-,', BadArgsError],
|
['1-,', BadArgsError],
|
||||||
['start-end', BadArgsError],
|
['start-end', BadArgsError],
|
||||||
|
['1-0', BadArgsError],
|
||||||
|
['99-98', BadArgsError],
|
||||||
|
['0-0', BadArgsError],
|
||||||
|
['1-0,3-4', BadArgsError],
|
||||||
|
[',', BadArgsError],
|
||||||
|
['', BadArgsError],
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_pages(pages, result):
|
def test_pages(pages, result):
|
||||||
|
|||||||
Reference in New Issue
Block a user