Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
84d120e850 | ||
|
|
8159cc6b88 | ||
|
|
4046766ca5 | ||
|
|
810390df0b | ||
|
|
de749bc7ae | ||
|
|
9965b8800c | ||
|
|
ab870fddd6 | ||
|
|
a79d6807cf | ||
|
|
8185fb7e43 | ||
|
|
4b10929b25 | ||
|
|
2d8aad1086 | ||
|
|
74ca736333 | ||
|
|
5e4fd8b0b9 | ||
|
|
8ab8132411 | ||
|
|
6899dd46e4 | ||
|
|
8ad0697a20 | ||
|
|
b47e5672e6 | ||
|
|
45c7bd9a60 | ||
|
|
e7bcb95635 | ||
|
|
3de83627a9 | ||
|
|
f248576994 | ||
|
|
aac5b6de3b | ||
|
|
24435f11e0 | ||
|
|
a9da839c39 | ||
|
|
fa2c0296d6 | ||
|
|
1dfc32d7e6 | ||
|
|
4a61beae41 | ||
|
|
bd30587bf1 | ||
|
|
e0070e3e18 | ||
|
|
019513696b | ||
|
|
ad7a4476db | ||
|
|
4812b20fb2 | ||
|
|
f5e07c9427 | ||
|
|
75dcb90621 | ||
|
|
dfc0434cc2 | ||
|
|
882fc2257c | ||
|
|
41e83b52fc | ||
|
|
47758b4d8f | ||
|
|
6bf1f970a0 | ||
|
|
7d451f101f | ||
|
|
7edbfe0e40 | ||
|
|
91b42cbfa8 | ||
|
|
6907df41b4 | ||
|
|
2cebd90cbd | ||
|
|
376a121aaa | ||
|
|
da11fd17ee | ||
|
|
a40689a0ff | ||
|
|
44a45fc3fb | ||
|
|
ec4bb5359a | ||
|
|
d2217632df | ||
|
|
2cc044feed | ||
|
|
64fd0cb54f | ||
|
|
c5a1d22e81 | ||
|
|
a7b307af04 | ||
|
|
3269eba16c | ||
|
|
d472860e3b | ||
|
|
731c9ea55e | ||
|
|
0b6af8d965 | ||
|
|
92ca9e954c | ||
|
|
67aa00e2c5 | ||
|
|
a1bf161eb7 | ||
|
|
56614fcaa4 | ||
|
|
4d9169e15f | ||
|
|
3a167af2c4 | ||
|
|
64b9005030 | ||
|
|
965de3a235 | ||
|
|
2040ae4856 | ||
|
|
31a1f9f5ea | ||
|
|
7eadd82048 | ||
|
|
31d0eaac8e | ||
|
|
f45d012635 | ||
|
|
5a777ee9bc | ||
|
|
7bbf6bc7f4 | ||
|
|
9bfc45702d | ||
|
|
40aa82ab41 | ||
|
|
5d2c67c62b | ||
|
|
a00ef4836d | ||
|
|
3ef766bb93 | ||
|
|
44b5a18462 | ||
|
|
4df716f0e8 | ||
|
|
fcbf34a4d3 | ||
|
|
c7b8b6e18b | ||
|
|
9fd165bf4b | ||
|
|
2061092ec5 |
@@ -1,7 +1,6 @@
|
||||
# OCRmyPDF
|
||||
#
|
||||
FROM ubuntu:17.04
|
||||
MAINTAINER James R. Barlow <jim@purplerock.ca>
|
||||
FROM ubuntu:17.10
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
software-properties-common python-software-properties \
|
||||
@@ -52,4 +51,4 @@ WORKDIR /home/docker
|
||||
|
||||
# Must use array form of ENTRYPOINT
|
||||
# Non-array form does not append other arguments, because that is "intuitive"
|
||||
ENTRYPOINT ["/application/docker-wrapper.sh"]
|
||||
ENTRYPOINT ["/application/.docker/docker-wrapper.sh"]
|
||||
@@ -14,4 +14,4 @@ USER docker
|
||||
|
||||
# Must use array form of ENTRYPOINT
|
||||
# Non-array form does not append other arguments, because that is "intuitive"
|
||||
ENTRYPOINT ["/application/docker-wrapper.sh"]
|
||||
ENTRYPOINT ["/application/.docker/docker-wrapper.sh"]
|
||||
@@ -1,7 +1,6 @@
|
||||
# OCRmyPDF
|
||||
#
|
||||
FROM ubuntu:17.04
|
||||
MAINTAINER James R. Barlow <jim@purplerock.ca>
|
||||
FROM ubuntu:17.10
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
software-properties-common python-software-properties \
|
||||
@@ -62,4 +61,4 @@ WORKDIR /home/docker
|
||||
|
||||
# Must use array form of ENTRYPOINT
|
||||
# Non-array form does not append other arguments, because that is "intuitive"
|
||||
ENTRYPOINT ["/application/docker-wrapper.sh"]
|
||||
ENTRYPOINT ["/application/.docker/docker-wrapper.sh"]
|
||||
+3
-12
@@ -3,18 +3,9 @@
|
||||
*.pyc
|
||||
*.rst
|
||||
*.sublime*
|
||||
*/*.pyc
|
||||
*/*/*.pyc
|
||||
*/*/*/*.pyc
|
||||
*/*/*/*/*.pyc
|
||||
*/*/*/*/*/*.pyc
|
||||
*/*/*/*/*/*/*.pyc
|
||||
*/*/*/*/*/*/*/*.pyc
|
||||
.cache/
|
||||
.eggs/
|
||||
.github/
|
||||
.git/
|
||||
.ipynb_checkpoints/
|
||||
**/*.pyc
|
||||
.*/
|
||||
!.docker/
|
||||
.ruffus_history.sqlite
|
||||
bin/
|
||||
build/
|
||||
|
||||
+5
-2
@@ -34,7 +34,9 @@ before_install: |
|
||||
if [[ "$TRAVIS_OS_NAME" == "linux" ]]; then
|
||||
bash .travis/linux_before_install.sh
|
||||
elif [[ "$TRAVIS_OS_NAME" == "osx" ]]; then
|
||||
bash .travis/osx_before_install.sh
|
||||
brew update && brew bundle --file=.travis/Brewfile
|
||||
pip3 install --upgrade pip
|
||||
pip3 install wheel
|
||||
fi
|
||||
|
||||
install:
|
||||
@@ -44,7 +46,8 @@ install:
|
||||
script:
|
||||
- mv ocrmypdf dont_import_this_ocrmypdf
|
||||
- tesseract --version
|
||||
- pytest -n auto
|
||||
- qpdf --version
|
||||
- pytest
|
||||
- mv dont_import_this_ocrmypdf ocrmypdf
|
||||
|
||||
deploy:
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
brew 'python'
|
||||
|
||||
brew 'ghostscript'
|
||||
brew 'jbig2dec'
|
||||
brew 'leptonica'
|
||||
brew 'libffi'
|
||||
brew 'libtiff'
|
||||
brew 'libxml2'
|
||||
brew 'openjpeg'
|
||||
brew 'qpdf'
|
||||
brew 'tesseract'
|
||||
brew 'unpaper'
|
||||
+35
-23
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
# © 2016 James R. Barlow: github.com/jbarlow83
|
||||
# © 2017-18 James R. Barlow: github.com/jbarlow83
|
||||
|
||||
from string import Template
|
||||
from subprocess import run, PIPE
|
||||
@@ -14,38 +14,50 @@ class Ocrmypdf < Formula
|
||||
${ocrmypdf_url}
|
||||
${ocrmypdf_sha256}
|
||||
|
||||
depends_on :python3
|
||||
depends_on :x11 # Pillow needs XQuartz
|
||||
depends_on "pkg-config" => :build
|
||||
depends_on "libffi"
|
||||
depends_on "tesseract"
|
||||
depends_on "ghostscript"
|
||||
depends_on "unpaper"
|
||||
depends_on "qpdf"
|
||||
|
||||
# For Pillow source install
|
||||
depends_on "openjpeg"
|
||||
depends_on "freetype"
|
||||
depends_on "ghostscript"
|
||||
depends_on "jpeg"
|
||||
depends_on "libpng"
|
||||
depends_on "libjpeg"
|
||||
depends_on "webp"
|
||||
depends_on "little-cms2"
|
||||
depends_on "python"
|
||||
depends_on "qpdf"
|
||||
depends_on "tesseract"
|
||||
depends_on "unpaper"
|
||||
|
||||
${resources}
|
||||
def install
|
||||
ENV.append ["SETUPTOOLS_SCM_PRETEND_VERSION"], "v${ocrmypdf_version}"
|
||||
ENV.each do |key, value|
|
||||
puts "#{key}:#{value}"
|
||||
venv = virtualenv_create(libexec, "python3")
|
||||
|
||||
resource("Pillow").stage do
|
||||
inreplace "setup.py" do |s|
|
||||
sdkprefix = MacOS::CLT.installed? ? "" : MacOS.sdk_path
|
||||
s.gsub! "openjpeg.h", "probably_not_a_header_called_this_eh.h"
|
||||
s.gsub! "ZLIB_ROOT = None", "ZLIB_ROOT = ('#{sdkprefix}/usr/lib', '#{sdkprefix}/usr/include')"
|
||||
s.gsub! "JPEG_ROOT = None", "JPEG_ROOT = ('#{Formula["jpeg"].opt_prefix}/lib', '#{Formula["jpeg"].opt_prefix}/include')"
|
||||
s.gsub! "FREETYPE_ROOT = None", "FREETYPE_ROOT = ('#{Formula["freetype"].opt_prefix}/lib', '#{Formula["freetype"].opt_prefix}/include')"
|
||||
end
|
||||
|
||||
# avoid triggering "helpful" distutils code that doesn't recognize Xcode 7 .tbd stubs
|
||||
ENV.append "CFLAGS", "-I#{MacOS.sdk_path}/System/Library/Frameworks/Tk.framework/Versions/8.5/Headers" unless MacOS::CLT.installed?
|
||||
venv.pip_install Pathname.pwd
|
||||
end
|
||||
virtualenv_install_with_resources
|
||||
|
||||
res = resources.map(&:name).to_set - ["Pillow"]
|
||||
|
||||
res.each do |r|
|
||||
venv.pip_install resource(r)
|
||||
end
|
||||
|
||||
venv.pip_install_and_link buildpath
|
||||
end
|
||||
|
||||
test do
|
||||
# `test do` will create, run in and delete a temporary directory.
|
||||
#
|
||||
# The installed folder is not in the path, so use the entire path to any
|
||||
# executables being tested: `system "#{bin}/program", "do", "something"`.
|
||||
system "#{bin}/ocrmypdf", "--version"
|
||||
# Since we use Python 3, we require a UTF-8 locale
|
||||
ENV["LC_ALL"] = "en_US.UTF-8"
|
||||
|
||||
system "#{bin}/ocrmypdf", "-f", "-q", "--deskew",
|
||||
test_fixtures("test.pdf"), "ocr.pdf"
|
||||
assert_predicate testpath/"ocr.pdf", :exist?
|
||||
end
|
||||
end
|
||||
""")
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
#!/bin/bash
|
||||
# © 2017 James R. Barlow: github.com/jbarlow83
|
||||
set -euo pipefail
|
||||
set -x
|
||||
|
||||
brew update
|
||||
|
||||
brew install openjpeg jbig2dec libtiff # image libraries
|
||||
brew install qpdf
|
||||
brew install ghostscript
|
||||
brew install python3
|
||||
brew install libxml2 libffi leptonica
|
||||
brew install unpaper # optional
|
||||
brew install tesseract
|
||||
|
||||
pip3 install --upgrade pip
|
||||
pip3 install wheel
|
||||
+4
-1
@@ -1,4 +1,7 @@
|
||||
Copyright (c) 2013-2016, The OCRmyPDF Authors
|
||||
Copyright (c) 2014-2017, James R. Barlow
|
||||
|
||||
Copyright (c) 2013-2014, Julien Pfefferkorn
|
||||
Copyright (c) 2013-2017, The OCRmyPDF Authors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a
|
||||
copy of this software and associated documentation files (the
|
||||
|
||||
+8
-2
@@ -74,11 +74,10 @@ Users of Debian 9 or later or Ubuntu 16.10 or later may simply
|
||||
|
||||
apt-get install ocrmypdf
|
||||
|
||||
and macOS users may simply
|
||||
and macOS users with Homebrew may simply
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
brew tap jbarlow83/ocrmypdf
|
||||
brew install ocrmypdf
|
||||
|
||||
For everyone else, `see our documentation <https://ocrmypdf.readthedocs.io/en/latest/installation.html>`_ for installation steps.
|
||||
@@ -122,6 +121,13 @@ If you detect an issue, please:
|
||||
- If possible provide your input PDF file as well as the content of the
|
||||
temporary folder (using a file sharing service like Dropbox)
|
||||
|
||||
Requirements
|
||||
------------
|
||||
|
||||
Runs on CPython 3.6, and requires external program installations of Ghostscript, Tesseract OCR, QPDF, and Leptonica. ocrmypdf is pure Python, but uses CFFI to portably generate library bindings.
|
||||
|
||||
Python 3.5 is fully supported, but support will end at the end of 2017, to allow f-strings in the code base.
|
||||
|
||||
Press & Media
|
||||
-------------
|
||||
|
||||
|
||||
+1
-1
@@ -147,7 +147,7 @@ This is only possible for x86-based Synology products. Some Synology products us
|
||||
# use this uid:gid in -u flag
|
||||
# rw rights for docker:docker at source dir are also necessary
|
||||
# the script is processed as root user via chron
|
||||
cmd = ['docker', 'run', '--rm', '-v', docker_mount, '-u="1030:65538"', 'jbarlow83/ocrmypdf', , '--deskew' , filename, filename_OCR]
|
||||
cmd = ['docker', 'run', '--rm', '-v', docker_mount, '-u=1030:65538', 'jbarlow83/ocrmypdf', , '--deskew' , filename, filename_OCR]
|
||||
logging.info(cmd)
|
||||
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
|
||||
result = proc.stdout.read()
|
||||
|
||||
@@ -157,6 +157,29 @@ If you set ``--tesseract-timeout 0`` OCRmyPDF will apply its image processing wi
|
||||
ocrmypdf --tesseract-timeout=0 --remove-background input.pdf output.pdf
|
||||
|
||||
|
||||
Redo OCR
|
||||
""""""""
|
||||
|
||||
To redo OCR on a file OCRed with other OCR software or a previous version of OCRmyPDF and/or Tesseract, you may use the ``--force-ocr`` argument. Normally, OCRmyPDF does not modify files that already appear to contain OCR text.
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
ocrmypdf --force-ocr input.pdf output.pdf
|
||||
|
||||
Note that the method above will force rasterization of all pages, potentially reducing quality or losing vector content.
|
||||
|
||||
To ensure quality is preserved, one could extract all of the images and rebuild the PDF for a lossless transformation. This recipe does not work when PDFs contain multiple images per page, as many do in practice. It will also lose any page rotation information.
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
pdfimages -all old-ocr.pdf prefix # extract all images
|
||||
img2pdf -o temp.pdf prefix* # construct new PDF from the images
|
||||
# review the new PDF to ensure it visually matches the old one
|
||||
ocrmypdf --output-type pdf temp.pdf new-ocr.pdf
|
||||
|
||||
``--output-type pdf`` is used here to avoid using Ghostscript which will also rasterize images.
|
||||
|
||||
|
||||
Improving OCR quality
|
||||
---------------------
|
||||
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ OCRmyPDF documentation
|
||||
OCRmyPDF adds an OCR text layer to scanned PDF files, allowing them to
|
||||
be searched.
|
||||
|
||||
PDFs are the best format for scanned documents. Unfortunately, PDFs can be difficult to work with. OCRmyPDF makes it easy to apply image processing and OCR to existing PDFs.
|
||||
PDF is the best format for storing and exchanging scanned documents. Unfortunately, PDFs can be difficult to modify. OCRmyPDF makes it easy to apply image processing and OCR to existing PDFs.
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 1
|
||||
|
||||
+32
-8
@@ -15,18 +15,20 @@ Users of Debian 9 ("stretch") or later or Ubuntu 16.10 or later may simply
|
||||
Installing on macOS
|
||||
-------------------
|
||||
|
||||
A `Homebrew <https://brew.sh>`_ tap is available for macOS:
|
||||
OCRmyPDF is now a standard `Homebrew <https://brew.sh>`_ formula. To install on macOS:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
brew tap jbarlow83/ocrmypdf
|
||||
brew install ocrmypdf
|
||||
|
||||
|
||||
.. warning::
|
||||
.. note::
|
||||
|
||||
Users who previously installed OCRmyPDF on macOS using ``pip install ocrmypdf`` should remove the pip version (``pip3 uninstall ocrmypdf``) before switching to the Homebrew version.
|
||||
|
||||
.. note::
|
||||
|
||||
Users who previously installed OCRmyPDF from the private tap should switch to the mainline version (``brew untap jbarlow83/ocrmypdf``) and install from there.
|
||||
|
||||
.. _Docker-install:
|
||||
|
||||
Installing the Docker image
|
||||
@@ -105,6 +107,12 @@ In this worked example, the current working directory contains an input file cal
|
||||
|
||||
Note that ``ocrmypdf`` has its own separate ``-v VERBOSITYLEVEL`` argument to control debug verbosity. All Docker arguments should before the ``ocrmypdf`` image name and all arguments to ``ocrmypdf`` should be listed after.
|
||||
|
||||
In some environments the permissions associated with Docker can be complex to configure. The process that executes Docker may end up not having the permissions to write the specified file system. In that case one can stream the file into and out of the Docker process and avoid all permission hassles, using ``-`` as the input and output filename:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
docker run --rm -i ocrmypdf <other arguments to ocrmypdf> - - <input.pdf >output.pdf
|
||||
|
||||
For convenience, a shell alias can hide the docker command:
|
||||
|
||||
.. code-block:: bash
|
||||
@@ -119,6 +127,9 @@ Or in the wonderful `fish shell <https://fishshell.com/>`_:
|
||||
alias ocrmypdf 'docker run --rm -v (pwd):/home/docker ocrmypdf'
|
||||
funcsave ocrmypdf
|
||||
|
||||
.. note::
|
||||
|
||||
The ocrmypdf Docker containers are designed to be used for a single OCR job. The ``docker run --rm`` argument tells Docker to delete temporary storage associated with container when it is done executing.
|
||||
|
||||
Manual installation on macOS
|
||||
----------------------------
|
||||
@@ -165,7 +176,13 @@ Update the homebrew pip and install Pillow:
|
||||
pip3 install --upgrade pip
|
||||
pip3 install --upgrade pillow
|
||||
|
||||
You can then install OCRmyPDF from PyPI:
|
||||
You can then install OCRmyPDF from PyPI, for the current user:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
pip3 install --user ocrmypdf
|
||||
|
||||
or system-wide:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
@@ -194,8 +211,13 @@ No package is currently available for Ubuntu 16.04, but you can install the depe
|
||||
python3-pip \
|
||||
python3-cffi
|
||||
|
||||
If you wish install OCRmyPDF to the system Python, then install as follows (note this installs new packages
|
||||
into your system Python, which could interfere with other programs):
|
||||
If you wish install OCRmyPDF for the current user:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
pip3 install --user ocrmypdf
|
||||
|
||||
Alternately, system-wide. Note that this may modify the system Python environment:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
@@ -215,7 +237,7 @@ Installing on Ubuntu 14.04 LTS
|
||||
------------------------------
|
||||
|
||||
Installing on Ubuntu 14.04 LTS (trusty) is more difficult than some other options,
|
||||
because it is older.
|
||||
because it is older and does not provide ``pip``.
|
||||
|
||||
Update apt-get:
|
||||
|
||||
@@ -265,6 +287,8 @@ Now we need to install ``pip`` and let it install ocrmypdf:
|
||||
wget -O - -o /dev/null https://bootstrap.pypa.io/get-pip.py | python3.6
|
||||
pip3.6 install ocrmypdf
|
||||
|
||||
The ``wget`` command will download a program and run it.
|
||||
|
||||
These installation instructions omit the optional dependency ``unpaper``, which is only available at version 0.4.2 in Ubuntu 14.04. The author could not find a backport of ``unpaper``, and created a .deb package to do the job of installing unpaper 6.1 (for x86 64-bit only):
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
@@ -16,7 +16,7 @@ OCRmyPDF uses `Tesseract <https://github.com/tesseract-ocr/tesseract>`_, the bes
|
||||
About PDFs
|
||||
----------
|
||||
|
||||
PDFs are page description files that attempts to preserve a layout exactly. They can contain `vector graphic files <http://vector-conversions.com/vectorizing/raster_vs_vector.html>`_ that can contain raster objects such as scanned images. Because PDFs can contain multiple pages (unlike many image formats) and can contain fonts and text, it is a good formats for exchanging scanned documents.
|
||||
PDFs are page description files that attempts to preserve a layout exactly. They contain `vector graphics <http://vector-conversions.com/vectorizing/raster_vs_vector.html>`_ that can contain raster objects such as scanned images. Because PDFs can contain multiple pages (unlike many image formats) and can contain fonts and text, it is a good formats for exchanging scanned documents.
|
||||
|
||||
.. image:: bitmap_vs_svg.svg
|
||||
|
||||
@@ -42,15 +42,13 @@ PDF/A has a few drawbacks. Some PDF viewers include an alert that the file is a
|
||||
What OCRmyPDF does
|
||||
------------------
|
||||
|
||||
OCRmyPDF analyzes each page of a PDF to determine the colorspace and resolution (DPI) needed to capture all of the information on that page without losing content. It uses `Ghostscript <http://ghostscript.com/>`_ to rasterize the page, and then performs on OCR on the rasterized image. It is not enough to simply extract the images from each page and run OCR on them individually. Of course one could use Ghostscript or another PDF rasterizer and then pass the image to Tesseract. OCRmyPDF automates this process and produces a minimally changed output file that contains the same information, colorspace and resolution.
|
||||
OCRmyPDF analyzes each page of a PDF to determine the colorspace and resolution (DPI) needed to capture all of the information on that page without losing content. It uses `Ghostscript <http://ghostscript.com/>`_ to rasterize the page, and then performs on OCR on the rasterized image to create an OCR "layer". The layer is then grafted back onto the original PDF.
|
||||
|
||||
The Tesseract OCR engine can output 'hOCR' files, which are XML files that contain a description of the text it found on the page. OCRmyPDF will render a new PDF that contains only the hidden text layer, and merge this with the original page.
|
||||
While one can use a program like Ghostscript or ImageMagick to get an image and put the image through Tesseract, that actually creates a new PDF and many details may be lost. OCRmyPDF can produce a minimally changed PDF as output.
|
||||
|
||||
Alternately, OCRmyPDF can use the Tesseract OCR engine to directly output PDFs for each page, then merge them.
|
||||
OCRmyPDF also some image processing options like deskew which improve the appearance of files and quality of OCR. When these are used, the OCR layer is grafted onto the processed image instead.
|
||||
|
||||
By default, OCRmyPDF will convert the file to a PDF/A. This behavior can be disabled with the ``--output-type pdf`` argument.
|
||||
|
||||
Depending on the settings selected, OCRmyPDF may "graft" the OCR layer into the existing PDF, or reconstruct a visually equivalent new PDF.
|
||||
By default, OCRmyPDF produces archival PDFs – PDF/A, which are a stricter subset of PDF features designed for long term archives. If regular PDFs are desired, this can be disabled with ``--output-type pdf``.
|
||||
|
||||
|
||||
Why you shouldn't do this manually
|
||||
|
||||
@@ -5,6 +5,60 @@ OCRmyPDF uses `semantic versioning <http://semver.org/>`_ for its command line i
|
||||
|
||||
The OCRmyPDF package itself does not contain a public API, although it is fairly stable and breaking changes are usually timed with a major release. A future release will clearly define the stable public API.
|
||||
|
||||
v5.6.1
|
||||
------
|
||||
|
||||
- Fix issue #219: change how the final output file is created to avoid triggering permission errors when the output is a special file such as ``/dev/null``
|
||||
- Fix test suite failures due to a qpdf 8.0.0 regression and Python 3.5's handling of symlink
|
||||
- The "encrypted PDF" error message was different depending on the type of PDF encryption. Now a single clear message appears for all types of PDF encryption.
|
||||
- ocrmypdf is now in Homebrew. Homebrew users are advised to the version of ocrmypdf in the official homebrew-core formulas rather than the private tap.
|
||||
- Some linting
|
||||
|
||||
|
||||
v5.6.0
|
||||
------
|
||||
|
||||
- Fix issue #216: preserve "text as curves" PDFs without rasterizing file
|
||||
- Related to the above, messages about rasterizing are more consistent
|
||||
- For consistency versions minor releases will now get the trailing .0 they always should have had.
|
||||
|
||||
|
||||
v5.5
|
||||
----
|
||||
|
||||
- Add new argument ``--max-image-mpixels``. Pillow 5.0 now raises an exception when images may be decompression bombs. This argument can be used to override the limit Pillow sets.
|
||||
- Fix output page cropped when using the sandwich renderer and OCR is skipped on a rotated and image-processed page
|
||||
- A warning is now issued when old versions of Ghostscript are used in cases known to cause issues with non-Latin characters
|
||||
- Fix a few parameter validation checks for ``-output-type pdfa-1`` and ``pdfa-2``
|
||||
|
||||
|
||||
v5.4.4
|
||||
------
|
||||
|
||||
- Fix issue #181: fix final merge failure for PDFs with more pages than the system file handle limit (``ulimit -n``)
|
||||
- Fix issue #200: an uncommon syntax for formatting decimal numbers in a PDF would cause qpdf to issue a warning, which ocrmypdf treated as an error. Now this the warning is relayed.
|
||||
- Fix an issue where intermediate PDFs would be created at version 1.3 instead of the version of the original file. It's possible but unlikely this had side effects.
|
||||
- A warning is now issued when older versions of qpdf are used since issues like #200 cause qpdf to infinite-loop
|
||||
- Address issue #140: if Tesseract outputs invalid UTF-8, escape it and print its message instead of aborting with a Unicode error
|
||||
- Adding previously unlisted setup requirement, pytest-runner
|
||||
- Update documentation: fix an error in the example script for Synology with Docker images, improved security guidance, advised ``pip install --user``
|
||||
|
||||
|
||||
v5.4.3
|
||||
------
|
||||
|
||||
- If a subprocess fails to report its version when queried, exit cleanly with an error instead of throwing an exception
|
||||
- Added test to confirm that the system locale is Unicode-aware and fail early if it's not
|
||||
- Clarified some copyright information
|
||||
- Updated pinned requirements.txt so the homebrew formula captures more recent versions
|
||||
|
||||
|
||||
v5.4.2
|
||||
------
|
||||
|
||||
- Fixed a regression from v5.4.1 that caused sidecar files to be created as empty files
|
||||
|
||||
|
||||
v5.4.1
|
||||
------
|
||||
|
||||
|
||||
+43
-8
@@ -10,7 +10,7 @@ The disclaimer applies: this software has no warranties of any kind.
|
||||
PDFs may contain malware
|
||||
------------------------
|
||||
|
||||
PDF is a rich, complex file format. The official PDF 1.7 specification, ISO 32000:2008, is hundreds of packages long and references several annexes each of which are similar in length. PDFs can contain video, audio, JavaScript and other programming, and forms. In some cases, they can open internet connections to pre-selected URLs. All of these possible attack vectors.
|
||||
PDF is a rich, complex file format. The official PDF 1.7 specification, ISO 32000:2008, is hundreds of pages long and references several annexes each of which are similar in length. PDFs can contain video, audio, XML, JavaScript and other programming, and forms. In some cases, they can open internet connections to pre-selected URLs. All of these possible attack vectors.
|
||||
|
||||
In short, PDFs `may contain viruses <https://security.stackexchange.com/questions/64052/can-a-pdf-file-contain-a-virus>`_.
|
||||
|
||||
@@ -21,23 +21,58 @@ How OCRmyPDF processes PDFs
|
||||
|
||||
OCRmyPDF must open and interpret your PDF in order to insert an OCR layer. First, it runs all PDFs through `qpdf <https://github.com/qpdf/qpdf>`_, a program that repairs PDFs with syntax errors. This is done because, in the author's experience, a significant number of PDFs in the wild especially those created by scanners are not well-formed files. qpdf makes it more likely that OCRmyPDF will succeed, but offers no security guarantees. qpdf is also used to split the PDF into single page PDFs.
|
||||
|
||||
After qpdf, OCRmyPDF examines each page using `PyPDF2 <https://github.com/mstamy2/PyPDF2>`_. This library also has no warranties or guarantees.
|
||||
After qpdf, OCRmyPDF examines each page using `PyPDF2 <https://github.com/mstamy2/PyPDF2>`_. This library also has no warranties or guarantees. OCRmyPDF works with qpdf 5.0 and up, but version 7.0 is recommended because of known security vulnerabilities in early versions.
|
||||
|
||||
Finally, OCRmyPDF rasterizes each page of the PDF using `Ghostscript <http://ghostscript.com/>`_ in ``-dSAFER`` mode.
|
||||
Finally, OCRmyPDF rasterizes each page of the PDF using `Ghostscript <http://ghostscript.com/>`_ in ``-dSAFER`` mode.
|
||||
|
||||
Depending on the options specified, OCRmyPDF may graft the OCR layer into the existing PDF or it may essentially reconstruct ("re-fry") a visually identical PDF that may be quite different at the binary level. That said, OCRmyPDF is not a tool designed for sanitizing PDFs.
|
||||
|
||||
Using OCRmyPDF online
|
||||
---------------------
|
||||
Using OCRmyPDF online or as a service
|
||||
-------------------------------------
|
||||
|
||||
OCRmyPDF is not designed to be deployed "as a service", in a setting where a user/attacker could upload a file for OCR processing online. It is not designed to be secure in this case.
|
||||
OCRmyPDF should not be deployed as a public-facing service, such as a website where a potential attacker could upload a PDF of their choice for OCR. OCRmyPDF is not designed to be secure against PDF malware. Another concern is PDFs specifically designed to be a denial of service attack: PDFs can contain recursive data structures that sometimes send parsers into infinite loops, and issue complex graphics drawing commands.
|
||||
|
||||
Setting aside these concerns, a side effect of OCRmyPDF is it may incidentally sanitize PDFs that contain malware. It runs ``qpdf`` to repair the PDF, which could correct malformed PDF structures that are part of an attack. When PDF/A output is selected (the default), the input PDF is partially reconstructed by Ghostscript. When ``--force-ocr`` is used, all pages are rasterized and reconverted to PDF, which could remove malware in embedded images. No guarantees.
|
||||
|
||||
OCRmyPDF should be relatively safe to use in a trusted intranet, with some considerations:
|
||||
|
||||
Limiting CPU usage
|
||||
^^^^^^^^^^^^^^^^^^
|
||||
|
||||
OCRmyPDF will attempt to use all available CPUs and storage, so executing ``nice ocrmypdf`` or limiting the number of jobs with the ``-j`` argument may ensure the server remains available. Another option would be run OCRmyPDF jobs inside a Docker container, a virtual machine, or a cloud instance, which can impose its own limits on CPU usage and be terminated "from orbit" if it fails to complete.
|
||||
|
||||
Temporary storage requirements
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
OCRmyPDF will use a large amount of temporary storage for its work, proportional to the total number of pixels needed to rasterize the PDF. The raster image of a 8.5×11" color page at 300 DPI takes 25 MB uncompressed; OCRmyPDF saves its intermediates as PNG, but that still means it requires about 9 MB per intermediate based on average compression ratios. Multiple intermediates per page are also required, depending on the command line given. A rule of thumb would be to allow 100 MB of temporary storage per page in a file – meaning that a small cloud servers or small VM partitions should be provisioned with plenty of extra space, if say, a 500 page file might be sent.
|
||||
|
||||
To check temporary storage usage on actual files, run ``ocrmypdf -k ...`` which will preserve and print the path to temporary storage when the job is done.
|
||||
|
||||
To change where temporary files are stored, change the ``TMPDIR`` environment variable for ocrmypdf's environment. (Python's ``tempfile.gettempdir()`` returns the root directory in which temporary files will be stored.) For example, one could redirect ``TMPDIR`` to a large RAM disk to avoid wear on HDD/SSD and potentially improve performance. On Amazon Web Services, ``TMPDIR`` can be set to `empheral storage <https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/InstanceStorage.html>`_.
|
||||
|
||||
Timeouts
|
||||
^^^^^^^^
|
||||
|
||||
To prevent excessively long OCR jobs consider setting ``--tesseract-timeout`` and/or ``--skip-big`` arguments. ``--skip-big`` is particularly helpful if your PDFs include documents such as reports on standard page sizes with large images attached - often large images are not worth OCR'ing anyway.
|
||||
|
||||
Commercial alternatives
|
||||
^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
The author also provides professional services that include OCR and building databases around PDFs, and is happy to provide consultation.
|
||||
|
||||
Abbyy Cloud OCR is a viable commercial alternative with a web services API.
|
||||
|
||||
Abbyy Cloud OCR is a viable commercial alternative with a web services API. The author also provides professional services that include OCR and building databases around PDFs, and is happy to provide consultation.
|
||||
|
||||
Password protection, digital signatures and certification
|
||||
---------------------------------------------------------
|
||||
|
||||
OCRmyPDF cannot remove password protection from a PDF. ``qpdf``, one of its dependencies, has this capability. After OCR is applied, password protection is not permitted on PDF/A documents but the file can be converted to regular PDF.
|
||||
Password protected PDFs usually have two passwords, and owner and user password. When the user password is set to empty, PDF readers will open the file automatically and marked it as "(SECURED)". While not as reliable as a digital signature, this indicates that whoever set the password approved of the file at that time. When the user password is set, the document cannot be viewed without the password.
|
||||
|
||||
Either way, OCRmyPDF does not remove passwords from PDFs and exits with an error on encountering them.
|
||||
|
||||
``qpdf``, one of OCRmyPDF's dependencies, can remove passwords. If the owner and user password are set, a password is required for ``qpdf``. If only the owner password is set, then the password can be stripped, even if one does not have the owner password.
|
||||
|
||||
After OCR is applied, password protection is not permitted on PDF/A documents but the file can be converted to regular PDF.
|
||||
|
||||
Many programs exist which are capable of inserting an image of someone's signature. On its own, this offers no security guarantees. It is trivial to remove the signature image and apply it to other files. This practice offers no real security.
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import pkg_resources
|
||||
|
||||
PROGRAM_NAME = 'ocrmypdf'
|
||||
|
||||
VERSION = pkg_resources.get_distribution('ocrmypdf').version
|
||||
|
||||
# Official PEP 396
|
||||
__version__ = pkg_resources.get_distribution('ocrmypdf').version
|
||||
|
||||
VERSION = __version__
|
||||
|
||||
+77
-29
@@ -14,6 +14,7 @@ import logging
|
||||
import argparse
|
||||
|
||||
import PyPDF2 as pypdf
|
||||
import PIL
|
||||
|
||||
import ruffus.ruffus_exceptions as ruffus_exceptions
|
||||
import ruffus.cmdline as cmdline
|
||||
@@ -23,11 +24,12 @@ from .pipeline import JobContext, JobContextManager, \
|
||||
cleanup_working_files, build_pipeline
|
||||
from .pdfa import file_claims_pdfa
|
||||
from .helpers import is_iterable_notstr, re_symlink, is_file_writable
|
||||
from .exec import tesseract, qpdf
|
||||
from .exec import tesseract, qpdf, ghostscript
|
||||
from . import PROGRAM_NAME, VERSION
|
||||
|
||||
from .exceptions import *
|
||||
from .exceptions import ExitCode, ExitCodeException, MissingDependencyError
|
||||
from . import exceptions as ocrmypdf_exceptions
|
||||
from ._unicodefun import verify_python3_env
|
||||
|
||||
warnings.simplefilter('ignore', pypdf.utils.PdfReadWarning)
|
||||
|
||||
@@ -49,6 +51,11 @@ def complain(message):
|
||||
if 'IDE_PROJECT_ROOTS' in os.environ:
|
||||
os.environ['PATH'] = '/usr/local/bin:' + os.environ['PATH']
|
||||
|
||||
# --------
|
||||
# Critical environment tests
|
||||
|
||||
verify_python3_env()
|
||||
|
||||
if tesseract.version() < MINIMUM_TESS_VERSION:
|
||||
complain(
|
||||
"Please install tesseract {0} or newer "
|
||||
@@ -235,6 +242,11 @@ ocrsettings.add_argument(
|
||||
advanced = parser.add_argument_group(
|
||||
"Advanced",
|
||||
"Advanced options to control Tesseract's OCR behavior")
|
||||
advanced.add_argument(
|
||||
'--max-image-mpixels', action='store', type=float, metavar='MPixels',
|
||||
help="Set maximum number of pixels to unpack before treating an image as a "
|
||||
"decompression bomb",
|
||||
default=128.0)
|
||||
advanced.add_argument(
|
||||
'--tesseract-config', action='append', metavar='CFG', default=[],
|
||||
help="Additional Tesseract configuration files -- see documentation")
|
||||
@@ -315,11 +327,12 @@ def check_options_languages(options, _log):
|
||||
if '+' in options.language[0]:
|
||||
options.language = options.language[0].split('+')
|
||||
|
||||
if not set(options.language).issubset(tesseract.languages()):
|
||||
languages = set(options.language)
|
||||
if not languages.issubset(tesseract.languages()):
|
||||
msg = (
|
||||
"The installed version of tesseract does not have language "
|
||||
"data for the following requested languages: \n")
|
||||
for lang in (set(options.language) - tesseract.languages()):
|
||||
for lang in (languages - tesseract.languages()):
|
||||
msg += lang + '\n'
|
||||
raise MissingDependencyError(msg)
|
||||
|
||||
@@ -342,7 +355,8 @@ def check_options_output(options, log):
|
||||
options.pdf_renderer = 'sandwich'
|
||||
|
||||
if options.pdf_renderer == 'tesseract':
|
||||
if tesseract.version() < '3.05' and options.output_type == 'pdfa':
|
||||
if tesseract.version() < '3.05' and \
|
||||
options.output_type.startswith('pdfa'):
|
||||
log.warning(
|
||||
"For best results use --pdf-renderer=tesseract "
|
||||
"--output-type=pdf to disable PDF/A generation via "
|
||||
@@ -406,9 +420,9 @@ def check_options_ocr_behavior(options, log):
|
||||
# if options.redo_ocr and (options.skip_text or options.force_ocr):
|
||||
# raise argparse.ArgumentError(
|
||||
# "Error: --redo-ocr and other OCR options are incompatible.")
|
||||
|
||||
languages = set(options.language)
|
||||
if options.pdf_renderer == 'hocr' and \
|
||||
not set(options.language).issubset(HOCR_OK_LANGS):
|
||||
not languages.issubset(HOCR_OK_LANGS):
|
||||
msg = (
|
||||
"The 'hocr' PDF renderer is known to cause problems with one "
|
||||
"or more of the languages in your document. ")
|
||||
@@ -421,6 +435,15 @@ def check_options_ocr_behavior(options, log):
|
||||
"Use --pdf-renderer tesseract --output-type pdf to avoid "
|
||||
"this issue")
|
||||
log.warning(msg)
|
||||
elif ghostscript.version() < '9.20' and \
|
||||
not languages.issubset(HOCR_OK_LANGS) \
|
||||
and options.output_type != 'pdf':
|
||||
msg = (
|
||||
"The installed version of Ghostscript does not work correctly "
|
||||
"with the OCR languages you specified. Use --output-type pdf or "
|
||||
"upgrade to Ghostscript 9.20 or later to avoid this issue.")
|
||||
msg += "Found Ghostscript {}".format(ghostscript.version())
|
||||
log.warning(msg)
|
||||
|
||||
|
||||
def check_options_advanced(options, log):
|
||||
@@ -435,10 +458,10 @@ def check_options_advanced(options, log):
|
||||
"--pdf-renderer tess4 requires Tesseract 4.x "
|
||||
"commit 3d9fb3b or later")
|
||||
if options.pdfa_image_compression != 'auto' and \
|
||||
options.output_type != 'pdfa':
|
||||
options.output_type.startswith('pdfa'):
|
||||
log.warning(
|
||||
"--pdfa-image-compression argument has no effect when "
|
||||
"--output-type is not 'pdfa'"
|
||||
"--output-type is not 'pdfa', 'pdfa-1', or 'pdfa-2'"
|
||||
)
|
||||
|
||||
|
||||
@@ -531,6 +554,7 @@ def do_ruffus_exception(ruffus_five_tuple, options, log):
|
||||
exit_code = None
|
||||
|
||||
task_name, job_name, exc_name, exc_value, exc_stack = ruffus_five_tuple
|
||||
job_name = job_name # unused
|
||||
if exc_name == 'builtins.SystemExit':
|
||||
match = re.search(r"\.(.+?)\)", exc_value)
|
||||
exit_code_name = match.groups()[0]
|
||||
@@ -553,6 +577,21 @@ def do_ruffus_exception(ruffus_five_tuple, options, log):
|
||||
msg = "Error occurred while running this command:"
|
||||
log.error(msg + '\n' + exc_value)
|
||||
exit_code = ExitCode.child_process_error
|
||||
elif (exc_name == 'PyPDF2.utils.PdfReadError' and \
|
||||
'not been decrypted' in exc_value) or \
|
||||
(exc_name == 'ocrmypdf.exceptions.EncryptedPdfError'):
|
||||
log.error(textwrap.dedent("""\
|
||||
Input PDF is encrypted. The encryption must be removed to
|
||||
perform OCR.
|
||||
|
||||
For information about this PDF's security use
|
||||
qpdf --show-encryption infilename
|
||||
|
||||
You can remove the encryption using
|
||||
qpdf --decrypt [--password=[password]] infilename
|
||||
|
||||
"""))
|
||||
exit_code = ExitCode.encrypted_pdf
|
||||
elif exc_name == 'ocrmypdf.exceptions.PdfMergeFailedError':
|
||||
log.error(textwrap.dedent("""\
|
||||
Failed to merge PDF image layer with OCR layer
|
||||
@@ -568,19 +607,12 @@ def do_ruffus_exception(ruffus_five_tuple, options, log):
|
||||
base_exc_name = exc_name.replace('ocrmypdf.exceptions.', '')
|
||||
exc_class = getattr(ocrmypdf_exceptions, base_exc_name)
|
||||
exit_code = exc_class.exit_code
|
||||
elif exc_name == 'PyPDF2.utils.PdfReadError' and \
|
||||
'not been decrypted' in exc_value:
|
||||
log.error(textwrap.dedent("""\
|
||||
Input PDF uses either an encryption algorithm or a PDF security
|
||||
handler that is not supported by ocrmypdf.
|
||||
|
||||
For information about this PDF's security use
|
||||
qpdf --show-encryption [...input PDF...]
|
||||
|
||||
(Only algorithms "R = 1" and "R = 2" are supported.)
|
||||
|
||||
"""))
|
||||
exit_code = ExitCode.encrypted_pdf
|
||||
elif exc_name == 'PIL.Image.DecompressionBombError':
|
||||
msg = cleanup_ruffus_error_message(exc_value)
|
||||
msg += ("\nUse the --max-image-mpixels argument to set increase the "
|
||||
"maximum number of megapixels to accept.")
|
||||
log.error(msg)
|
||||
exit_code = ExitCode.input_file
|
||||
|
||||
if exit_code is not None:
|
||||
return exit_code
|
||||
@@ -654,7 +686,7 @@ def log_page_orientations(pdfinfo, _log):
|
||||
180: 's', 270: 'w'}
|
||||
orientations = []
|
||||
for n, page in enumerate(pdfinfo):
|
||||
angle = pdfinfo[n].rotation or 0
|
||||
angle = page.rotation or 0
|
||||
if angle != 0:
|
||||
orientations.append('{0}{1}'.format(
|
||||
n + 1,
|
||||
@@ -676,9 +708,23 @@ def run_pipeline():
|
||||
logging_factory, __name__, logger_args)
|
||||
_log.debug('ocrmypdf ' + VERSION)
|
||||
_log.debug('tesseract ' + tesseract.version())
|
||||
_log.debug('qpdf ' + qpdf.version())
|
||||
|
||||
check_options(options, _log)
|
||||
|
||||
PIL.Image.MAX_IMAGE_PIXELS = int(options.max_image_mpixels * 1000000)
|
||||
if PIL.Image.MAX_IMAGE_PIXELS == 0:
|
||||
PIL.Image.MAX_IMAGE_PIXELS = None
|
||||
|
||||
# Complain about qpdf version < 7.0.0
|
||||
# Suppress the warning if in the test suite, since there are no PPAs
|
||||
# for qpdf 7.0.0 for Ubuntu trusty (i.e. Travis)
|
||||
if qpdf.version() < '7.0.0' and not os.environ.get('PYTEST_CURRENT_TEST'):
|
||||
complain(
|
||||
"You are using qpdf version {0} which has known issues including "
|
||||
"security vulnerabilities with certain malformed PDFs. Consider "
|
||||
"upgrading to version 7.0.0 or newer.".format(qpdf.version()))
|
||||
|
||||
# Any changes to options will not take effect for options that are already
|
||||
# bound to function parameters in the pipeline. (For example
|
||||
# options.input_file, options.pdf_renderer are already bound.)
|
||||
@@ -714,14 +760,14 @@ def run_pipeline():
|
||||
elif not is_file_writable(options.output_file):
|
||||
_log.error(
|
||||
"Output file location (" + options.output_file + ") " +
|
||||
"is not writable.")
|
||||
"is not a writable file.")
|
||||
return ExitCode.file_access_error
|
||||
|
||||
manager = JobContextManager()
|
||||
manager.register('JobContext', JobContext)
|
||||
manager.register('JobContext', JobContext) # pylint: disable=no-member
|
||||
manager.start()
|
||||
|
||||
context = manager.JobContext()
|
||||
context = manager.JobContext() # pylint: disable=no-member
|
||||
context.set_options(options)
|
||||
context.set_work_folder(work_folder)
|
||||
|
||||
@@ -761,7 +807,11 @@ def run_pipeline():
|
||||
|
||||
if options.flowchart:
|
||||
_log.info("Flowchart saved to {}".format(options.flowchart))
|
||||
elif options.output_file != '-':
|
||||
elif options.output_file == '-':
|
||||
_log.info("Output sent to stdout")
|
||||
elif os.path.samefile(options.output_file, os.devnull):
|
||||
pass # Say nothing when sending to dev null
|
||||
else:
|
||||
if options.output_type.startswith('pdfa'):
|
||||
pdfa_info = file_claims_pdfa(options.output_file)
|
||||
if pdfa_info['pass']:
|
||||
@@ -774,8 +824,6 @@ def run_pipeline():
|
||||
if not qpdf.check(options.output_file, _log):
|
||||
_log.warning('Output file: The generated PDF is INVALID')
|
||||
return ExitCode.invalid_output_pdf
|
||||
else:
|
||||
_log.info("Output sent to stdout")
|
||||
|
||||
pdfinfo = context.get_pdfinfo()
|
||||
if options.verbose:
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
# Copyright (c) 2014, Armin Ronacher
|
||||
#
|
||||
# Copyright (c) 2017, James R Barlow
|
||||
#
|
||||
# Some rights reserved.
|
||||
#
|
||||
# Redistribution and use in source and binary forms, with or without
|
||||
# modification, are permitted provided that the following conditions are
|
||||
# met:
|
||||
#
|
||||
# * Redistributions of source code must retain the above copyright
|
||||
# notice, this list of conditions and the following disclaimer.
|
||||
#
|
||||
# * Redistributions in binary form must reproduce the above
|
||||
# copyright notice, this list of conditions and the following
|
||||
# disclaimer in the documentation and/or other materials provided
|
||||
# with the distribution.
|
||||
#
|
||||
# * The names of the contributors may not be used to endorse or
|
||||
# promote products derived from this software without specific
|
||||
# prior written permission.
|
||||
#
|
||||
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
||||
# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
||||
# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
|
||||
# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
|
||||
# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
||||
# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
|
||||
# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
|
||||
# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
|
||||
# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
|
||||
import os
|
||||
import sys
|
||||
import codecs
|
||||
|
||||
|
||||
def verify_python3_env():
|
||||
"""Ensures that the environment is good for unicode on Python 3."""
|
||||
try:
|
||||
import locale
|
||||
fs_enc = codecs.lookup(locale.getpreferredencoding()).name
|
||||
except Exception:
|
||||
fs_enc = 'ascii'
|
||||
if fs_enc != 'ascii':
|
||||
return
|
||||
|
||||
extra = ''
|
||||
if os.name == 'posix':
|
||||
import subprocess
|
||||
rv = subprocess.Popen(['locale', '-a'], stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE).communicate()[0]
|
||||
good_locales = set()
|
||||
has_c_utf8 = False
|
||||
|
||||
# Make sure we're operating on text here.
|
||||
if isinstance(rv, bytes):
|
||||
rv = rv.decode('ascii', 'replace')
|
||||
|
||||
for line in rv.splitlines():
|
||||
locale = line.strip()
|
||||
if locale.lower().endswith(('.utf-8', '.utf8')):
|
||||
good_locales.add(locale)
|
||||
if locale.lower() in ('c.utf8', 'c.utf-8'):
|
||||
has_c_utf8 = True
|
||||
|
||||
extra += '\n\n'
|
||||
if not good_locales:
|
||||
extra += (
|
||||
'Additional information: on this system no suitable UTF-8\n'
|
||||
'locales were discovered. This most likely requires resolving\n'
|
||||
'by reconfiguring the locale system.'
|
||||
)
|
||||
elif has_c_utf8:
|
||||
extra += (
|
||||
'This system supports the C.UTF-8 locale which is recommended.\n'
|
||||
'You might be able to resolve your issue by exporting the\n'
|
||||
'following environment variables:\n\n'
|
||||
' export LC_ALL=C.UTF-8\n'
|
||||
' export LANG=C.UTF-8'
|
||||
)
|
||||
else:
|
||||
extra += (
|
||||
'This system lists a couple of UTF-8 supporting locales that\n'
|
||||
'you can pick from. The following suitable locales were\n'
|
||||
'discovered: %s'
|
||||
) % ', '.join(sorted(good_locales))
|
||||
|
||||
bad_locale = None
|
||||
for locale in os.environ.get('LC_ALL'), os.environ.get('LANG'):
|
||||
if locale and locale.lower().endswith(('.utf-8', '.utf8')):
|
||||
bad_locale = locale
|
||||
if locale is not None:
|
||||
break
|
||||
if bad_locale is not None:
|
||||
extra += (
|
||||
'\nocrmypdf discovered that you exported a UTF-8 locale\n'
|
||||
'but the locale system could not pick up from it because\n'
|
||||
'it does not exist. The exported locale is "%s" but it\n'
|
||||
'is not supported'
|
||||
) % bad_locale
|
||||
|
||||
raise RuntimeError('ocrmypdf will abort further execution because Python 3 '
|
||||
'was configured to use ASCII as encoding for the '
|
||||
'environment.' + extra)
|
||||
@@ -1,4 +1,3 @@
|
||||
#!/usr/bin/env python3
|
||||
# © 2016 James R. Barlow: github.com/jbarlow83
|
||||
|
||||
|
||||
|
||||
@@ -1,12 +1,48 @@
|
||||
#!/usr/bin/env python3
|
||||
# © 2016 James R. Barlow: github.com/jbarlow83
|
||||
|
||||
"""Wrappers to manage subprocess calls"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from subprocess import run, STDOUT, PIPE, CalledProcessError
|
||||
from ..exceptions import MissingDependencyError
|
||||
|
||||
|
||||
def get_program(name):
|
||||
"Check environment variables for overrides to this program"
|
||||
envvar = 'OCRMYPDF_' + name.upper()
|
||||
return os.environ.get(envvar, name)
|
||||
|
||||
|
||||
def get_version(program, *,
|
||||
version_arg='--version', regex=r'(\d+(\.\d+)*)'):
|
||||
"Get the version of the specified program, "
|
||||
args_prog = [
|
||||
get_program(program),
|
||||
version_arg
|
||||
]
|
||||
try:
|
||||
proc = run(
|
||||
args_prog, close_fds=True, universal_newlines=True,
|
||||
stdout=PIPE, stderr=STDOUT, check=True)
|
||||
output = proc.stdout
|
||||
except CalledProcessError as e:
|
||||
if get_program(program) == program:
|
||||
raise MissingDependencyError(
|
||||
"Could not find program '{}' on the PATH".format(
|
||||
program)) from e
|
||||
else:
|
||||
raise MissingDependencyError(
|
||||
"Could not find program '{}'".format(
|
||||
get_program(program))) from e
|
||||
|
||||
try:
|
||||
version = re.match(regex, output.strip()).group(1)
|
||||
except AttributeError as e:
|
||||
raise MissingDependencyError(
|
||||
("The program '{}' did not report its version. "
|
||||
"Message was:\n{}").format(program, output)
|
||||
)
|
||||
|
||||
return version
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
#!/usr/bin/env python3
|
||||
# © 2017 James R. Barlow: github.com/jbarlow83
|
||||
|
||||
from tempfile import NamedTemporaryFile
|
||||
@@ -8,28 +7,14 @@ from functools import lru_cache
|
||||
import re
|
||||
import sys
|
||||
from PIL import Image
|
||||
from . import get_program
|
||||
from . import get_program, get_version
|
||||
from ..exceptions import SubprocessOutputError, MissingDependencyError
|
||||
from ..helpers import fspath
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def version():
|
||||
args_gs = [
|
||||
get_program('gs'),
|
||||
'--version'
|
||||
]
|
||||
try:
|
||||
proc = run(
|
||||
args_gs, close_fds=True, universal_newlines=True,
|
||||
stdout=PIPE, stderr=STDOUT, check=True)
|
||||
ver = proc.stdout
|
||||
except CalledProcessError as e:
|
||||
print("Could not find Ghostscript executable on system PATH.",
|
||||
file=sys.stderr)
|
||||
raise MissingDependencyError from e
|
||||
|
||||
return ver.strip()
|
||||
return get_version('gs')
|
||||
|
||||
|
||||
def _gs_error_reported(stream):
|
||||
@@ -51,7 +36,7 @@ def rasterize_pdf(input_file, output_file, xres, yres, raster_device, log,
|
||||
:param yres:
|
||||
:param raster_device:
|
||||
:param log:
|
||||
:param pageno: page number to rasterize
|
||||
:param pageno: page number to rasterize (beginning at page 1)
|
||||
:param page_dpi: resolution tuple (x, y) overriding output image DPI
|
||||
:return:
|
||||
"""
|
||||
@@ -69,11 +54,11 @@ def rasterize_pdf(input_file, output_file, xres, yres, raster_device, log,
|
||||
'-sDEVICE=%s' % raster_device,
|
||||
'-dFirstPage=%i' % pageno,
|
||||
'-dLastPage=%i' % pageno,
|
||||
'-o', tmp.name,
|
||||
'-r{0}x{1}'.format(str(int_res[0]), str(int_res[1])),
|
||||
'-o', tmp.name,
|
||||
fspath(input_file)
|
||||
]
|
||||
|
||||
|
||||
p = run(args_gs, stdout=PIPE, stderr=STDOUT,
|
||||
universal_newlines=True)
|
||||
if _gs_error_reported(p.stdout):
|
||||
@@ -89,6 +74,7 @@ def rasterize_pdf(input_file, output_file, xres, yres, raster_device, log,
|
||||
# if the resolution happens to be fractional, then the discrepancy
|
||||
# would change the size of the output page, especially if the DPI
|
||||
# is quite low. Resize the image to the expected size
|
||||
|
||||
tmp.seek(0)
|
||||
with Image.open(tmp) as im:
|
||||
expected_size = round(im.size[0] / int_res[0] * res[0]), \
|
||||
|
||||
+93
-27
@@ -1,33 +1,21 @@
|
||||
#!/usr/bin/env python3
|
||||
# © 2017 James R. Barlow: github.com/jbarlow83
|
||||
|
||||
from subprocess import CalledProcessError, STDOUT, PIPE, run, check_output
|
||||
from subprocess import CalledProcessError, STDOUT, PIPE, run
|
||||
from functools import lru_cache
|
||||
import sys
|
||||
import os
|
||||
import re
|
||||
import resource
|
||||
|
||||
from ..exceptions import InputFileError, SubprocessOutputError, \
|
||||
MissingDependencyError, EncryptedPdfError
|
||||
from . import get_program
|
||||
from . import get_program, get_version
|
||||
from ..helpers import re_symlink
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def version():
|
||||
args_qpdf = [
|
||||
get_program('qpdf'),
|
||||
'--version'
|
||||
]
|
||||
try:
|
||||
p = run(args_qpdf, universal_newlines=True, stderr=STDOUT,
|
||||
stdout=PIPE)
|
||||
except CalledProcessError as e:
|
||||
print("Could not find qpdf executable on system PATH.",
|
||||
file=sys.stderr)
|
||||
raise MissingDependencyError() from e
|
||||
|
||||
qpdf_version = re.match(r'qpdf version (.+)', p.stdout).group(1)
|
||||
return qpdf_version
|
||||
return get_version('qpdf', regex=r'qpdf version (.+)')
|
||||
|
||||
|
||||
def check(input_file, log=None):
|
||||
@@ -41,7 +29,8 @@ def check(input_file, log=None):
|
||||
import logging as log
|
||||
|
||||
try:
|
||||
check_output(args_qpdf, stderr=STDOUT, universal_newlines=True)
|
||||
run(args_qpdf, stderr=STDOUT, stdout=PIPE, universal_newlines=True,
|
||||
check=True)
|
||||
except CalledProcessError as e:
|
||||
if e.returncode == 2:
|
||||
log.error("{0}: not a valid PDF, and could not repair it.".format(
|
||||
@@ -70,16 +59,14 @@ def repair(input_file, output_file, log):
|
||||
get_program('qpdf'), input_file, output_file
|
||||
]
|
||||
try:
|
||||
check_output(args_qpdf, stderr=STDOUT, universal_newlines=True)
|
||||
run(args_qpdf, stderr=STDOUT, stdout=PIPE, universal_newlines=True,
|
||||
check=True)
|
||||
except CalledProcessError as e:
|
||||
if e.returncode == 3 and e.output.find("operation succeeded"):
|
||||
log.debug('qpdf found and fixed errors: ' + e.output)
|
||||
log.debug(e.output)
|
||||
return
|
||||
|
||||
if _probably_encrypted(e):
|
||||
log.error("{0}: this PDF is password-protected - password must "
|
||||
"be removed for OCR".format(input_file))
|
||||
raise EncryptedPdfError() from e
|
||||
elif e.returncode == 2:
|
||||
log.error("{0}: not a valid PDF, and could not repair it.".format(
|
||||
@@ -95,9 +82,9 @@ def repair(input_file, output_file, log):
|
||||
|
||||
def get_npages(input_file, log):
|
||||
try:
|
||||
pages = check_output(
|
||||
pages = run(
|
||||
[get_program('qpdf'), '--show-npages', input_file],
|
||||
universal_newlines=True, close_fds=True)
|
||||
universal_newlines=True, check=True, stdout=PIPE, stderr=STDOUT)
|
||||
except CalledProcessError as e:
|
||||
if e.returncode == 2 and e.output.find('No such file'):
|
||||
log.error(e.output)
|
||||
@@ -120,18 +107,97 @@ def split_pages(input_file, work_folder, npages):
|
||||
run(args_qpdf, check=True)
|
||||
|
||||
|
||||
def merge(input_files, output_file, min_version=None):
|
||||
def _merge_inner(input_files, output_file, min_version=None, log=None):
|
||||
"""Merge the list of input files (all filenames) into the output file.
|
||||
|
||||
The input files may contain one or more pages.
|
||||
"""
|
||||
|
||||
# Single page 'merges' should still be attempted to that the same error
|
||||
# checking is applied to single page case
|
||||
|
||||
version_arg = ['--min-version={}'.format(min_version)] \
|
||||
if min_version else []
|
||||
|
||||
if log is None:
|
||||
import logging as log
|
||||
|
||||
args_qpdf = [
|
||||
get_program('qpdf')
|
||||
] + version_arg + [
|
||||
input_files[0], '--pages'
|
||||
] + input_files + ['--', output_file]
|
||||
run(args_qpdf, check=True)
|
||||
|
||||
|
||||
try:
|
||||
run(args_qpdf, check=True, stderr=PIPE, universal_newlines=True)
|
||||
except CalledProcessError as e:
|
||||
if e.returncode == 3 and \
|
||||
e.stderr.find("unknown token while reading object") and \
|
||||
e.stderr.find("operation succeeded"):
|
||||
# Only whitelist the 'unknown token' problem (decimal/string issue)
|
||||
# qpdf issue #165
|
||||
log.warning('qpdf found and fixed errors: ' + e.stderr)
|
||||
return
|
||||
raise e from e
|
||||
|
||||
|
||||
def merge(input_files, output_file, min_version=None, log=None, max_files=None):
|
||||
"""Merge the list of input files (all filenames) into the output file.
|
||||
|
||||
The input files may contain one or more pages.
|
||||
|
||||
"""
|
||||
# qpdf requires that every file that contributes to the output has a file
|
||||
# descriptor that remains open. That means, given our approach of one
|
||||
# intermediate PDF per, we can practically hit the number of file
|
||||
# descriptors.
|
||||
|
||||
if max_files is None or max_files < 2:
|
||||
# Find out how many open file descriptors we can get away with
|
||||
ulimits = resource.getrlimit(resource.RLIMIT_NOFILE)
|
||||
max_open_files = ulimits[0]
|
||||
max_files = max_open_files // 2 # Conservative guess
|
||||
|
||||
# We'll write things alongside the output file
|
||||
output_dir = os.path.dirname(output_file)
|
||||
|
||||
import random
|
||||
import string
|
||||
|
||||
def randstr():
|
||||
return ''.join(random.sample(string.ascii_lowercase, 6))
|
||||
|
||||
# How many files to grab at once, merging all their contents
|
||||
step_size = max_files
|
||||
|
||||
workqueue = input_files.copy()
|
||||
counter = 1
|
||||
next_workqueue = []
|
||||
while len(workqueue) > 1 or len(next_workqueue) > 0:
|
||||
# Take n files out of the queue
|
||||
n = min(step_size, len(workqueue))
|
||||
job = workqueue[0:n]
|
||||
del workqueue[0:n]
|
||||
print('merging ' + repr(job))
|
||||
|
||||
# Merge them into 1 file, which will contain n^depth pages
|
||||
merge_file = os.path.join(
|
||||
output_dir, "merge-{:06d}-{}.pdf".format(counter, randstr()))
|
||||
counter += 1
|
||||
_merge_inner(job, merge_file, min_version=min_version, log=log)
|
||||
|
||||
# On the next
|
||||
next_workqueue.append(merge_file)
|
||||
print('next_workqueue ' + repr(next_workqueue))
|
||||
|
||||
# If we're out of things to do in this queue, move on to the next
|
||||
# queue. On the counter-th pass of the workqueue we can chew through
|
||||
# (step_size)**N pages, so on most systems the second pass finishes
|
||||
# the job.
|
||||
if len(workqueue) == 0:
|
||||
workqueue = next_workqueue
|
||||
next_workqueue = []
|
||||
|
||||
re_symlink(workqueue.pop(), output_file)
|
||||
|
||||
|
||||
|
||||
+30
-35
@@ -1,4 +1,3 @@
|
||||
#!/usr/bin/env python3
|
||||
# © 2017 James R. Barlow: github.com/jbarlow83
|
||||
|
||||
import sys
|
||||
@@ -14,7 +13,7 @@ from subprocess import PIPE, CalledProcessError, \
|
||||
|
||||
from ..exceptions import MissingDependencyError, TesseractConfigError
|
||||
from ..helpers import page_number
|
||||
from . import get_program
|
||||
from . import get_program, get_version
|
||||
|
||||
OrientationConfidence = namedtuple(
|
||||
'OrientationConfidence',
|
||||
@@ -40,21 +39,7 @@ HOCR_TEMPLATE = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def version():
|
||||
args_tess = [
|
||||
get_program('tesseract'),
|
||||
'--version'
|
||||
]
|
||||
try:
|
||||
versions = check_output(
|
||||
args_tess, close_fds=True, universal_newlines=True,
|
||||
stderr=STDOUT)
|
||||
except CalledProcessError as e:
|
||||
print("Could not find Tesseract executable on system PATH.",
|
||||
file=sys.stderr)
|
||||
raise MissingDependencyError from e
|
||||
|
||||
tesseract_version = re.match(r'tesseract\s(.+)', versions).group(1)
|
||||
return tesseract_version
|
||||
return get_version('tesseract', regex=r'tesseract\s(.+)')
|
||||
|
||||
|
||||
def v4():
|
||||
@@ -76,8 +61,7 @@ def has_textonly_pdf():
|
||||
params = ''
|
||||
try:
|
||||
params = check_output(
|
||||
args_tess, close_fds=True, universal_newlines=True,
|
||||
stderr=STDOUT)
|
||||
args_tess, universal_newlines=True, stderr=STDOUT)
|
||||
except CalledProcessError as e:
|
||||
print("Could not --print-parameters from tesseract",
|
||||
file=sys.stderr)
|
||||
@@ -100,8 +84,7 @@ def languages():
|
||||
]
|
||||
try:
|
||||
langs = check_output(
|
||||
args_tess, close_fds=True, universal_newlines=True,
|
||||
stderr=STDOUT)
|
||||
args_tess, universal_newlines=True, stderr=STDOUT)
|
||||
except CalledProcessError as e:
|
||||
msg = dedent("""Tesseract failed to report available languages.
|
||||
Output from Tesseract:
|
||||
@@ -134,19 +117,18 @@ def get_orientation(input_file, language: list, engine_mode, timeout: float,
|
||||
|
||||
try:
|
||||
stdout = check_output(
|
||||
args_tesseract, close_fds=True, stderr=STDOUT,
|
||||
universal_newlines=True, timeout=timeout)
|
||||
args_tesseract, stderr=STDOUT, timeout=timeout)
|
||||
except TimeoutExpired:
|
||||
return OrientationConfidence(angle=0, confidence=0.0)
|
||||
except CalledProcessError as e:
|
||||
tesseract_log_output(log, e.output, input_file)
|
||||
if ('Too few characters. Skipping this page' in e.output or
|
||||
'Image too large' in e.output):
|
||||
if (b'Too few characters. Skipping this page' in e.output or
|
||||
b'Image too large' in e.output):
|
||||
return OrientationConfidence(0, 0)
|
||||
raise e from e
|
||||
else:
|
||||
osd = {}
|
||||
for line in stdout.splitlines():
|
||||
for line in stdout.decode().splitlines():
|
||||
line = line.strip()
|
||||
parts = line.split(':', maxsplit=2)
|
||||
if len(parts) == 2:
|
||||
@@ -171,8 +153,17 @@ def get_orientation(input_file, language: list, engine_mode, timeout: float,
|
||||
|
||||
|
||||
def tesseract_log_output(log, stdout, input_file):
|
||||
lines = stdout.splitlines()
|
||||
prefix = "{0:4d}: [tesseract] ".format(page_number(input_file))
|
||||
|
||||
try:
|
||||
text = stdout.decode()
|
||||
except UnicodeDecodeError:
|
||||
log.error(prefix + "command line output was not utf-8. " +
|
||||
"This usually means Tesseract's language packs do not match "
|
||||
"the installed version of Tesseract.")
|
||||
text = stdout.decode('utf-8', 'backslashreplace')
|
||||
|
||||
lines = text.splitlines()
|
||||
for line in lines:
|
||||
if line.startswith("Tesseract Open Source"):
|
||||
continue
|
||||
@@ -245,8 +236,8 @@ def generate_hocr(input_file, output_files, language: list, engine_mode,
|
||||
try:
|
||||
log.debug(args_tesseract)
|
||||
stdout = check_output(
|
||||
args_tesseract, close_fds=True, stderr=STDOUT,
|
||||
universal_newlines=True, timeout=timeout)
|
||||
args_tesseract, stderr=STDOUT,
|
||||
timeout=timeout)
|
||||
except TimeoutExpired:
|
||||
# Generate a HOCR file with no recognized text if tesseract times out
|
||||
# Temporary workaround to hocrTransform not being able to function if
|
||||
@@ -255,9 +246,9 @@ def generate_hocr(input_file, output_files, language: list, engine_mode,
|
||||
_generate_null_hocr(output_hocr, output_sidecar, input_file)
|
||||
except CalledProcessError as e:
|
||||
tesseract_log_output(log, e.output, input_file)
|
||||
if 'read_params_file: parameter not found' in e.output:
|
||||
if b'read_params_file: parameter not found' in e.output:
|
||||
raise TesseractConfigError() from e
|
||||
if 'Image too large' in e.output:
|
||||
if b'Image too large' in e.output:
|
||||
_generate_null_hocr(output_hocr, output_sidecar, input_file)
|
||||
return
|
||||
|
||||
@@ -287,6 +278,10 @@ def use_skip_page(text_only, skip_pdf, output_pdf, output_text):
|
||||
with open(output_pdf, 'wb') as out:
|
||||
pdf_out = pypdf.PdfFileWriter()
|
||||
w, h = page0.mediaBox.getWidth(), page0.mediaBox.getHeight()
|
||||
# If skip page has a /Rotate key, replicate the rotation
|
||||
rotation = int(page0.get('/Rotate', 0))
|
||||
if rotation % 180 == 90:
|
||||
w, h = h, w
|
||||
pdf_out.addBlankPage(w, h)
|
||||
pdf_out.write(out)
|
||||
|
||||
@@ -338,8 +333,8 @@ def generate_pdf(*, input_image, skip_pdf, output_pdf, output_text,
|
||||
try:
|
||||
log.debug(args_tesseract)
|
||||
stdout = check_output(
|
||||
args_tesseract, close_fds=True, stderr=STDOUT,
|
||||
universal_newlines=True, timeout=timeout)
|
||||
args_tesseract, stderr=STDOUT,
|
||||
timeout=timeout)
|
||||
if os.path.exists(prefix + '.txt'):
|
||||
shutil.move(prefix + '.txt', output_text)
|
||||
except TimeoutExpired:
|
||||
@@ -347,10 +342,10 @@ def generate_pdf(*, input_image, skip_pdf, output_pdf, output_text,
|
||||
use_skip_page(text_only, skip_pdf, output_pdf, output_text)
|
||||
except CalledProcessError as e:
|
||||
tesseract_log_output(log, e.output, input_image)
|
||||
if 'read_params_file: parameter not found' in e.output:
|
||||
if b'read_params_file: parameter not found' in e.output:
|
||||
raise TesseractConfigError() from e
|
||||
|
||||
if 'Image too large' in e.output:
|
||||
if b'Image too large' in e.output:
|
||||
use_skip_page(text_only, skip_pdf, output_pdf, output_text)
|
||||
return
|
||||
raise e from e
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
#!/usr/bin/env python3
|
||||
# © 2015 James R. Barlow: github.com/jbarlow83
|
||||
# unpaper documentation:
|
||||
# https://github.com/Flameeyes/unpaper/blob/master/doc/basic-concepts.md
|
||||
@@ -9,19 +8,7 @@ import sys
|
||||
import os
|
||||
from functools import lru_cache
|
||||
from ..exceptions import MissingDependencyError
|
||||
from . import get_program
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def version():
|
||||
args_unpaper = [
|
||||
get_program('unpaper'),
|
||||
'--version'
|
||||
]
|
||||
ver = check_output(
|
||||
args_unpaper, close_fds=True, universal_newlines=True,
|
||||
stderr=STDOUT, timeout=5)
|
||||
return ver.strip()
|
||||
from . import get_program, get_version
|
||||
|
||||
|
||||
try:
|
||||
@@ -31,6 +18,11 @@ except ImportError:
|
||||
raise
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def version():
|
||||
return get_version('unpaper')
|
||||
|
||||
|
||||
def run(input_file, output_file, dpi, log, mode_args):
|
||||
args_unpaper = [
|
||||
get_program('unpaper'),
|
||||
|
||||
+22
-8
@@ -1,9 +1,9 @@
|
||||
#!/usr/bin/env python3
|
||||
# © 2016 James R. Barlow: github.com/jbarlow83
|
||||
|
||||
from functools import partial
|
||||
from collections.abc import Iterable
|
||||
from contextlib import suppress, contextmanager
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import os
|
||||
|
||||
@@ -32,7 +32,7 @@ def re_symlink(input_file, soft_link_name, log=None):
|
||||
"%s exists and is not a link" % soft_link_name)
|
||||
try:
|
||||
os.unlink(soft_link_name)
|
||||
except:
|
||||
except OSError:
|
||||
prdebug("Can't unlink %s" % (soft_link_name))
|
||||
|
||||
if not os.path.exists(input_file):
|
||||
@@ -63,19 +63,33 @@ def is_file_writable(test_file):
|
||||
can replace it atomically. Before doing the OCR work, make sure
|
||||
the location is writable.
|
||||
"""
|
||||
if os.path.exists(test_file):
|
||||
p = Path(test_file)
|
||||
|
||||
if p.is_symlink():
|
||||
# Python 3.5 does not accept parameters for Path.resolve() and behaves
|
||||
# as if strict=True (throws an exception on failure). Python 3.6
|
||||
# defaults to strict=False. This implements strict=False like behavior
|
||||
# for Python 3.5.
|
||||
if sys.version_info[0:2] <= (3, 5):
|
||||
resolve = lambda: Path(os.path.realpath(str(p)))
|
||||
else:
|
||||
resolve = lambda: p.resolve(strict=False)
|
||||
p = resolve()
|
||||
|
||||
# p.is_file() throws an exception in some cases
|
||||
if p.exists() and p.is_file():
|
||||
return os.access(
|
||||
test_file, os.W_OK,
|
||||
str(p), os.W_OK,
|
||||
effective_ids=(os.access in os.supports_effective_ids))
|
||||
else:
|
||||
try:
|
||||
fp = open(test_file, 'wb')
|
||||
except OSError as e:
|
||||
fp = p.open('wb')
|
||||
except OSError:
|
||||
return False
|
||||
else:
|
||||
fp.close()
|
||||
with suppress(OSError):
|
||||
os.unlink(test_file)
|
||||
p.unlink()
|
||||
return True
|
||||
|
||||
|
||||
@@ -118,4 +132,4 @@ if sys.version_info[0:2] <= (3, 5):
|
||||
|
||||
else:
|
||||
universal_open = open
|
||||
fspath = os.fspath
|
||||
fspath = os.fspath
|
||||
|
||||
@@ -1,12 +1,33 @@
|
||||
#!/usr/bin/env python3
|
||||
##############################################################################
|
||||
# Copyright (c) 2013-14: fritz-hh from Github
|
||||
# (https://github.com/fritz-hh)
|
||||
#
|
||||
# Copyright (c) 2010: Jonathan Brinley from Github
|
||||
# (https://github.com/jbrinley/HocrConverter)
|
||||
# Initial version by Jonathan Brinley, jonathanbrinley@gmail.com
|
||||
##############################################################################
|
||||
# Copyright (c) 2010, Jonathan Brinley
|
||||
# Original version from: https://github.com/jbrinley/HocrConverter
|
||||
#
|
||||
# Copyright (c) 2013-14, Julien Pfefferkorn
|
||||
# Modifications
|
||||
#
|
||||
# Copyright (c) 2015-16, James R. Barlow
|
||||
# Set text to transparent
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a
|
||||
# copy of this software and associated documentation files (the
|
||||
# "Software"), to deal in the Software without restriction, including
|
||||
# without limitation the rights to use, copy, modify, merge, publish,
|
||||
# distribute, sublicense, and/or sell copies of the Software, and to
|
||||
# permit persons to whom the Software is furnished to do so, subject to
|
||||
# the following conditions:
|
||||
#
|
||||
# The above copyright notice and this permission notice shall be included
|
||||
# in all copies or substantial portions of the Software.
|
||||
#
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
|
||||
# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||
# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
||||
# CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
||||
# TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
||||
# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
from reportlab.pdfgen.canvas import Canvas
|
||||
from reportlab.lib.units import inch
|
||||
from xml.etree import ElementTree
|
||||
|
||||
+1
-32
@@ -566,35 +566,4 @@ if __name__ == '__main__':
|
||||
parser_deskew.set_defaults(func=deskew)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if get_leptonica_version() != u'leptonica-1.69':
|
||||
print("Unexpected leptonica version: %s" % getLeptonicaVersion())
|
||||
|
||||
args.func(args)
|
||||
|
||||
|
||||
def test_skew_angle():
|
||||
from PIL import Image, ImageDraw
|
||||
from tempfile import NamedTemporaryFile
|
||||
|
||||
im = Image.new(mode='1', size=(1000, 1000), color=1)
|
||||
|
||||
draw = ImageDraw.Draw(im)
|
||||
for n in range(20):
|
||||
draw.line([(50, 25 + 50*n), (950, 25 + 50*n)], width=1)
|
||||
del draw
|
||||
|
||||
test_angles = [0.1 * ang for ang in range(1, 10)] + \
|
||||
[float(ang) for ang in range(1, 7)]
|
||||
test_angles += [-ang for ang in test_angles]
|
||||
test_angles = sorted(test_angles)
|
||||
|
||||
for rotate_angle in test_angles:
|
||||
rotated_im = im.rotate(rotate_angle)
|
||||
with NamedTemporaryFile(prefix='lept-skew', suffix='.png', delete=True) as tmpfile:
|
||||
rotated_im.save(tmpfile)
|
||||
pix = pixRead(tmpfile.name)
|
||||
angle, confidence = pixFindSkew(pix)
|
||||
print('{0} {1} {2}'.format(rotate_angle, angle, confidence), file=sys.stderr)
|
||||
|
||||
|
||||
args.func(args)
|
||||
@@ -1,3 +1,4 @@
|
||||
#!/usr/bin/env python3
|
||||
# © 2017 James R. Barlow: github.com/jbarlow83
|
||||
|
||||
from cffi import FFI
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
#!/usr/bin/env python3
|
||||
# © 2015 James R. Barlow: github.com/jbarlow83
|
||||
#
|
||||
# Generate a PDFA_def.ps file for Ghostscript >= 9.14
|
||||
|
||||
@@ -662,10 +662,6 @@ class PageInfo:
|
||||
else:
|
||||
return '1.5'
|
||||
|
||||
@property
|
||||
def images(self):
|
||||
return self._pageinfo['images']
|
||||
|
||||
def __repr__(self):
|
||||
return (
|
||||
'<PageInfo '
|
||||
|
||||
+69
-40
@@ -1,12 +1,12 @@
|
||||
#!/usr/bin/env python3
|
||||
# © 2016 James R. Barlow: github.com/jbarlow83
|
||||
|
||||
from contextlib import suppress
|
||||
from shutil import copyfileobj
|
||||
import sys
|
||||
import os
|
||||
import shutil
|
||||
import img2pdf
|
||||
|
||||
import re
|
||||
import PyPDF2 as pypdf
|
||||
from PIL import Image
|
||||
|
||||
@@ -17,7 +17,8 @@ from .pdfinfo import PdfInfo, Encoding, Colorspace
|
||||
from .pdfa import generate_pdfa_ps
|
||||
from .helpers import re_symlink, is_iterable_notstr, page_number
|
||||
from .exec import ghostscript, tesseract, qpdf
|
||||
from .exceptions import *
|
||||
from .exceptions import PdfMergeFailedError, UnsupportedImageFormatError, \
|
||||
DpiError, PriorOcrFoundError, InputFileError
|
||||
from . import leptonica
|
||||
from . import PROGRAM_NAME, VERSION
|
||||
|
||||
@@ -149,6 +150,22 @@ def triage_image_file(input_file, output_file, log, options):
|
||||
raise UnsupportedImageFormatError() from e
|
||||
|
||||
|
||||
def _pdf_guess_version(input_file, search_window=1024):
|
||||
"""Try to find version signature at start of file.
|
||||
|
||||
Not robust enough to deal with appended files.
|
||||
|
||||
Returns empty string if not found, indicating file is probably not PDF.
|
||||
"""
|
||||
|
||||
with open(input_file, 'rb') as f:
|
||||
signature = f.read(1024)
|
||||
m = re.search(br'%PDF-(\d\.\d)', signature)
|
||||
if m:
|
||||
return m.group(1)
|
||||
return ''
|
||||
|
||||
|
||||
def triage(
|
||||
input_file,
|
||||
output_file,
|
||||
@@ -157,14 +174,12 @@ def triage(
|
||||
|
||||
options = context.get_options()
|
||||
try:
|
||||
with open(input_file, 'rb') as f:
|
||||
signature = f.read(1024)
|
||||
if b'%PDF' in signature:
|
||||
if options.image_dpi:
|
||||
log.warning("Argument --image-dpi ignored because the "
|
||||
"input file is a PDF, not an image.")
|
||||
re_symlink(input_file, output_file, log)
|
||||
return
|
||||
if _pdf_guess_version(input_file):
|
||||
if options.image_dpi:
|
||||
log.warning("Argument --image-dpi ignored because the "
|
||||
"input file is a PDF, not an image.")
|
||||
re_symlink(input_file, output_file, log)
|
||||
return
|
||||
except EnvironmentError as e:
|
||||
log.error(e)
|
||||
raise InputFileError() from e
|
||||
@@ -230,7 +245,30 @@ def get_canvas_square_dpi(pageinfo, options):
|
||||
def is_ocr_required(pageinfo, log, options):
|
||||
page = pageinfo.pageno + 1
|
||||
ocr_required = True
|
||||
if not pageinfo.images:
|
||||
|
||||
if pageinfo.has_text:
|
||||
msg = "{0:4d}: page already has text! – {1}"
|
||||
|
||||
if not options.force_ocr and not options.skip_text:
|
||||
log.error(msg.format(page,
|
||||
"aborting (use --force-ocr to force OCR)"))
|
||||
raise PriorOcrFoundError()
|
||||
elif options.force_ocr:
|
||||
log.info(msg.format(page,
|
||||
"rasterizing text and running OCR anyway"))
|
||||
ocr_required = True
|
||||
elif options.skip_text:
|
||||
log.info(msg.format(page,
|
||||
"skipping all processing on this page"))
|
||||
ocr_required = False
|
||||
elif not pageinfo.images and not options.lossless_reconstruction:
|
||||
# We found a page with no images and no text. That means it may
|
||||
# have vector art that the user wants to OCR. If we determined
|
||||
# lossless reconstruction is not possible then we have to rasterize
|
||||
# the image. So if OCR is being forced, take that to mean YES, go
|
||||
# ahead and rasterize. If not forced, then pretend there's no text
|
||||
# on the page at all so we don't lose anything.
|
||||
# This could be made smarter by explicitly searching for vector art.
|
||||
if options.force_ocr and options.oversample:
|
||||
# The user really wants to reprocess this file
|
||||
log.info(
|
||||
@@ -249,23 +287,9 @@ def is_ocr_required(pageinfo, log, options):
|
||||
else:
|
||||
log.info(
|
||||
"{0:4d}: page has no images - "
|
||||
"skipping all processing on this page".format(page))
|
||||
ocr_required = False
|
||||
|
||||
elif pageinfo.has_text:
|
||||
msg = "{0:4d}: page already has text! – {1}"
|
||||
|
||||
if not options.force_ocr and not options.skip_text:
|
||||
log.error(msg.format(page,
|
||||
"aborting (use --force-ocr to force OCR)"))
|
||||
raise PriorOcrFoundError()
|
||||
elif options.force_ocr:
|
||||
log.info(msg.format(page,
|
||||
"rasterizing text and running OCR anyway"))
|
||||
ocr_required = True
|
||||
elif options.skip_text:
|
||||
log.info(msg.format(page,
|
||||
"skipping all processing on this page"))
|
||||
"skipping all processing on this page to avoid losing detail. "
|
||||
"Use --force-ocr if you wish to perform OCR on pages that "
|
||||
"have vector content.".format(page))
|
||||
ocr_required = False
|
||||
|
||||
if ocr_required and options.skip_big and pageinfo.images:
|
||||
@@ -914,6 +938,7 @@ def merge_pages_qpdf(
|
||||
first_page = pypdf.PdfFileReader(pdf_pages[0])
|
||||
|
||||
writer = pypdf.PdfFileWriter()
|
||||
writer._header = b'%PDF-' + _pdf_guess_version(pdf_pages[0]) # copy version from source
|
||||
writer.appendPagesFromReader(first_page)
|
||||
writer.addMetadata(pdfmark)
|
||||
writer_file = pdf_pages[0].replace('.pdf', '.metadata.pdf')
|
||||
@@ -923,7 +948,8 @@ def merge_pages_qpdf(
|
||||
pdf_pages[0] = writer_file
|
||||
|
||||
qpdf.merge(input_files=pdf_pages, output_file=output_file,
|
||||
min_version=context.get_pdfinfo().min_version)
|
||||
min_version=context.get_pdfinfo().min_version,
|
||||
log=log)
|
||||
|
||||
|
||||
def merge_sidecars(
|
||||
@@ -945,7 +971,7 @@ def merge_sidecars(
|
||||
if page_num != 0:
|
||||
stream.write('\f') # Form feed between pages
|
||||
if txt_file:
|
||||
with open(txt_file, 'r') as in_:
|
||||
with open(txt_file, 'r', encoding="utf-8") as in_:
|
||||
txt = in_.read()
|
||||
# Tesseract v4 alpha started adding form feeds in
|
||||
# commit aa6eb6b
|
||||
@@ -953,9 +979,9 @@ def merge_sidecars(
|
||||
# for consistency just ignore its form feeds and insert our
|
||||
# own
|
||||
if txt.endswith('\f'):
|
||||
stream.write(in_.read()[:-1])
|
||||
stream.write(txt[:-1])
|
||||
else:
|
||||
stream.write(in_.read())
|
||||
stream.write(txt)
|
||||
else:
|
||||
stream.write('[OCR skipped on page {}]'.format(
|
||||
page_num + 1))
|
||||
@@ -964,7 +990,7 @@ def merge_sidecars(
|
||||
write_pages(sys.stdout)
|
||||
sys.stdout.flush()
|
||||
else:
|
||||
with open(output_file, 'w', encoding='utf-8') as out:
|
||||
with open(output_file, 'w', encoding="utf-8") as out:
|
||||
write_pages(out)
|
||||
|
||||
|
||||
@@ -975,13 +1001,16 @@ def copy_final(
|
||||
context):
|
||||
input_file = next((ii for ii in input_files if ii.endswith('.pdf')))
|
||||
|
||||
if output_file == '-':
|
||||
from shutil import copyfileobj
|
||||
with open(input_file, 'rb') as input_stream:
|
||||
with open(input_file, 'rb') as input_stream:
|
||||
if output_file == '-':
|
||||
copyfileobj(input_stream, sys.stdout.buffer)
|
||||
sys.stdout.flush()
|
||||
else:
|
||||
shutil.copy(input_file, output_file)
|
||||
else:
|
||||
# At this point we overwrite the output_file specified by the user
|
||||
# use copyfileobj because then we use open() to create the file and
|
||||
# get the appropriate umask, ownership, etc.
|
||||
with open(output_file, 'wb') as output_stream:
|
||||
copyfileobj(input_stream, output_stream)
|
||||
|
||||
|
||||
def build_pipeline(options, work_folder, log, context):
|
||||
@@ -1210,7 +1239,7 @@ def build_pipeline(options, work_folder, log, context):
|
||||
task_merge_sidecars.active_if(options.sidecar)
|
||||
|
||||
# Finalize
|
||||
task_copy_final = main_pipeline.merge(
|
||||
main_pipeline.merge(
|
||||
task_func=copy_final,
|
||||
input=[task_merge_pages_ghostscript, task_merge_pages_qpdf],
|
||||
output=options.output_file,
|
||||
|
||||
+3
-3
@@ -2,8 +2,8 @@
|
||||
# setup.py lists a separate set of requirements that are looser to simplify
|
||||
# installation
|
||||
ruffus == 2.6.3
|
||||
Pillow == 4.1.1
|
||||
Pillow == 5.0.0
|
||||
reportlab == 3.4.0
|
||||
PyPDF2 == 1.26.0
|
||||
img2pdf == 0.2.3
|
||||
cffi == 1.10.0
|
||||
img2pdf == 0.2.4
|
||||
cffi == 1.11.2
|
||||
|
||||
@@ -217,9 +217,11 @@ setup(
|
||||
"Topic :: Text Processing :: Indexing",
|
||||
"Topic :: Text Processing :: Linguistic",
|
||||
],
|
||||
python_requires='>=3.5',
|
||||
setup_requires=[
|
||||
'setuptools_scm', # so that version will work
|
||||
'cffi>=1.9.1' # to build the leptonica module
|
||||
'cffi>=1.9.1', # to build the leptonica module
|
||||
'pytest-runner' # to enable python setup.py test
|
||||
],
|
||||
use_scm_version={'version_scheme': 'post-release'},
|
||||
cffi_modules=[
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
pytest >= 3.0
|
||||
pytest >= 3.2
|
||||
pytest-helpers-namespace
|
||||
pytest-xdist
|
||||
pytest-cov
|
||||
pytest-cov
|
||||
pytest-timeout
|
||||
@@ -1,4 +1,3 @@
|
||||
#!/usr/bin/env python3
|
||||
# © 2017 James R. Barlow: github.com/jbarlow83
|
||||
|
||||
import sys
|
||||
|
||||
@@ -33,7 +33,7 @@ In some cases they were converted from one image format to another without other
|
||||
- Creative Commons BY-SA 3.0
|
||||
* - typewriter.png, 2400dpi.pdf
|
||||
- `Wikimedia: Triumph typewrtier text Linzensoep`_
|
||||
* Creative Commons BY-SA 2.5
|
||||
- Creative Commons BY-SA 2.5
|
||||
* - baiona.png
|
||||
- `Wikimedia: Baionako udalerri mugakideak`_
|
||||
- Creative Commons BY-SA 4.0
|
||||
@@ -61,6 +61,9 @@ under the terms of the license in LICENSE.rst.
|
||||
* - cmyk.pdf
|
||||
- @jbarlow83
|
||||
- a CMYK image created in Photoshop
|
||||
* - crom.png
|
||||
- @jbarlow83
|
||||
- test for non-dictionary words
|
||||
* - enormous.pdf
|
||||
- @jbarlow83
|
||||
- very large PDF page
|
||||
@@ -69,7 +72,7 @@ under the terms of the license in LICENSE.rst.
|
||||
- a linearized PDF containing some unusual indirect objects, created by an Epson printer; printout of a Wikipedia article (CC BY-SA)
|
||||
* - formxobject.pdf
|
||||
- @jbarlow83
|
||||
- hand-crafted exotic PDF containing an image inside a Form XObject
|
||||
- hand-crafted PDF containing an image inside a Form XObject
|
||||
* - francais.pdf
|
||||
- @jbarlow83
|
||||
- a page containing French accents (diacritics)
|
||||
@@ -88,12 +91,18 @@ under the terms of the license in LICENSE.rst.
|
||||
* - overlay.pdf
|
||||
- @maxandersen
|
||||
- PDF file generated by PDFPen pro that triggered content stream parse errors
|
||||
* - negzero.pdf
|
||||
- @jbarlow83
|
||||
- copy of formxobject.pdf with token that qpdf doesn't like
|
||||
* - no_contents.pdf
|
||||
- @jbarlow83
|
||||
- synthetic PDF with a blank page that has no /Contents entry
|
||||
* - crom.png
|
||||
* - trivial.pdf
|
||||
- @jbarlow83
|
||||
- test for non-dictionary words
|
||||
- smallest possible valid PDF-1.3 with all required fields
|
||||
* - vector.pdf
|
||||
- @Catscratch
|
||||
- a PDF with vector art and text rendered as curves with no fonts
|
||||
|
||||
|
||||
Assemblies
|
||||
@@ -110,7 +119,7 @@ These test resources are assemblies or derivatives from other previously mention
|
||||
- palette.pdf (congress.jpg, converted to a 256-color palette)
|
||||
- rotated_skew.pdf (a /Rotate'd and skewed document from LinnSequencer.jpg)
|
||||
- skew.pdf (from LinnSequencer.jpg, skew simulated by adjusting the transformation matrix)
|
||||
- skew-encrypted.pdf (skew.pdf with encryption - access supported by PyPDF2)
|
||||
- skew-encrypted.pdf (skew.pdf with encryption - access supported by PyPDF2, password is "password")
|
||||
- baiona_gray.png (from baiona.png)
|
||||
- poster.pdf (from LinnSequencer.jpg)
|
||||
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,27 @@
|
||||
%PDF-1.3
|
||||
%¿÷¢þ
|
||||
1 0 obj
|
||||
<< /Pages 2 0 R /Type /Catalog >>
|
||||
endobj
|
||||
2 0 obj
|
||||
<< /Count 1 /Kids [ 3 0 R ] /Type /Pages >>
|
||||
endobj
|
||||
3 0 obj
|
||||
<< /Contents 4 0 R /MediaBox [ 0 0 300 300 ] /Parent 2 0 R /Resources << >> /Type /Page >>
|
||||
endobj
|
||||
4 0 obj
|
||||
<< /Length 0 >>
|
||||
stream
|
||||
endstream
|
||||
endobj
|
||||
xref
|
||||
0 5
|
||||
0000000000 65535 f
|
||||
0000000015 00000 n
|
||||
0000000064 00000 n
|
||||
0000000123 00000 n
|
||||
0000000229 00000 n
|
||||
trailer << /Root 1 0 R /Size 5 /ID [<8088c23e0edc07ef4fbed3daa55f52ed><8088c23e0edc07ef4fbed3daa55f52ed>] >>
|
||||
startxref
|
||||
277
|
||||
%%EOF
|
||||
Binary file not shown.
@@ -22,7 +22,7 @@ not permitted in PDF/A-2, overprint mode not set"""
|
||||
def main():
|
||||
if '--version' in sys.argv:
|
||||
print('9.20')
|
||||
print('SPOOFED: ' + os.path.basename(__filename__))
|
||||
print('SPOOFED: ' + os.path.basename(__file__))
|
||||
sys.exit(0)
|
||||
|
||||
gs_args = ['gs'] + sys.argv[1:]
|
||||
|
||||
@@ -17,7 +17,7 @@ def real_ghostscript(argv):
|
||||
def main():
|
||||
if '--version' in sys.argv:
|
||||
print('9.20')
|
||||
print('SPOOFED: ' + os.path.basename(__filename__))
|
||||
print('SPOOFED: ' + os.path.basename(__file__))
|
||||
sys.exit(0)
|
||||
|
||||
# Unless some argument is calling for PDFA generation, forward to
|
||||
|
||||
@@ -16,7 +16,7 @@ def real_ghostscript(argv):
|
||||
def main():
|
||||
if '--version' in sys.argv:
|
||||
print('9.20')
|
||||
print('SPOOFED: ' + os.path.basename(__filename__))
|
||||
print('SPOOFED: ' + os.path.basename(__file__))
|
||||
sys.exit(0)
|
||||
|
||||
# For any rendering calls (device == pdfwrite) call real ghostscript
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
# © 2016 James R. Barlow: github.com/jbarlow83
|
||||
# © 2016-18 James R. Barlow: github.com/jbarlow83
|
||||
|
||||
import sys
|
||||
import os
|
||||
@@ -16,7 +16,7 @@ def real_ghostscript(argv):
|
||||
def main():
|
||||
if '--version' in sys.argv:
|
||||
print('9.20')
|
||||
print('SPOOFED: ' + os.path.basename(__filename__))
|
||||
print('SPOOFED: ' + os.path.basename(__file__))
|
||||
sys.exit(0)
|
||||
|
||||
# For any rasterize calls (device != pdfwrite) call real ghostscript
|
||||
|
||||
@@ -5,6 +5,9 @@ import sys
|
||||
|
||||
|
||||
def main():
|
||||
if sys.argv[-1] == '--version':
|
||||
print('qpdf version 7.0.0')
|
||||
sys.exit(0)
|
||||
print('qpdf dummy')
|
||||
sys.exit(2)
|
||||
|
||||
|
||||
Executable
+58
@@ -0,0 +1,58 @@
|
||||
#!/usr/bin/env python3
|
||||
# © 2017 James R. Barlow: github.com/jbarlow83
|
||||
import sys
|
||||
import img2pdf
|
||||
import PyPDF2 as pypdf
|
||||
from PIL import Image
|
||||
|
||||
|
||||
"""Tesseract bad utf8 spoof
|
||||
|
||||
In 'hocr' mode or 'pdf' mode, return error code 1 and some non-Unicode
|
||||
text because tesseract seems to do that in some cases related to
|
||||
language pack version mismatches
|
||||
|
||||
"""
|
||||
|
||||
|
||||
VERSION_STRING = '''tesseract 3.05.01
|
||||
leptonica-1.72
|
||||
libjpeg 8d : libpng 1.6.19 : libtiff 4.0.6 : zlib 1.2.5
|
||||
SPOOFED
|
||||
'''
|
||||
|
||||
# Japanese "Invalid UTF-8" encoded in Shift JIS
|
||||
BAD_UTF8 = b'\x96\xb3\x8c\xf8\x82\xc8UTF-8\x0a'
|
||||
|
||||
|
||||
def main():
|
||||
if sys.argv[1] == '--version':
|
||||
print(VERSION_STRING, file=sys.stderr)
|
||||
sys.exit(0)
|
||||
elif sys.argv[1] == '--list-langs':
|
||||
print('List of available languages (1):\neng', file=sys.stderr)
|
||||
sys.exit(0)
|
||||
elif sys.argv[1] == '--print-parameters':
|
||||
print("Some parameters", file=sys.stderr)
|
||||
print("textonly_pdf\t1\tSome help text")
|
||||
sys.exit(0)
|
||||
elif sys.argv[-2] in ('hocr', 'pdf'):
|
||||
sys.stdout.buffer.write(BAD_UTF8)
|
||||
sys.exit(1)
|
||||
elif sys.argv[-1] == 'stdout':
|
||||
# input file is at sys.argv[-2] but we don't look at it
|
||||
print("""Orientation: 0
|
||||
Orientation in degrees: 0
|
||||
Orientation confidence: 100.00
|
||||
Script: 1
|
||||
Script confidence: 100.00""", file=sys.stderr)
|
||||
else:
|
||||
print("Spoof doesn't understand arguments", file=sys.stderr)
|
||||
print(sys.argv, file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -73,10 +73,11 @@ def main():
|
||||
output = sys.argv[-3]
|
||||
with Image.open(inputf) as im:
|
||||
dpi = im.info['dpi']
|
||||
imsize = im.size[0] * dpi[0] / 72, im.size[1] * dpi[1] / 72
|
||||
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(imsize[0], imsize[1])
|
||||
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:
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
#!/usr/bin/env python3
|
||||
# © 2015 James R. Barlow: github.com/jbarlow83
|
||||
|
||||
from ocrmypdf import hocrtransform
|
||||
|
||||
+158
-12
@@ -1,19 +1,25 @@
|
||||
#!/usr/bin/env python3
|
||||
# © 2015 James R. Barlow: github.com/jbarlow83
|
||||
# © 2015-17 James R. Barlow: github.com/jbarlow83
|
||||
|
||||
from subprocess import Popen, PIPE, check_output, check_call, DEVNULL
|
||||
from pathlib import Path
|
||||
import os
|
||||
import shutil
|
||||
import resource
|
||||
import pytest
|
||||
from ocrmypdf.pdfinfo import PdfInfo, Colorspace, Encoding
|
||||
import PyPDF2 as pypdf
|
||||
from ocrmypdf.exceptions import ExitCode
|
||||
from ocrmypdf import leptonica
|
||||
from ocrmypdf.pdfa import file_claims_pdfa
|
||||
from ocrmypdf.exec import ghostscript, tesseract
|
||||
from ocrmypdf.exec import ghostscript, tesseract, qpdf
|
||||
import logging
|
||||
from math import isclose
|
||||
|
||||
import PIL
|
||||
|
||||
# pytest.helpers is dynamic
|
||||
# pylint: disable=no-member
|
||||
# pylint: disable=w0612
|
||||
|
||||
check_ocrmypdf = pytest.helpers.check_ocrmypdf
|
||||
run_ocrmypdf = pytest.helpers.run_ocrmypdf
|
||||
@@ -55,6 +61,11 @@ def spoof_no_tess_gs_raster_fail():
|
||||
return spoof(tesseract='tesseract_noop.py', gs='gs_raster_failure.py')
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def spoof_tess_bad_utf8():
|
||||
return spoof(tesseract='tesseract_badutf8.py')
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def spoof_qpdf_always_error():
|
||||
return spoof(qpdf='qpdf_dummy_return2.py')
|
||||
@@ -67,7 +78,7 @@ def test_quick(spoof_tesseract_cache, resources, outpdf):
|
||||
def test_deskew(spoof_tesseract_noop, resources, outdir):
|
||||
# Run with deskew
|
||||
deskewed_pdf = check_ocrmypdf(
|
||||
resources / 'skew.pdf', outdir / 'skew.pdf', '-d', '-v', '1',
|
||||
resources / 'skew.pdf', outdir / 'skew.pdf', '-d',
|
||||
env=spoof_tesseract_noop)
|
||||
|
||||
# Now render as an image again and use Leptonica to find the skew angle
|
||||
@@ -82,7 +93,8 @@ def test_deskew(spoof_tesseract_noop, resources, outdir):
|
||||
xres=150,
|
||||
yres=150,
|
||||
raster_device='pngmono',
|
||||
log=log)
|
||||
log=log,
|
||||
pageno=1)
|
||||
|
||||
from ocrmypdf.leptonica import Pix
|
||||
pix = Pix.read(str(deskewed_png))
|
||||
@@ -121,8 +133,8 @@ def test_remove_background(spoof_tesseract_noop, resources, outdir):
|
||||
xres=100,
|
||||
yres=100,
|
||||
raster_device='png16m',
|
||||
log=log)
|
||||
|
||||
log=log,
|
||||
pageno=1)
|
||||
|
||||
# The output image should contain pure white and black
|
||||
im = Image.open(output_png)
|
||||
@@ -425,7 +437,7 @@ def test_klingon(resources, outpdf):
|
||||
|
||||
def test_missing_docinfo(spoof_tesseract_noop, resources, outpdf):
|
||||
p, out, err = run_ocrmypdf(
|
||||
resources / 'missing_docinfo.pdf', outpdf, '-l', 'eng', '-c',
|
||||
resources / 'missing_docinfo.pdf', outpdf, '-l', 'eng', '--skip-text',
|
||||
env=spoof_tesseract_noop)
|
||||
assert p.returncode == ExitCode.ok, err
|
||||
|
||||
@@ -472,7 +484,7 @@ def test_encrypted(resources, no_outpdf):
|
||||
p, out, err = run_ocrmypdf(
|
||||
resources / 'skew-encrypted.pdf', no_outpdf)
|
||||
assert p.returncode == ExitCode.encrypted_pdf
|
||||
assert out.find('password')
|
||||
assert out.find('encrypted')
|
||||
|
||||
|
||||
@pytest.mark.parametrize('renderer', [
|
||||
@@ -515,7 +527,9 @@ def test_tesseract_image_too_big(renderer, spoof_tesseract_big_image_error,
|
||||
resources, outpdf):
|
||||
check_ocrmypdf(
|
||||
resources / 'hugemono.pdf', outpdf, '-r',
|
||||
'--pdf-renderer', renderer, env=spoof_tesseract_big_image_error)
|
||||
'--pdf-renderer', renderer,
|
||||
'--max-image-mpixels', '0',
|
||||
env=spoof_tesseract_big_image_error)
|
||||
|
||||
|
||||
def test_no_unpaper(resources, no_outpdf):
|
||||
@@ -805,7 +819,6 @@ def test_user_words(resources, outdir):
|
||||
'--image-dpi', 150,
|
||||
'--sidecar', sidecar_before
|
||||
)
|
||||
|
||||
assert 'cromulent' not in sidecar_before.open().read()
|
||||
|
||||
with word_list.open('w') as f:
|
||||
@@ -872,6 +885,8 @@ def test_gs_raster_failure(spoof_no_tess_gs_raster_fail, resources, outpdf):
|
||||
assert p.returncode == ExitCode.child_process_error
|
||||
|
||||
|
||||
@pytest.mark.skipif('8.0.0' <= qpdf.version() <= '8.0.1',
|
||||
reason="qpdf regression")
|
||||
def test_no_contents(spoof_tesseract_noop, resources, outpdf):
|
||||
check_ocrmypdf(resources / 'no_contents.pdf', outpdf, '--force-ocr',
|
||||
env=spoof_tesseract_noop)
|
||||
@@ -984,6 +999,19 @@ def test_sidecar_pagecount(spoof_tesseract_cache, resources, outpdf):
|
||||
"Sidecar page count does not match PDF page count"
|
||||
|
||||
|
||||
def test_sidecar_nonempty(spoof_tesseract_cache, resources, outpdf):
|
||||
sidecar = outpdf + '.txt'
|
||||
check_ocrmypdf(
|
||||
resources / 'ccitt.pdf', outpdf,
|
||||
'--sidecar', sidecar,
|
||||
env=spoof_tesseract_cache
|
||||
)
|
||||
|
||||
with open(sidecar, 'r') as f:
|
||||
ocr_text = f.read()
|
||||
assert 'the' in ocr_text
|
||||
|
||||
|
||||
def test_pdfa_1(spoof_tesseract_cache, resources, outpdf):
|
||||
check_ocrmypdf(
|
||||
resources / 'ccitt.pdf', outpdf,
|
||||
@@ -992,4 +1020,122 @@ def test_pdfa_1(spoof_tesseract_cache, resources, outpdf):
|
||||
)
|
||||
|
||||
pdfa_info = file_claims_pdfa(outpdf)
|
||||
assert pdfa_info['conformance'] == 'PDF/A-1B'
|
||||
assert pdfa_info['conformance'] == 'PDF/A-1B'
|
||||
|
||||
|
||||
def test_bad_locale():
|
||||
env = os.environ.copy()
|
||||
env['LC_ALL'] = 'C'
|
||||
|
||||
p, out, err = run_ocrmypdf(
|
||||
'a', 'b', env=env
|
||||
)
|
||||
assert out == '', "stdout not clean"
|
||||
assert p.returncode != 0
|
||||
assert 'configured to use ASCII as encoding' in err, "should whine"
|
||||
|
||||
|
||||
@pytest.mark.parametrize('renderer', RENDERERS)
|
||||
def test_bad_utf8(spoof_tess_bad_utf8, renderer, resources, no_outpdf):
|
||||
p, out, err = run_ocrmypdf(
|
||||
resources / 'ccitt.pdf', no_outpdf,
|
||||
'--pdf-renderer', renderer,
|
||||
env=spoof_tess_bad_utf8
|
||||
)
|
||||
|
||||
assert out == '', "stdout not clean"
|
||||
assert p.returncode != 0
|
||||
assert 'not utf-8' in err, "should whine about utf-8"
|
||||
assert '\\x96' in err, 'should repeat backslash encoded output'
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not tesseract.has_textonly_pdf(),
|
||||
reason="issue only affects sandwich")
|
||||
def test_rotate_deskew_timeout(resources, outdir):
|
||||
check_ocrmypdf(
|
||||
resources / 'rotated_skew.pdf',
|
||||
outdir / 'deskewed.pdf',
|
||||
'--deskew',
|
||||
'--tesseract-timeout', '0',
|
||||
'--pdf-renderer', 'sandwich'
|
||||
)
|
||||
|
||||
correlation = check_monochrome_correlation(
|
||||
outdir,
|
||||
reference_pdf=resources / 'ccitt.pdf',
|
||||
reference_pageno=1,
|
||||
test_pdf=outdir / 'deskewed.pdf',
|
||||
test_pageno=1)
|
||||
|
||||
# Confirm that the page still got deskewed
|
||||
assert correlation > 0.50
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
PIL.PILLOW_VERSION < '5.0.0',
|
||||
reason="Pillow < 5.0.0 doesn't raise the exception")
|
||||
def test_decompression_bomb(resources, outpdf):
|
||||
p, out, err = run_ocrmypdf(
|
||||
resources / 'hugemono.pdf',
|
||||
outpdf
|
||||
)
|
||||
assert 'decompression bomb' in err
|
||||
|
||||
p, out, err = run_ocrmypdf(
|
||||
resources / 'hugemono.pdf',
|
||||
outpdf,
|
||||
'--max-image-mpixels', '2000'
|
||||
)
|
||||
assert p.returncode == 0
|
||||
|
||||
|
||||
def test_text_curves(spoof_tesseract_noop, resources, outpdf):
|
||||
check_ocrmypdf(
|
||||
resources / 'vector.pdf', outpdf, env=spoof_tesseract_noop)
|
||||
|
||||
info = PdfInfo(outpdf)
|
||||
assert len(info.pages[0].images) == 0, "added images to the vector PDF"
|
||||
|
||||
check_ocrmypdf(
|
||||
resources / 'vector.pdf', outpdf, '--force-ocr',
|
||||
env=spoof_tesseract_noop)
|
||||
|
||||
info = PdfInfo(outpdf)
|
||||
assert len(info.pages[0].images) != 0, "force did not rasterize"
|
||||
|
||||
|
||||
def test_dev_null(spoof_tesseract_noop, resources):
|
||||
p, out, err = run_ocrmypdf(
|
||||
resources / 'trivial.pdf',
|
||||
os.devnull,
|
||||
'--force-ocr',
|
||||
env=spoof_tesseract_noop
|
||||
)
|
||||
assert p.returncode == 0, "could not send output to /dev/null"
|
||||
assert len(out) == 0, "wrote to stdout"
|
||||
|
||||
|
||||
def test_output_is_dir(spoof_tesseract_noop, resources, outdir):
|
||||
p, out, err = run_ocrmypdf(
|
||||
resources / 'trivial.pdf',
|
||||
outdir,
|
||||
'--force-ocr',
|
||||
env=spoof_tesseract_noop
|
||||
)
|
||||
assert p.returncode == ExitCode.file_access_error
|
||||
assert 'is not a writable file' in err
|
||||
|
||||
|
||||
def test_output_is_symlink(spoof_tesseract_noop, resources, outdir):
|
||||
sym = Path(outdir / 'this_is_a_symlink')
|
||||
sym.symlink_to(outdir / 'out.pdf')
|
||||
p, out, err = run_ocrmypdf(
|
||||
resources / 'trivial.pdf',
|
||||
sym,
|
||||
'--force-ocr',
|
||||
env=spoof_tesseract_noop
|
||||
)
|
||||
assert p.returncode == ExitCode.ok, err
|
||||
assert (outdir / 'out.pdf').stat().st_size > 0, 'target file not created'
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
#!/usr/bin/env python3
|
||||
# © 2015 James R. Barlow: github.com/jbarlow83
|
||||
|
||||
from ocrmypdf import pdfinfo
|
||||
@@ -122,4 +121,4 @@ def test_no_contents(resources):
|
||||
def test_oversized_page(resources):
|
||||
pdf = pdfinfo.PdfInfo(resources / 'poster.pdf')
|
||||
image = pdf[0].images[0]
|
||||
assert image.width * image.xres > 200, "this is supposed to be oversized"
|
||||
assert image.width * image.xres > 200, "this is supposed to be oversized"
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
# © 2017 James R. Barlow: github.com/jbarlow83
|
||||
|
||||
import logging
|
||||
import resource
|
||||
import pytest
|
||||
|
||||
from ocrmypdf.exec import ghostscript, tesseract, qpdf
|
||||
from ocrmypdf.pdfinfo import PdfInfo
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
qpdf.version() < '7.0.0',
|
||||
reason="negzero.pdf crashes earlier versions")
|
||||
def test_qpdf_negative_zero(resources, outpdf):
|
||||
negzero = resources / 'negzero.pdf'
|
||||
hugemono = resources / 'hugemono.pdf'
|
||||
# raises exception on err
|
||||
qpdf.merge([str(negzero), str(hugemono)], outpdf, log=logging.getLogger())
|
||||
|
||||
|
||||
@pytest.mark.timeout(15)
|
||||
@pytest.mark.parametrize('max_files,skip', [
|
||||
(2, 0), # Can we merge correctly without opening more than 2 files at once?
|
||||
(16, 0), # And does this work properly when we can one-shot it?
|
||||
(2, 1), # Or playing with even/odd
|
||||
(3, 0) # Or odd step size
|
||||
])
|
||||
def test_qpdf_merge_correctness(resources, outpdf, max_files, skip):
|
||||
# All of these must be only one page long
|
||||
inputs = [
|
||||
'2400dpi.pdf', 'aspect.pdf', 'blank.pdf', 'ccitt.pdf',
|
||||
'linn.pdf', 'masks.pdf', 'poster.pdf', 'overlay.pdf',
|
||||
'skew.pdf', 'trivial.pdf']
|
||||
|
||||
input_files = [str(resources / f) for f in inputs]
|
||||
|
||||
qpdf.merge(
|
||||
input_files[skip:], outpdf, log=logging.getLogger(),
|
||||
max_files=max_files)
|
||||
assert len(PdfInfo(outpdf).pages) == len(input_files[skip:])
|
||||
|
||||
|
||||
@pytest.mark.timeout(15)
|
||||
@pytest.mark.skipif(
|
||||
True,
|
||||
reason='qpdf binary cannot open multiple files multiple times')
|
||||
def test_page_merge_ulimit(resources, outpdf):
|
||||
# Ensure we can merge pages without opening one file descriptor per page
|
||||
ulimits = resource.getrlimit(resource.RLIMIT_NOFILE)
|
||||
page_count = ulimits[0]
|
||||
print(page_count)
|
||||
input_files = [str(resources / 'trivial.pdf')] * page_count
|
||||
|
||||
qpdf.merge(input_files, outpdf, log=logging.getLogger())
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
#!/usr/bin/env python3
|
||||
# © 2017 James R. Barlow: github.com/jbarlow83
|
||||
|
||||
import pytest
|
||||
|
||||
+2
-3
@@ -1,4 +1,3 @@
|
||||
#!/usr/bin/env python3
|
||||
# © 2017 James R. Barlow: github.com/jbarlow83
|
||||
|
||||
import pytest
|
||||
@@ -10,7 +9,7 @@ import os
|
||||
import PyPDF2 as pypdf
|
||||
from contextlib import contextmanager
|
||||
|
||||
|
||||
# pylint: disable=no-member
|
||||
spoof = pytest.helpers.spoof
|
||||
|
||||
|
||||
@@ -128,4 +127,4 @@ def test_content_preservation(ensure_tess4, resources, outpdf):
|
||||
|
||||
info = pdfinfo.PdfInfo(outpdf)
|
||||
page = info[0]
|
||||
assert len(page.images) > 1, "masks were rasterized"
|
||||
assert len(page.images) > 1, "masks were rasterized"
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
#!/usr/bin/env python3
|
||||
# © 2017 James R. Barlow: github.com/jbarlow83
|
||||
|
||||
from subprocess import Popen, PIPE, check_output, check_call, DEVNULL
|
||||
|
||||
Reference in New Issue
Block a user