Compare commits

...
24 Commits
Author SHA1 Message Date
James R. Barlow 9471bc8921 Fix versions with leading v, e.g. v5.0 2020-04-10 13:42:33 -07:00
James R. Barlow 7fe06c64fc v9.7.1 release notes 2020-04-10 13:00:19 -07:00
James R. Barlow d13d70fd56 Fix version checker failing for qpdf 10.0.0
Fixes #527
2020-04-10 13:00:19 -07:00
James R. Barlow 58ec56180a Add a few more type annotations to public APIs 2020-04-10 13:00:19 -07:00
James R. Barlow 32a88f1bad docs: warn that AWS Lambda doesn't work 2020-04-10 13:00:19 -07:00
James R. Barlow 99ef42940c docs: warn that Windows users should use an ifmain guard 2020-04-10 13:00:19 -07:00
jbarlow83andGitHub c152710617 Update issue templates 2020-04-04 15:41:53 -07:00
James R. Barlow 8de0f9b86f v9.7.0 release notes 2020-03-29 22:45:25 -07:00
James R. Barlow 23bc3d3a29 tests: workaround for Ghostscript 9.52 txtwrite problem 2020-03-29 22:45:16 -07:00
James R. Barlow 8307832ce9 tests: add force OCR to a file with text that Ghostscript doesn't see
For gs 9.52 support.

