Compare commits

...
48 Commits
Author SHA1 Message Date
James R. Barlow 683ffb84e8 Update reqs 2020-02-10 01:20:33 -08:00
James R. Barlow 2f2602357b v9.6.0 notes updated 2020-02-10 01:20:12 -08:00
James R. Barlow a5f429f499 Merge branch 'pr479' 2020-02-10 01:10:30 -08:00
James R. Barlow bdb7f92131 ifmain -> main() 2020-02-10 01:10:12 -08:00
James R. Barlow 09f15ac4c0 v9.6.0 notes 2020-02-10 01:01:49 -08:00
James R. Barlow 4fdbf55c11 setup: approve pdfminer.six 20200124 2020-02-09 23:50:56 -08:00
James R. Barlow fe2b07652b docs: simplify/fix Ubuntu 18.04 install instructions 2020-02-09 23:48:53 -08:00
James R. Barlow f6d7aa6e33 Refactor page rotation and re-enable message at info level 2020-01-30 17:35:20 -08:00
James R. Barlow a005d14f91 Merge branch 'master' of github.com:jbarlow83/OCRmyPDF 2020-01-30 16:24:16 -08:00
Matthias BraunandGitHub 6f66232d44 Fix typos, add instructions for training data (#477) 2020-01-30 16:24:41 -08:00
James R. Barlow b8a780d684 Wait for file based on pikepdf 2020-01-30 12:40:48 -08:00
James R. Barlow 82f393dd09 Order of events 2020-01-30 12:40:19 -08:00
James R. Barlow 4952af1604 watcher: some refactoring 2020-01-28 12:56:19 -08:00
James R. Barlow bcf77375c0 Fix grammar in output message 2020-01-28 07:33:28 -08:00
Ian Alexander 3eab161771 Update logging and env var extensibility 2020-01-20 10:45:28 -08:00
Ian Alexander b7f38e976b Watched folder bug fixes, new flags, and docs updates. 2020-01-20 00:20:29 -08:00
James R. Barlow a6567f2ae4 v9.5.0 release notes revised 2020-01-18 01:48:33 -08:00
James R. Barlow e860c56b75 Fix regression: metadata updates not taking effect 2020-01-17 23:01:37 -08:00
James R. Barlow 2e15d52895 v9.5.0 release notes 2020-01-17 03:11:33 -08:00
James R. Barlow ce97af5a79 Add OCR quality measurement API 2020-01-17 03:10:27 -08:00
James R. Barlow 3831c4cd4d Refactor metadata_fixup 2020-01-14 01:10:15 -08:00
James R. Barlow 61a2674317 Skip test that needs chmod when on Windows 2020-01-06 02:36:04 -08:00
James R. Barlow 9ad8cbf1f6 Fix assert that depends on POSIX-y file handling 2020-01-06 02:02:05 -08:00
James R. Barlow 123fde174d Don't use debug.log in pytest
pytest does not reset the state of logging if we install a file handler,
which will cause FileNotFoundError after the temporary folder is removed.

Semi-related:
https://github.com/pytest-dev/pytest/issues/5502
2020-01-06 01:46:19 -08:00
James R. Barlow fd991a2380 Allow pdfminer.six 20200104 and update recommended versions 2020-01-05 21:37:28 -08:00
James R. Barlow 6f5d77d930 Also generate log file in temp folder on verbose mode 2020-01-05 21:33:32 -08:00
James R. Barlow 5169ac633b docs: mention pdfgrep too 2020-01-05 21:32:36 -08:00
James R. Barlow 5b6ab1e003 lept: improve lib not found error message
Closes #471
2020-01-05 01:05:53 -08:00
James R. Barlow 8f984bf958 docs: add note on limitations of sidecar file 2020-01-04 16:43:13 -08:00
James R. Barlow 9c5f0d0ec6 Eliminate last use of PyPDF2 from test suite 2020-01-04 16:32:01 -08:00
James R. Barlow 32041c43e1 tests: improve tesseract coverage 2020-01-04 02:35:14 -08:00
James R. Barlow 599028bebb tesseract: don't explicitly set lstm_use_matrix
Apparently tesseract does this own its own as needed.
2020-01-04 01:17:33 -08:00
James R. Barlow 6faa8f7221 logging: always log process arguments and stderr when at debug
Also remove ad-hoc logging of this information.
2020-01-01 16:48:48 -08:00
James R. Barlow a4dc5e365f logging: fix incorrect usage: logging.Logger() 2020-01-01 16:47:36 -08:00
James R. Barlow e2a563cc76 logging: create a debug log when -k parameter is issued 2020-01-01 16:47:15 -08:00
James R. Barlow 1037d73efb tests: use smaller files for ghostscript 2019-12-31 17:20:28 -08:00
James R. Barlow aeb7b142a9 tests: skip tests not compatible with coverage
For reasons not entirely clear, stdout will get some data injected when
pytest-cov is running. Our tests that
check for clean stdout need to ignore this.

We check for an environment variable that is defined only when coverage is
running.
2019-12-31 17:10:51 -08:00
James R. Barlow 422ea9777e Remove session scope from fixtures
pytest seems to prepare os.environ in complex ways, so we want to ensure
these fixtures are not reused.
2019-12-31 17:09:23 -08:00
James R. Barlow 2f1c743227 Rewrite main pool loop
pytest-cov documentation recommends using explicit
management of multiprocessing.Pool rather than the context manager.
This is supposed to work better for collecting coverage data, particularly
on Windows.
2019-12-31 16:23:41 -08:00
James R. Barlow 96ee21aee9 Try to set up subprocess coverage better 2019-12-31 15:39:45 -08:00
James R. Barlow 4b759af6ff tests: fix problems with ghostscript spoofers 2019-12-31 15:33:03 -08:00
James R. Barlow 25d2b0cda4 test: environment warnings/cleanup 2019-12-30 22:38:50 -08:00
James R. Barlow 16dd8b54a8 ghostscript: don't delete output_file that will never exist
We stream output now, so no point in deleting.
2019-12-30 22:38:38 -08:00
James R. Barlow c4dc5269d2 tests: remove some obscure things from coverage 2019-12-30 21:16:16 -08:00
James R. Barlow c36e9950ae tests: test TqdmConsole 2019-12-30 17:51:09 -08:00
James R. Barlow 0c0d53b10f tests: AcroForm test case did not work correctly; fixed 2019-12-30 17:50:32 -08:00
James R. Barlow 63de7e1677 Improve error message for unreadable input files 2019-12-30 16:14:52 -08:00
James R. Barlow b0e92760a2 tests: add coverage for helpers 2019-12-30 15:52:10 -08:00
37 changed files with 829 additions and 257 deletions
+3 -3
View File
@@ -1,5 +1,3 @@
# Coverage isn't really compatible with subprocesses so results are unreliable
[paths]
source =
src
@@ -8,9 +6,11 @@ source =
[run]
branch = true
parallel = true
concurrency =
thread
multiprocessing
source =
src/ocrmypdf
tests
omit =
tests/spoof/*
+11 -8
View File
@@ -38,7 +38,7 @@ Main features
- Keeps the exact resolution of the original embedded images
- When possible, inserts OCR information as a "lossless" operation without disrupting any other content
- Optimizes PDF images, often producing files smaller than the input file
- If requested deskews and/or cleans the image before performing OCR
- If requested, deskews and/or cleans the image before performing OCR
- Validates input and output files
- Distributes work across all available CPU cores
- Uses [Tesseract OCR](https://github.com/tesseract-ocr/tesseract) engine to recognize more than [100 languages](https://github.com/tesseract-ocr/tessdata)
@@ -50,7 +50,7 @@ For details: please consult the [documentation](https://ocrmypdf.readthedocs.io/
Motivation
----------
I searched the web for a free command line tool to OCR PDF files: I found many, but none of them were really satisfying.
I searched the web for a free command line tool to OCR PDF files: I found many, but none of them were really satisfying:
- Either they produced PDF files with misplaced text under the image (making copy/paste impossible)
- Or they did not handle accents and multilingual characters
@@ -97,7 +97,10 @@ OCRmyPDF uses Tesseract for OCR, and relies on its language packs. For Linux use
apt-cache search tesseract-ocr
# Debian/Ubuntu users
apt-get install tesseract-ocr-chi-sim # Example: Install Chinese Simplified language back
apt-get install tesseract-ocr-chi-sim # Example: Install Chinese Simplified language pack
# Arch Linux users
pacman -S tesseract-data-eng tesseract-data-deu # Example: Install the English and German language packs
```
You can then pass the `-l LANG` argument to OCRmyPDF to give a hint as to what languages it should search for. Multiple languages can be requested.
@@ -105,7 +108,7 @@ You can then pass the `-l LANG` argument to OCRmyPDF to give a hint as to what l
Documentation and support
-------------------------
Once ocrmypdf is installed, the built-in help which explains the command syntax and options can be accessed via:
Once OCRmyPDF is installed, the built-in help which explains the command syntax and options can be accessed via:
```bash
ocrmypdf --help
@@ -118,20 +121,20 @@ Please report issues on our [GitHub issues](https://github.com/jbarlow83/OCRmyPD
Requirements
------------
In addition to the required Python version (3.6+), OCRmyPDF requires external program installations of Ghostscript, Tesseract OCR, QPDF, and Leptonica. ocrmypdf is pure Python, but uses CFFI to portably generate library bindings. OCRmyPDF works on pretty much everything: Linux, macOS, Windows and FreeBSD.
In addition to the required Python version (3.6+), OCRmyPDF requires external program installations of Ghostscript, Tesseract OCR, QPDF, and Leptonica. OCRmyPDF is pure Python, but uses CFFI to portably generate library bindings. OCRmyPDF works on pretty much everything: Linux, macOS, Windows and FreeBSD.
Press & Media
-------------
- [Going paperless with OCRmyPDF](https://medium.com/@ikirichenko/going-paperless-with-ocrmypdf-e2f36143f46a)
- [Converting a scanned document into a compressed searchable PDF with redactions](https://medium.com/@treyharris/converting-a-scanned-document-into-a-compressed-searchable-pdf-with-redactions-63f61c34fe4c)
- [c't 1-2014, page 59](http://heise.de/-2279695): Detailed presentation of OCRmyPDF v1.0 in the leading German IT magazine c't
- [heise Open Source, 09/2014: Texterkennung mit OCRmyPDF](http://heise.de/-2356670)
- [c't 1-2014, page 59](https://heise.de/-2279695): Detailed presentation of OCRmyPDF v1.0 in the leading German IT magazine c't
- [heise Open Source, 09/2014: Texterkennung mit OCRmyPDF](https://heise.de/-2356670)
Business enquiries
------------------
OCRmyPDF would not be the software that it is today is without companies and users choosing to provide support for feature development and consulting enquiries. We are happy to discuss all enquiries, whether for extending the existing feature set, or integrating OCRmyPDF into a larger system.
OCRmyPDF would not be the software that it is today without companies and users choosing to provide support for feature development and consulting enquiries. We are happy to discuss all enquiries, whether for extending the existing feature set, or integrating OCRmyPDF into a larger system.
License
-------
+6
View File
@@ -210,6 +210,9 @@ be launched as follows:
-v <path to files to convert>:/input \
-v <path to store results>:/output \
-e OCR_OUTPUT_DIRECTORY_YEAR_MONTH=1 \
-e OCR_ON_SUCCESS_DELETE=1 \
-e OCR_DESKEW=1 \
-e PYTHONUNBUFFERED=1 \
-it --entrypoint python3 \
jbarlow83/ocrmypdf \
watcher.py
@@ -224,6 +227,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 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_ON_SUCCESS_DELETE=1``", "This will delete the input file if the exit code is 0 (OK)"
"``-e OCR_DESKEW=1``", "This will enable deskew for crooked PDFs"
"``-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
may not be suitable for some environments, such as filesystems shared on a
+12
View File
@@ -89,6 +89,18 @@ This produces a file named "output.pdf" and a companion text file named
ocrmypdf --sidecar output.txt input.pdf output.pdf
.. note::
The sidecar file contains the **OCR text** found by OCRmyPDF. If the document
contains pages that already have text, that text will not appear in the
sidecar. If the option ``--pages`` is used, only those pages on which OCR
was performed will be included in the sidecar. If certain pages were skipped
because of options like ``--skip-big`` or ``--tesseract-timeout``, those pages
will not be in the sidecar.
To extract all text from a PDF, whether generated from OCR or otherwise,
use a program like Poppler's ``pdftotext`` or ``pdfgrep``.
OCR images, not PDFs
--------------------
+13 -11
View File
@@ -141,18 +141,20 @@ first install the system version to get most of the dependencies:
.. code-block:: bash
sudo apt-get update
sudo apt-get install \
ocrmypdf
There are a few system dependency changes since ocrmypdf 6.1.2. Let's
get these, too.
.. code-block:: bash
sudo apt-get install \
sudo apt-get -y update
sudo apt-get -y install \
ghostscript \
icc-profiles-free \
liblept5 \
libxml2 \
pngquant
pngquant \
python3-cffi \
python3-distutils \
python3-pkg-resources \
python3-reportlab \
qpdf \
tesseract-ocr \
zlib1g
We will need a newer version of ``pip`` then was available for Ubuntu 18.04:
+30
View File
@@ -13,6 +13,36 @@ Note that it is licensed under GPLv3, so scripts that
``import ocrmypdf`` and are released publicly should probably also be
licensed under GPLv3.
v9.6.0
======
- Fixed a regression with transferring metadata from the input PDF to the output
PDF in certain situations.
- pdfminer.six is now supported up to version 2020-01-24.
- Messages are explaining page rotation decisions are now shown at the standard
verbosity level again when ``--rotate-pages``. In some previous version they
were set to debug level messages that only appeared with the parameter ``-v1``.
- Improvements to ``misc/watcher.py``. Thanks to @ianalexander and @svenihoney.
- Documentation improvements.
v9.5.0
======
- Added API functions to measure OCR quality.
- Modest improvements to handling PDFs with difficult/non compliant metadata.
v9.4.0
======
- Updated recommended dependency versions.
- Improvements to test coverage and changes to facilitate better measurement of
test coverage, such as when tests run in subprocesses.
- Improvements to error messages when Leptonica is not installed correctly.
- Fixed use of pytest "session scope" that may have caused some intermittent
CI failures.
- When the argument ``--keep-temporary-files`` or verbosity is set to ``-v1``,
a debug log file is generated in the working temporary folder.
v9.3.0
======
+78 -11
View File
@@ -13,51 +13,114 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import logging
import os
import time
from datetime import datetime
from pathlib import Path
import pikepdf
from watchdog.events import PatternMatchingEventHandler
from watchdog.observers import Observer
import ocrmypdf
# pylint: disable=logging-format-interpolation
INPUT_DIRECTORY = os.getenv('OCR_INPUT_DIRECTORY', '/input')
OUTPUT_DIRECTORY = os.getenv('OCR_OUTPUT_DIRECTORY', '/output')
OUTPUT_DIRECTORY_YEAR_MONTH = bool(os.getenv('OCR_OUTPUT_DIRECTORY_YEAR_MONTH', False))
ON_SUCCESS_DELETE = bool(os.getenv('OCR_ON_SUCCESS_DELETE', False))
DESKEW = bool(os.getenv('OCR_DESKEW', False))
POLL_NEW_FILE_SECONDS = os.getenv('OCR_POLL_NEW_FILE_SECONDS', 1)
LOGLEVEL = os.environ.get('OCR_LOGLEVEL', 'INFO').upper()
PATTERNS = ['*.pdf']
log = logging.getLogger('ocrmypdf-watcher')
def execute_ocrmypdf(file_path):
filename = Path(file_path).name
def get_output_dir(root, basename):
if OUTPUT_DIRECTORY_YEAR_MONTH:
today = datetime.today()
output_directory_year_month = Path(
f'{OUTPUT_DIRECTORY}/{today.year}/{today.month}'
output_directory_year_month = (
Path(root) / str(today.year) / f'{today.month:02d}'
)
if not output_directory_year_month.exists():
output_directory_year_month.mkdir(parents=True, exist_ok=True)
output_path = Path(output_directory_year_month) / filename
output_path = Path(output_directory_year_month) / basename
else:
output_path = Path(OUTPUT_DIRECTORY) / filename
print(f'New file: {file_path}.\nAttempting to OCRmyPDF to: {output_path}')
ocrmypdf.ocr(file_path, output_path)
output_path = Path(OUTPUT_DIRECTORY) / basename
return output_path
def wait_for_file_ready(file_path):
# This loop waits to make sure that the file is completely loaded on
# disk before attempting to read. Docker sometimes will publish the
# watchdog event before the file is actually fully on disk, causing
# pikepdf to fail.
retries = 5
while retries:
try:
pdf = pikepdf.open(file_path)
except (FileNotFoundError, pikepdf.PdfError) as e:
log.info(f"File {file_path} is not ready yet")
log.debug("Exception was", exc_info=e)
time.sleep(POLL_NEW_FILE_SECONDS)
retries -= 1
else:
pdf.close()
return True
return False
def execute_ocrmypdf(file_path):
file_path = Path(file_path)
output_path = get_output_dir(OUTPUT_DIRECTORY, file_path.name)
log.info("-" * 20)
log.info(f'New file: {file_path}. Waiting until fully loaded...')
if not wait_for_file_ready(file_path):
log.info(f"Gave up waiting for {file_path} to become ready")
return
log.info(f'Attempting to OCRmyPDF to: {output_path}')
exit_code = ocrmypdf.ocr(
input_file=file_path, output_file=output_path, deskew=DESKEW
)
if exit_code == 0 and ON_SUCCESS_DELETE:
log.info(f'OCR is done. Deleting: {file_path}')
file_path.unlink()
else:
log.info('OCR is done')
class HandleObserverEvent(PatternMatchingEventHandler):
def on_any_event(self, event):
if event.event_type in ['created', 'modified']:
if event.event_type in ['created']:
execute_ocrmypdf(event.src_path)
if __name__ == "__main__":
print(
def main():
ocrmypdf.configure_logging(
verbosity=ocrmypdf.Verbosity.default, manage_root_logger=True
)
log.info(
f"Starting OCRmyPDF watcher with config:\n"
f"Input Directory: {INPUT_DIRECTORY}\n"
f"Output Directory: {OUTPUT_DIRECTORY}\n"
f"Output Directory Year & Month: {OUTPUT_DIRECTORY_YEAR_MONTH}"
)
log.debug(
f"INPUT_DIRECTORY: {INPUT_DIRECTORY}\n"
f"OUTPUT_DIRECTORY: {OUTPUT_DIRECTORY}\n"
f"OUTPUT_DIRECTORY_YEAR_MONTH: {OUTPUT_DIRECTORY_YEAR_MONTH}\n"
f"ON_SUCCESS_DELETE: {ON_SUCCESS_DELETE}\n"
f"DESKEW: {DESKEW}\n"
f"POLL_NEW_FILE_SECONDS: {POLL_NEW_FILE_SECONDS}\n"
f"LOGLEVEL: {LOGLEVEL}\n"
)
handler = HandleObserverEvent(patterns=PATTERNS)
observer = Observer()
observer.schedule(handler, INPUT_DIRECTORY, recursive=True)
@@ -68,3 +131,7 @@ if __name__ == "__main__":
except KeyboardInterrupt:
observer.stop()
observer.join()
if __name__ == "__main__":
main()
+6 -6
View File
@@ -1,10 +1,10 @@
# requirements.txt can be used to replicate the developer's build environment
# setup.py lists a separate set of requirements that are looser to simplify
# installation
cffi == 1.13.2
cffi == 1.14.0
img2pdf == 0.3.3
pdfminer.six == 20191110
pikepdf == 1.8.1
Pillow >= 6.2.0
reportlab == 3.5.32
tqdm == 4.37.0
pdfminer.six == 20200124
pikepdf == 1.10.1
Pillow == 7.0.0
reportlab == 3.5.34
tqdm == 4.42.1
+2 -3
View File
@@ -1,8 +1,7 @@
pytest >= 5.0.0
pytest-helpers-namespace >= 2019.1.8
pytest-xdist >= 1.29.0 # For DumpError fix
pytest-cov >= 2.6.1
pytest-xdist >= 1.31.0
pytest-cov >= 2.8.0
python-xmp-toolkit == 2.0.1 # requires apt-get install libexempi3
# or brew install exempi
PyPDF2 >= 1.26.0
#PyMuPDF == 1.13.4 # optional
+1 -1
View File
@@ -23,7 +23,7 @@ force_grid_wrap=0
use_parentheses=True
line_length=88
known_first_party = ocrmypdf
known_third_party = PIL,PyPDF2,_cffi_backend,cffi,flask,gs,img2pdf,pdfminer,pikepdf,pkg_resources,pytest,reportlab,setuptools,sphinx_rtd_theme,tqdm,watchdog,werkzeug
known_third_party = PIL,_cffi_backend,cffi,flask,gs,img2pdf,pdfminer,pikepdf,pkg_resources,pytest,reportlab,setuptools,sphinx_rtd_theme,tqdm,watchdog,werkzeug
[metadata]
license_file = LICENSE
+3 -2
View File
@@ -21,11 +21,12 @@ from __future__ import print_function, unicode_literals
import sys
from setuptools import find_packages, setup
if sys.version_info < (3, 6):
print("Python 3.6 or newer is required", file=sys.stderr)
sys.exit(1)
from setuptools import setup, find_packages
# pylint: disable=w0613
@@ -97,7 +98,7 @@ setup(
'chardet >= 3.0.4, < 4', # unlisted requirement of pdfminer.six 20181108
'cffi >= 1.9.1', # must be a setup and install requirement
'img2pdf >= 0.3.0, < 0.4', # pure Python, so track HEAD closely
'pdfminer.six >= 20181108, <= 20191110',
'pdfminer.six >= 20181108, <= 20200124',
'pikepdf >= 1.8.1, < 2',
'Pillow >= 6.2.0',
'reportlab >= 3.3.0', # oldest released version with sane image handling
+1
View File
@@ -23,6 +23,7 @@ from .exceptions import (
DpiError,
EncryptedPdfError,
ExitCode,
ExitCodeException,
InputFileError,
MissingDependencyError,
OutputFileAccessError,
+87 -80
View File
@@ -19,6 +19,7 @@ import os
import re
import sys
from datetime import datetime, timezone
from pathlib import Path
from shutil import copyfileobj
import img2pdf
@@ -123,7 +124,7 @@ def _pdf_guess_version(input_file, search_window=1024):
return ''
def triage(input_file, output_file, options, log):
def triage(original_filename, input_file, output_file, options, log):
try:
if _pdf_guess_version(input_file):
if options.image_dpi:
@@ -135,8 +136,9 @@ def triage(input_file, output_file, options, log):
safe_symlink(input_file, output_file)
return output_file
except EnvironmentError as e:
log.error(e)
raise InputFileError() from e
log.debug(f"Temporary file was at: {input_file}")
msg = str(e).replace(input_file, original_filename)
raise InputFileError(msg) from e
triage_image_file(input_file, output_file, options, log)
return output_file
@@ -181,7 +183,7 @@ def validate_pdfinfo_options(context):
)
raise InputFileError()
else:
log.warn(
log.warning(
"This PDF has a fillable form. "
"Chances are it is a pure digital "
"document that does not need OCR."
@@ -327,6 +329,35 @@ def rasterize_preview(input_file, page_context):
return output_file
def describe_rotation(page_context, orient_conf, correction):
"""
Describe the page rotation we are going to perform.
"""
direction = {0: '', 90: '', 180: '', 270: ''}
turns = {0: ' ', 90: '', 180: '', 270: ''}
existing_rotation = page_context.pageinfo.rotation
action = ''
if orient_conf.confidence >= page_context.options.rotate_pages_threshold:
if correction != 0:
action = 'will rotate ' + turns[correction]
else:
action = 'rotation appears correct'
else:
if correction != 0:
action = 'confidence too low to rotate'
else:
action = 'no change'
facing = ''
if existing_rotation != 0:
facing = f"with existing rotation {direction.get(existing_rotation, '?')}, "
facing += f"page is facing {direction.get(orient_conf.angle, '?')}"
return f"{facing}, confidence {orient_conf.confidence:.2f} - {action}"
def get_orientation_correction(preview, page_context):
"""
Work out orientation correct for each page.
@@ -353,44 +384,14 @@ def get_orientation_correction(preview, page_context):
tesseract_env=page_context.options.tesseract_env,
)
direction = {0: '', 90: '', 180: '', 270: ''}
existing_rotation = page_context.pageinfo.rotation
correction = orient_conf.angle % 360
apply_correction = False
action = ''
if orient_conf.confidence >= page_context.options.rotate_pages_threshold:
if correction != 0:
apply_correction = True
action = ' - will rotate'
else:
action = ' - rotation appears correct'
else:
if correction != 0:
action = ' - confidence too low to rotate'
else:
action = ' - no change'
facing = ''
if existing_rotation != 0:
facing = 'with existing rotation {}, '.format(
direction.get(existing_rotation, '?')
)
facing += 'page is facing {}'.format(direction.get(orient_conf.angle, '?'))
page_context.log.debug(
'{pagenum:4d}: {facing}, confidence {conf:.2f}{action}'.format(
pagenum=page_context.pageinfo.pageno,
facing=facing,
conf=orient_conf.confidence,
action=action,
)
)
if apply_correction:
page_context.log.info(describe_rotation(page_context, orient_conf, correction))
if (
orient_conf.confidence >= page_context.options.rotate_pages_threshold
and correction != 0
):
return correction
return 0
@@ -725,47 +726,53 @@ def should_linearize(working_file, context):
def metadata_fixup(working_file, context):
output_file = context.get_path('metafix.pdf')
options = context.options
original = pikepdf.open(context.origin)
docinfo = get_docinfo(original, options)
pdf = pikepdf.open(working_file)
with pdf.open_metadata() as meta:
meta.load_from_docinfo(docinfo, delete_missing=False)
# If xmp:CreateDate is missing, set it to the modify date to
# match Ghostscript, for consistency
if 'xmp:CreateDate' not in meta:
meta['xmp:CreateDate'] = meta.get('xmp:ModifyDate', '')
meta_original = original.open_metadata()
not_copied = set(meta_original.keys()) - set(meta.keys())
if not_copied:
if options.output_type.startswith('pdfa'):
context.log.warning(
"Some input metadata could not be copied because it is not "
"permitted in PDF/A. You may wish to examine the output "
"PDF's XMP metadata."
)
context.log.debug(
"The following metadata fields were not copied: %r", not_copied
)
else:
context.log.error(
"Some input metadata could not be copied."
"You may wish to examine the output PDF's XMP metadata."
)
context.log.info(
"The following metadata fields were not copied: %r", not_copied
)
pdf.save(
output_file,
compress_streams=True,
preserve_pdfa=True,
object_stream_mode=pikepdf.ObjectStreamMode.generate,
linearize=( # Don't linearize if optimize() will be linearizing too
should_linearize(working_file, context) if options.optimize == 0 else False
),
)
original.close()
pdf.close()
def report_on_metadata(missing):
if not missing:
return
if options.output_type.startswith('pdfa'):
context.log.warning(
"Some input metadata could not be copied because it is not "
"permitted in PDF/A. You may wish to examine the output "
"PDF's XMP metadata."
)
context.log.debug(
"The following metadata fields were not copied: %r", missing
)
else:
context.log.error(
"Some input metadata could not be copied."
"You may wish to examine the output PDF's XMP metadata."
)
context.log.info(
"The following metadata fields were not copied: %r", missing
)
with pikepdf.open(context.origin) as original, pikepdf.open(working_file) as pdf:
docinfo = get_docinfo(original, options)
with pdf.open_metadata() as meta:
meta.load_from_docinfo(docinfo, delete_missing=False, raise_failure=False)
# If xmp:CreateDate is missing, set it to the modify date to
# match Ghostscript, for consistency
if 'xmp:CreateDate' not in meta:
meta['xmp:CreateDate'] = meta.get('xmp:ModifyDate', '')
meta_original = original.open_metadata()
missing = set(meta_original.keys()) - set(meta.keys())
report_on_metadata(missing)
pdf.save(
output_file,
compress_streams=True,
preserve_pdfa=True,
object_stream_mode=pikepdf.ObjectStreamMode.generate,
linearize=( # Don't linearize if optimize() will be linearizing too
should_linearize(working_file, context)
if options.optimize == 0
else False
),
)
return output_file
+60 -23
View File
@@ -23,6 +23,7 @@ import signal
import sys
import threading
from collections import namedtuple
from pathlib import Path
from tempfile import mkdtemp
import PIL
@@ -230,7 +231,7 @@ def exec_concurrent(context):
# Run exec_page_sync on every page context
max_workers = min(len(context.pdfinfo), context.options.jobs)
if max_workers > 1:
context.log.info("Start processing %d pages concurrent", max_workers)
context.log.info("Start processing %d pages concurrently", max_workers)
# Tesseract 4.x can be multithreaded, and we also run multiple workers. We want
# to manage how many threads it uses to avoid creating total threads than cores.
@@ -267,28 +268,43 @@ def exec_concurrent(context):
unit='page',
unit_scale=0.5,
disable=not context.options.progress_bar,
) as pbar, Pool(
processes=max_workers,
initializer=initializer,
initargs=(log_queue, PIL.Image.MAX_IMAGE_PIXELS),
) as pool:
results = pool.imap_unordered(exec_page_sync, context.get_page_contexts())
while True:
try:
page_result = results.next()
sidecars[page_result.pageno] = page_result.text
pbar.update()
ocrgraft.graft_page(page_result)
pbar.update()
except StopIteration:
break
except (Exception, KeyboardInterrupt):
) as pbar:
pool = Pool(
processes=max_workers,
initializer=initializer,
initargs=(log_queue, PIL.Image.MAX_IMAGE_PIXELS),
)
try:
results = pool.imap_unordered(exec_page_sync, context.get_page_contexts())
while True:
try:
page_result = results.next()
sidecars[page_result.pageno] = page_result.text
pbar.update()
ocrgraft.graft_page(page_result)
pbar.update()
except StopIteration:
break
except KeyboardInterrupt:
# Terminate pool so we exit instantly
pool.terminate()
# Don't try listener.join() here, will deadlock
raise
except Exception:
if not os.environ.get("PYTEST_CURRENT_TEST", ""):
# Unless inside pytest, exit immediately because no one wants
# to wait for child processes to finalize results that will be
# thrown away. Inside pytest, we want child processes to exit
# cleanly so that they output an error messages or coverage data
# we need from them.
pool.terminate()
log_queue.put_nowait(None) # Terminate log listener
# Don't try listener.join() here, will deadlock
raise
raise
finally:
# Terminate log listener
log_queue.put_nowait(None)
pool.close()
pool.join()
log_queue.put_nowait(None)
listener.join()
# Output sidecar text
@@ -320,6 +336,17 @@ def samefile(f1, f2):
return os.path.samefile(f1, f2)
def configure_debug_logging(log_filename, prefix=''):
log_file_handler = logging.FileHandler(log_filename, delay=True)
log_file_handler.setLevel(logging.DEBUG)
formatter = logging.Formatter(
'[%(asctime)s] - %(name)s - %(levelname)7s - %(message)s'
)
log_file_handler.setFormatter(formatter)
logging.getLogger(prefix).addHandler(log_file_handler)
return log_file_handler
def run_pipeline(options, api=False):
log = make_logger(options, __name__)
@@ -330,13 +357,22 @@ def run_pipeline(options, api=False):
options.jobs = available_cpu_count()
work_folder = mkdtemp(prefix="com.github.ocrmypdf.")
if (options.keep_temporary_files or options.verbose >= 1) and not os.environ.get(
'PYTEST_CURRENT_TEST', ''
):
configure_debug_logging(Path(work_folder) / "debug.log")
try:
check_requested_output_file(options)
start_input_file = create_input_file(options, work_folder)
start_input_file, original_filename = create_input_file(options, work_folder)
# Triage image or pdf
origin_pdf = triage(
start_input_file, os.path.join(work_folder, 'origin.pdf'), options, log
original_filename,
start_input_file,
os.path.join(work_folder, 'origin.pdf'),
options,
log,
)
# Gather pdfinfo and create context
@@ -345,6 +381,7 @@ def run_pipeline(options, api=False):
detailed_page_analysis=options.redo_ocr,
progbar=options.progress_bar,
)
context = PDFContext(options, work_folder, origin_pdf, pdfinfo)
# Validate options are okay for this pdf
+3 -3
View File
@@ -314,7 +314,7 @@ def check_options(options):
check_dependency_versions(options)
def check_closed_streams(options):
def check_closed_streams(options): # pragma: no cover
"""Work around Python issue with multiprocessing forking on closed streams
https://bugs.python.org/issue28326
@@ -380,12 +380,12 @@ def create_input_file(options, work_folder):
target = os.path.join(work_folder, 'stdin')
with open(target, 'wb') as stream_buffer:
copyfileobj(sys.stdin.buffer, stream_buffer)
return target
return target, "<stdin>"
else:
try:
target = os.path.join(work_folder, 'origin')
safe_symlink(options.input_file, target)
return target
return target, os.fspath(options.input_file)
except FileNotFoundError:
raise InputFileError(f"File not found - {options.input_file}")
+18 -7
View File
@@ -18,10 +18,10 @@
import logging
import os
import sys
import warnings
from contextlib import suppress
from enum import IntEnum
from pathlib import Path
from typing import Dict, List, Optional
from typing import Dict, List
from tqdm import tqdm
@@ -31,7 +31,15 @@ from .cli import parser
class TqdmConsole:
"""Wrapper to log messages in a way that is compatible with tqdm progress bar"""
"""Wrapper to log messages in a way that is compatible with tqdm progress bar
This routes log messages through tqdm so that it can print them above the
progress bar, and then refresh the progress bar, rather than overwriting
it which looks messy.
For some reason Python 3.6 prints extra empty messages from time to time,
so we suppress those.
"""
def __init__(self, file):
self.file = file
@@ -46,7 +54,7 @@ class TqdmConsole:
tqdm.write(msg.rstrip(), end='\n', file=self.file)
def flush(self):
if hasattr(self.file, "flush"):
with suppress(AttributeError):
self.file.flush()
@@ -80,11 +88,14 @@ def configure_logging(verbosity, progress_bar_friendly=True, manage_root_logger=
overwrite the progress bar
manage_root_logger (bool): Configure the process's root logger, to ensure
all log output is sent through
Returns:
The toplevel logger for ocrmypdf (or the root logger, if we are managing it).
"""
prefix = '' if manage_root_logger else 'ocrmypdf'
log = logging.getLogger(prefix)
log.setLevel(logging.INFO)
log.setLevel(logging.DEBUG)
if progress_bar_friendly:
console = logging.StreamHandler(stream=TqdmConsole(sys.stderr))
@@ -99,8 +110,6 @@ def configure_logging(verbosity, progress_bar_friendly=True, manage_root_logger=
console.setLevel(logging.INFO)
formatter = logging.Formatter('%(levelname)7s - %(message)s')
if verbosity >= 1:
log.setLevel(logging.DEBUG)
if verbosity >= 2:
formatter = logging.Formatter('%(name)s - %(levelname)7s - %(message)s')
@@ -116,6 +125,8 @@ def configure_logging(verbosity, progress_bar_friendly=True, manage_root_logger=
if manage_root_logger:
logging.captureWarnings(True)
return log
def create_options(*, input_file, output_file, **kwargs):
cmdline = []
+12 -3
View File
@@ -29,7 +29,7 @@ from subprocess import run as subprocess_run
from ..exceptions import ExitCode, MissingDependencyError
log = logging.Logger(__name__)
log = logging.getLogger(__name__)
def _get_program(args, env=None):
@@ -77,12 +77,21 @@ def run(args, *, env=None, **kwargs):
if new_args0:
args[0] = new_args0
log.debug(args)
process_log = log.getChild(os.path.basename(program))
process_log.debug("Running: %s", args)
if sys.version_info < (3, 7) and 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
return subprocess_run(args, env=env, **kwargs)
proc = subprocess_run(args, env=env, **kwargs)
if process_log.isEnabledFor(logging.DEBUG):
try:
stderr = proc.stderr.decode('utf-8', 'replace')
except AttributeError:
stderr = proc.stderr
if stderr:
process_log.debug("stderr = %s", stderr)
return proc
def get_version(program, *, version_arg='--version', regex=r'(\d+(\.\d+)*)', env=None):
-7
View File
@@ -195,8 +195,6 @@ def rasterize_pdf(
try:
p = run(args_gs, stdout=PIPE, stderr=PIPE, check=True)
except CalledProcessError as e:
with suppress(OSError):
Path(output_file).unlink() # no unfinished files
log.error(e.stderr.decode(errors='replace'))
raise SubprocessOutputError('Ghostscript rasterizing failed')
else:
@@ -313,15 +311,12 @@ def generate_pdfa(
]
)
args_gs.extend(fspath(s) for s in pdf_pages) # Stringify Path objs
log.debug(args_gs)
try:
with Path(output_file).open('wb') as output:
p = run(args_gs, stdout=output, stderr=PIPE, check=True)
except CalledProcessError as e:
# Ghostscript does not change return code when it fails to create
# PDF/A - check PDF/A status elsewhere
with suppress(OSError):
Path(output_file).unlink()
log.error(e.stderr.decode(errors='replace'))
raise SubprocessOutputError('Ghostscript PDF/A rendering failed')
else:
@@ -346,5 +341,3 @@ def generate_pdfa(
"Ghostscript had to remove PDF 'overprinting' from the "
"input file to complete PDF/A conversion. "
)
else:
log.debug(stderr)
+2 -7
View File
@@ -260,8 +260,8 @@ def generate_hocr(
log,
):
output_hocr = next(o for o in output_files if o.endswith('.hocr'))
output_sidecar = next(o for o in output_files if o.endswith('.txt'))
output_hocr = next(o for o in output_files if fspath(o).endswith('.hocr'))
output_sidecar = next(o for o in output_files if fspath(o).endswith('.txt'))
prefix = os.path.splitext(output_hocr)[0]
args_tesseract = tess_base_args(language, engine_mode)
@@ -275,14 +275,10 @@ def generate_hocr(
if user_patterns:
args_tesseract.extend(['--user-patterns', user_patterns])
if user_words or user_patterns:
args_tesseract.extend(['-c', 'lstm_use_matrix=1'])
# Reminder: test suite tesseract spoofers will break after any changes
# to the number of order parameters here
args_tesseract.extend([input_file, prefix, 'hocr', 'txt'] + tessconfig)
try:
log.debug(args_tesseract)
p = run(
args_tesseract,
stdout=PIPE,
@@ -381,7 +377,6 @@ def generate_pdf(
args_tesseract.extend([input_image, prefix, 'pdf', 'txt'] + tessconfig)
try:
log.debug(args_tesseract)
p = run(
args_tesseract,
stdout=PIPE,
+29 -21
View File
@@ -104,28 +104,36 @@ def is_file_writable(test_file):
can replace it atomically. Before doing the OCR work, make sure
the location is writable.
"""
p = Path(test_file)
if p.is_symlink():
p = p.resolve(strict=False)
# p.is_file() throws an exception in some cases
if p.exists() and p.is_file():
return os.access(
os.fspath(p),
os.W_OK,
effective_ids=(os.access in os.supports_effective_ids),
)
else:
try:
fp = p.open('wb')
except OSError:
return False
try:
if not isinstance(test_file, Path):
p = Path(test_file)
else:
fp.close()
with suppress(OSError):
p.unlink()
return True
p = test_file
if p.is_symlink():
p = p.resolve(strict=False)
# p.is_file() throws an exception in some cases
if p.exists() and p.is_file():
return os.access(
os.fspath(p),
os.W_OK,
effective_ids=(os.access in os.supports_effective_ids),
)
else:
try:
fp = p.open('wb')
except OSError:
return False
else:
fp.close()
with suppress(OSError):
p.unlink()
return True
except (EnvironmentError, RuntimeError) as e:
log.debug(e)
log.error(str(e))
return False
def deprecated(func):
+5 -5
View File
@@ -88,7 +88,7 @@ class HocrTransform:
if self.width is None or self.height is None:
raise HocrTransformError("hocr file is missing page dimensions")
def __str__(self):
def __str__(self): # pragma: no cover
"""
Return the textual content of the HTML body
"""
@@ -190,7 +190,7 @@ class HocrTransform:
pt = self.pt_from_pixel(pxl_coords)
# draw the bbox border
if showBoundingboxes:
if showBoundingboxes: # pragma: no cover
pdf.rect(
pt.x1, self.height - pt.y2, pt.x2 - pt.x1, pt.y2 - pt.y1, fill=1
)
@@ -231,7 +231,7 @@ class HocrTransform:
pdf.save()
@classmethod
def polyval(cls, poly, x):
def polyval(cls, poly, x): # pragma: no cover
return x * poly[0] + poly[1]
def _do_line(
@@ -269,7 +269,7 @@ class HocrTransform:
# of the line box
baseline_y2 = self.height - (line_box.y2 + intercept)
if showBoundingboxes:
if showBoundingboxes: # pragma: no cover
# draw the baseline in magenta, dashed
pdf.setDash()
pdf.setStrokeColorRGB(0.95, 0.65, 0.95)
@@ -318,7 +318,7 @@ class HocrTransform:
font_width = pdf.stringWidth(elemtxt, fontname, fontsize)
# draw the bbox border
if showBoundingboxes:
if showBoundingboxes: # pragma: no cover
pdf.rect(
box.x1, self.height - line_box.y2, box_width, line_height, fill=0
)
+18 -7
View File
@@ -47,21 +47,32 @@ if os.name == 'nt':
else:
libname = 'lept'
_libpath = find_library(libname)
if not _libpath and os.name == 'nt':
if not _libpath:
raise MissingDependencyError(
"""
---------------------------------------------------------------------
This error normally occurs when ocrmypdf can't find a file named
liblept-5.dll (Leptonica). Please ensure Tesseract-OCR is installed
and its location is added to the system PATH environment variable.
This error normally occurs when ocrmypdf can't find the Leptonica
library, which is usually installed with Tesseract OCR. It could be that
Tesseract is not installed properly, we can't find the installation
on your system PATH environment variable.
For details see:
The library we are looking for is usually called:
liblept-5.dll (Windows)
liblept*.dylib (macOS)
liblept*.so (Linux/BSD)
Please review our installation procedures to find a solution:
https://ocrmypdf.readthedocs.io/en/latest/installation.html
---------------------------------------------------------------------
"""
)
lept = ffi.dlopen(_libpath)
lept.setMsgSeverity(lept.L_SEVERITY_WARNING)
try:
lept = ffi.dlopen(_libpath)
lept.setMsgSeverity(lept.L_SEVERITY_WARNING)
except ffi.error as e:
raise MissingDependencyError(
f"Leptonica library found at {_libpath}, but we could not access it"
) from e
class _LeptonicaErrorTrap:
+60
View File
@@ -0,0 +1,60 @@
# © 2020 James R. Barlow: github.com/jbarlow83
#
# This file is part of OCRmyPDF.
#
# OCRmyPDF is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# OCRmyPDF is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with OCRmyPDF. If not, see <http://www.gnu.org/licenses/>.
import re
from typing import Iterable
"""Utilities to measure OCR quality"""
class OcrQualityDictionary:
"""Manages a dictionary for simple OCR quality checks."""
def __init__(self, *, wordlist: Iterable[str] = []):
"""Construct a dictionary from a list of words.
Words for which capitalization is important should be capitalized in the
dictionary. Words that contain spaces or other punctuation will never match.
"""
self.dictionary = set()
self.dictionary.update(w for w in wordlist)
def measure_words_matched(self, ocr_text: str) -> float:
"""Check how many unique words in the OCR text match a dictionary.
Words with mixed capitalized are only considered a match if the test word
matches that capitalization.
Returns:
number of words that match / number
"""
text = re.sub(r"[0-9_]+", ' ', ocr_text)
text = re.sub(r'\W+', ' ', text)
text_words_list = re.split(r'\s+', text)
text_words = {w for w in text_words_list if len(w) >= 3}
matches = 0
for w in text_words:
if w in self.dictionary or (
w != w.lower() and w.lower() in self.dictionary
):
matches += 1
if matches > 0:
hit_ratio = matches / len(text_words)
else:
hit_ratio = 0.0
return hit_ratio
+19 -10
View File
@@ -28,12 +28,6 @@ from ocrmypdf import api, cli
pytest_plugins = ['helpers_namespace']
try:
from pytest_cov.embed import cleanup_on_sigterm
except ImportError:
pass
else:
cleanup_on_sigterm()
# pylint: disable=E1101
# pytest.helpers is dynamic so it confuses pylint
@@ -137,12 +131,12 @@ def spoof(tmp_path_factory, **kwargs):
return env
@pytest.fixture(scope='session')
@pytest.fixture
def spoof_tesseract_noop(tmp_path_factory):
return spoof(tmp_path_factory, tesseract='tesseract_noop.py')
@pytest.fixture(scope='session')
@pytest.fixture
def spoof_tesseract_cache(tmp_path_factory):
if running_in_docker():
return os.environ.copy()
@@ -201,7 +195,10 @@ def check_ocrmypdf(input_file, output_file, *args, env=None):
@pytest.helpers.register
def run_ocrmypdf_api(input_file, output_file, *args, env=None):
"Run ocrmypdf and let caller deal with results"
"""Run ocrmypdf via API and let caller deal with results
Does not currently have a way to manipulate the PATH except for Tesseract.
"""
options = cli.parser.parse_args(
[str(input_file), str(output_file)]
@@ -211,6 +208,10 @@ def run_ocrmypdf_api(input_file, output_file, *args, env=None):
if env:
options.tesseract_env = env.copy()
options.tesseract_env['_OCRMYPDF_TEST_INFILE'] = os.fspath(input_file)
first_path = env.get('_OCRMYPDF_TEST_PATH', '').split(os.pathsep)[0]
if 'spoof' in first_path:
assert 'gs' not in first_path, "use run_ocrmypdf() for gs"
assert 'tesseract' in first_path
if options.tesseract_env:
assert all(isinstance(v, (str, bytes)) for v in options.tesseract_env.values())
@@ -222,13 +223,21 @@ def run_ocrmypdf(input_file, output_file, *args, env=None, universal_newlines=Tr
"Run ocrmypdf and let caller deal with results"
if env is None:
env = os.environ
env = os.environ.copy()
p_args = (
OCRMYPDF
+ [str(arg) for arg in args if arg is not None]
+ [str(input_file), str(output_file)]
)
# Tell subprocess where to find coverage.py configuration
# This has no effect except when coverage is running
# Details: https://coverage.readthedocs.io/en/coverage-5.0/subprocess.html
coverage_rc = Path(__file__).parent.parent / '.coveragerc'
assert coverage_rc.exists()
env['COVERAGE_PROCESS_START'] = os.fspath(coverage_rc)
p = run(
p_args, stdout=PIPE, stderr=PIPE, universal_newlines=universal_newlines, env=env
)
Binary file not shown.
+3 -3
View File
@@ -35,13 +35,13 @@ def main():
print('SPOOFED: ' + os.path.basename(__file__))
sys.exit(0)
# For any rendering calls (device == pdfwrite) call real ghostscript
if '-sDEVICE=pdfwrite' in sys.argv:
# For non-image rastering calls, use real ghostscript
if '-sDEVICE=pdfwrite' in sys.argv or '-sDEVICE=txtwrite' in sys.argv:
real_ghostscript(sys.argv)
return
# Fail
print("ERROR: Ghost story archive not found")
print("ERROR: Ghost story archive not found", file=sys.stderr)
sys.exit(1)
+1 -1
View File
@@ -40,7 +40,7 @@ def main():
return
# Fail
print("ERROR: Casper is not a friendly ghost")
print("ERROR: Casper is not a friendly ghost", file=sys.stderr)
sys.exit(1)
+6 -7
View File
@@ -32,9 +32,10 @@ In orientation check mode, report the orientation is upright.
"""
import sys
from pathlib import Path
import img2pdf
import PyPDF2 as pypdf
import pikepdf
from PIL import Image
VERSION_STRING = '''tesseract 4.0.0
@@ -99,12 +100,10 @@ def main():
pagesize = im.size[0] / dpi[0], im.size[1] / dpi[1]
ptsize = pagesize[0] * 72, pagesize[1] * 72
pdf_out = pypdf.PdfFileWriter()
pdf_out.addBlankPage(ptsize[0], ptsize[1])
with open(output + '.pdf', 'wb') as f:
pdf_out.write(f)
with open(output + '.txt', 'w') as f:
f.write('')
pdf_out = pikepdf.new()
pdf_out.add_blank_page(page_size=ptsize)
pdf_out.save(Path(output).with_suffix('.pdf'), static_id=True)
Path(output).with_suffix('.txt').write_text('')
else:
inputf = sys.argv[-4]
output = sys.argv[-3]
+10 -1
View File
@@ -15,6 +15,8 @@
# You should have received a copy of the GNU General Public License
# along with OCRmyPDF. If not, see <http://www.gnu.org/licenses/>.
import logging
import pytest
import ocrmypdf
@@ -30,4 +32,11 @@ def acroform(resources):
def test_acroform_and_redo(acroform, caplog, no_outpdf):
with pytest.raises(ocrmypdf.exceptions.InputFileError):
check_ocrmypdf(acroform, no_outpdf, '--redo-ocr')
assert '--redo-ocr is not currently possible' in caplog.text
assert '--redo-ocr is not currently possible' in caplog.text
def test_acroform_message(acroform, caplog, spoof_tesseract_noop, outpdf):
caplog.set_level(logging.INFO)
check_ocrmypdf(acroform, outpdf, env=spoof_tesseract_noop)
assert 'fillable form' in caplog.text
assert '--force-ocr' in caplog.text
+61
View File
@@ -0,0 +1,61 @@
# © 2019 James R. Barlow: github.com/jbarlow83
#
# This file is part of OCRmyPDF.
#
# OCRmyPDF is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# OCRmyPDF is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with OCRmyPDF. If not, see <http://www.gnu.org/licenses/>.
import logging
from io import StringIO
import pytest
from tqdm import tqdm
import ocrmypdf
def test_raw_console():
bio = StringIO()
tqconsole = ocrmypdf.api.TqdmConsole(file=bio)
tqconsole.write("Test")
tqconsole.flush()
assert "Test" in bio.getvalue()
def test_tqdm_console():
log = logging.getLogger()
log.setLevel(logging.INFO)
formatter = logging.Formatter('%(message)s')
bio = StringIO()
console = logging.StreamHandler(ocrmypdf.api.TqdmConsole(file=bio))
console.setFormatter(formatter)
log.addHandler(console)
def before_pbar(message):
# Ensure that log messages appear before the progress bar, even when
# printed after the progress bar updates.
v = bio.getvalue()
pbar_start_marker = '|#'
return v.index(message) < v.index(pbar_start_marker)
with tqdm(total=2, file=bio, disable=False) as pbar:
pbar.update()
msg = "1/2 above progress bar"
log.info(msg)
assert before_pbar(msg)
log.info("done")
assert not before_pbar("done")
+23 -23
View File
@@ -31,28 +31,28 @@ run_ocrmypdf_api = pytest.helpers.run_ocrmypdf_api
spoof = pytest.helpers.spoof
@pytest.fixture(scope='session')
@pytest.fixture
def spoof_no_tess_gs_render_fail(tmp_path_factory):
return spoof(
tmp_path_factory, tesseract='tesseract_noop.py', gs='gs_render_failure.py'
)
@pytest.fixture(scope='session')
@pytest.fixture
def spoof_no_tess_gs_raster_fail(tmp_path_factory):
return spoof(
tmp_path_factory, tesseract='tesseract_noop.py', gs='gs_raster_failure.py'
)
@pytest.fixture(scope='session')
@pytest.fixture
def spoof_no_tess_no_pdfa(tmp_path_factory):
return spoof(
tmp_path_factory, tesseract='tesseract_noop.py', gs='gs_pdfa_failure.py'
)
@pytest.fixture(scope='session')
@pytest.fixture
def spoof_no_tess_pdfa_warning(tmp_path_factory):
return spoof(
tmp_path_factory, tesseract='tesseract_noop.py', gs='gs_feature_elision.py'
@@ -60,18 +60,18 @@ def spoof_no_tess_pdfa_warning(tmp_path_factory):
@pytest.fixture
def linn(resources):
path = resources / 'linn.pdf'
def francais(resources):
path = resources / 'francais.pdf'
return path, pikepdf.open(path)
def test_rasterize_size(linn, outdir, caplog):
path, pdf = linn
def test_rasterize_size(francais, outdir, caplog):
path, pdf = francais
page_size_pts = (pdf.pages[0].MediaBox[2], pdf.pages[0].MediaBox[3])
assert pdf.pages[0].MediaBox[0] == pdf.pages[0].MediaBox[1] == 0
page_size = (page_size_pts[0] / Decimal(72), page_size_pts[1] / Decimal(72))
target_size = Decimal('200.0'), Decimal('150.0')
target_dpi = 42.0, 4242.0
target_size = Decimal('50.0'), Decimal('30.0')
forced_dpi = 42.0, 4242.0
log = logging.getLogger()
rasterize_pdf(
@@ -81,21 +81,21 @@ def test_rasterize_size(linn, outdir, caplog):
target_size[1] / page_size[1],
raster_device='pngmono',
log=log,
page_dpi=target_dpi,
page_dpi=forced_dpi,
)
with Image.open(outdir / 'out.png') as im:
assert im.size == target_size
assert im.info['dpi'] == target_dpi
assert im.info['dpi'] == forced_dpi
def test_rasterize_rotated(linn, outdir, caplog):
path, pdf = linn
def test_rasterize_rotated(francais, outdir, caplog):
path, pdf = francais
page_size_pts = (pdf.pages[0].MediaBox[2], pdf.pages[0].MediaBox[3])
assert pdf.pages[0].MediaBox[0] == pdf.pages[0].MediaBox[1] == 0
page_size = (page_size_pts[0] / Decimal(72), page_size_pts[1] / Decimal(72))
target_size = Decimal('200.0'), Decimal('150.0')
target_dpi = 42.0, 4242.0
target_size = Decimal('50.0'), Decimal('30.0')
forced_dpi = 42.0, 4242.0
log = logging.getLogger()
caplog.set_level(logging.DEBUG)
@@ -106,34 +106,34 @@ def test_rasterize_rotated(linn, outdir, caplog):
target_size[1] / page_size[1],
raster_device='pngmono',
log=log,
page_dpi=target_dpi,
page_dpi=forced_dpi,
rotation=90,
)
with Image.open(outdir / 'out.png') as im:
assert im.size == (target_size[1], target_size[0])
assert im.info['dpi'] == (target_dpi[1], target_dpi[0])
assert im.info['dpi'] == (forced_dpi[1], forced_dpi[0])
def test_gs_render_failure(spoof_no_tess_gs_render_fail, resources, outpdf):
p, out, err = run_ocrmypdf(
resources / 'blank.pdf', outpdf, env=spoof_no_tess_gs_render_fail
)
print(err)
assert 'Casper is not a friendly ghost' in err
assert p.returncode == ExitCode.child_process_error
def test_gs_raster_failure(spoof_no_tess_gs_raster_fail, resources, outpdf):
p, out, err = run_ocrmypdf(
resources / 'ccitt.pdf', outpdf, env=spoof_no_tess_gs_raster_fail
resources / 'francais.pdf', outpdf, env=spoof_no_tess_gs_raster_fail
)
print(err)
assert 'Ghost story archive not found' in err
assert p.returncode == ExitCode.child_process_error
def test_ghostscript_pdfa_failure(spoof_no_tess_no_pdfa, resources, outpdf):
p, out, err = run_ocrmypdf(
resources / 'ccitt.pdf', outpdf, env=spoof_no_tess_no_pdfa
resources / 'francais.pdf', outpdf, env=spoof_no_tess_no_pdfa
)
assert (
p.returncode == ExitCode.pdfa_conversion_failed
@@ -141,4 +141,4 @@ def test_ghostscript_pdfa_failure(spoof_no_tess_no_pdfa, resources, outpdf):
def test_ghostscript_feature_elision(spoof_no_tess_pdfa_warning, resources, outpdf):
check_ocrmypdf(resources / 'ccitt.pdf', outpdf, env=spoof_no_tess_pdfa_warning)
check_ocrmypdf(resources / 'francais.pdf', outpdf, env=spoof_no_tess_pdfa_warning)
+97
View File
@@ -0,0 +1,97 @@
# © 2019 James R. Barlow: github.com/jbarlow83
#
# This file is part of OCRmyPDF.
#
# OCRmyPDF is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# OCRmyPDF is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with OCRmyPDF. If not, see <http://www.gnu.org/licenses/>.
import logging
import multiprocessing
from pathlib import Path
from unittest.mock import MagicMock
import pytest
import ocrmypdf.helpers as helpers
class TestSafeSymlink:
def test_safe_symlink_link_self(self, tmp_path, caplog):
helpers.safe_symlink(tmp_path / 'self', tmp_path / 'self')
assert caplog.record_tuples[0][1] == logging.WARNING
def test_safe_symlink_overwrite(self, tmp_path):
(tmp_path / 'regular_file').touch()
with pytest.raises(FileExistsError):
helpers.safe_symlink(tmp_path / 'input', tmp_path / 'regular_file')
def test_safe_symlink_relink(self, tmp_path):
(tmp_path / 'regular_file_a').touch()
(tmp_path / 'regular_file_b').write_bytes(b'ABC')
(tmp_path / 'link').symlink_to(tmp_path / 'regular_file_a')
helpers.safe_symlink(tmp_path / 'regular_file_b', tmp_path / 'link')
assert (tmp_path / 'link').samefile(tmp_path / 'regular_file_b') or (
tmp_path / 'link'
).read_bytes() == b'ABC'
def test_no_cpu_count(monkeypatch):
def cpu_count_raises():
raise NotImplementedError()
monkeypatch.setattr(multiprocessing, 'cpu_count', cpu_count_raises)
with pytest.warns(expected_warning=UserWarning):
assert helpers.available_cpu_count() == 1
def test_deprecated():
@helpers.deprecated
def old_function():
return 42
with pytest.deprecated_call():
assert old_function() == 42
class TestFileIsWritable:
@pytest.fixture
def non_existent(self, tmp_path):
return tmp_path / 'nofile'
@pytest.fixture
def basic_file(self, tmp_path):
basic = tmp_path / 'basic'
basic.touch()
return basic
def test_plain(self, non_existent):
assert helpers.is_file_writable(non_existent)
def test_symlink_loop(self, tmp_path):
loop = tmp_path / 'loop'
loop.symlink_to(loop)
assert not helpers.is_file_writable(loop)
def test_chmod(self, basic_file):
assert helpers.is_file_writable(basic_file)
basic_file.chmod(0o400)
assert not helpers.is_file_writable(basic_file)
basic_file.chmod(0o000)
assert not helpers.is_file_writable(basic_file)
def test_permission_error(self, basic_file):
pathmock = MagicMock(spec_set=basic_file)
pathmock.is_symlink.return_value = False
pathmock.exists.return_value = True
pathmock.is_file.side_effect = PermissionError
assert not helpers.is_file_writable(pathmock)
+12 -2
View File
@@ -45,12 +45,12 @@ spoof = pytest.helpers.spoof
RENDERERS = ['hocr', 'sandwich']
@pytest.fixture(scope='session')
@pytest.fixture
def spoof_tesseract_crash(tmp_path_factory):
return spoof(tmp_path_factory, tesseract='tesseract_crash.py')
@pytest.fixture(scope='session')
@pytest.fixture
def spoof_tesseract_big_image_error(tmp_path_factory):
return spoof(tmp_path_factory, tesseract='tesseract_big_image_error.py')
@@ -270,6 +270,16 @@ def test_input_file_not_found(caplog, no_outpdf):
assert input_file in caplog.text
@pytest.mark.skipif(os.name == 'nt', reason="chmod")
def test_input_file_not_readable(caplog, resources, outdir, no_outpdf):
input_file = outdir / 'trivial.pdf'
shutil.copy(resources / 'trivial.pdf', input_file)
input_file.chmod(0o000)
result = run_ocrmypdf_api(input_file, no_outpdf)
assert result == ExitCode.input_file
assert str(input_file) in caplog.text
def test_input_file_not_a_pdf(caplog, no_outpdf):
input_file = __file__ # Try to OCR this file
result = run_ocrmypdf_api(input_file, no_outpdf)
+35
View File
@@ -0,0 +1,35 @@
# © 2020 James R. Barlow: github.com/jbarlow83
#
# This file is part of OCRmyPDF.
#
# OCRmyPDF is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# OCRmyPDF is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with OCRmyPDF. If not, see <http://www.gnu.org/licenses/>.
import pytest
import ocrmypdf.quality as qual
def test_quality_measurement():
oqd = qual.OcrQualityDictionary(
wordlist=["words", "words", "quick", "brown", "fox", "dog", "lazy"]
)
assert len(oqd.dictionary) == 6 # 6 unique
assert (
oqd.measure_words_matched("The quick brown fox jumps quickly over the lazy dog")
== 0.5
)
assert oqd.measure_words_matched("12345 10% _f 7fox -brown | words") == 1.0
assert oqd.measure_words_matched("quick quick quick") == 1.0
+7 -1
View File
@@ -33,7 +33,7 @@ run_ocrmypdf_api = pytest.helpers.run_ocrmypdf
spoof = pytest.helpers.spoof
@pytest.fixture(scope='session')
@pytest.fixture
def spoof_tess_bad_utf8(tmp_path_factory):
return spoof(tmp_path_factory, tesseract='tesseract_badutf8.py')
@@ -56,6 +56,9 @@ def test_stdin(spoof_tesseract_noop, ocrmypdf_exec, resources, outpdf):
def test_stdout(spoof_tesseract_noop, ocrmypdf_exec, resources, outpdf):
if 'COV_CORE_DATAFILE' in spoof_tesseract_noop:
pytest.skip(msg="Coverage uses stdout")
input_file = str(resources / 'francais.pdf')
output_file = str(outpdf)
@@ -121,6 +124,9 @@ def test_bad_locale():
reason="Windows does not like this; not sure how to fix",
)
def test_dev_null(spoof_tesseract_noop, resources):
if 'COV_CORE_DATAFILE' in spoof_tesseract_noop:
pytest.skip(msg="Coverage uses stdout")
p, out, err = run_ocrmypdf(
resources / 'trivial.pdf', os.devnull, '--force-ocr', env=spoof_tesseract_noop
)
+94
View File
@@ -15,7 +15,9 @@
# You should have received a copy of the GNU General Public License
# along with OCRmyPDF. If not, see <http://www.gnu.org/licenses/>.
import logging
import os
import subprocess
from contextlib import contextmanager
from os import fspath
from pathlib import Path
@@ -81,3 +83,95 @@ def test_no_languages(tmp_path):
with pytest.raises(MissingDependencyError):
tesseract.languages(tesseract_env=env)
def test_image_too_large_hocr(monkeypatch, resources, outdir):
log = logging.getLogger('test_image_too_large_hocr')
def dummy_run(args, *, env=None, **kwargs):
raise subprocess.CalledProcessError(1, 'tesseract', output=b'Image too large')
monkeypatch.setattr(tesseract, 'run', dummy_run)
tesseract.generate_hocr(
input_file=resources / 'crom.png',
output_files=[outdir / 'out.hocr', outdir / 'out.txt'],
language=['eng'],
engine_mode=None,
tessconfig=[],
timeout=180.0,
pagesegmode=None,
log=log,
user_words=None,
user_patterns=None,
tesseract_env=None,
)
assert "name='ocr-capabilities'" in Path(outdir / 'out.hocr').read_text()
def test_image_too_large_pdf(monkeypatch, resources, outdir):
log = logging.getLogger('test_image_too_large_pdf')
def dummy_run(args, *, env=None, **kwargs):
raise subprocess.CalledProcessError(1, 'tesseract', output=b'Image too large')
monkeypatch.setattr(tesseract, 'run', dummy_run)
tesseract.generate_pdf(
input_image=resources / 'crom.png',
skip_pdf=resources / 'blank.pdf',
output_pdf=outdir / 'pdf.pdf',
output_text=outdir / 'txt.txt',
language=['eng'],
engine_mode=None,
text_only=False,
tessconfig=[],
timeout=180.0,
pagesegmode=None,
log=log,
user_words=None,
user_patterns=None,
tesseract_env=None,
)
assert Path(outdir / 'txt.txt').read_text() == '[skipped page]'
if os.name != 'nt': # different semantics
assert Path(outdir / 'pdf.pdf').samefile(resources / 'blank.pdf')
def test_timeout(caplog):
log = logging.getLogger('test_timeout')
tesseract.page_timedout(log, '123456.png', 5)
assert "123456" in caplog.text
assert "took too long" in caplog.text
@pytest.mark.parametrize(
'in_, logged',
[
(b'Tesseract Open Source', ''),
(b'lots of diacritics blah blah', 'diacritics'),
(b'Warning in pixReadMem', ''),
(b'OSD: Weak margin', 'unsure about page orientation'),
(b'Error in pixScanForForeground', ''),
(b'Error in boxClipToRectangle', ''),
(b'an unexpected error', 'an unexpected error'),
(b'a dire warning', 'a dire warning'),
(b'read_params_file something', 'read_params_file'),
(b'an innocent message', 'innocent'),
(b'\x7f\x7f\x80innocent unicode failure', 'innocent'),
],
)
def test_tesseract_log_output(caplog, in_, logged):
log = logging.getLogger('tesseract_log_output')
log.setLevel(logging.INFO)
tesseract.tesseract_log_output(log, in_, 'dummy')
if logged == '':
assert caplog.text == ''
else:
assert logged in caplog.text
def test_tesseract_log_output_raises(caplog):
log = logging.getLogger('tesseract_log_output')
with pytest.raises(tesseract.TesseractConfigError):
tesseract.tesseract_log_output(log, b'parameter not found: moo', 'dummy')
assert 'not found' in caplog.text
+1 -1
View File
@@ -43,7 +43,7 @@ def have_unpaper():
return True
@pytest.fixture(scope="session")
@pytest.fixture
def spoof_unpaper_oldversion(tmp_path_factory):
return spoof(tmp_path_factory, unpaper="unpaper_oldversion.py")