Also refactor use of pikepdf.open() to use with blocks.
2020-03-29 22:44:27 -07:00
James R. Barlow dd1cf567db watcher: Fix JSONDecodeError if OCR_JSON_SETTINGS not set
Fixes #516
2020-03-29 21:58:37 -07:00
James R. Barlow 2490be8490 Fix debug.log not being deleted on Windows (probably)
Fixes #515
2020-03-29 21:53:56 -07:00
James R. Barlow 85e6c6669a docs: Add username to WSL instructions
Fixes #519
2020-03-29 21:16:24 -07:00
James R. Barlow 00498282f5 validation: blacklist Ghostscript 9.51 too 2020-03-24 21:27:18 -07:00
James R. Barlow e4cc9fcba7 Wrong number of threads to use shown when OMP_THREAD_LIMIT is defined 2020-03-23 01:06:55 -07:00
James R. Barlow a4555b1dae Add halftone mask to leptonica 2020-03-18 23:09:39 -07:00
James R. Barlow f35a2303bb info.py: linearize O(n^2) search for use images on a page 2020-03-18 22:59:18 -07:00
James R. Barlow 82142fe5ef Merge branch 'master' of github.com:jbarlow83/OCRmyPDF 2020-03-16 04:11:03 -07:00
James R. Barlow 9be533b5f4 watcher: allow all parameters to ocrmypdf.pdf to be passed by JSON 2020-03-15 21:45:51 -07:00
James R. Barlow 99653fcd32 optimize: consider ICCBased 1 bit for optimization 2020-03-15 02:20:44 -07:00
James R. Barlow 5442c97ed8 Consult ICC profile when determining image colorspace 2020-03-11 04:03:09 -07:00
tlwhitecandGitHub 0165255bd9 fix install instructions for Ubunti 16.04 (#507)
`pip3` defaults to the system's outdated version which downloads wrong qpdf package.
2020-03-11 02:57:37 -07:00
James R. Barlow 378e4dae3b Expand documentation for subprocess.run() from test 2020-03-04 13:37:44 -08:00
James R. Barlow cdf5afa753 reqs: update pikepdf version 2020-03-03 11:56:10 -08:00
22 changed files with 360 additions and 133 deletions
+36
View File
@@ -0,0 +1,36 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: ''
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
What command line or API call were you trying to run?
```bash
ocrmypdf ...arguments... input.pdf output.pdf
```
Run with verbosity or higher `-v1` to see more detailed logging. This information may be helpful.
**Example file**
Please include an example *input* PDF (or image). The input file is more helpful.
If possible, use an input file with no personal or confidential information. At your option you may GPG-encrypt the file for OCRmyPDF's author only.
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**System**
- OS: [e.g. Linux, Windows, macOS]
- OCRmyPDF Version: ``ocrmypdf --version``
- How did you install ocrmypdf? Did you use a system package manager, `pip`, or a Docker image?
+17
View File
@@ -0,0 +1,17 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: enhancement
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Additional context**
Add any other context or screenshots about the feature request here.
+8
View File
@@ -51,6 +51,14 @@ Forking a child process to call ``ocrmypdf.ocr()`` is suggested. That
way your application will survive and remain interactive even if way your application will survive and remain interactive even if
OCRmyPDF does not. OCRmyPDF does not.
.. warning::
On Windows, the script that calls ``ocrmypdf.ocr()`` must be protected
by an "ifmain" guard (``if __name__ == '__main__'``) or you must use
``ocrmypdf.ocr(...use_threads=True)``. If you do not take at least one
of these steps, Windows fork semantics will prevent OCRmyPDF from working
correct.
Logging Logging
------- -------
+46 -55
View File
@@ -99,11 +99,45 @@ and all inquiries are appreciated.
Hot (watched) folders Hot (watched) folders
===================== =====================
Watched folders with watcher.py
-------------------------------
OCRmyPDF has a folder watcher called watcher.py, which is currently included in source
distributions but not part of the main program. It may be used natively or may run
in a Docker container. Native instances tend to give better performance. watcher.py
works on all platforms.
Users may need to customize the script to meet their requirements.
.. code-block:: bash
pip3 install -r requirements/watcher.txt
env OCR_INPUT_DIRECTORY=/mnt/input-pdfs \
OCR_OUTPUT_DIRECTORY=/mnt/output-pdfs \
OCR_OUTPUT_DIRECTORY_YEAR_MONTH=1 \
python3 watcher.py
.. csv-table:: watcher.py environment variables
:header: "Environment variable", "Description"
:widths: 50, 50
"OCR_INPUT_DIRECTORY", "Set input directory to monitor (recursive)"
"OCR_OUTPUT_DIRECTORY", "Set output directory (should not be under input)"
"OCR_ON_SUCCESS_DELETE", "This will delete the input file if the exit code is 0 (OK)"
"OCR_OUTPUT_DIRECTORY_YEAR_MONTH", "This will place files in the output in ``{output}/{year}/{month}/{filename}``"
"OCR_DESKEW", "Apply deskew to crooked input PDFs"
"OCR_JSON_SETTINGS", "A JSON string specifying any other arguments for ``ocrmypdf.ocr``"
"OCR_POLL_NEW_FILE_SECONDS", "Polling interval"
"OCR_LOGLEVEL", "Level of log messages to report"
One could configure a networked scanner or scanning computer to drop files in the
watched folder.
Watched folders with Docker Watched folders with Docker
--------------------------- ---------------------------
The OCRmyPDF Docker image includes a watcher service. This service can The watcher service is included in the OCRmyPDF Docker image. To run it:
be launched as follows:
.. code-block:: bash .. code-block:: bash
@@ -127,9 +161,9 @@ convert it to a OCRed PDF in ``/output/``. The parameters to this image are:
"``-v <path to files to convert>:/input``", "Files placed in this location will be OCRed" "``-v <path to files to convert>:/input``", "Files placed in this location will be OCRed"
"``-v <path to store results>:/output``", "This is where OCRed files will be stored" "``-v <path to store results>:/output``", "This is where OCRed files will be stored"
"``-e OCR_OUTPUT_DIRECTORY_YEAR_MONTH=1``", "This will place files in the output in {output}/{year}/{month}/{filename}" "``-e OCR_OUTPUT_DIRECTORY_YEAR_MONTH=1``", "Define environment variable OCR_OUTPUT_DIRECTORY_YEAR_MONTH=1"
"``-e OCR_ON_SUCCESS_DELETE=1``", "This will delete the input file if the exit code is 0 (OK)" "``-e OCR_ON_SUCCESS_DELETE=1``", "Define environment variable"
"``-e OCR_DESKEW=1``", "This will enable deskew for crooked PDFs" "``-e OCR_DESKEW=1``", "Define environment variable"
"``-e PYTHONBUFFERED=1``", "This will force STDOUT to be unbuffered and allow you to see messages in docker logs" "``-e PYTHONBUFFERED=1``", "This will force STDOUT to be unbuffered and allow you to see messages in docker logs"
This service relies on polling to check for changes to the filesystem. It This service relies on polling to check for changes to the filesystem. It
@@ -143,56 +177,6 @@ service is always available.
:language: yaml :language: yaml
:caption: misc/docker-compose.example.yml :caption: misc/docker-compose.example.yml
Watched folders with watcher.py
-------------------------------
The watcher service may also be run natively, without Docker:
.. code-block:: bash
pip3 install -r requirements/watcher.txt
env OCR_INPUT_DIRECTORY=/mnt/input-pdfs \
OCR_OUTPUT_DIRECTORY=/mnt/output-pdfs \
OCR_OUTPUT_DIRECTORY_YEAR_MONTH=1 \
python3 watcher.py
Watched folders with CLI
------------------------
To set up a "hot folder" that will trigger OCR for every file inserted,
use a program like Python
`watchdog <https://pypi.python.org/pypi/watchdog>`__ (supports all major
OS).
One could then configure a scanner to automatically place scanned files
in a hot folder, so that they will be queued for OCR and copied to the
destination.
.. code-block:: bash
pip install watchdog
watchdog installs the command line program ``watchmedo``, which can be
told to run ``ocrmypdf`` on any .pdf added to the current directory
(``.``) and place the result in the previously created ``out/`` folder.
.. code-block:: bash
cd hot-folder
mkdir out
watchmedo shell-command \
--patterns="*.pdf" \
--ignore-directories \
--command='ocrmypdf "${watch_src_path}" "out/${watch_src_path}" ' \
. # don't forget the final dot
On file servers, you could configure watchmedo as a system service so it
will run all the time.
For more complex behavior you can write a Python script around to use
the watchdog API. You can refer to the watcher.py script as an example.
Caveats Caveats
------- -------
@@ -218,6 +202,13 @@ Alternatives
- `Watchman <https://facebook.github.io/watchman/>`__ is a more - `Watchman <https://facebook.github.io/watchman/>`__ is a more
powerful alternative to ``watchmedo``. powerful alternative to ``watchmedo``.
AWS Lambda is not viable
------------------------
AWS Lambda and its equivalents have low limits on execution time and payload
size, relative to OCRmyPDF's needs. As of this writing, the request/response
payload for AWS Lambda was 6 MB, which means many PDFs will not fit.
macOS Automator macOS Automator
=============== ===============
+2 -2
View File
@@ -232,7 +232,7 @@ environment variable contains ``$HOME/.local/bin``.
.. code-block:: bash .. code-block:: bash
export PATH=$HOME/.local/bin:$PATH export PATH=$HOME/.local/bin:$PATH
pip3 install --user ocrmypdf pip3.6 install --user ocrmypdf
To add JBIG2 encoding, see :ref:`jbig2`. To add JBIG2 encoding, see :ref:`jbig2`.
@@ -574,7 +574,7 @@ Installing on Windows Subsystem for Linux
.. code-block:: powershell .. code-block:: powershell
wsl sudo ln -s /home/user/.local/bin/ocrmypdf /usr/local/bin/ocrmypdf wsl sudo ln -s /home/$USER/.local/bin/ocrmypdf /usr/local/bin/ocrmypdf
Then confirm that the expected version from PyPI (|latest|) is installed: Then confirm that the expected version from PyPI (|latest|) is installed:
+23
View File
@@ -14,6 +14,29 @@ Note that it is licensed under GPLv3, so scripts that
licensed under GPLv3. licensed under GPLv3.
v9.7.1
======
- Fixed version check failing when used with qpdf 10.0.0.
- Added some missing type annotations.
- Updated documentation to warn about need for "ifmain" guard and Windows.
v9.7.0
======
- Fixed an error in watcher.py if ``OCR_JSON_SETTINGS`` was not defined.
- Ghostscript 9.51 is now blacklisted, due to numerous problems with this version.
- Added a workaround for a problem with "txtwrite" in Ghostscript 9.52.
- Fixed an issue where the incorrect number of threads used was shown when
``OMP_THREAD_LIMIT`` was manipulated.
- Removed a possible performance bottlenecks for files that use hundreds to
thousands of images on the same page.
- Documentation improvements.
- Optimization will now be applied to some monochrome images that have a color
profile defined instead of only black and white.
- ICC profiles are consulted when determining the simplified colorspace of an
image.
v9.6.1 v9.6.1
====== ======
+12 -1
View File
@@ -14,8 +14,10 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
import json
import logging import logging
import os import os
import sys
import time import time
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
@@ -33,6 +35,7 @@ OUTPUT_DIRECTORY = os.getenv('OCR_OUTPUT_DIRECTORY', '/output')
OUTPUT_DIRECTORY_YEAR_MONTH = bool(os.getenv('OCR_OUTPUT_DIRECTORY_YEAR_MONTH', False)) OUTPUT_DIRECTORY_YEAR_MONTH = bool(os.getenv('OCR_OUTPUT_DIRECTORY_YEAR_MONTH', False))
ON_SUCCESS_DELETE = bool(os.getenv('OCR_ON_SUCCESS_DELETE', False)) ON_SUCCESS_DELETE = bool(os.getenv('OCR_ON_SUCCESS_DELETE', False))
DESKEW = bool(os.getenv('OCR_DESKEW', False)) DESKEW = bool(os.getenv('OCR_DESKEW', False))
OCR_JSON_SETTINGS = json.loads(os.getenv('OCR_JSON_SETTINGS', '{}'))
POLL_NEW_FILE_SECONDS = os.getenv('OCR_POLL_NEW_FILE_SECONDS', 1) POLL_NEW_FILE_SECONDS = os.getenv('OCR_POLL_NEW_FILE_SECONDS', 1)
LOGLEVEL = os.environ.get('OCR_LOGLEVEL', 'INFO').upper() LOGLEVEL = os.environ.get('OCR_LOGLEVEL', 'INFO').upper()
PATTERNS = ['*.pdf'] PATTERNS = ['*.pdf']
@@ -87,7 +90,10 @@ def execute_ocrmypdf(file_path):
return return
log.info(f'Attempting to OCRmyPDF to: {output_path}') log.info(f'Attempting to OCRmyPDF to: {output_path}')
exit_code = ocrmypdf.ocr( exit_code = ocrmypdf.ocr(
input_file=file_path, output_file=output_path, deskew=DESKEW input_file=file_path,
output_file=output_path,
deskew=DESKEW,
**OCR_JSON_SETTINGS,
) )
if exit_code == 0 and ON_SUCCESS_DELETE: if exit_code == 0 and ON_SUCCESS_DELETE:
log.info(f'OCR is done. Deleting: {file_path}') log.info(f'OCR is done. Deleting: {file_path}')
@@ -118,10 +124,15 @@ def main():
f"OUTPUT_DIRECTORY_YEAR_MONTH: {OUTPUT_DIRECTORY_YEAR_MONTH}\n" f"OUTPUT_DIRECTORY_YEAR_MONTH: {OUTPUT_DIRECTORY_YEAR_MONTH}\n"
f"ON_SUCCESS_DELETE: {ON_SUCCESS_DELETE}\n" f"ON_SUCCESS_DELETE: {ON_SUCCESS_DELETE}\n"
f"DESKEW: {DESKEW}\n" f"DESKEW: {DESKEW}\n"
f"ARGS: {OCR_JSON_SETTINGS}\n"
f"POLL_NEW_FILE_SECONDS: {POLL_NEW_FILE_SECONDS}\n" f"POLL_NEW_FILE_SECONDS: {POLL_NEW_FILE_SECONDS}\n"
f"LOGLEVEL: {LOGLEVEL}\n" f"LOGLEVEL: {LOGLEVEL}\n"
) )
if 'input_file' in OCR_JSON_SETTINGS or 'output_file' in OCR_JSON_SETTINGS:
log.error('OCR_JSON_SETTINGS should not specify input file or output file')
sys.exit(1)
handler = HandleObserverEvent(patterns=PATTERNS) handler = HandleObserverEvent(patterns=PATTERNS)
observer = Observer() observer = Observer()
observer.schedule(handler, INPUT_DIRECTORY, recursive=True) observer.schedule(handler, INPUT_DIRECTORY, recursive=True)
+1 -1
View File
@@ -4,7 +4,7 @@
cffi == 1.14.0 cffi == 1.14.0
img2pdf == 0.3.3 img2pdf == 0.3.3
pdfminer.six == 20200124 pdfminer.six == 20200124
pikepdf == 1.10.1 pikepdf == 1.10.2
Pillow == 7.0.0 Pillow == 7.0.0
reportlab == 3.5.34 reportlab == 3.5.34
tqdm == 4.42.1 tqdm == 4.42.1
+12 -1
View File
@@ -245,6 +245,10 @@ def exec_concurrent(context):
if context.options.tesseract_env is None: if context.options.tesseract_env is None:
context.options.tesseract_env = os.environ.copy() context.options.tesseract_env = os.environ.copy()
context.options.tesseract_env.setdefault('OMP_THREAD_LIMIT', str(tess_threads)) context.options.tesseract_env.setdefault('OMP_THREAD_LIMIT', str(tess_threads))
try:
tess_threads = int(context.options.tesseract_env['OMP_THREAD_LIMIT'])
except ValueError: # OMP_THREAD_LIMIT initialized to non-numeric
context.log.error("Environment variable OMP_THREAD_LIMIT is not numeric")
if tess_threads > 1: if tess_threads > 1:
context.log.info("Using Tesseract OpenMP thread limit %d", tess_threads) context.log.info("Using Tesseract OpenMP thread limit %d", tess_threads)
@@ -357,10 +361,11 @@ def run_pipeline(options, api=False):
options.jobs = available_cpu_count() options.jobs = available_cpu_count()
work_folder = mkdtemp(prefix="com.github.ocrmypdf.") work_folder = mkdtemp(prefix="com.github.ocrmypdf.")
debug_log_handler = None
if (options.keep_temporary_files or options.verbose >= 1) and not os.environ.get( if (options.keep_temporary_files or options.verbose >= 1) and not os.environ.get(
'PYTEST_CURRENT_TEST', '' 'PYTEST_CURRENT_TEST', ''
): ):
configure_debug_logging(Path(work_folder) / "debug.log") debug_log_handler = configure_debug_logging(Path(work_folder) / "debug.log")
try: try:
check_requested_output_file(options) check_requested_output_file(options)
@@ -428,6 +433,12 @@ def run_pipeline(options, api=False):
log.exception("An exception occurred while executing the pipeline") log.exception("An exception occurred while executing the pipeline")
return ExitCode.other_error return ExitCode.other_error
finally: finally:
if debug_log_handler:
try:
debug_log_handler.close()
log.removeHandler(debug_log_handler)
except EnvironmentError as e:
print(e, file=sys.stderr)
cleanup_working_files(work_folder, options) cleanup_working_files(work_folder, options)
return ExitCode.ok return ExitCode.ok
+5 -4
View File
@@ -466,11 +466,12 @@ def check_dependency_versions(options):
version_checker=ghostscript.version, version_checker=ghostscript.version,
need_version='9.15', # limited by Travis CI / Ubuntu 14.04 backports need_version='9.15', # limited by Travis CI / Ubuntu 14.04 backports
) )
if ghostscript.version() == '9.24': gs_version = ghostscript.version()
if gs_version in ('9.24', '9.51'):
raise MissingDependencyError( raise MissingDependencyError(
"Ghostscript 9.24 contains serious regressions and is not " f"Ghostscript {gs_version} contains serious regressions and is not "
"supported. Please upgrade to Ghostscript 9.25 or use an older " "supported. Please upgrade to a newer version, or downgrade to the "
"version." "previous version."
) )
check_external_program( check_external_program(
program='qpdf', program='qpdf',
+6 -2
View File
@@ -67,7 +67,11 @@ class Verbosity(IntEnum):
debug_all = 2 #: More detailed debugging from ocrmypdf and dependent modules debug_all = 2 #: More detailed debugging from ocrmypdf and dependent modules
def configure_logging(verbosity, progress_bar_friendly=True, manage_root_logger=False): def configure_logging(
verbosity: Verbosity,
progress_bar_friendly: bool = True,
manage_root_logger: bool = False,
):
"""Set up logging. """Set up logging.
Library users may wish to use this function if they want their log output to be Library users may wish to use this function if they want their log output to be
@@ -128,7 +132,7 @@ def configure_logging(verbosity, progress_bar_friendly=True, manage_root_logger=
return log return log
def create_options(*, input_file, output_file, **kwargs): def create_options(*, input_file: os.PathLike, output_file: os.PathLike, **kwargs):
cmdline = [] cmdline = []
deferred = [] deferred = []
+2
View File
@@ -20,6 +20,8 @@ import argparse
from ._version import PROGRAM_NAME as _PROGRAM_NAME from ._version import PROGRAM_NAME as _PROGRAM_NAME
from ._version import __version__ as _VERSION from ._version import __version__ as _VERSION
__all__ = ['parser']
def numeric(basetype, min_=None, max_=None): def numeric(basetype, min_=None, max_=None):
"""Validator for numeric params""" """Validator for numeric params"""
+10 -1
View File
@@ -23,6 +23,7 @@ import re
import shutil import shutil
import sys import sys
from collections.abc import Mapping from collections.abc import Mapping
from distutils.version import LooseVersion
from functools import lru_cache from functools import lru_cache
from subprocess import PIPE, STDOUT, CalledProcessError from subprocess import PIPE, STDOUT, CalledProcessError
from subprocess import run as subprocess_run from subprocess import run as subprocess_run
@@ -270,7 +271,15 @@ def check_external_program(
raise MissingDependencyError() raise MissingDependencyError()
return return
if found_version < need_version: def remove_leading_v(s):
if s.startswith('v'):
return s[1:]
return s
found_version = remove_leading_v(found_version)
need_version = remove_leading_v(need_version)
if LooseVersion(found_version) < LooseVersion(need_version):
_error_old_version(program, package, need_version, found_version, required_for) _error_old_version(program, package, need_version, found_version, required_for)
if not recommended: if not recommended:
raise MissingDependencyError() raise MissingDependencyError()
+4 -4
View File
@@ -28,7 +28,7 @@ from pathlib import Path
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
def safe_symlink(input_file, soft_link_name, *args, **kwargs): def safe_symlink(input_file: os.PathLike, soft_link_name: os.PathLike, *args, **kwargs):
""" """
Helper function: relinks soft symbolic link if necessary Helper function: relinks soft symbolic link if necessary
""" """
@@ -76,12 +76,12 @@ def is_iterable_notstr(thing):
return isinstance(thing, Iterable) and not isinstance(thing, str) return isinstance(thing, Iterable) and not isinstance(thing, str)
def monotonic(L): def monotonic(L: Iterable):
"""Does list increase monotonically?""" """Does list increase monotonically?"""
return all(b > a for a, b in zip(L, L[1:])) return all(b > a for a, b in zip(L, L[1:]))
def page_number(input_file): def page_number(input_file: os.PathLike):
"""Get one-based page number implied by filename (000002.pdf -> 2)""" """Get one-based page number implied by filename (000002.pdf -> 2)"""
return int(os.path.basename(os.fspath(input_file))[0:6]) return int(os.path.basename(os.fspath(input_file))[0:6])
@@ -97,7 +97,7 @@ def available_cpu_count():
return 1 return 1
def is_file_writable(test_file): def is_file_writable(test_file: os.PathLike):
"""Intentionally racy test if target is writable. """Intentionally racy test if target is writable.
We intend to write to the output file if and only if we succeed and We intend to write to the output file if and only if we succeed and
File diff suppressed because one or more lines are too long
+6
View File
@@ -443,6 +443,12 @@ pixReadBarcodes(PIXA *pixa,
SARRAY **psaw, SARRAY **psaw,
l_int32 debugflag); l_int32 debugflag);
PIX *
pixGenHalftoneMask(PIX *pixs,
PIX **ppixtext,
l_int32 *phtfound,
PIXA *pixadb);
l_int32 l_int32
l_generateCIDataForPdf(const char *fname, l_generateCIDataForPdf(const char *fname,
PIX *pix, PIX *pix,
+11
View File
@@ -155,6 +155,17 @@ def extract_image_generic(*, pike, root, log, image, xref, options):
# generating a PNG from compressed data # generating a PNG from compressed data
pim.as_pil_image().save(png_name(root, xref)) pim.as_pil_image().save(png_name(root, xref))
return xref, '.png' return xref, '.png'
elif (
not pim.indexed
and pim.colorspace == Name.ICCBased
and pim.bits_per_component == 1
and not options.jbig2_lossy
):
# We can losslessly optimize 1-bit images to CCITT or JBIG2 without
# paying any attention to the ICC profile, provided we're not doing
# lossy JBIG2
pim.as_pil_image().save(png_name(root, xref))
return xref, '.png'
return None return None
+59 -43
View File
@@ -18,11 +18,11 @@
import logging import logging
import re import re
from collections import namedtuple from collections import defaultdict, namedtuple
from decimal import Decimal from decimal import Decimal
from enum import Enum from enum import Enum
from math import hypot, isclose from math import hypot, isclose
from os import fspath from os import PathLike, fspath
from pathlib import Path from pathlib import Path
from warnings import warn from warnings import warn
@@ -30,17 +30,17 @@ import pikepdf
from pikepdf import PdfMatrix from pikepdf import PdfMatrix
from tqdm import tqdm from tqdm import tqdm
from ocrmypdf.exceptions import EncryptedPdfError, MissingDependencyError from ocrmypdf.exceptions import EncryptedPdfError
from ocrmypdf.exec import ghostscript
from . import ghosttext from ocrmypdf.pdfinfo import ghosttext
from .layout import get_page_analysis, get_text_boxes from ocrmypdf.pdfinfo.layout import get_page_analysis, get_text_boxes
logger = logging.getLogger() logger = logging.getLogger()
Colorspace = Enum('Colorspace', 'gray rgb cmyk lab icc index sep devn pattern jpeg2000') Colorspace = Enum('Colorspace', 'gray rgb cmyk lab icc index sep devn pattern jpeg2000')
Encoding = Enum( Encoding = Enum(
'Encoding', 'ccitt jpeg jpeg2000 jbig2 asciihex ascii85 lzw flate ' + 'runlength' 'Encoding', 'ccitt jpeg jpeg2000 jbig2 asciihex ascii85 lzw flate runlength'
) )
FRIENDLY_COLORSPACE = { FRIENDLY_COLORSPACE = {
@@ -98,7 +98,7 @@ XobjectSettings = namedtuple('XobjectSettings', ['name', 'shorthand', 'stack_dep
InlineSettings = namedtuple('InlineSettings', ['iimage', 'shorthand', 'stack_depth']) InlineSettings = namedtuple('InlineSettings', ['iimage', 'shorthand', 'stack_depth'])
ContentsInfo = namedtuple( ContentsInfo = namedtuple(
'ContentsInfo', ['xobject_settings', 'inline_images', 'found_vector'] 'ContentsInfo', ['xobject_settings', 'inline_images', 'found_vector', 'name_index']
) )
TextboxInfo = namedtuple('TextboxInfo', ['bbox', 'is_visible', 'is_corrupt']) TextboxInfo = namedtuple('TextboxInfo', ['bbox', 'is_visible', 'is_corrupt'])
@@ -151,6 +151,7 @@ def _interpret_contents(contentstream, initial_shorthand=UNIT_SQUARE):
ctm = PdfMatrix(initial_shorthand) ctm = PdfMatrix(initial_shorthand)
xobject_settings = [] xobject_settings = []
inline_images = [] inline_images = []
name_index = defaultdict(lambda: [])
found_vector = False found_vector = False
vector_ops = set('S s f F f* B B* b b*'.split()) vector_ops = set('S s f F f* B B* b b*'.split())
image_ops = set('BI ID EI q Q Do cm'.split()) image_ops = set('BI ID EI q Q Do cm'.split())
@@ -185,6 +186,7 @@ def _interpret_contents(contentstream, initial_shorthand=UNIT_SQUARE):
name=image_name, shorthand=ctm.shorthand, stack_depth=len(stack) name=image_name, shorthand=ctm.shorthand, stack_depth=len(stack)
) )
xobject_settings.append(settings) xobject_settings.append(settings)
name_index[image_name].append(settings)
elif operator == 'INLINE IMAGE': # BI/ID/EI are grouped into this elif operator == 'INLINE IMAGE': # BI/ID/EI are grouped into this
iimage = operands[0] iimage = operands[0]
inline = InlineSettings( inline = InlineSettings(
@@ -198,6 +200,7 @@ def _interpret_contents(contentstream, initial_shorthand=UNIT_SQUARE):
xobject_settings=xobject_settings, xobject_settings=xobject_settings,
inline_images=inline_images, inline_images=inline_images,
found_vector=found_vector, found_vector=found_vector,
name_index=name_index,
) )
@@ -303,14 +306,22 @@ class ImageInfo:
if self._enc == Encoding.jpeg2000: if self._enc == Encoding.jpeg2000:
self._color = Colorspace.jpeg2000 self._color = Colorspace.jpeg2000
self._comp = FRIENDLY_COMP.get(self._color, '?') if self._color == Colorspace.icc:
# Check the ICC profile to determine actual colorspace
pim_icc = pim.icc
if pim_icc.profile.xcolor_space == 'GRAY':
self._comp = 1
elif pim_icc.profile.xcolor_space == 'CMYK':
self._comp = 4
else:
self._comp = 3
else:
self._comp = FRIENDLY_COMP.get(self._color, '?')
# Bit of a hack... infer grayscale if component count is uncertain # Bit of a hack... infer grayscale if component count is uncertain
# but encoding must be monochrome. This happens if a monochrome image # but encoding only supports monochrome.
# has an ICC profile attached. Better solution would be to examine if self._comp == '?' and self._enc in (Encoding.ccitt, Encoding.jbig2):
# the ICC profile. self._comp = FRIENDLY_COMP[Colorspace.gray]
if self._comp == '?' and self._enc in (Encoding.ccitt, Encoding.jbig2):
self._comp = FRIENDLY_COMP[Colorspace.gray]
@property @property
def name(self): def name(self):
@@ -411,13 +422,9 @@ def _find_regular_images(container, contentsinfo):
""" """
for pdfimage, xobj in _image_xobjects(container): for pdfimage, xobj in _image_xobjects(container):
if xobj not in contentsinfo.name_index:
# For each image that is drawn on this, check if we drawing the continue
# current image - yes this is O(n^2), but n == 1 almost always for draw in contentsinfo.name_index[xobj]:
for draw in contentsinfo.xobject_settings:
if draw.name != xobj:
continue
if draw.stack_depth == 0 and _is_unit_square(draw.shorthand): if draw.stack_depth == 0 and _is_unit_square(draw.shorthand):
# At least one PDF in the wild (and test suite) draws an image # At least one PDF in the wild (and test suite) draws an image
# when the graphics stack depth is 0, meaning that the image # when the graphics stack depth is 0, meaning that the image
@@ -551,7 +558,7 @@ def simplify_textboxes(miner, textbox_getter):
yield TextboxInfo(box.bbox, visible, corrupt) yield TextboxInfo(box.bbox, visible, corrupt)
def _pdf_get_pageinfo(pdf, pageno: int, infile, xmltext): def _pdf_get_pageinfo(pdf, pageno: int, infile: PathLike, xmltext: str):
pageinfo = {} pageinfo = {}
pageinfo['pageno'] = pageno pageinfo['pageno'] = pageno
pageinfo['images'] = [] pageinfo['images'] = []
@@ -611,25 +618,28 @@ def _pdf_get_pageinfo(pdf, pageno: int, infile, xmltext):
def _pdf_get_all_pageinfo(infile, detailed_analysis=False, log=None, progbar=False): def _pdf_get_all_pageinfo(infile, detailed_analysis=False, log=None, progbar=False):
pdf = pikepdf.open(infile) # Do not close in this function pdf = pikepdf.open(infile) # Do not close in this function
if pdf.is_encrypted: try:
pdf.close() if pdf.is_encrypted:
raise EncryptedPdfError() # Triggered by encryption with empty passwd raise EncryptedPdfError() # Triggered by encryption with empty passwd
if detailed_analysis: if detailed_analysis:
pages_xml = None pages_xml = None
else: else:
pages_xml = ghosttext.extract_text_xml(infile, pdf, pageno=None, log=log) pages_xml = ghosttext.extract_text_xml(infile, pdf, pageno=None, log=log)
pages = [] pages = []
for n, _ in tqdm( for n, _ in tqdm(
enumerate(pdf.pages), enumerate(pdf.pages),
total=len(pdf.pages), total=len(pdf.pages),
desc="Scan", desc="Scan",
unit='page', unit='page',
disable=not progbar, disable=not progbar,
): ):
page_xml = pages_xml[n] if pages_xml else None page_xml = pages_xml[n] if pages_xml else None
page = PageInfo(pdf, n, infile, page_xml, detailed_analysis) page = PageInfo(pdf, n, infile, page_xml, detailed_analysis)
pages.append(page) pages.append(page)
except Exception:
pdf.close()
raise
return pages, pdf return pages, pdf
@@ -750,6 +760,8 @@ class PdfInfo:
def __init__(self, infile, detailed_page_analysis=False, log=logger, progbar=False): def __init__(self, infile, detailed_page_analysis=False, log=logger, progbar=False):
self._infile = infile self._infile = infile
if ghostscript.version() in ('9.52',):
detailed_page_analysis = True # txtwrite doesn't work in these versions
self._pages, pdf = _pdf_get_all_pageinfo( self._pages, pdf = _pdf_get_all_pageinfo(
infile, detailed_page_analysis, log=log, progbar=progbar infile, detailed_page_analysis, log=log, progbar=progbar
) )
@@ -805,10 +817,14 @@ def main():
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
parser.add_argument('infile') parser.add_argument('infile')
args = parser.parse_args() args = parser.parse_args()
info = _pdf_get_all_pageinfo(args.infile) pagesinfo, pdfinfo = _pdf_get_all_pageinfo(args.infile)
from pprint import pprint from pprint import pprint
pprint(info) pprint(pdfinfo)
for page in pagesinfo:
pprint(page)
for im in page.images:
pprint(im)
if __name__ == '__main__': if __name__ == '__main__':
+40 -2
View File
@@ -97,10 +97,48 @@ assert ast.parse(WINDOWS_SHIM_TEMPLATE.format(spoofer=repr(r"C:\\Temp\\file.py")
def spoof(tmp_path_factory, **kwargs): def spoof(tmp_path_factory, **kwargs):
"""Modify PATH to override subprocess executables """Modify PATH to override subprocess executables
spoof(program1='replacement', ...) spoof(tmp_path_factory, program1='replacement', ...)
Creates temporary directory with symlinks to targets. For the test suite we need a way override executables, so that we can
substitute desired results such as errors or just speed up OCR.
On POSIXish platforms we create a temporary folder with overrides that
are symlinks to the executables we want to run. We do not actually override
PATH. We also set an environment variable _OCRMYPDF_TEST_PATH, which
OCRmyPDF's subprocess wrapper will check before they use regular PATH. The
output is a folder full of executables we are overriding. We can override
multiple executables. The end result is a folder we can use in a PATH-style
lookup to override some executables:
/tmp/abcxyz/tesseract -> ocrmypdf/tests/resources/spoof/tesseract_crash.py
/tmp/abcxyz/gs -> ocrmypdf/tests/resources/spoof/gs_backflip.py
Windows needs extra help from us because usually, only the Administrator
can create symlinks. Instead we create small Python scripts that call
the programs we want, implementing the effect of a symlink. This is cleaner
than creating Windows executables or trying to use non-Python scripts.
The temporary folder generated for Windows could like:
%TEMP%\abcxyz\tesseract.py:
(script that runs ocrmypdf/tests/resources/spoof/tesseract_crash.py)
%TEMP%\abcxyz\gswin32c.py:
(script that runs ocrmypdf/tests/resources/spoof/gs_backflip.py)
%TEMP%\abcxyz\gswin64c.py:
(script that runs ocrmypdf/tests/resources/spoof/gs_backflip.py)
We also address one quirk here, that Ghostscript may be known as gswin32c
or gswin64c, depending on what the user installed (regardless of Windows
itself). On POSIX, Ghostscript is just 'gs'. We handle the special case here
too.
All of this is intimately dependent on the machinery in ocrmypdf.exec.run().
In particular, for Windows, that code has to know that if there is a .py
file, it needs to run it with Python, since Windows does not like being
asked to execute files.
We don't overload PATH directly because we have some tests where we call
ocrmypdf as a subprocess (to exercise the command line interface) and some
tests where we call it as an API.
""" """
env = os.environ.copy() env = os.environ.copy()
slug = '-'.join(v.replace('.py', '') for v in sorted(kwargs.values())) slug = '-'.join(v.replace('.py', '') for v in sorted(kwargs.values()))
+17 -12
View File
@@ -25,25 +25,30 @@ import ocrmypdf
def test_no_glyphless_graft(resources, outdir): def test_no_glyphless_graft(resources, outdir):
pdf = pikepdf.open(resources / 'francais.pdf') with pikepdf.open(resources / 'francais.pdf') as pdf, pikepdf.open(
pdf_aspect = pikepdf.open(resources / 'aspect.pdf') resources / 'aspect.pdf'
pdf_cmyk = pikepdf.open(resources / 'cmyk.pdf') ) as pdf_aspect, pikepdf.open(resources / 'cmyk.pdf') as pdf_cmyk:
pdf.pages.extend(pdf_aspect.pages) pdf.pages.extend(pdf_aspect.pages)
pdf.pages.extend(pdf_cmyk.pages) pdf.pages.extend(pdf_cmyk.pages)
pdf.save(outdir / 'test.pdf') pdf.save(outdir / 'test.pdf')
with patch('ocrmypdf._graft.MAX_REPLACE_PAGES', 2): with patch('ocrmypdf._graft.MAX_REPLACE_PAGES', 2):
ocrmypdf.ocr( ocrmypdf.ocr(
outdir / 'test.pdf', outdir / 'out.pdf', deskew=True, tesseract_timeout=0 outdir / 'test.pdf',
outdir / 'out.pdf',
deskew=True,
tesseract_timeout=0,
force_ocr=True,
) )
# This test needs asserts
def test_links(resources, outpdf): def test_links(resources, outpdf):
ocrmypdf.ocr( ocrmypdf.ocr(
resources / 'link.pdf', outpdf, redo_ocr=True, oversample=200, output_type='pdf' resources / 'link.pdf', outpdf, redo_ocr=True, oversample=200, output_type='pdf'
) )
pdf = pikepdf.open(outpdf) with pikepdf.open(outpdf) as pdf:
p1 = pdf.pages[0] p1 = pdf.pages[0]
p2 = pdf.pages[1] p2 = pdf.pages[1]
assert p1.Annots[0].A.D[0].objgen == p2.objgen assert p1.Annots[0].A.D[0].objgen == p2.objgen
assert p2.Annots[0].A.D[0].objgen == p1.objgen assert p2.Annots[0].A.D[0].objgen == p1.objgen
+4
View File
@@ -26,6 +26,7 @@ from PIL import Image
from reportlab.pdfgen.canvas import Canvas from reportlab.pdfgen.canvas import Canvas
from ocrmypdf import pdfinfo from ocrmypdf import pdfinfo
from ocrmypdf.exec import ghostscript
from ocrmypdf.pdfinfo import Colorspace, Encoding from ocrmypdf.pdfinfo import Colorspace, Encoding
# pylint: disable=protected-access # pylint: disable=protected-access
@@ -183,6 +184,9 @@ def test_ocr_detection(resources):
@pytest.mark.parametrize( @pytest.mark.parametrize(
'testfile', ('truetype_font_nomapping.pdf', 'type3_font_nomapping.pdf') 'testfile', ('truetype_font_nomapping.pdf', 'type3_font_nomapping.pdf')
) )
@pytest.mark.xfail(
ghostscript.version() in ('9.52',), reason="gs 9.52 txtwrite doesn't work"
)
def test_corrupt_font_detection(resources, testfile): def test_corrupt_font_detection(resources, testfile):
filename = resources / testfile filename = resources / testfile
with pytest.raises(NotImplementedError): with pytest.raises(NotImplementedError):
+34
View File
@@ -176,3 +176,37 @@ def test_language_warning(caplog):
vd.check_options_languages(opts) vd.check_options_languages(opts)
assert opts.language == ['eng'] assert opts.language == ['eng']
assert 'assuming --language' in caplog.text assert 'assuming --language' in caplog.text
def test_version_comparison():
vd.check_external_program(
program="dummy_basic",
package="dummy",
version_checker=lambda: '9.0',
need_version='8.0.2',
)
vd.check_external_program(
program="dummy_doubledigit",
package="dummy",
version_checker=lambda: '10.0',
need_version='8.0.2',
)
vd.check_external_program(
program="tesseract",
package="tesseract",
version_checker=lambda: '4.0.0-beta.1',
need_version='4.0.0',
)
vd.check_external_program(
program="tesseract",
package="tesseract",
version_checker=lambda: 'v5.0.0-alpha.20200201',
need_version='4.0.0',
)
with pytest.raises(MissingDependencyError):
vd.check_external_program(
program="dummy_fails",
package="dummy",
version_checker=lambda: '1.0',
need_version='2.0',
)