Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab632f57cd | ||
|
|
13d20bd993 | ||
|
|
b973208137 | ||
|
|
942abf8074 | ||
|
|
ed9bb985e2 | ||
|
|
5a7a8e573b | ||
|
|
ce878db913 | ||
|
|
a3d58683b2 | ||
|
|
039e8ca7e7 | ||
|
|
0ebbd4e21b | ||
|
|
2cb75f6076 | ||
|
|
857d871364 | ||
|
|
632dab2cc0 | ||
|
|
7647918f2d | ||
|
|
75c5d8055c | ||
|
|
a938bbea55 | ||
|
|
414407fbd6 | ||
|
|
076fc717df | ||
|
|
2a04b2d82b | ||
|
|
065db414c0 | ||
|
|
19a054a78b | ||
|
|
9df24a81b7 | ||
|
|
40c0acd3f2 | ||
|
|
20db7f0a8f | ||
|
|
e54f6ee37f | ||
|
|
2da556bf79 | ||
|
|
b183ad8167 | ||
|
|
9e6b54c7ed | ||
|
|
d3b334c10f | ||
|
|
622f2c4bab | ||
|
|
07b638a394 | ||
|
|
9bee2405d8 | ||
|
|
8f040491bf | ||
|
|
8a18988706 | ||
|
|
47a954514b | ||
|
|
e3b65d4288 | ||
|
|
4704f7ed1d | ||
|
|
3a2745445a | ||
|
|
12e15bab15 | ||
|
|
9593aa4fb9 | ||
|
|
817d520e63 | ||
|
|
700abbb8a5 | ||
|
|
701ef1df3f | ||
|
|
0f5c484b62 | ||
|
|
cc7f2a3f02 | ||
|
|
755b5d87e3 | ||
|
|
e55a4115e1 | ||
|
|
16a6fd2ea9 | ||
|
|
e3fce112ed | ||
|
|
eacd26a68b | ||
|
|
0e88b3c38a | ||
|
|
a2170ef8d6 | ||
|
|
eed0424390 | ||
|
|
5ed05e08b1 | ||
|
|
58b26f6715 | ||
|
|
806daf4284 | ||
|
|
c64bc9329e | ||
|
|
dd01745519 | ||
|
|
501ce726e7 | ||
|
|
03076e89ce | ||
|
|
02f37293ee | ||
|
|
590942ad14 | ||
|
|
2ac028c759 | ||
|
|
2125b5bfab | ||
|
|
b96532caa4 | ||
|
|
995fc58466 | ||
|
|
c023cae299 | ||
|
|
237eaf9130 | ||
|
|
8b9ab25125 | ||
|
|
77e87abe8f | ||
|
|
3be02e1e8d | ||
|
|
64c9ede979 | ||
|
|
5b8d197812 | ||
|
|
2cba62dc4f | ||
|
|
288e28328f | ||
|
|
b8214b3c49 | ||
|
|
8681693994 | ||
|
|
1364c63b7c | ||
|
|
4ba9e8fe25 | ||
|
|
a195713bb4 | ||
|
|
600d31a907 | ||
|
|
be31cec332 | ||
|
|
22a7cd3421 | ||
|
|
8b61d2d521 | ||
|
|
559e5269d2 | ||
|
|
ebf6acb318 | ||
|
|
7acd75f013 | ||
|
|
93623b2226 | ||
|
|
d71fd089cb | ||
|
|
05aa43c856 | ||
|
|
de80fb6bc8 | ||
|
|
8e396f4be2 | ||
|
|
efec6da377 | ||
|
|
00ef53195e | ||
|
|
f564aaf485 | ||
|
|
5ac2d31d0d | ||
|
|
fda890ab47 | ||
|
|
a873278c2a | ||
|
|
e6d64be890 | ||
|
|
0e4d978d20 | ||
|
|
b12c2cfedf | ||
|
|
58cc70725e | ||
|
|
339afb02aa | ||
|
|
7ba0ff5c36 | ||
|
|
ff41fbf673 | ||
|
|
2435cd23ce | ||
|
|
a063cff720 | ||
|
|
0d396e1ac0 | ||
|
|
f5807a2053 | ||
|
|
eb4938a36f | ||
|
|
c5ad530bbf | ||
|
|
d11c428407 | ||
|
|
6182b1f53e | ||
|
|
00fc1a12e2 | ||
|
|
16af753206 | ||
|
|
fa48205bb8 | ||
|
|
f7dbf94071 | ||
|
|
b18e66e2ca | ||
|
|
7a5504dfa5 | ||
|
|
d1cad7bc68 | ||
|
|
c58d5c097c | ||
|
|
46157ca94e | ||
|
|
dd99511bcc | ||
|
|
5bc2efd3c7 |
@@ -0,0 +1,19 @@
|
||||
# OCRmyPDF webservice
|
||||
#
|
||||
FROM jbarlow83/ocrmypdf-polyglot:latest
|
||||
|
||||
USER root
|
||||
|
||||
# Update system and install our dependencies
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
python3-flask
|
||||
|
||||
RUN apt-get autoremove -y && apt-get clean -y
|
||||
|
||||
EXPOSE 5000
|
||||
|
||||
COPY .docker/webservice.py /application
|
||||
|
||||
USER docker
|
||||
|
||||
ENTRYPOINT ["python3", "/application/webservice.py"]
|
||||
@@ -0,0 +1,94 @@
|
||||
# webservice.py wrapper for OCRmyPDF
|
||||
# Copyright (C) 2018 James R. Barlow: github.com/jbarlow83
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
"""This is a simple web service/HTTP wrapper for OCRmyPDF
|
||||
|
||||
This may be more convenient than the command line tool for some Docker users.
|
||||
Note that OCRmyPDF uses Ghostscript, which is licensed under AGPL3+. While
|
||||
OCRmyPDF is under GPL3, this file is distributed under the Affero GPL3+ license,
|
||||
to emphasize that SaaS deployments should make sure they comply with
|
||||
Ghostscript's license as well as OCRmyPDF's.
|
||||
"""
|
||||
|
||||
from flask import Flask, Response, flash, request, redirect, url_for, abort, send_from_directory
|
||||
from subprocess import run, PIPE
|
||||
from tempfile import TemporaryDirectory
|
||||
from werkzeug.utils import secure_filename
|
||||
import os
|
||||
import shlex
|
||||
|
||||
app = Flask(__name__)
|
||||
app.secret_key = "secret"
|
||||
|
||||
uploaddir = TemporaryDirectory(prefix="ocrmypdf-upload")
|
||||
downloaddir = TemporaryDirectory(prefix="ocrmypdf-download")
|
||||
|
||||
app.config["UPLOAD_FOLDER"] = uploaddir
|
||||
ALLOWED_EXTENSIONS = set(["pdf"])
|
||||
|
||||
|
||||
def allowed_file(filename):
|
||||
return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_EXTENSIONS
|
||||
|
||||
|
||||
def do_ocrmypdf(file):
|
||||
filename = secure_filename(file.filename)
|
||||
up_file = os.path.join(uploaddir.name, filename)
|
||||
file.save(up_file)
|
||||
down_file = os.path.join(downloaddir.name, filename)
|
||||
|
||||
cmd_args = [arg for arg in shlex.split(request.form["params"])]
|
||||
if "--sidecar" in cmd_args:
|
||||
return Response("--sidecar not supported", 501, mimetype='text/plain')
|
||||
|
||||
ocrmypdf_args = ["ocrmypdf", *cmd_args, up_file, down_file]
|
||||
proc = run(ocrmypdf_args, stdout=PIPE, stderr=PIPE, encoding="utf-8")
|
||||
if proc.returncode != 0:
|
||||
stderr = proc.stderr
|
||||
return Response(stderr, 400, mimetype='text/plain')
|
||||
|
||||
return send_from_directory(downloaddir.name, filename)
|
||||
|
||||
|
||||
@app.route("/", methods=["GET", "POST"])
|
||||
def upload_file():
|
||||
if request.method == "POST":
|
||||
if "file" not in request.files:
|
||||
return Response("No file in POST", 400, mimetype='text/plain')
|
||||
file = request.files["file"]
|
||||
if file.filename == "":
|
||||
return Response("Empty filename", 400, mimetype='text/plain')
|
||||
if not allowed_file(file.filename):
|
||||
return Response("Invalid filename", 400, mimetype='text/plain')
|
||||
if file and allowed_file(file.filename):
|
||||
return do_ocrmypdf(file)
|
||||
return Response("Some other problem", 400, mimetype='text/plain')
|
||||
|
||||
return """
|
||||
<!doctype html>
|
||||
<title>OCRmyPDF webapp</title>
|
||||
<h1>Upload a PDF (debug UI)</h1>
|
||||
<form method=post enctype=multipart/form-data>
|
||||
<label for="args">Command line parameters</label>
|
||||
<input type=textbox name=params>
|
||||
<label for="file">File to upload</label>
|
||||
<input type=file name=file>
|
||||
<input type=submit value=Upload>
|
||||
</form>
|
||||
"""
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(host='0.0.0.0')
|
||||
+2
-1
@@ -22,7 +22,7 @@ docs/Makefile
|
||||
ocrmypdf/lib/_*.py
|
||||
|
||||
# Code coverage
|
||||
.coverage
|
||||
.coverage*
|
||||
htmlcov/
|
||||
|
||||
# Testing
|
||||
@@ -32,6 +32,7 @@ htmlcov/
|
||||
*.profile
|
||||
/*.pdf
|
||||
/*.qdf
|
||||
/*.png
|
||||
/scratch.py
|
||||
IDEAS
|
||||
log/
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
build:
|
||||
image: latest
|
||||
|
||||
python:
|
||||
version: 3.6
|
||||
|
||||
formats:
|
||||
- pdf
|
||||
|
||||
requirements_file: requirements/main.txt
|
||||
@@ -1,11 +1,15 @@
|
||||
OCRmyPDF
|
||||
========
|
||||
|
||||

|
||||
[![Travis build status][travis]](https://travis-ci.org/jbarlow83/OCRmyPDF) [![PyPI version][pypi]](https://pypi.org/project/ocrmypdf/) ![Homebrew version][homebrew] ![ReadTheDocs][docs]
|
||||
|
||||

|
||||
[travis]: https://travis-ci.org/jbarlow83/OCRmyPDF.svg?branch=master "Travis build status"
|
||||
|
||||

|
||||
[pypi]: https://img.shields.io/pypi/v/ocrmypdf.svg "PyPI version"
|
||||
|
||||
[homebrew]: https://img.shields.io/homebrew/v/ocrmypdf.svg "Homebrew version"
|
||||
|
||||
[docs]: https://readthedocs.org/projects/ocrmypdf/badge/?version=latest "RTD"
|
||||
|
||||
OCRmyPDF adds an OCR text layer to scanned PDF files, allowing them to be searched or copy-pasted.
|
||||
|
||||
@@ -64,6 +68,12 @@ Users of Debian 9 or later or Ubuntu 16.10 or later may simply
|
||||
apt-get install ocrmypdf
|
||||
```
|
||||
|
||||
and users of Fedora 29 or later may simply
|
||||
|
||||
```bash
|
||||
dnf install ocrmypdf
|
||||
```
|
||||
|
||||
and macOS users with Homebrew may simply
|
||||
|
||||
```bash
|
||||
|
||||
+3
-1
@@ -13,7 +13,9 @@ If a page in a PDF seems to have text, by default OCRmyPDF will exit without mod
|
||||
|
||||
If ``--skip-text`` is issued, then no OCR will be performed on pages that already have text. The page will be copied to the output. This may be useful for documents that contain both "born digital" and scanned content, or to use OCRmyPDF to normalize and convert to PDF/A regardless of their contents.
|
||||
|
||||
If ``--force-ocr`` is issued, then all pages will be rasterized to images, discarding any hidden OCR text, and rasterizing any printable text. This is useful for redoing OCR, for fixing OCR text with a damaged character map (text is selectable but not searchable), and destroying redacted information.
|
||||
If ``--redo-ocr`` is issued, then a detailed text analysis is performed. Text is categorized as either visible or invisible. Invisible text (OCR) is stripped out. Then an image of each page is created with visible text masked out. The page image is sent for OCR, and any additional text is inserted as OCR. If a file contains a mix of text and bitmap images that contain text, OCRmyPDF will locate the additional text in images without disrupting the existing text.
|
||||
|
||||
If ``--force-ocr`` is issued, then all pages will be rasterized to images, discarding any hidden OCR text, and rasterizing any printable text. This is useful for redoing OCR, for fixing OCR text with a damaged character map (text is selectable but not searchable), and destroying redacted information. Any forms and vector graphics will be rasterized as well.
|
||||
|
||||
|
||||
Time and image size limits
|
||||
|
||||
+1
-1
@@ -74,7 +74,7 @@ if on_rtd:
|
||||
def __getattr__(cls, name):
|
||||
return MagicMock()
|
||||
|
||||
MOCK_MODULES = ['pikepdf', 'libxmp', 'libxmp.utils']
|
||||
MOCK_MODULES = ['pikepdf', 'ocrmypdf.leptonica']
|
||||
sys.modules.update((mod_name, Mock()) for mod_name in MOCK_MODULES)
|
||||
|
||||
|
||||
|
||||
+12
-21
@@ -57,7 +57,6 @@ You can increase (decrease) the parameter ``--rotate-pages-threshold`` to make p
|
||||
|
||||
If the page is "just a little off horizontal", like a crooked picture, then you want ``--deskew``. ``--rotate-pages`` is for when the cardinal angle is wrong.
|
||||
|
||||
|
||||
OCR languages other than English
|
||||
""""""""""""""""""""""""""""""""
|
||||
|
||||
@@ -70,7 +69,6 @@ By default OCRmyPDF assumes the document is English.
|
||||
|
||||
Language packs must be installed for all languages specified. See :ref:`Installing additional language packs <lang-packs>`.
|
||||
|
||||
|
||||
Produce PDF and text file containing OCR text
|
||||
"""""""""""""""""""""""""""""""""""""""""""""
|
||||
|
||||
@@ -94,7 +92,7 @@ If you are starting with images, you can just use Tesseract 3.04 or later direct
|
||||
# When there are multiple images
|
||||
tesseract text-file-containing-list-of-image-filenames.txt output-prefix pdf
|
||||
|
||||
Tesseract's PDF output is quite good – OCRmyPDF uses it by internally by default. However, OCRmyPDF has many features not available in Tesseract like like image processing, metadata control, and PDF/A generation.
|
||||
Tesseract's PDF output is quite good – OCRmyPDF uses it internally by default. However, OCRmyPDF has many features not available in Tesseract like like image processing, metadata control, and PDF/A generation.
|
||||
|
||||
Use a program like `img2pdf <https://gitlab.mister-muffin.de/josch/img2pdf>`_ to convert your images to PDFs, and then pipe the results to run ocrmypdf. The `-` tells ocrmypdf to read standard input.
|
||||
|
||||
@@ -116,7 +114,6 @@ If you have multiple images, you must use ``img2pdf`` to convert the images to P
|
||||
|
||||
ImageMagick ``convert`` can also convert a group of images to PDF, but in the author's experience it takes a long time, transcodes unnecessarily and gives poor results.
|
||||
|
||||
|
||||
Image processing
|
||||
----------------
|
||||
|
||||
@@ -132,6 +129,8 @@ OCRmyPDF perform some image processing on each page of a PDF, if desired. The s
|
||||
|
||||
* ``--clean-final`` uses unpaper to clean up pages before OCR and inserts the page into the final output. You will want to review each page to ensure that unpaper did not remove something important.
|
||||
|
||||
* ``--mask-barcodes`` will "cover up" any barcodes detected in the image of a page. Barcodes are known to confuse Tesseract OCR and interfere with the recognition of text on the same baseline as a barcode. The output file will contain the unaltered image of the barcode.
|
||||
|
||||
.. note::
|
||||
|
||||
In many cases image processing will rasterize PDF pages as images, potentially losing quality.
|
||||
@@ -140,7 +139,6 @@ OCRmyPDF perform some image processing on each page of a PDF, if desired. The s
|
||||
|
||||
``--clean-final`` and ``-remove-background`` may leave undesirable visual artifacts in some images where their algorithms have shortcomings. Files should be visually reviewed after using these options.
|
||||
|
||||
|
||||
OCR and correct document skew (crooked scan)
|
||||
""""""""""""""""""""""""""""""""""""""""""""
|
||||
|
||||
@@ -167,28 +165,22 @@ 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
|
||||
""""""""
|
||||
Redo existing 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.
|
||||
To redo OCR on a file OCRed with other OCR software or a previous version of OCRmyPDF and/or Tesseract, you may use the ``--redo-ocr`` argument. (Normally, OCRmyPDF will exit with an error if asked to modify a file with OCR.)
|
||||
|
||||
This may be helpful for users who want to take advantage of accuracy improvements in Tesseract 4.0 for files they previously OCRed with an earlier version of Tesseract and OCRmyPDF.
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
ocrmypdf --force-ocr input.pdf output.pdf
|
||||
ocrmypdf --redo-ocr input.pdf output.pdf
|
||||
|
||||
Note that the method above will force rasterization of all pages, potentially reducing quality or losing vector content.
|
||||
This method will replace OCR without rasterizing, reducing quality or removing vector content. If a file contains a mix of pure digital text and OCR, digital text will be ignored and OCR will be replaced. As such this mode is incompatible with image processing options, since they alter the appearance of the file.
|
||||
|
||||
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.
|
||||
In some cases, existing OCR cannot be detected or replaced. Files produced by OCRmyPDF v2.2 or earlier, for example, are internally represented as having visible text with an opaque image drawn on top. This situation cannot be detected.
|
||||
|
||||
If ``--redo-ocr`` does not work, you can use ``--force-ocr``, which will force rasterization of all pages, potentially reducing quality or losing vector content.
|
||||
|
||||
Improving OCR quality
|
||||
---------------------
|
||||
@@ -199,7 +191,6 @@ Rotating pages and deskewing helps to ensure that the page orientation is correc
|
||||
|
||||
OCR quality will suffer if the resolution of input images is not correct (since the range of pixel sizes that will be checked for possible fonts will also be incorrect).
|
||||
|
||||
|
||||
PDF optimization
|
||||
----------------
|
||||
|
||||
|
||||
+47
-8
@@ -40,7 +40,7 @@ Debian and Ubuntu 16.10 or newer
|
||||
|
||||
|
||||
+-------------------------------------------+
|
||||
| **OCRmyPDF version** |
|
||||
| **OCRmyPDF versions in Debian & Ubuntu** |
|
||||
+-------------------------------------------+
|
||||
| |latest| |
|
||||
+-------------------------------------------+
|
||||
@@ -63,6 +63,45 @@ For full details on version availability for your platform, check the `Debian Pa
|
||||
|
||||
OCRmyPDF for Debian and Ubuntu currently omit the JBIG2 encoder. OCRmyPDF works fine without it but will produce larger output files. If you build jbig2enc from source, ocrmypdf 7.0.0 and later will automatically detect it (specifically the ``jbig2`` binary) on the ``PATH``. To add JBIG2 encoding, see :ref:`jbig2`.
|
||||
|
||||
Fedora 29 or newer
|
||||
^^^^^^^^^^^^^^^^^^
|
||||
|
||||
.. |fedora-29| image:: https://repology.org/badge/version-for-repo/fedora29/ocrmypdf.svg
|
||||
:alt: Fedora 29
|
||||
|
||||
.. |fedora-rawhide| image:: https://repology.org/badge/version-for-repo/fedora_rawhide/ocrmypdf.svg
|
||||
:alt: Fedore Rawhide
|
||||
|
||||
|
||||
+------------------------------+
|
||||
| **OCRmyPDF version** |
|
||||
+------------------------------+
|
||||
| |latest| |
|
||||
+------------------------------+
|
||||
| |fedora-29| |fedora-rawhide| |
|
||||
+------------------------------+
|
||||
|
||||
Users of Fedora 29 later may simply
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
dnf install ocrmypdf
|
||||
|
||||
For full details on version availability, check the `Fedora Package Tracker
|
||||
<https://apps.fedoraproject.org/packages/ocrmypdf>`_.
|
||||
|
||||
If the version available for your platform is out of date, you could opt to
|
||||
install the latest version from source. See `Installing HEAD revision from
|
||||
sources`_.
|
||||
|
||||
.. note::
|
||||
|
||||
OCRmyPDF for Fedora currently omits the JBIG2 encoder due to patent issues.
|
||||
OCRmyPDF works fine without it but will produce larger output files. If you
|
||||
build jbig2enc from source, ocrmypdf 7.0.0 and later will automatically
|
||||
detect it on the ``PATH``. To add JBIG2 encoding, see `Installing the JBIG2
|
||||
encoder`_.
|
||||
|
||||
Installing the latest version on Ubuntu 18.04 LTS
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
@@ -457,13 +496,15 @@ OCRmyPDF currently requires these external programs and libraries to be installe
|
||||
- qpdf 8.1.0 or newer
|
||||
- Tesseract 3.04 or newer
|
||||
|
||||
As of ocrmypdf 7.0.0, the following versions are recommended:
|
||||
Using Python 3.5 in production is discouraged. Python 3.6 and 3.7 give much better performance.
|
||||
|
||||
As of ocrmypdf 7.2.1, the following versions are recommended:
|
||||
|
||||
- Python 3.7
|
||||
- Ghostscript 9.23
|
||||
- libexempi3 2.4.5
|
||||
- qpdf 8.1.0
|
||||
- Tesseract 4.0.0-beta1
|
||||
- Ghostscript 9.23 or newer
|
||||
- libexempi3 2.4.5 or newer
|
||||
- qpdf 8.2.1
|
||||
- Tesseract 4.0.0-rc1
|
||||
- jbig2enc 0.29 or newer
|
||||
- pngquant 2.5 or newer
|
||||
- unpaper 6.1
|
||||
@@ -478,8 +519,6 @@ jbig2enc, pngquant, and unpaper are optional. If missing certain features are di
|
||||
|
||||
These are in addition to the Python packaging dependencies, meaning that unfortunately, the ``pip install`` command cannot satisfy all of them.
|
||||
|
||||
Python 3.7 and Tesseract 4.0.0-beta.1 are recommended for best OCR results and best performance.
|
||||
|
||||
|
||||
Installing HEAD revision from sources
|
||||
-------------------------------------
|
||||
|
||||
@@ -64,7 +64,7 @@ In the case of a PDF that is nothing other than a container of images (no rotati
|
||||
|
||||
OCRmyPDF uses several strategies depending on input options and the input PDF itself, but generally speaking it rasterizes a page for OCR and then grafts the OCR back onto the original. As such it can handle complex PDFs and still preserve their contents as much as possible.
|
||||
|
||||
OCRmyPDF also supports a many, many edge cases that have cropped over several years of development. We support PDF features like images inside of Form XObjects, and pages with UserUnit scaling. We support rare image formats like non-monochrome 1-bit images. Thanks to pikepdf and QPDF, we auto-repair PDFs that are damaged. (Not that you need to know what any of these are! You should be able to throw any PDF at it.)
|
||||
OCRmyPDF also supports a many, many edge cases that have cropped over several years of development. We support PDF features like images inside of Form XObjects, and pages with UserUnit scaling. We support rare image formats like non-monochrome 1-bit images. We warn about files you may not to OCR. Thanks to pikepdf and QPDF, we auto-repair PDFs that are damaged. (Not that you need to know what any of these are! You should be able to throw any PDF at it.)
|
||||
|
||||
|
||||
Limitations
|
||||
@@ -76,20 +76,20 @@ OCRmyPDF is limited by the Tesseract OCR engine. As such it experiences these l
|
||||
* It is not capable of recognizing handwriting.
|
||||
* It may find gibberish and report this as OCR output.
|
||||
* If a document contains languages outside of those given in the ``-l LANG`` arguments, results may be poor.
|
||||
* It is not always good at analyzing the natural reading order of documents. For example, it may fail to recognize that a document contains two columns and join text across the columns.
|
||||
* It is not always good at analyzing the natural reading order of documents. For example, it may fail to recognize that a document contains two columns, and may try to join text across columns.
|
||||
* Poor quality scans may produce poor quality OCR. Garbage in, garbage out.
|
||||
* It does not expose information about what font family text belongs to.
|
||||
|
||||
OCRmyPDF is also limited by the PDF specification:
|
||||
|
||||
* PDF encodes the position of text glyphs but does not encode document structure. There is no markup that divides a document in sections, paragraphs, sentences, or even words (since blank spaces are not represented). As such all elements of document structure including the spaces between words must be derived heuristically. Some PDF viewers do a better job of this than others.
|
||||
* Because some popular open source PDF viewers have a particularly hard time with spaces betweem words, OCRmyPDF appends a space to each text element as a workaround. While this mixes document structure with graphical information that ideally should be left to the PDF viewer to interpret, it improves compatibility with some viewers and does not cause problems for better ones.
|
||||
* Because some popular open source PDF viewers have a particularly hard time with spaces betweem words, OCRmyPDF appends a space to each text element as a workaround (when using ``--pdf-renderer hocr``). While this mixes document structure with graphical information that ideally should be left to the PDF viewer to interpret, it improves compatibility with some viewers and does not cause problems for better ones.
|
||||
|
||||
Ghostscript also imposes some limitations:
|
||||
|
||||
* PDFs containing JBIG2-encoded content will be converted to CCITT Group4 encoding, which has lower compression ratios, if Ghostscript PDF/A is enabled.
|
||||
* PDFs containing JPEG 2000-encoded content will be converted to JPEG encoding, which may introduce compression artifacts, if Ghostscript PDF/A is enabled.
|
||||
* Ghostscript may transcode grayscale and color images, either lossy to lossless or lossless to lossy, based on an internal algorithm. This behavior can be suppressed by setting ``--pdfa-image-compression`` to ``jpeg`` or ``lossless`` to set all images to one type or the other. Ghostscript has no option to maintain the input image's format.
|
||||
* Ghostscript may transcode grayscale and color images, either lossy to lossless or lossless to lossy, based on an internal algorithm. This behavior can be suppressed by setting ``--pdfa-image-compression`` to ``jpeg`` or ``lossless`` to set all images to one type or the other. Ghostscript has no option to maintain the input image's format. (Ghostscript 9.25+ can copy JPEG images without transcoding them; earlier versions will transcode.)
|
||||
|
||||
Regarding OCRmyPDF itself:
|
||||
|
||||
|
||||
+18
-3
@@ -17,12 +17,27 @@ Debian and Ubuntu users
|
||||
# Display a list of all Tesseract language packs
|
||||
apt-cache search tesseract-ocr
|
||||
|
||||
# Debian/Ubuntu users
|
||||
apt-get install tesseract-ocr-chi-sim # Example: Install Chinese Simplified language back
|
||||
|
||||
# Install Chinese Simplified language pack
|
||||
apt-get install tesseract-ocr-chi-sim
|
||||
|
||||
You can then pass the ``-l LANG`` argument to OCRmyPDF to give a hint as to what languages it should search for. Multiple
|
||||
languages can be requested using either ``-l eng+fre`` (English and French) or ``-l eng -l fre``.
|
||||
|
||||
Fedora users
|
||||
------------
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
# Display a list of all Tesseract language packs
|
||||
dnf search tesseract
|
||||
|
||||
# Install Chinese Simplified language pack
|
||||
dnf install tesseract-langpack-chi_sim
|
||||
|
||||
You can then pass the ``-l LANG`` argument to OCRmyPDF to give a hint as to
|
||||
what languages it should search for. Multiple languages can be requested using
|
||||
either ``-l eng+fre`` (English and French) or ``-l eng -l fre``.
|
||||
|
||||
macOS users
|
||||
-----------
|
||||
|
||||
|
||||
@@ -13,6 +13,63 @@ Note that it is licensed under GPLv3, so scripts that ``import ocrmypdf`` and ar
|
||||
find: [^`]\#([0-9]{1,3})[^0-9]
|
||||
replace: `#$1 <https://github.com/jbarlow83/OCRmyPDF/issues/$1>`_
|
||||
|
||||
v7.4.0
|
||||
------
|
||||
|
||||
- ``--force-ocr`` may now be used with the new ``--threshold`` and ``--mask-barcodes`` features
|
||||
|
||||
- pikepdf >= 0.9.1 is now required.
|
||||
|
||||
- Changed metadata handling to pikepdf 0.9.1. As a result, metadata handling of non-ASCII characters in Ghostscript 9.25 or later is fixed.
|
||||
|
||||
- chardet >= 3.0.4 is temporarily listed as required. pdfminer.six depends on it, but the most recent release does not specify this requirement. (`#326 <https://github.com/jbarlow83/OCRmyPDF/issues/326>`_)
|
||||
|
||||
- python-xmp-toolkit and libexempi are no longer required.
|
||||
|
||||
- A new Docker image is now being provided for users who wish to access OCRmyPDF over a simple HTTP interface, instead of the command line.
|
||||
|
||||
- Increase tolerance of PDFs that overflow or underflow the PDF graphics stack. (`#325 <https://github.com/jbarlow83/OCRmyPDF/issues/325>`_)
|
||||
|
||||
v7.3.1
|
||||
------
|
||||
|
||||
- Fixed performance regression from v7.3.0; fast page analysis was not selected when it should be.
|
||||
|
||||
- Fixed a few exceptions related to the new ``--mask-barcodes`` feature and improved argument checking
|
||||
|
||||
- Added missing detection of TrueType fonts that lack a Unicode mapping
|
||||
|
||||
|
||||
v7.3.0
|
||||
------
|
||||
|
||||
- Added a new feature ``--redo-ocr`` to detect existing OCR in a file, remove it, and redo the OCR. This may be particularly helpful for anyone who wants to take advantage of OCR quality improvements in Tesseract 4.0. Note that OCR added by OCRmyPDF before version 3.0 cannot be detected since it was not properly marked as invisible text in the earliest versions. OCR that constructs a font from visible text, such as Adobe Acrobat's ClearScan.
|
||||
|
||||
- OCRmyPDF's content detection is generally more sophisticated. It learns more about the contents of each PDF and makes better recommendations:
|
||||
|
||||
- OCRmyPDF can now detect when a PDF contains text that cannot be mapped to Unicode (meaning it is readable to human eyes but copy-pastes as gibberish). In these cases it recommends ``--force-ocr`` to make the text searchable.
|
||||
|
||||
- PDFs containing vector objects are now rendered at more appropriate resolution for OCR.
|
||||
|
||||
- We now exit with an error for PDFs that contain Adobe LiveCycle Designer's dynamic XFA forms. Currently the open source community does not have tools to work with these files.
|
||||
|
||||
- OCRmyPDF now warns when a PDF that contains Adobe AcroForms, since such files probably do not need OCR. It can work with these files.
|
||||
|
||||
- Added three new **experimental** features to improve OCR quality in certain conditions. The name, syntax and behavior of these arguments is subject to change. They may also be incompatible with some other features.
|
||||
|
||||
- ``--remove-vectors`` which strips out vector graphics. This can improve OCR quality since OCR will not search artwork for readable text; however, it currently removes "text as curves" as well.
|
||||
|
||||
- ``--mask-barcodes`` to detect and suppress barcodes in files. We have observed that barcodes can interfere with OCR because they are "text-like" but not actually textual.
|
||||
|
||||
- ``--threshold`` which uses a more sophisticated thresholding algorithm than is currently in use in Tesseract OCR. This works around a `known issue in Tesseract 4.0 <https://github.com/tesseract-ocr/tesseract/issues/1990>`_ with dark text on bright backgrounds.
|
||||
|
||||
- Fixed an issue where an error message was not reported when the installed Ghostscript was very old.
|
||||
|
||||
- The PDF optimizer now saves files with object streams enabled when the optimization level is ``--optimize 1`` or higher (the default). This makes files a little bit smaller, but requires PDF 1.5. PDF 1.5 was first released in 2003 and is broadly supported by PDF viewers, but some rudimentary PDF parsers such as PyPDF2 do not understand object streams. You can use the command line tool ``qpdf --object-streams=disable`` or `pikepdf <https://github.com/pikepdf/pikepdf>`_ library to remove them.
|
||||
|
||||
- New dependency: pdfminer.six 20181108. Note this is a fork of the Python 2-only pdfminer.
|
||||
|
||||
- Deprecation notice: At the end of 2018, we will be ending support for Python 3.5 and Tesseract 3.x. OCRmyPDF v7 will continue to work with older versions.
|
||||
|
||||
v7.2.1
|
||||
------
|
||||
@@ -152,6 +209,15 @@ v7.0.0
|
||||
|
||||
+ It may be necessary to separately ``pip install pycparser`` to avoid `another Python 3.7 issue <https://github.com/eliben/pycparser/pull/135>`_.
|
||||
|
||||
v6.2.5
|
||||
------
|
||||
|
||||
- Disable a failing test due to Tesseract 4.0rc1 behavior change. Previously, Tesseract would exit with an error message if its configuration was invalid, and OCRmyPDF would intercept this message. Now Tesseract issues a warning, which OCRmyPDF v6.2.5 may relay or ignore. (In v7.x, OCRmyPDF will respond to the warning.)
|
||||
|
||||
- This release branch no longer supports using the optional PyMuPDF installation, since it was removed in v7.x.
|
||||
|
||||
- This release branch no longer supports macOS. macOS users should upgrade to v7.x.
|
||||
|
||||
v6.2.4
|
||||
------
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
check-manifest >= 0.35
|
||||
twine >= 1.8.1
|
||||
coverage >= 4.4
|
||||
coverage >= 4.5
|
||||
GitPython == 2.1.3
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
# requirements.txt can be used to replicate the developer's build environment
|
||||
# setup.py lists a separate set of requirements that are looser to simplify
|
||||
# installation
|
||||
chardet == 3.0.4
|
||||
cffi == 1.11.5
|
||||
img2pdf == 0.3.0
|
||||
pikepdf == 0.3.4
|
||||
img2pdf == 0.3.1
|
||||
pdfminer.six == 20181108
|
||||
pikepdf == 0.9.1
|
||||
Pillow >= 5.0.0, != 5.1.0 ; sys_platform == "darwin"
|
||||
pycparser == 2.18
|
||||
pycparser == 2.19
|
||||
python-xmp-toolkit == 2.0.1
|
||||
reportlab == 3.4.0
|
||||
ruffus == 2.7.0
|
||||
reportlab == 3.5.9
|
||||
ruffus == 2.8.0
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
pytest >= 3.2
|
||||
pytest == 3.9.3
|
||||
pytest-helpers-namespace
|
||||
pytest-xdist
|
||||
pytest-cov
|
||||
|
||||
@@ -11,6 +11,8 @@ ignore =
|
||||
[tool:pytest]
|
||||
norecursedirs = lib .pc .git output cache resources
|
||||
testpaths = tests
|
||||
filterwarnings =
|
||||
ignore:.*XMLParser.*:DeprecationWarning
|
||||
|
||||
[metadata]
|
||||
license_file = LICENSE
|
||||
license_file = LICENSE
|
||||
|
||||
@@ -28,7 +28,6 @@ from setuptools import setup, find_packages # nopep8
|
||||
from subprocess import STDOUT, check_output, CalledProcessError # nopep8
|
||||
from collections.abc import Mapping # nopep8
|
||||
import re # nopep8
|
||||
import os # nopep8
|
||||
|
||||
|
||||
missing_program = '''
|
||||
@@ -249,13 +248,14 @@ setup(
|
||||
'src/ocrmypdf/lib/compile_leptonica.py:ffibuilder'
|
||||
],
|
||||
install_requires=[
|
||||
'chardet >= 3.0.4, < 4', # unlisted requirement of pdfminer.six 20181108
|
||||
'cffi >= 1.9.1', # must be a setup and install requirement
|
||||
'img2pdf >= 0.2.4, < 0.4', # pure Python, so track HEAD closely
|
||||
'pikepdf >= 0.3.3, < 0.4',
|
||||
'img2pdf >= 0.3.0, < 0.4', # pure Python, so track HEAD closely
|
||||
'pdfminer.six == 20181108',
|
||||
'pikepdf >= 0.9.1',
|
||||
'Pillow >= 4.0.0, != 5.1.0 ; sys_platform == "darwin"',
|
||||
# Pillow < 4 has BytesIO/TIFF bug w/img2pdf 0.2.3
|
||||
# block 5.1.0, broken wheels
|
||||
'python-xmp-toolkit >= 2, < 3',
|
||||
'reportlab >= 3.3.0', # oldest released version with sane image handling
|
||||
'ruffus >= 2.7.0',
|
||||
],
|
||||
|
||||
@@ -244,19 +244,38 @@ preprocessing.add_argument(
|
||||
'--oversample', metavar='DPI', type=numeric(int, 0, 5000), default=0,
|
||||
help="Oversample images to at least the specified DPI, to improve OCR "
|
||||
"results slightly")
|
||||
preprocessing.add_argument(
|
||||
'--remove-vectors', action='store_true',
|
||||
help="EXPERIMENTAL. Mask out any vector objects in the PDF so that they "
|
||||
"will not be included in OCR. This can eliminate false characters.")
|
||||
preprocessing.add_argument(
|
||||
'--mask-barcodes', action='store_true',
|
||||
help="EXPERIMENTAL. Mask out any barcodes that appear in the PDF so they are not "
|
||||
"considered during OCR. Barcodes can introduce false characters into "
|
||||
"OCR.")
|
||||
preprocessing.add_argument(
|
||||
'--threshold', action='store_true',
|
||||
help="EXPERIMENTAL. Threshold image to 1bpp before sending it to Tesseract for OCR. Can "
|
||||
"improve OCR quality compared to Tesseract's thresholder.")
|
||||
|
||||
ocrsettings = parser.add_argument_group(
|
||||
"OCR options",
|
||||
"Control how OCR is applied")
|
||||
ocrsettings.add_argument(
|
||||
'-f', '--force-ocr', action='store_true',
|
||||
help="Rasterize any fonts or vector objects on each page, apply OCR, and "
|
||||
help="Rasterize any text or vector objects on each page, apply OCR, and "
|
||||
"save the rastered output (this rewrites the PDF)")
|
||||
ocrsettings.add_argument(
|
||||
'-s', '--skip-text', action='store_true',
|
||||
help="Skip OCR on any pages that already contain text, but include the "
|
||||
"page in final output; useful for PDFs that contain a mix of "
|
||||
"images, text pages, and/or previously OCRed pages")
|
||||
ocrsettings.add_argument(
|
||||
'--redo-ocr', action='store_true',
|
||||
help="Attempt to detect and remove the hidden OCR layer from files that "
|
||||
"were previously OCRed with OCRmyPDF or another program. Apply OCR "
|
||||
"to text found in raster images. Existing visible text objects will "
|
||||
"not be changed. If there is no existing OCR, OCR will be added.")
|
||||
ocrsettings.add_argument(
|
||||
'--skip-big', type=numeric(float, 0, 5000), metavar='MPixels',
|
||||
help="Skip OCR on pages larger than the specified amount of megapixels, "
|
||||
@@ -461,12 +480,18 @@ def check_options_output(options, log):
|
||||
)
|
||||
|
||||
lossless_reconstruction = False
|
||||
if options.pdf_renderer in ('hocr', 'sandwich'):
|
||||
if not any((options.deskew, options.clean_final, options.force_ocr,
|
||||
options.remove_background)):
|
||||
lossless_reconstruction = True
|
||||
if not any((options.deskew, options.clean_final, options.force_ocr,
|
||||
options.remove_background)):
|
||||
lossless_reconstruction = True
|
||||
options.lossless_reconstruction = lossless_reconstruction
|
||||
|
||||
if not options.lossless_reconstruction and options.redo_ocr:
|
||||
raise argparse.ArgumentError(
|
||||
None,
|
||||
"--redo-ocr is not currently compatible with --deskew, "
|
||||
"--clean-final, and --remove-background"
|
||||
)
|
||||
|
||||
|
||||
def check_options_sidecar(options, log):
|
||||
if options.sidecar == '\0':
|
||||
@@ -511,10 +536,15 @@ def check_options_preprocessing(options, log):
|
||||
|
||||
|
||||
def check_options_ocr_behavior(options, log):
|
||||
if options.force_ocr and options.skip_text:
|
||||
exclusive_options = sum(
|
||||
[(1 if opt else 0)
|
||||
for opt in (options.force_ocr, options.skip_text, options.redo_ocr)
|
||||
]
|
||||
)
|
||||
if exclusive_options >= 2:
|
||||
raise argparse.ArgumentError(
|
||||
None,
|
||||
"Error: --force-ocr and --skip-text are mutually exclusive.")
|
||||
"Error: choose only one of --force-ocr, --skip-text, --redo-ocr.")
|
||||
|
||||
|
||||
def check_options_optimizing(options, log):
|
||||
@@ -653,6 +683,8 @@ def do_ruffus_exception(ruffus_five_tuple, options, log):
|
||||
try:
|
||||
if isinstance(exc_value, exc_class):
|
||||
exc_msg = str(exc_value)
|
||||
elif isinstance(exc_value, str):
|
||||
exc_msg = exc_value
|
||||
else:
|
||||
exc_msg = str(exc_class())
|
||||
except Exception:
|
||||
|
||||
+141
-67
@@ -29,11 +29,13 @@ import pikepdf
|
||||
from PIL import Image
|
||||
from ruffus import formatter, regex, Pipeline, suffix
|
||||
|
||||
from pikepdf.models.metadata import encode_pdf_date
|
||||
|
||||
from .hocrtransform import HocrTransform
|
||||
from .pdfinfo import PdfInfo, Colorspace
|
||||
from .pdfa import generate_pdfa_ps, encode_pdf_date
|
||||
from .pdfa import generate_pdfa_ps
|
||||
from .helpers import re_symlink, is_iterable_notstr, page_number, flatten_groups
|
||||
from .exec import ghostscript, tesseract, qpdf
|
||||
from .exec import ghostscript, tesseract
|
||||
from .exceptions import UnsupportedImageFormatError, \
|
||||
DpiError, PriorOcrFoundError, InputFileError, EncryptedPdfError
|
||||
from . import leptonica
|
||||
@@ -162,8 +164,12 @@ def repair_and_parse_pdf(
|
||||
options = context.get_options()
|
||||
copyfile(input_file, output_file)
|
||||
|
||||
detailed_page_analysis = False
|
||||
if options.redo_ocr:
|
||||
detailed_page_analysis = True
|
||||
|
||||
try:
|
||||
pdfinfo = PdfInfo(output_file, log=log)
|
||||
pdfinfo = PdfInfo(output_file, detailed_page_analysis=detailed_page_analysis, log=log)
|
||||
except pikepdf.PasswordError as e:
|
||||
raise EncryptedPdfError()
|
||||
except pikepdf.PdfError as e:
|
||||
@@ -193,6 +199,25 @@ def repair_and_parse_pdf(
|
||||
"high page count files. Python 3.6 or newer is recommended."
|
||||
)
|
||||
|
||||
if pdfinfo.has_acroform:
|
||||
if options.redo_ocr:
|
||||
log.error(
|
||||
"This PDF has a user fillable form. --redo-ocr is not "
|
||||
"currently possible on such files."
|
||||
)
|
||||
raise PriorOcrFoundError()
|
||||
else:
|
||||
log.warning(
|
||||
"This PDF has a fillable form. Chances are it is a pure digital "
|
||||
"document that does not need OCR."
|
||||
)
|
||||
if not options.force_ocr:
|
||||
log.info(
|
||||
"Use the option --force-ocr to produce an image of the "
|
||||
"form and all filled form fields. The output PDF will be "
|
||||
"'flattened' and will no longer be fillable."
|
||||
)
|
||||
|
||||
context.set_pdfinfo(pdfinfo)
|
||||
log.debug(pdfinfo)
|
||||
|
||||
@@ -206,8 +231,12 @@ def get_pageinfo(input_file, context):
|
||||
|
||||
def get_page_dpi(pageinfo, options):
|
||||
"Get the DPI when nonsquare DPI is tolerable"
|
||||
xres = max(pageinfo.xres or VECTOR_PAGE_DPI, options.oversample or 0)
|
||||
yres = max(pageinfo.yres or VECTOR_PAGE_DPI, options.oversample or 0)
|
||||
xres = max(pageinfo.xres or VECTOR_PAGE_DPI,
|
||||
options.oversample or 0,
|
||||
VECTOR_PAGE_DPI if pageinfo.has_vector else 0)
|
||||
yres = max(pageinfo.yres or VECTOR_PAGE_DPI,
|
||||
options.oversample or 0,
|
||||
VECTOR_PAGE_DPI if pageinfo.has_vector else 0)
|
||||
return (float(xres), float(yres))
|
||||
|
||||
|
||||
@@ -219,6 +248,7 @@ def get_page_square_dpi(pageinfo, options):
|
||||
return float(max(
|
||||
(xres * userunit) or VECTOR_PAGE_DPI,
|
||||
(yres * userunit) or VECTOR_PAGE_DPI,
|
||||
VECTOR_PAGE_DPI if pageinfo.has_vector else 0,
|
||||
options.oversample or 0))
|
||||
|
||||
|
||||
@@ -227,6 +257,7 @@ def get_canvas_square_dpi(pageinfo, options):
|
||||
return float(max(
|
||||
(pageinfo.xres) or VECTOR_PAGE_DPI,
|
||||
(pageinfo.yres) or VECTOR_PAGE_DPI,
|
||||
VECTOR_PAGE_DPI if pageinfo.has_vector else 0,
|
||||
options.oversample or 0))
|
||||
|
||||
|
||||
@@ -237,7 +268,7 @@ def is_ocr_required(pageinfo, log, options):
|
||||
if pageinfo.has_text:
|
||||
msg = "{0:4d}: page already has text! – {1}"
|
||||
|
||||
if not options.force_ocr and not options.skip_text:
|
||||
if not options.force_ocr and not (options.skip_text or options.redo_ocr):
|
||||
log.error(msg.format(page,
|
||||
"aborting (use --force-ocr to force OCR)"))
|
||||
raise PriorOcrFoundError()
|
||||
@@ -245,6 +276,18 @@ def is_ocr_required(pageinfo, log, options):
|
||||
log.info(msg.format(page,
|
||||
"rasterizing text and running OCR anyway"))
|
||||
ocr_required = True
|
||||
elif options.redo_ocr:
|
||||
if pageinfo.has_corrupt_text:
|
||||
log.warning(msg.format(
|
||||
page,
|
||||
"some text on this page cannot be mapped to characters: "
|
||||
"consider using --force-ocr instead")
|
||||
)
|
||||
raise PriorOcrFoundError() # Wrong error but will do for now
|
||||
else:
|
||||
log.info(msg.format(page,
|
||||
"redoing OCR"))
|
||||
ocr_required = True
|
||||
elif options.skip_text:
|
||||
log.info(msg.format(page,
|
||||
"skipping all processing on this page"))
|
||||
@@ -487,7 +530,8 @@ def rasterize_with_ghostscript(
|
||||
ghostscript.rasterize_pdf(
|
||||
input_file, output_file, xres=canvas_dpi, yres=canvas_dpi,
|
||||
raster_device=device, log=log, page_dpi=(page_dpi, page_dpi),
|
||||
pageno=page_number(input_file), rotation=correction)
|
||||
pageno=page_number(input_file), rotation=correction,
|
||||
filter_vector=options.remove_vectors)
|
||||
|
||||
|
||||
def preprocess_remove_background(
|
||||
@@ -553,10 +597,7 @@ def select_ocr_image(
|
||||
user."""
|
||||
|
||||
image = infiles[0]
|
||||
if context.get_options().force_ocr:
|
||||
re_symlink(image, output_file, log)
|
||||
return
|
||||
|
||||
options = context.get_options()
|
||||
pageinfo = get_pageinfo(image, context)
|
||||
|
||||
with Image.open(image) as im:
|
||||
@@ -569,22 +610,42 @@ def select_ocr_image(
|
||||
|
||||
xres, yres = im.info['dpi']
|
||||
log.debug('resolution %r %r', xres, yres)
|
||||
for textarea in pageinfo.get_textareas():
|
||||
# Calculate resolution based on the image size and page dimensions
|
||||
# without regard whatever resolution is in pageinfo (may differ or
|
||||
# be None)
|
||||
bbox = textarea
|
||||
pixcoords = [Decimal(bbox[0]) / Decimal(72) * xres,
|
||||
Decimal(bbox[1]) / Decimal(72) * yres,
|
||||
Decimal(bbox[2]) / Decimal(72) * xres,
|
||||
Decimal(bbox[3]) / Decimal(72) * yres]
|
||||
pixcoords = [int(c) for c in pixcoords]
|
||||
log.debug('blanking %r', pixcoords)
|
||||
draw.rectangle(pixcoords, fill=white)
|
||||
#draw.rectangle(pixcoords, outline=pink)
|
||||
|
||||
if not options.force_ocr:
|
||||
# Do not mask text areas when forcing OCR, because we need to OCR
|
||||
# all text areas
|
||||
mask = None # Exclude both visible and invisible text from OCR
|
||||
if options.redo_ocr:
|
||||
mask = True # Mask visible text, but not invisible text
|
||||
|
||||
for textarea in pageinfo.get_textareas(visible=mask, corrupt=None):
|
||||
# Calculate resolution based on the image size and page dimensions
|
||||
# without regard whatever resolution is in pageinfo (may differ or
|
||||
# be None)
|
||||
bbox = [float(v) for v in textarea]
|
||||
xscale, yscale = float(xres) / 72.0, float(yres) / 72.0
|
||||
pixcoords = [bbox[0] * xscale,
|
||||
im.height - bbox[3] * yscale,
|
||||
bbox[2] * xscale,
|
||||
im.height - bbox[1] * yscale]
|
||||
pixcoords = [int(round(c)) for c in pixcoords]
|
||||
log.debug('blanking %r', pixcoords)
|
||||
draw.rectangle(pixcoords, fill=white)
|
||||
#draw.rectangle(pixcoords, outline=pink)
|
||||
|
||||
if options.mask_barcodes or options.threshold:
|
||||
pix = leptonica.Pix.frompil(im)
|
||||
if options.threshold:
|
||||
pix = pix.masked_threshold_on_background_norm()
|
||||
if options.mask_barcodes:
|
||||
barcodes = pix.locate_barcodes()
|
||||
for barcode in barcodes:
|
||||
decoded, rect = barcode
|
||||
log.info('masking barcode %s %r', decoded, rect)
|
||||
draw.rectangle(rect, fill=white)
|
||||
im = pix.topil()
|
||||
|
||||
del draw
|
||||
|
||||
# Pillow requires integer DPI
|
||||
dpi = round(xres), round(yres)
|
||||
im.save(output_file, dpi=dpi)
|
||||
@@ -734,10 +795,10 @@ def ocr_tesseract_textonly_pdf(
|
||||
log=log)
|
||||
|
||||
|
||||
def get_pdfmark(base_pdf, options):
|
||||
def get_docinfo(base_pdf, options):
|
||||
def from_document_info(key):
|
||||
try:
|
||||
s = base_pdf.metadata[key]
|
||||
s = base_pdf.docinfo[key]
|
||||
return str(s)
|
||||
except (KeyError, TypeError):
|
||||
return ''
|
||||
@@ -779,21 +840,34 @@ def generate_postscript_stub(
|
||||
context):
|
||||
options = context.get_options()
|
||||
pdf = pikepdf.open(input_file)
|
||||
pdfmark = get_pdfmark(pdf, options)
|
||||
generate_pdfa_ps(output_file)
|
||||
|
||||
ascii_docinfo = False
|
||||
if ghostscript.version() >= '9.24':
|
||||
ascii_docinfo = True
|
||||
try:
|
||||
for v in pdfmark.values():
|
||||
v.encode('ascii', errors='strict')
|
||||
except UnicodeEncodeError:
|
||||
log.warning(
|
||||
"Ghostscript 9.24+ does not support Unicode strings in "
|
||||
" metadata. These will be converted to ASCII if possible."
|
||||
)
|
||||
|
||||
generate_pdfa_ps(output_file, pdfmark, ascii_docinfo=ascii_docinfo)
|
||||
def convert_to_pdfa(
|
||||
input_files_groups,
|
||||
output_file,
|
||||
log,
|
||||
context
|
||||
):
|
||||
options = context.get_options()
|
||||
input_pdfinfo = context.get_pdfinfo()
|
||||
|
||||
input_files = list(f for f in flatten_groups(input_files_groups))
|
||||
layers_file = next(
|
||||
(ii for ii in input_files if ii.endswith('layers.rendered.pdf')), None
|
||||
)
|
||||
ps = next(
|
||||
(ii for ii in input_files if ii.endswith('.ps')), None
|
||||
)
|
||||
ghostscript.generate_pdfa(
|
||||
pdf_version=input_pdfinfo.min_version,
|
||||
pdf_pages=[layers_file, ps],
|
||||
output_file=output_file,
|
||||
compression=options.pdfa_image_compression,
|
||||
log=log,
|
||||
threads=options.jobs or 1,
|
||||
pdfa_part=options.output_type[-1] # is pdfa-1, pdfa-2, or pdfa-3
|
||||
)
|
||||
|
||||
|
||||
def metadata_fixup(
|
||||
@@ -810,33 +884,23 @@ def metadata_fixup(
|
||||
layers_file = next(
|
||||
(ii for ii in input_files if ii.endswith('layers.rendered.pdf')), None
|
||||
)
|
||||
ps = next(
|
||||
(ii for ii in input_files if ii.endswith('.ps')), None
|
||||
pdfa = next(
|
||||
(ii for ii in input_files if ii.endswith('pdfa.pdf')), None
|
||||
)
|
||||
metadata = pikepdf.open(metadata_file)
|
||||
docinfo = get_docinfo(metadata, options)
|
||||
|
||||
if options.output_type.startswith('pdfa'):
|
||||
input_pdfinfo = context.get_pdfinfo()
|
||||
ghostscript.generate_pdfa(
|
||||
pdf_version=input_pdfinfo.min_version,
|
||||
pdf_pages=[layers_file, ps],
|
||||
output_file=output_file,
|
||||
compression=options.pdfa_image_compression,
|
||||
log=log,
|
||||
threads=options.jobs or 1,
|
||||
pdfa_part=options.output_type[-1] # is pdfa-1, pdfa-2, or pdfa-3
|
||||
)
|
||||
else:
|
||||
metadata = pikepdf.open(metadata_file)
|
||||
pdfmark = get_pdfmark(metadata, options)
|
||||
pdf = pikepdf.open(layers_file)
|
||||
pdf.metadata = pdf.make_indirect(pikepdf.Dictionary(pdfmark))
|
||||
try:
|
||||
pdf.save(output_file, compress_streams=True,
|
||||
object_stream_mode=pikepdf.ObjectStreamMode.generate)
|
||||
except AttributeError:
|
||||
# pikepdf <= 0.3.4
|
||||
pdf.save(output_file,
|
||||
stream_data_mode=pikepdf.StreamDataMode.compress)
|
||||
working_file = pdfa if pdfa else layers_file
|
||||
|
||||
pdf = pikepdf.open(working_file)
|
||||
with pdf.open_metadata() as meta:
|
||||
meta.load_from_docinfo(docinfo, delete_missing=False)
|
||||
# If xmp:CreateDate is missing, set it to the modify date to
|
||||
# match Ghostscript, for consistency
|
||||
if 'xmp:CreateDate' not in meta:
|
||||
meta['xmp:CreateDate'] = meta.get('xmp:ModifyDate', '')
|
||||
pdf.save(output_file, compress_streams=True,
|
||||
object_stream_mode=pikepdf.ObjectStreamMode.generate)
|
||||
|
||||
|
||||
def optimize_pdf(
|
||||
@@ -1059,7 +1123,7 @@ def build_pipeline(options, work_folder, log, context):
|
||||
extras=[log, context])
|
||||
task_weave_layers.graphviz(fillcolor='"#00cc66"')
|
||||
|
||||
# PDF/A
|
||||
# PDF/A pdfmark
|
||||
task_generate_postscript_stub = main_pipeline.transform(
|
||||
task_func=generate_postscript_stub,
|
||||
input=task_repair_and_parse_pdf,
|
||||
@@ -1068,11 +1132,21 @@ def build_pipeline(options, work_folder, log, context):
|
||||
extras=[log, context])
|
||||
task_generate_postscript_stub.active_if(options.output_type.startswith('pdfa'))
|
||||
|
||||
# PDF/A conversion
|
||||
task_convert_to_pdfa = main_pipeline.merge(
|
||||
task_func=convert_to_pdfa,
|
||||
input=[task_generate_postscript_stub,
|
||||
task_weave_layers],
|
||||
output=os.path.join(work_folder, 'pdfa.pdf'),
|
||||
extras=[log, context]
|
||||
)
|
||||
task_convert_to_pdfa.active_if(options.output_type.startswith('pdfa'))
|
||||
|
||||
task_metadata_fixup = main_pipeline.merge(
|
||||
task_func=metadata_fixup,
|
||||
input=[task_repair_and_parse_pdf,
|
||||
task_weave_layers,
|
||||
task_generate_postscript_stub],
|
||||
task_convert_to_pdfa],
|
||||
output=os.path.join(work_folder, 'metafix.pdf'),
|
||||
extras=[log, context]
|
||||
)
|
||||
|
||||
@@ -38,7 +38,7 @@ import sys
|
||||
import codecs
|
||||
|
||||
|
||||
def verify_python3_env():
|
||||
def verify_python3_env(): # pragma: no cover
|
||||
"""Ensures that the environment is good for unicode on Python 3."""
|
||||
|
||||
# PEP 538 changes in Python 3.7 should make this wrangling unnecessary
|
||||
|
||||
+52
-2
@@ -43,8 +43,54 @@ def _update_page_resources(*, page, font, font_key, procset):
|
||||
resources['/ProcSet'] = procset
|
||||
|
||||
|
||||
def strip_invisible_text(pdf, page, log):
|
||||
stream = []
|
||||
in_text_obj = False
|
||||
render_mode = 0
|
||||
text_objects = []
|
||||
|
||||
page.page_contents_coalesce()
|
||||
for operands, operator in pikepdf.parse_content_stream(page, ''):
|
||||
if not in_text_obj:
|
||||
if operator == pikepdf.Operator('BT'):
|
||||
in_text_obj = True
|
||||
render_mode = 0
|
||||
text_objects.append((operands, operator))
|
||||
else:
|
||||
stream.append((operands, operator))
|
||||
else:
|
||||
if operator == pikepdf.Operator('Tr'):
|
||||
render_mode = operands[0]
|
||||
text_objects.append((operands, operator))
|
||||
if operator == pikepdf.Operator('ET'):
|
||||
in_text_obj = False
|
||||
if render_mode != 3:
|
||||
stream.extend(text_objects)
|
||||
text_objects.clear()
|
||||
|
||||
def convert(op):
|
||||
try:
|
||||
return op.unparse()
|
||||
except AttributeError:
|
||||
return str(op).encode('ascii')
|
||||
|
||||
lines = []
|
||||
|
||||
for operands, operator in stream:
|
||||
if operator == pikepdf.Operator('INLINE IMAGE'):
|
||||
iim = operands[0]
|
||||
line = iim.unparse()
|
||||
else:
|
||||
line = b' '.join(convert(op) for op in operands) + b' ' + operator.unparse()
|
||||
lines.append(line)
|
||||
|
||||
content_stream = b'\n'.join(lines)
|
||||
page.Contents = pikepdf.Stream(pdf, content_stream)
|
||||
|
||||
|
||||
def _weave_layers_graft(
|
||||
*, pdf_base, page_num, text, font, font_key, procset, rotation, log):
|
||||
*, pdf_base, page_num, text, font, font_key, procset, rotation,
|
||||
strip_old_text, log):
|
||||
"""Insert the text layer from text page 0 on to pdf_base at page_num"""
|
||||
|
||||
log.debug("Grafting")
|
||||
@@ -109,6 +155,9 @@ def _weave_layers_graft(
|
||||
|
||||
new_text_layer = pikepdf.Stream(pdf_base, pdf_text_contents)
|
||||
|
||||
if strip_old_text:
|
||||
strip_invisible_text(pdf_base, base_page, log)
|
||||
|
||||
base_page.page_contents_add(new_text_layer, prepend=True)
|
||||
|
||||
_update_page_resources(
|
||||
@@ -336,10 +385,11 @@ def weave_layers(
|
||||
|
||||
if text and font:
|
||||
# Graft the text layer onto this page, whether new or old
|
||||
strip_old = context.get_options().redo_ocr
|
||||
_weave_layers_graft(
|
||||
pdf_base=pdf_base, page_num=page_num, text=text, font=font,
|
||||
font_key=font_key, rotation=text_misaligned, procset=procset,
|
||||
log=log
|
||||
strip_old_text=strip_old, log=log
|
||||
)
|
||||
|
||||
# Correct the rotation if applicable
|
||||
|
||||
@@ -101,7 +101,7 @@ def extract_text(input_file, pageno=1):
|
||||
|
||||
|
||||
def rasterize_pdf(input_file, output_file, xres, yres, raster_device, log,
|
||||
pageno=1, page_dpi=None, rotation=None):
|
||||
pageno=1, page_dpi=None, rotation=None, filter_vector=False):
|
||||
"""Rasterize one page of a PDF at resolution (xres, yres) in canvas units.
|
||||
|
||||
The image is sized to match the integer pixels dimensions implied by
|
||||
@@ -116,6 +116,8 @@ def rasterize_pdf(input_file, output_file, xres, yres, raster_device, log,
|
||||
:param log:
|
||||
:param pageno: page number to rasterize (beginning at page 1)
|
||||
:param page_dpi: resolution tuple (x, y) overriding output image DPI
|
||||
:param rotation: 0, 90, 180, 270: clockwise angle to rotate page
|
||||
:param filter_vector: if True, remove vector graphics objects
|
||||
:return:
|
||||
"""
|
||||
res = xres, yres
|
||||
@@ -134,6 +136,7 @@ def rasterize_pdf(input_file, output_file, xres, yres, raster_device, log,
|
||||
'-dFirstPage=%i' % pageno,
|
||||
'-dLastPage=%i' % pageno,
|
||||
'-r{0}x{1}'.format(str(int_res[0]), str(int_res[1])),
|
||||
] + (['-dFILTERVECTOR'] if filter_vector else []) + [
|
||||
'-o', tmp.name,
|
||||
'-dAutoRotatePages=/None', # Probably has no effect on raster
|
||||
'-f',
|
||||
|
||||
@@ -15,14 +15,11 @@
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with OCRmyPDF. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from subprocess import CalledProcessError, run, PIPE
|
||||
from subprocess import run, PIPE
|
||||
from functools import lru_cache
|
||||
import sys
|
||||
import os
|
||||
import shutil
|
||||
|
||||
from . import get_version
|
||||
from ..exceptions import ExitCode, MissingDependencyError
|
||||
from ..exceptions import MissingDependencyError
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
|
||||
@@ -15,15 +15,11 @@
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with OCRmyPDF. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from subprocess import CalledProcessError, run
|
||||
from tempfile import TemporaryFile
|
||||
from subprocess import run
|
||||
from functools import lru_cache
|
||||
import sys
|
||||
import os
|
||||
import shutil
|
||||
|
||||
from . import get_version
|
||||
from ..exceptions import ExitCode, MissingDependencyError
|
||||
from ..exceptions import MissingDependencyError
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
|
||||
@@ -18,9 +18,8 @@
|
||||
from subprocess import CalledProcessError, STDOUT, PIPE, run
|
||||
from functools import lru_cache
|
||||
|
||||
from ..exceptions import InputFileError, SubprocessOutputError, \
|
||||
EncryptedPdfError
|
||||
from . import get_version
|
||||
from ..helpers import fspath
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
@@ -32,7 +31,7 @@ def check(input_file, log=None):
|
||||
args_qpdf = [
|
||||
'qpdf',
|
||||
'--check',
|
||||
input_file
|
||||
fspath(input_file)
|
||||
]
|
||||
|
||||
if log is None:
|
||||
|
||||
@@ -21,11 +21,11 @@ import shutil
|
||||
from functools import lru_cache
|
||||
from collections import namedtuple
|
||||
from textwrap import dedent
|
||||
from subprocess import CalledProcessError, TimeoutExpired, check_output, STDOUT
|
||||
from subprocess import CalledProcessError, TimeoutExpired, check_output, STDOUT, run, PIPE
|
||||
from contextlib import suppress
|
||||
|
||||
from ..exceptions import MissingDependencyError, TesseractConfigError
|
||||
from ..helpers import page_number
|
||||
from ..helpers import page_number, fspath
|
||||
from . import get_version
|
||||
|
||||
OrientationConfidence = namedtuple(
|
||||
@@ -92,22 +92,33 @@ def psm():
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def languages():
|
||||
def lang_error(output):
|
||||
msg = dedent("""Tesseract failed to report available languages.
|
||||
Output from Tesseract:
|
||||
-----------
|
||||
""")
|
||||
msg += output
|
||||
print(msg, file=sys.stderr)
|
||||
|
||||
args_tess = [
|
||||
'tesseract',
|
||||
'--list-langs'
|
||||
]
|
||||
try:
|
||||
langs = check_output(
|
||||
args_tess, universal_newlines=True, stderr=STDOUT)
|
||||
proc = run(
|
||||
args_tess, universal_newlines=True, stdout=PIPE, stderr=STDOUT,
|
||||
check=True
|
||||
)
|
||||
output = proc.stdout
|
||||
except CalledProcessError as e:
|
||||
msg = dedent("""Tesseract failed to report available languages.
|
||||
Output from Tesseract:
|
||||
-----------
|
||||
""")
|
||||
msg += e.output
|
||||
print(msg, file=sys.stderr)
|
||||
lang_error(e.output)
|
||||
raise MissingDependencyError from e
|
||||
return set(lang.strip() for lang in langs.splitlines()[1:])
|
||||
|
||||
header, *rest = output.splitlines()
|
||||
if not header.startswith('List of available languages'):
|
||||
lang_error(output)
|
||||
raise MissingDependencyError
|
||||
return set(lang.strip() for lang in rest)
|
||||
|
||||
|
||||
def tess_base_args(langs, engine_mode):
|
||||
@@ -124,7 +135,7 @@ def tess_base_args(langs, engine_mode):
|
||||
def get_orientation(input_file, engine_mode, timeout: float, log):
|
||||
args_tesseract = tess_base_args(['osd'], engine_mode) + [
|
||||
psm(), '0',
|
||||
input_file,
|
||||
fspath(input_file),
|
||||
'stdout'
|
||||
]
|
||||
|
||||
|
||||
+14
-1
@@ -15,7 +15,7 @@
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with OCRmyPDF. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from functools import partial
|
||||
from functools import partial, wraps
|
||||
from collections.abc import Iterable
|
||||
from contextlib import suppress
|
||||
from pathlib import Path
|
||||
@@ -168,3 +168,16 @@ def flatten_groups(groups):
|
||||
yield from obj
|
||||
else:
|
||||
yield obj
|
||||
|
||||
|
||||
def deprecated(func):
|
||||
"""Warn that function is deprecated"""
|
||||
@wraps(func)
|
||||
def new_func(*args, **kwargs):
|
||||
warnings.simplefilter('always', DeprecationWarning) # turn off filter
|
||||
warnings.warn("Call to deprecated function {}.".format(func.__name__),
|
||||
category=DeprecationWarning,
|
||||
stacklevel=2)
|
||||
warnings.simplefilter('default', DeprecationWarning) # reset filter
|
||||
return func(*args, **kwargs)
|
||||
return new_func
|
||||
|
||||
+299
-93
@@ -20,24 +20,28 @@
|
||||
#
|
||||
# Python FFI wrapper for Leptonica library
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
import os
|
||||
import logging
|
||||
import warnings
|
||||
from tempfile import TemporaryFile
|
||||
from collections.abc import Sequence
|
||||
from contextlib import suppress
|
||||
from ctypes.util import find_library
|
||||
from functools import lru_cache
|
||||
from io import BytesIO
|
||||
from tempfile import TemporaryFile
|
||||
import argparse
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import warnings
|
||||
|
||||
from .lib._leptonica import ffi
|
||||
from .helpers import fspath
|
||||
|
||||
# pylint: disable=protected-access
|
||||
|
||||
lept = ffi.dlopen(find_library('lept'))
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
lept = ffi.dlopen(find_library('lept'))
|
||||
lept.setMsgSeverity(lept.L_SEVERITY_WARNING)
|
||||
|
||||
|
||||
def stderr(*objs):
|
||||
"""Shorthand print to stderr."""
|
||||
@@ -103,6 +107,8 @@ class _LeptonicaErrorTrap:
|
||||
raise FileNotFoundError()
|
||||
if 'pixWrite: stream not opened' in leptonica_output:
|
||||
raise LeptonicaIOError()
|
||||
if 'index not valid' in leptonica_output:
|
||||
raise IndexError()
|
||||
raise LeptonicaError(leptonica_output)
|
||||
|
||||
return False
|
||||
@@ -116,7 +122,43 @@ class LeptonicaIOError(LeptonicaError):
|
||||
pass
|
||||
|
||||
|
||||
class Pix:
|
||||
class LeptonicaObject:
|
||||
"""General wrapper for Leptonica objects
|
||||
|
||||
When Leptonica returns an object, we bundled it in a wrapper class, which
|
||||
manages its memory. The wrapper class assumes that it will be calling some
|
||||
sort of lept.thingDestroy() function when the instance is deleted. Most
|
||||
Leptonica objects are reference counted, and destroy decrements the
|
||||
refcount.
|
||||
|
||||
Most of the time, when Leptonica returns something, we wrap and it the job
|
||||
is done. When wrapping objects that came from a Leptonica container, like
|
||||
a PIXA returning PIX, the subclass must clone the object before passing it
|
||||
here, to maintain the reference count.
|
||||
|
||||
CFFI ensures that the destroy function is called at garbage collection time
|
||||
so we do not need to mess with __del__.
|
||||
"""
|
||||
|
||||
cdata_destroy = lambda cdata: None
|
||||
LEPTONICA_TYPENAME = ''
|
||||
|
||||
def __init__(self, cdata):
|
||||
if not cdata:
|
||||
raise ValueError('Tried to wrap a NULL ' + self.LEPTONICA_TYPENAME)
|
||||
self._cdata = ffi.gc(cdata, self._destroy)
|
||||
|
||||
@classmethod
|
||||
def _destroy(cls, cdata):
|
||||
"""Destroy some cdata"""
|
||||
# Leptonica API uses double-pointers for its destroy APIs to prevent
|
||||
# dangling pointers. This means we need to put our single pointer,
|
||||
# cdata, in a temporary CDATA**.
|
||||
pp = ffi.new('{} **'.format(cls.LEPTONICA_TYPENAME), cdata)
|
||||
cls.cdata_destroy(pp)
|
||||
|
||||
|
||||
class Pix(LeptonicaObject):
|
||||
"""
|
||||
Wrapper around leptonica's PIX object.
|
||||
|
||||
@@ -135,18 +177,17 @@ class Pix:
|
||||
modified objects. This allows convenient chaining:
|
||||
|
||||
>>> Pix.open('filename.jpg').scale((0.5, 0.5)).deskew().show()
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, pix):
|
||||
self._pix = ffi.gc(pix, Pix._pix_destroy)
|
||||
LEPTONICA_TYPENAME = "PIX"
|
||||
cdata_destroy = lept.pixDestroy
|
||||
|
||||
def __repr__(self):
|
||||
if self._pix:
|
||||
if self._cdata:
|
||||
s = "<leptonica.Pix image size={0}x{1} depth={2}{4} at 0x{3:x}>"
|
||||
return s.format(self._pix.w, self._pix.h, self._pix.d,
|
||||
int(ffi.cast('intptr_t', self._pix)),
|
||||
'(colormapped)' if self._pix.colormap else '')
|
||||
return s.format(self._cdata.w, self._cdata.h, self._cdata.d,
|
||||
int(ffi.cast('intptr_t', self._cdata)),
|
||||
'(colormapped)' if self._cdata.colormap else '')
|
||||
else:
|
||||
return "<leptonica.Pix image NULL>"
|
||||
|
||||
@@ -159,7 +200,7 @@ class Pix:
|
||||
data = ffi.new('l_uint8 **')
|
||||
size = ffi.new('size_t *')
|
||||
|
||||
err = lept.pixWriteMemPng(data, size, self._pix, 0)
|
||||
err = lept.pixWriteMemPng(data, size, self._cdata, 0)
|
||||
if err != 0:
|
||||
raise LeptonicaIOError("pixWriteMemPng")
|
||||
|
||||
@@ -170,7 +211,7 @@ class Pix:
|
||||
data = ffi.new('l_uint32 **')
|
||||
size = ffi.new('size_t *')
|
||||
|
||||
err = lept.pixSerializeToMemory(self._pix, data, size)
|
||||
err = lept.pixSerializeToMemory(self._cdata, data, size)
|
||||
if err != 0:
|
||||
raise LeptonicaIOError("pixSerializeToMemory")
|
||||
|
||||
@@ -187,32 +228,38 @@ class Pix:
|
||||
cdata_bytes = ffi.new('char[]', state['data'])
|
||||
cdata_uint32 = ffi.cast('l_uint32 *', cdata_bytes)
|
||||
|
||||
pix = lept.pixDeserializeFromMemory(
|
||||
cdata_uint32, len(state['data']))
|
||||
pix = lept.pixDeserializeFromMemory(cdata_uint32, len(state['data']))
|
||||
Pix.__init__(self, pix)
|
||||
|
||||
def __eq__(self, other):
|
||||
return self.__getstate__() == other.__getstate__()
|
||||
if not isinstance(other, Pix):
|
||||
return NotImplemented
|
||||
same = ffi.new('l_int32 *', 0)
|
||||
with _LeptonicaErrorTrap():
|
||||
err = lept.pixEqual(self._cdata, other._cdata, same)
|
||||
if err:
|
||||
raise TypeError()
|
||||
return bool(same[0])
|
||||
|
||||
@property
|
||||
def width(self):
|
||||
return self._pix.w
|
||||
return self._cdata.w
|
||||
|
||||
@property
|
||||
def height(self):
|
||||
return self._pix.h
|
||||
return self._cdata.h
|
||||
|
||||
@property
|
||||
def depth(self):
|
||||
return self._pix.d
|
||||
return self._cdata.d
|
||||
|
||||
@property
|
||||
def size(self):
|
||||
return (self._pix.w, self._pix.h)
|
||||
return (self._cdata.w, self._cdata.h)
|
||||
|
||||
@property
|
||||
def info(self):
|
||||
return {'dpi': (self._pix.xres, self._pix.yres)}
|
||||
return {'dpi': (self._cdata.xres, self._cdata.yres)}
|
||||
|
||||
@property
|
||||
def mode(self):
|
||||
@@ -221,7 +268,7 @@ class Pix:
|
||||
return '1'
|
||||
elif self.depth >= 16:
|
||||
return 'RGB'
|
||||
elif not self._pix.colormap:
|
||||
elif not self._cdata.colormap:
|
||||
return 'L'
|
||||
else:
|
||||
return 'P'
|
||||
@@ -253,10 +300,21 @@ class Pix:
|
||||
with _LeptonicaErrorTrap():
|
||||
lept.pixWriteImpliedFormat(
|
||||
os.fsencode(filename),
|
||||
self._pix, jpeg_quality, jpeg_progressive)
|
||||
self._cdata, jpeg_quality, jpeg_progressive)
|
||||
|
||||
@classmethod
|
||||
def frompil(self, pillow_image):
|
||||
"""Create a copy of a PIL.Image from this Pix"""
|
||||
bio = BytesIO()
|
||||
pillow_image.save(bio, format='png', compress_level=1)
|
||||
py_buffer = bio.getbuffer()
|
||||
c_buffer = ffi.from_buffer(py_buffer)
|
||||
with _LeptonicaErrorTrap():
|
||||
pix = Pix(lept.pixReadMem(c_buffer, len(c_buffer)))
|
||||
return pix
|
||||
|
||||
def topil(self):
|
||||
"Returns a PIL.Image version of this Pix"
|
||||
"""Returns a PIL.Image version of this Pix"""
|
||||
from PIL import Image
|
||||
|
||||
# Leptonica manages data in words, so it implicitly does an endian
|
||||
@@ -269,17 +327,17 @@ class Pix:
|
||||
raw_mode = 'ABGR'
|
||||
elif self.mode == '1':
|
||||
raw_mode = '1;I'
|
||||
pix = Pix(lept.pixEndianByteSwapNew(pix._pix))
|
||||
pix = Pix(lept.pixEndianByteSwapNew(pix._cdata))
|
||||
else:
|
||||
raw_mode = self.mode
|
||||
pix = Pix(lept.pixEndianByteSwapNew(pix._pix))
|
||||
pix = Pix(lept.pixEndianByteSwapNew(pix._cdata))
|
||||
else:
|
||||
raw_mode = self.mode # no endian swap needed
|
||||
|
||||
size = (pix._pix.w, pix._pix.h)
|
||||
bytecount = pix._pix.wpl * 4 * pix._pix.h
|
||||
buf = ffi.buffer(pix._pix.data, bytecount)
|
||||
stride = pix._pix.wpl * 4
|
||||
size = (pix._cdata.w, pix._cdata.h)
|
||||
bytecount = pix._cdata.wpl * 4 * pix._cdata.h
|
||||
buf = ffi.buffer(pix._cdata.data, bytecount)
|
||||
stride = pix._cdata.wpl * 4
|
||||
|
||||
im = Image.frombytes(self.mode, size, buf, 'raw', raw_mode, stride)
|
||||
|
||||
@@ -298,21 +356,21 @@ class Pix:
|
||||
for skew angle
|
||||
"""
|
||||
with _LeptonicaErrorTrap():
|
||||
return Pix(lept.pixDeskew(self._pix, reduction_factor))
|
||||
return Pix(lept.pixDeskew(self._cdata, reduction_factor))
|
||||
|
||||
def scale(self, scale_xy):
|
||||
"Returns the pix object rescaled according to the proportions given."
|
||||
with _LeptonicaErrorTrap():
|
||||
return Pix(lept.pixScale(self._pix, scale_xy[0], scale_xy[1]))
|
||||
return Pix(lept.pixScale(self._cdata, scale_xy[0], scale_xy[1]))
|
||||
|
||||
def rotate180(self):
|
||||
with _LeptonicaErrorTrap():
|
||||
return Pix(lept.pixRotate180(ffi.NULL, self._pix))
|
||||
return Pix(lept.pixRotate180(ffi.NULL, self._cdata))
|
||||
|
||||
def rotate_orth(self, quads):
|
||||
"Orthographic rotation, quads: 0-3, number of clockwise rotations"
|
||||
with _LeptonicaErrorTrap():
|
||||
return Pix(lept.pixRotateOrth(self._pix, quads))
|
||||
return Pix(lept.pixRotateOrth(self._cdata, quads))
|
||||
|
||||
def find_skew(self):
|
||||
"""Returns a tuple (deskew angle in degrees, confidence value).
|
||||
@@ -322,7 +380,7 @@ class Pix:
|
||||
with _LeptonicaErrorTrap():
|
||||
angle = ffi.new('float *', 0.0)
|
||||
confidence = ffi.new('float *', 0.0)
|
||||
result = lept.pixFindSkew(self._pix, angle, confidence)
|
||||
result = lept.pixFindSkew(self._cdata, angle, confidence)
|
||||
if result == 0:
|
||||
return (angle[0], confidence[0])
|
||||
else:
|
||||
@@ -330,7 +388,7 @@ class Pix:
|
||||
|
||||
def convert_rgb_to_luminance(self):
|
||||
with _LeptonicaErrorTrap():
|
||||
gray_pix = lept.pixConvertRGBToLuminance(self._pix)
|
||||
gray_pix = lept.pixConvertRGBToLuminance(self._cdata)
|
||||
if gray_pix:
|
||||
return Pix(gray_pix)
|
||||
return None
|
||||
@@ -344,7 +402,7 @@ class Pix:
|
||||
"""
|
||||
with _LeptonicaErrorTrap():
|
||||
return Pix(lept.pixRemoveColormapGeneral(
|
||||
self._pix, removal_type, lept.L_COPY))
|
||||
self._cdata, removal_type, lept.L_COPY))
|
||||
|
||||
def otsu_adaptive_threshold(
|
||||
self, tile_size=(300, 300), kernel_size=(4, 4), scorefract=0.1):
|
||||
@@ -353,8 +411,9 @@ class Pix:
|
||||
smoothx, smoothy = kernel_size
|
||||
p_pix = ffi.new('PIX **')
|
||||
|
||||
pix = Pix(lept.pixConvertTo8(self._cdata, 0))
|
||||
result = lept.pixOtsuAdaptiveThreshold(
|
||||
self._pix,
|
||||
pix._cdata,
|
||||
sx, sy,
|
||||
smoothx, smoothy,
|
||||
scorefract,
|
||||
@@ -371,22 +430,42 @@ class Pix:
|
||||
with _LeptonicaErrorTrap():
|
||||
sx, sy = tile_size
|
||||
smoothx, smoothy = kernel_size
|
||||
if mask is None:
|
||||
mask = ffi.NULL
|
||||
mask = ffi.NULL
|
||||
if isinstance(mask, Pix):
|
||||
mask = mask._pix
|
||||
mask = mask._cdata
|
||||
|
||||
pix = Pix(lept.pixConvertTo8(self._cdata, 0))
|
||||
thresh_pix = lept.pixOtsuThreshOnBackgroundNorm(
|
||||
self._pix,
|
||||
pix._cdata,
|
||||
mask,
|
||||
sx, sy,
|
||||
thresh, mincount, bgval,
|
||||
smoothx, smoothy,
|
||||
scorefract,
|
||||
ffi.NULL
|
||||
)
|
||||
if thresh_pix == ffi.NULL:
|
||||
return None
|
||||
)
|
||||
return Pix(thresh_pix)
|
||||
|
||||
def masked_threshold_on_background_norm(
|
||||
self, mask=None, tile_size=(10, 15), thresh=100, mincount=50,
|
||||
kernel_size=(2, 2), scorefract=0.1):
|
||||
with _LeptonicaErrorTrap():
|
||||
sx, sy = tile_size
|
||||
smoothx, smoothy = kernel_size
|
||||
mask = ffi.NULL
|
||||
if isinstance(mask, Pix):
|
||||
mask = mask._cdata
|
||||
|
||||
pix = Pix(lept.pixConvertTo8(self._cdata, 0))
|
||||
thresh_pix = lept.pixMaskedThreshOnBackgroundNorm(
|
||||
pix._cdata,
|
||||
mask,
|
||||
sx, sy,
|
||||
thresh, mincount,
|
||||
smoothx, smoothy,
|
||||
scorefract,
|
||||
ffi.NULL
|
||||
)
|
||||
return Pix(thresh_pix)
|
||||
|
||||
def crop_to_foreground(
|
||||
@@ -394,7 +473,7 @@ class Pix:
|
||||
showmorph=0, display=0, pdfdir=ffi.NULL):
|
||||
with _LeptonicaErrorTrap():
|
||||
cropbox = Box(lept.pixFindPageForeground(
|
||||
self._pix,
|
||||
self._cdata,
|
||||
threshold,
|
||||
mindist,
|
||||
erasedist,
|
||||
@@ -403,11 +482,9 @@ class Pix:
|
||||
display,
|
||||
pdfdir))
|
||||
|
||||
print(repr(cropbox))
|
||||
|
||||
cropped_pix = lept.pixClipRectangle(
|
||||
self._pix,
|
||||
cropbox._box,
|
||||
self._cdata,
|
||||
cropbox._cdata,
|
||||
ffi.NULL)
|
||||
|
||||
return Pix(cropped_pix)
|
||||
@@ -416,7 +493,7 @@ class Pix:
|
||||
self, mask=None, grayscale=None, gamma=1.0, black=0, white=255):
|
||||
with _LeptonicaErrorTrap():
|
||||
return Pix(lept.pixCleanBackgroundToWhite(
|
||||
self._pix,
|
||||
self._cdata,
|
||||
mask or ffi.NULL,
|
||||
grayscale or ffi.NULL,
|
||||
gamma,
|
||||
@@ -427,7 +504,7 @@ class Pix:
|
||||
with _LeptonicaErrorTrap():
|
||||
return Pix(lept.pixGammaTRC(
|
||||
ffi.NULL,
|
||||
self._pix,
|
||||
self._cdata,
|
||||
gamma,
|
||||
minval,
|
||||
maxval
|
||||
@@ -440,7 +517,7 @@ class Pix:
|
||||
target_pix = self.remove_colormap(lept.REMOVE_CMAP_BASED_ON_SRC)
|
||||
with _LeptonicaErrorTrap():
|
||||
return Pix(lept.pixBackgroundNorm(
|
||||
target_pix._pix,
|
||||
target_pix._cdata,
|
||||
mask or ffi.NULL,
|
||||
grayscale or ffi.NULL,
|
||||
tile_size[0],
|
||||
@@ -467,7 +544,7 @@ class Pix:
|
||||
raise LeptonicaError("Leptonica version is too old")
|
||||
|
||||
correlation = ffi.new('float *', 0.0)
|
||||
result = lept.pixCorrelationBinary(pix1._pix, pix2._pix,
|
||||
result = lept.pixCorrelationBinary(pix1._cdata, pix2._cdata,
|
||||
correlation)
|
||||
if result != 0:
|
||||
raise LeptonicaError("Correlation failed")
|
||||
@@ -476,25 +553,76 @@ class Pix:
|
||||
def generate_pdf_ci_data(self, type_, quality):
|
||||
"Convert to PDF data, with transcoding"
|
||||
p_compdata = ffi.new('L_COMP_DATA **')
|
||||
result = lept.pixGenerateCIData(self._pix, type_, quality, 0,
|
||||
result = lept.pixGenerateCIData(self._cdata, type_, quality, 0,
|
||||
p_compdata)
|
||||
if result != 0:
|
||||
raise LeptonicaError("Generate PDF data failed")
|
||||
return CompressedData(p_compdata[0])
|
||||
|
||||
def invert(self):
|
||||
return Pix(lept.pixInvert(ffi.NULL, self._pix))
|
||||
return Pix(lept.pixInvert(ffi.NULL, self._cdata))
|
||||
|
||||
@staticmethod
|
||||
def _pix_destroy(pix):
|
||||
p_pix = ffi.new('PIX **', pix)
|
||||
lept.pixDestroy(p_pix)
|
||||
# print('pix destroy ' + repr(pix))
|
||||
def locate_barcodes(self):
|
||||
try:
|
||||
with _LeptonicaErrorTrap():
|
||||
pix = Pix(lept.pixConvertTo8(self._cdata, 0))
|
||||
pixa_candidates = PixArray(lept.pixExtractBarcodes(pix._cdata, 0))
|
||||
with suppress(FileNotFoundError):
|
||||
os.unlink('junkpixt.png') # leptonica may produce this
|
||||
sarray = StringArray(lept.pixReadBarcodes(
|
||||
pixa_candidates._cdata,
|
||||
lept.L_BF_ANY,
|
||||
lept.L_USE_WIDTHS,
|
||||
ffi.NULL,
|
||||
0
|
||||
))
|
||||
except (LeptonicaError, ValueError) as e:
|
||||
return
|
||||
|
||||
for n, s in enumerate(sarray):
|
||||
decoded = s.decode()
|
||||
if decoded.strip() == '':
|
||||
continue
|
||||
box = pixa_candidates.get_box(n)
|
||||
left, top = box.x, box.y
|
||||
right, bottom = box.x + box.w, box.y + box.h
|
||||
yield (decoded, (left, top, right, bottom))
|
||||
|
||||
def despeckle(self, size):
|
||||
if size == 2:
|
||||
speckle2 = """
|
||||
oooo
|
||||
oC o
|
||||
o o
|
||||
oooo
|
||||
"""
|
||||
sel1 = Sel.from_selstr(speckle2, 'speckle2')
|
||||
sel2 = Sel.create_brick(2, 2, 0, 0, lept.SEL_HIT)
|
||||
elif size == 3:
|
||||
speckle3 = """
|
||||
ooooo
|
||||
oC o
|
||||
o o
|
||||
o o
|
||||
ooooo
|
||||
"""
|
||||
sel1 = Sel.from_selstr(speckle3, 'speckle3')
|
||||
sel2 = Sel.create_brick(3, 3, 0, 0, lept.SEL_HIT)
|
||||
else:
|
||||
raise ValueError(size)
|
||||
|
||||
pixhmt = Pix(lept.pixHMT(ffi.NULL, self._cdata, sel1._cdata))
|
||||
pixdilated = Pix(lept.pixDilate(ffi.NULL, pixhmt._cdata, sel2._cdata))
|
||||
|
||||
pixsub = Pix(lept.pixSubtract(ffi.NULL, self._cdata, pixdilated._cdata))
|
||||
return pixsub
|
||||
|
||||
|
||||
class CompressedData:
|
||||
def __init__(self, compdata):
|
||||
self._compdata = ffi.gc(compdata, CompressedData._destroy)
|
||||
class CompressedData(LeptonicaObject):
|
||||
"""Wrapper for L_COMP_DATA - abstract compressed image data"""
|
||||
|
||||
LEPTONICA_TYPENAME = 'L_COMP_DATA'
|
||||
cdata_destroy = lept.l_CIDataDestroy
|
||||
|
||||
@classmethod
|
||||
def open(cls, path, jpeg_quality=75):
|
||||
@@ -509,64 +637,142 @@ class CompressedData:
|
||||
return CompressedData(p_compdata[0])
|
||||
|
||||
def __len__(self):
|
||||
return self._compdata.nbytescomp
|
||||
return self._cdata.nbytescomp
|
||||
|
||||
def read(self):
|
||||
buf = ffi.buffer(self._compdata.datacomp, self._compdata.nbytescomp)
|
||||
buf = ffi.buffer(self._cdata.datacomp, self._cdata.nbytescomp)
|
||||
return bytes(buf)
|
||||
|
||||
def __getattr__(self, name):
|
||||
if hasattr(self._compdata, name):
|
||||
return getattr(self._compdata, name)
|
||||
if hasattr(self._cdata, name):
|
||||
return getattr(self._cdata, name)
|
||||
raise AttributeError(name)
|
||||
|
||||
def get_palette_pdf_string(self):
|
||||
"Returns palette pre-formatted for use in PDF"
|
||||
buflen = len('< ') + len(' rrggbb') * self._compdata.ncolors + len('>')
|
||||
buf = ffi.buffer(self._compdata.cmapdatahex, buflen)
|
||||
buflen = len('< ') + len(' rrggbb') * self._cdata.ncolors + len('>')
|
||||
buf = ffi.buffer(self._cdata.cmapdatahex, buflen)
|
||||
return bytes(buf)
|
||||
|
||||
@staticmethod
|
||||
def _destroy(compdata):
|
||||
pp = ffi.new('L_COMP_DATA **', compdata)
|
||||
lept.l_CIDataDestroy(pp)
|
||||
|
||||
class PixArray(LeptonicaObject, Sequence):
|
||||
"""Wrapper around PIXA (array of PIX)"""
|
||||
|
||||
LEPTONICA_TYPENAME = 'PIXA'
|
||||
cdata_destroy = lept.pixaDestroy
|
||||
|
||||
def __len__(self):
|
||||
return self._cdata[0].n
|
||||
|
||||
def __getitem__(self, n):
|
||||
with _LeptonicaErrorTrap():
|
||||
return Pix(lept.pixaGetPix(self._cdata, n, lept.L_CLONE))
|
||||
|
||||
def get_box(self, n):
|
||||
with _LeptonicaErrorTrap():
|
||||
return Box(lept.pixaGetBox(self._cdata, n, lept.L_CLONE))
|
||||
|
||||
|
||||
class Box:
|
||||
"""Wrapper around Leptonica's BOX objects.
|
||||
class Box(LeptonicaObject):
|
||||
"""Wrapper around Leptonica's BOX objects (a pixel rectangle)
|
||||
|
||||
See class Pix for notes about reference counting.
|
||||
Uses x, y, w, h coordinates.
|
||||
"""
|
||||
|
||||
def __init__(self, box):
|
||||
self._box = ffi.gc(box, Box._box_destroy)
|
||||
LEPTONICA_TYPENAME = 'BOX'
|
||||
cdata_destroy = lept.boxDestroy
|
||||
|
||||
def __repr__(self):
|
||||
if self._box:
|
||||
if self._cdata:
|
||||
return '<leptonica.Box x={0} y={1} w={2} h={3}>'.format(
|
||||
self.x, self.y, self.w, self.h)
|
||||
return '<leptonica.Box NULL>'
|
||||
|
||||
@property
|
||||
def x(self):
|
||||
return self._box.x
|
||||
return self._cdata.x
|
||||
|
||||
@property
|
||||
def y(self):
|
||||
return self._box.y
|
||||
return self._cdata.y
|
||||
|
||||
@property
|
||||
def w(self):
|
||||
return self._box.w
|
||||
return self._cdata.w
|
||||
|
||||
@property
|
||||
def h(self):
|
||||
return self._box.h
|
||||
return self._cdata.h
|
||||
|
||||
@staticmethod
|
||||
def _box_destroy(box):
|
||||
p_box = ffi.new('BOX **', box)
|
||||
lept.boxDestroy(p_box)
|
||||
|
||||
class BoxArray(LeptonicaObject, Sequence):
|
||||
"""Wrapper around Leptonica's BOXA (Array of BOX) objects."""
|
||||
|
||||
LEPTONICA_TYPENAME = 'BOXA'
|
||||
cdata_destroy = lept.boxaDestroy
|
||||
|
||||
def __repr__(self):
|
||||
if not self._cdata:
|
||||
return '<BoxArray>'
|
||||
boxes = (repr(box) for box in self)
|
||||
return '<BoxArray [' + ', '.join(boxes) + ']>'
|
||||
|
||||
def __len__(self):
|
||||
return self._cdata.n
|
||||
|
||||
def __getitem__(self, n):
|
||||
if not isinstance(n, int):
|
||||
raise TypeError('list indices must be integers')
|
||||
if 0 <= n < len(self):
|
||||
return Box(lept.boxaGetBox(self._cdata, n, lept.L_CLONE))
|
||||
raise IndexError(n)
|
||||
|
||||
|
||||
class StringArray(LeptonicaObject, Sequence):
|
||||
"""Leptonica SARRAY/string array"""
|
||||
|
||||
LEPTONICA_TYPENAME = 'SARRAY'
|
||||
cdata_destroy = lept.sarrayDestroy
|
||||
|
||||
def __len__(self):
|
||||
return self._cdata.n
|
||||
|
||||
def __getitem__(self, n):
|
||||
if 0 <= n < len(self):
|
||||
return ffi.string(self._cdata.array[n])
|
||||
raise IndexError(n)
|
||||
|
||||
|
||||
class Sel(LeptonicaObject):
|
||||
"""Leptonica 'sel'/selection element for hit-miss transform"""
|
||||
|
||||
LEPTONICA_TYPENAME = 'SEL'
|
||||
cdata_destroy = lept.selDestroy
|
||||
|
||||
@classmethod
|
||||
def from_selstr(cls, selstr, name):
|
||||
# TODO this will strip a horizontal line of don't care's
|
||||
lines = [line.strip() for line in selstr.split('\n') if line.strip()]
|
||||
h = len(lines)
|
||||
w = len(lines[0])
|
||||
lengths = set(len(line) for line in lines)
|
||||
if len(lengths) != 1:
|
||||
raise ValueError("All lines in selstr must be same length")
|
||||
|
||||
repacked = ''.join(line.strip() for line in lines)
|
||||
buf_selstr = ffi.from_buffer(repacked.encode('ascii'))
|
||||
buf_name = ffi.from_buffer(name.encode('ascii'))
|
||||
sel = lept.selCreateFromString(buf_selstr, h, w, buf_name)
|
||||
return cls(sel)
|
||||
|
||||
@classmethod
|
||||
def create_brick(cls, h, w, cy, cx, type_):
|
||||
sel = lept.selCreateBrick(h, w, cy, cx, type_)
|
||||
return cls(sel)
|
||||
|
||||
def __repr__(self):
|
||||
selstr = ffi.gc(lept.selPrintToString(self._cdata), lept.lept_free)
|
||||
return '<Sel \n' + ffi.string(selstr).decode('ascii') + '\n>'
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -19,7 +19,6 @@
|
||||
from cffi import FFI
|
||||
|
||||
ffibuilder = FFI()
|
||||
ffibuilder.set_source("ocrmypdf.lib._leptonica", None)
|
||||
ffibuilder.cdef("""
|
||||
typedef signed char l_int8;
|
||||
typedef unsigned char l_uint8;
|
||||
@@ -32,6 +31,8 @@ typedef double l_float64;
|
||||
typedef long long l_int64;
|
||||
typedef unsigned long long l_uint64;
|
||||
|
||||
typedef int l_ok; /*!< return type 0 if OK, 1 on error */
|
||||
|
||||
struct Pix
|
||||
{
|
||||
l_uint32 w; /* width in pixels */
|
||||
@@ -61,6 +62,17 @@ struct PixColormap
|
||||
};
|
||||
typedef struct PixColormap PIXCMAP;
|
||||
|
||||
/*! Array of pix */
|
||||
struct Pixa
|
||||
{
|
||||
l_int32 n; /*!< number of Pix in ptr array */
|
||||
l_int32 nalloc; /*!< number of Pix ptrs allocated */
|
||||
l_uint32 refcount; /*!< reference count (1 if no clones) */
|
||||
struct Pix **pix; /*!< the array of ptrs to pix */
|
||||
struct Boxa *boxa; /*!< array of boxes */
|
||||
};
|
||||
typedef struct Pixa PIXA;
|
||||
|
||||
struct Box
|
||||
{
|
||||
l_int32 x;
|
||||
@@ -72,6 +84,25 @@ struct Box
|
||||
};
|
||||
typedef struct Box BOX;
|
||||
|
||||
/*! Array of Box */
|
||||
struct Boxa
|
||||
{
|
||||
l_int32 n; /*!< number of box in ptr array */
|
||||
l_int32 nalloc; /*!< number of box ptrs allocated */
|
||||
l_uint32 refcount; /*!< reference count (1 if no clones) */
|
||||
struct Box **box; /*!< box ptr array */
|
||||
};
|
||||
typedef struct Boxa BOXA;
|
||||
|
||||
/*! String array: an array of C strings */
|
||||
struct Sarray
|
||||
{
|
||||
l_int32 nalloc; /*!< size of allocated ptr array */
|
||||
l_int32 n; /*!< number of strings allocated */
|
||||
l_int32 refcount; /*!< reference count (1 if no clones) */
|
||||
char **array; /*!< string array */
|
||||
};
|
||||
typedef struct Sarray SARRAY;
|
||||
|
||||
/*! Pdf formatted encoding types */
|
||||
enum {
|
||||
@@ -104,6 +135,18 @@ struct L_Compressed_Data
|
||||
};
|
||||
typedef struct L_Compressed_Data L_COMP_DATA;
|
||||
|
||||
/*! Selection */
|
||||
struct Sel
|
||||
{
|
||||
l_int32 sy; /*!< sel height */
|
||||
l_int32 sx; /*!< sel width */
|
||||
l_int32 cy; /*!< y location of sel origin */
|
||||
l_int32 cx; /*!< x location of sel origin */
|
||||
l_int32 **data; /*!< {0,1,2}; data[i][j] in [row][col] order */
|
||||
char *name; /*!< used to find sel by name */
|
||||
};
|
||||
typedef struct Sel SEL;
|
||||
|
||||
enum {
|
||||
REMOVE_CMAP_TO_BINARY = 0, /*!< remove colormap for conv to 1 bpp */
|
||||
REMOVE_CMAP_TO_GRAYSCALE = 1, /*!< remove colormap for conv to 8 bpp */
|
||||
@@ -122,10 +165,48 @@ enum {
|
||||
/*!< the array with clones (e.g., pix) */
|
||||
};
|
||||
|
||||
/*! Flags for method of extracting barcode widths */
|
||||
enum {
|
||||
L_USE_WIDTHS = 1, /*!< use histogram of barcode widths */
|
||||
L_USE_WINDOWS = 2 /*!< find best window for decoding transitions */
|
||||
};
|
||||
|
||||
/*! Flags for barcode formats */
|
||||
enum {
|
||||
L_BF_UNKNOWN = 0, /*!< unknown format */
|
||||
L_BF_ANY = 1, /*!< try decoding with all known formats */
|
||||
L_BF_CODE128 = 2, /*!< decode with Code128 format */
|
||||
L_BF_EAN8 = 3, /*!< decode with EAN8 format */
|
||||
L_BF_EAN13 = 4, /*!< decode with EAN13 format */
|
||||
L_BF_CODE2OF5 = 5, /*!< decode with Code 2 of 5 format */
|
||||
L_BF_CODEI2OF5 = 6, /*!< decode with Interleaved 2 of 5 format */
|
||||
L_BF_CODE39 = 7, /*!< decode with Code39 format */
|
||||
L_BF_CODE93 = 8, /*!< decode with Code93 format */
|
||||
L_BF_CODABAR = 9, /*!< decode with Code93 format */
|
||||
L_BF_UPCA = 10 /*!< decode with UPC A format */
|
||||
};
|
||||
|
||||
enum {
|
||||
L_SEVERITY_EXTERNAL = 0, /* Get the severity from the environment */
|
||||
L_SEVERITY_ALL = 1, /* Lowest severity: print all messages */
|
||||
L_SEVERITY_DEBUG = 2, /* Print debugging and higher messages */
|
||||
L_SEVERITY_INFO = 3, /* Print informational and higher messages */
|
||||
L_SEVERITY_WARNING = 4, /* Print warning and higher messages */
|
||||
L_SEVERITY_ERROR = 5, /* Print error and higher messages */
|
||||
L_SEVERITY_NONE = 6 /* Highest severity: print no messages */
|
||||
};
|
||||
|
||||
enum {
|
||||
SEL_DONT_CARE = 0,
|
||||
SEL_HIT = 1,
|
||||
SEL_MISS = 2
|
||||
};
|
||||
|
||||
""")
|
||||
|
||||
ffibuilder.cdef("""
|
||||
PIX * pixRead ( const char *filename );
|
||||
PIX * pixReadMem ( const l_uint8 *data, size_t size );
|
||||
PIX * pixScale ( PIX *pixs, l_float32 scalex, l_float32 scaley );
|
||||
l_int32 pixFindSkew ( PIX *pixs, l_float32 *pangle, l_float32 *pconf );
|
||||
l_int32 pixWriteImpliedFormat ( const char *filename, PIX *pix, l_int32 quality, l_int32 progressive );
|
||||
@@ -137,6 +218,11 @@ pixWriteMemPng(l_uint8 **pdata,
|
||||
|
||||
void pixDestroy ( PIX **ppix );
|
||||
|
||||
l_ok
|
||||
pixEqual(PIX *pix1,
|
||||
PIX *pix2,
|
||||
l_int32 *psame);
|
||||
|
||||
PIX *
|
||||
pixEndianByteSwapNew(PIX *pixs);
|
||||
|
||||
@@ -157,6 +243,8 @@ l_int32 pixSerializeToMemory ( PIX *pixs, l_uint32 **pdata, size_t *pnbytes );
|
||||
|
||||
PIX * pixConvertRGBToLuminance(PIX *pixs);
|
||||
|
||||
PIX * pixConvertTo8(PIX *pixs, l_int32 cmapflag);
|
||||
|
||||
PIX * pixRemoveColormap(PIX *pixs, l_int32 type);
|
||||
|
||||
l_int32
|
||||
@@ -182,6 +270,18 @@ pixOtsuThreshOnBackgroundNorm(PIX *pixs,
|
||||
l_float32 scorefract,
|
||||
l_int32 *pthresh);
|
||||
|
||||
PIX *
|
||||
pixMaskedThreshOnBackgroundNorm(PIX *pixs,
|
||||
PIX *pixim,
|
||||
l_int32 sx,
|
||||
l_int32 sy,
|
||||
l_int32 thresh,
|
||||
l_int32 mincount,
|
||||
l_int32 smoothx,
|
||||
l_int32 smoothy,
|
||||
l_float32 scorefract,
|
||||
l_int32 *pthresh);
|
||||
|
||||
PIX *
|
||||
pixCleanBackgroundToWhite(PIX *pixs,
|
||||
PIX *pixim,
|
||||
@@ -265,13 +365,13 @@ pixGetAverageMaskedRGB(PIX *pixs,
|
||||
l_float32 *pgval,
|
||||
l_float32 *pbval);
|
||||
|
||||
PIX *
|
||||
PIX *
|
||||
pixGlobalNormRGB(PIX * pixd,
|
||||
PIX * pixs,
|
||||
l_int32 rval,
|
||||
l_int32 gval,
|
||||
l_int32 bval,
|
||||
l_int32 mapval);
|
||||
l_int32 mapval);
|
||||
|
||||
PIX *
|
||||
pixInvert(PIX * pixd,
|
||||
@@ -289,22 +389,101 @@ pixGenerateCIData(PIX *pixs,
|
||||
l_int32 ascii85,
|
||||
L_COMP_DATA **pcid);
|
||||
|
||||
l_int32
|
||||
l_generateCIDataForPdf(const char *fname,
|
||||
PIX *pix,
|
||||
l_int32 quality,
|
||||
SARRAY *
|
||||
pixProcessBarcodes(PIX *pixs,
|
||||
l_int32 format,
|
||||
l_int32 method,
|
||||
SARRAY **psaw,
|
||||
l_int32 debugflag);
|
||||
|
||||
PIX *
|
||||
pixaGetPix(PIXA *pixa,
|
||||
l_int32 index,
|
||||
l_int32 accesstype);
|
||||
|
||||
BOX*
|
||||
pixaGetBox (PIXA * pixa,
|
||||
l_int32 index,
|
||||
l_int32 accesstype );
|
||||
|
||||
PIXA *
|
||||
pixExtractBarcodes(PIX *pixs,
|
||||
l_int32 debugflag);
|
||||
|
||||
BOXA *
|
||||
pixLocateBarcodes ( PIX *pixs, l_int32 thresh, PIX **ppixb, PIX **ppixm );
|
||||
|
||||
SARRAY *
|
||||
pixReadBarcodes(PIXA *pixa,
|
||||
l_int32 format,
|
||||
l_int32 method,
|
||||
SARRAY **psaw,
|
||||
l_int32 debugflag);
|
||||
|
||||
l_int32
|
||||
l_generateCIDataForPdf(const char *fname,
|
||||
PIX *pix,
|
||||
l_int32 quality,
|
||||
L_COMP_DATA **pcid);
|
||||
|
||||
void
|
||||
|
||||
BOX *
|
||||
boxClone ( BOX *box );
|
||||
|
||||
BOX *
|
||||
boxaGetBox ( BOXA *boxa, l_int32 index, l_int32 accessflag );
|
||||
|
||||
SEL *
|
||||
selCreateFromString ( const char *text, l_int32 h, l_int32 w, const char *name );
|
||||
|
||||
SEL *
|
||||
selCreateBrick ( l_int32 h, l_int32 w, l_int32 cy, l_int32 cx, l_int32 type );
|
||||
|
||||
char *
|
||||
selPrintToString(SEL *sel);
|
||||
|
||||
PIX *
|
||||
pixDilate ( PIX *pixd, PIX *pixs, SEL *sel );
|
||||
|
||||
PIX *
|
||||
pixErode ( PIX *pixd, PIX *pixs, SEL *sel );
|
||||
|
||||
PIX *
|
||||
pixHMT ( PIX *pixd, PIX *pixs, SEL *sel );
|
||||
|
||||
PIX *
|
||||
pixSubtract ( PIX *pixd, PIX *pixs1, PIX *pixs2 );
|
||||
|
||||
void
|
||||
boxDestroy(BOX **pbox);
|
||||
|
||||
void
|
||||
boxaDestroy ( BOXA **pboxa );
|
||||
|
||||
void
|
||||
pixaDestroy(PIXA **ppixa);
|
||||
|
||||
l_ok
|
||||
pixRenderBoxa ( PIX *pix, BOXA *boxa, l_int32 width, l_int32 op );
|
||||
|
||||
void
|
||||
l_CIDataDestroy(L_COMP_DATA **pcid);
|
||||
|
||||
void
|
||||
lept_free(void *ptr);
|
||||
sarrayDestroy(SARRAY **psa);
|
||||
|
||||
void
|
||||
lept_free(void *ptr);
|
||||
|
||||
void selDestroy ( SEL **psel );
|
||||
|
||||
l_int32
|
||||
setMsgSeverity(l_int32 newsev);
|
||||
|
||||
""")
|
||||
|
||||
|
||||
ffibuilder.set_source("ocrmypdf.lib._leptonica", None)
|
||||
|
||||
if __name__ == '__main__':
|
||||
ffibuilder.compile(verbose=True)
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
|
||||
from pathlib import Path
|
||||
import concurrent.futures
|
||||
from collections import defaultdict, namedtuple
|
||||
from collections import defaultdict
|
||||
import logging
|
||||
import sys
|
||||
|
||||
@@ -137,10 +137,9 @@ def extract_image_generic(*, pike, root, log, image, xref, options):
|
||||
# generating a PNG from compressed data
|
||||
pim.as_pil_image().save(png_name(root, xref))
|
||||
return xref, '.png'
|
||||
else:
|
||||
return None
|
||||
|
||||
return True
|
||||
return None
|
||||
|
||||
|
||||
|
||||
def extract_images(pike, root, log, options, extract_fn):
|
||||
@@ -300,8 +299,9 @@ def convert_to_jbig2(pike, jbig2_groups, root, log, options):
|
||||
jbig2_im_data = jbig2_im_file.read_bytes()
|
||||
im_obj = pike.get_object(xref, 0)
|
||||
im_obj.write(
|
||||
jbig2_im_data, pikepdf.Name('/JBIG2Decode'),
|
||||
jbig2_globals_dict
|
||||
jbig2_im_data,
|
||||
filter=pikepdf.Name('/JBIG2Decode'),
|
||||
decode_parms=jbig2_globals_dict
|
||||
)
|
||||
|
||||
|
||||
@@ -429,6 +429,7 @@ def optimize(
|
||||
convert_to_jbig2(pike, jbig2_groups, root, log, options)
|
||||
|
||||
target_file = Path(output_file).with_suffix('.opt.pdf')
|
||||
pike.remove_unreferenced_resources()
|
||||
pike.save(target_file, preserve_pdfa=True,
|
||||
object_stream_mode=pikepdf.ObjectStreamMode.generate)
|
||||
|
||||
|
||||
+25
-143
@@ -32,17 +32,20 @@ Ghostscript's handling of pdfmark.
|
||||
"""
|
||||
|
||||
from binascii import hexlify
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from shutil import copyfile
|
||||
from string import Template
|
||||
import pkg_resources
|
||||
import os
|
||||
|
||||
from libxmp.utils import file_to_dict
|
||||
from libxmp import consts
|
||||
import pikepdf
|
||||
|
||||
from pikepdf.models.metadata import (
|
||||
encode_pdf_date as _encode_date,
|
||||
decode_pdf_date as _decode_date
|
||||
)
|
||||
|
||||
from .helpers import deprecated
|
||||
|
||||
from ocrmypdf.helpers import fspath
|
||||
|
||||
ICC_PROFILE_RELPATH = 'data/sRGB.icc'
|
||||
|
||||
@@ -58,9 +61,6 @@ pdfa_def_template = u"""%!
|
||||
/ICCProfile $icc_profile
|
||||
def
|
||||
|
||||
[$docinfo
|
||||
/DOCINFO pdfmark
|
||||
|
||||
% Define an ICC profile :
|
||||
|
||||
[/_objdef {icc_PDFA} /type /stream /OBJ pdfmark
|
||||
@@ -91,6 +91,7 @@ def
|
||||
"""
|
||||
|
||||
|
||||
@deprecated
|
||||
def encode_text_string(s: str) -> str:
|
||||
"""
|
||||
Encode text string to hex string for use in a PDF
|
||||
@@ -136,93 +137,17 @@ def _encode_ascii(s: str) -> str:
|
||||
return s.translate(trans).encode('ascii', errors='replace').decode()
|
||||
|
||||
|
||||
def encode_pdf_date(d: datetime) -> str:
|
||||
"""
|
||||
Encode Python datetime object as PDF date string
|
||||
|
||||
From Adobe pdfmark manual:
|
||||
(D:YYYYMMDDHHmmSSOHH'mm')
|
||||
D: is an optional prefix. YYYY is the year. All fields after the year are
|
||||
optional. MM is the month (01-12), DD is the day (01-31), HH is the
|
||||
hour (00-23), mm are the minutes (00-59), and SS are the seconds
|
||||
(00-59). The remainder of the string defines the relation of local
|
||||
time to GMT. O is either + for a positive difference (local time is
|
||||
later than GMT) or - (minus) for a negative difference. HH' is the
|
||||
absolute value of the offset from GMT in hours, and mm' is the
|
||||
absolute value of the offset in minutes. If no GMT information is
|
||||
specified, the relation between the specified time and GMT is
|
||||
considered unknown. Regardless of whether or not GMT
|
||||
information is specified, the remainder of the string should specify
|
||||
the local time.
|
||||
"""
|
||||
|
||||
pdfmark_date_fmt = r'%Y%m%d%H%M%S'
|
||||
s = d.strftime(pdfmark_date_fmt)
|
||||
|
||||
tz = d.strftime('%z')
|
||||
if tz == 'Z' or tz == '':
|
||||
# Ghostscript <= 9.23 handles missing timezones incorrectly, so if
|
||||
# timezone is missing, move it into GMT.
|
||||
# https://bugs.ghostscript.com/show_bug.cgi?id=699182
|
||||
s += "+00'00'"
|
||||
else:
|
||||
sign, tz_hours, tz_mins = tz[0], tz[1:3], tz[3:5]
|
||||
s += "{}{}'{}'".format(sign, tz_hours, tz_mins)
|
||||
return s
|
||||
@deprecated
|
||||
def encode_pdf_date(*args, **kwargs):
|
||||
return _encode_date(*args, **kwargs)
|
||||
|
||||
|
||||
def decode_pdf_date(s: str) -> datetime:
|
||||
"""
|
||||
Decode a pdfmark date to a Python datetime object
|
||||
|
||||
A pdfmark date is a string in a paritcular format. See the pdfmark
|
||||
Reference for the specification.
|
||||
|
||||
"""
|
||||
if s.startswith('D:'):
|
||||
s = s[2:]
|
||||
|
||||
# Literal Z00'00', is incorrect but found in the wild,
|
||||
# probably made by OS X Quartz -- standardize
|
||||
if s.endswith("Z00'00'"):
|
||||
s = s.replace("Z00'00'", '+0000')
|
||||
elif s.endswith('Z'):
|
||||
s = s.replace('Z', '+0000')
|
||||
|
||||
s = s.replace("'", "") # Remove apos from PDF time strings
|
||||
|
||||
return datetime.strptime(s, r'%Y%m%d%H%M%S%z')
|
||||
@deprecated
|
||||
def decode_pdf_date(*args, **kwargs):
|
||||
return _decode_date(*args, **kwargs)
|
||||
|
||||
|
||||
def _get_pdfmark_dates(pdfmark):
|
||||
"""
|
||||
Encode dates in the expected format for pdfmark Postscript
|
||||
|
||||
The best way to deal with amissing date entry is set it to null, because if
|
||||
the key is omitted Ghostscript will set it to now - we do not want to erase
|
||||
the fact that the value was unknown. Setting to an empty string breaks
|
||||
Ghostscript 9.22 as reported here:
|
||||
https://bugs.ghostscript.com/show_bug.cgi?id=699182
|
||||
"""
|
||||
|
||||
for key in ('/CreationDate', '/ModDate'):
|
||||
if key not in pdfmark:
|
||||
continue
|
||||
if pdfmark[key].strip() == '':
|
||||
yield ' {} null'.format(key)
|
||||
continue
|
||||
date_str = pdfmark[key]
|
||||
if date_str.startswith('D:'):
|
||||
date_str = date_str[2:]
|
||||
try:
|
||||
yield ' {} (D:{})'.format(
|
||||
key,
|
||||
encode_pdf_date(decode_pdf_date(date_str)))
|
||||
except ValueError:
|
||||
yield ' {} null'.format(key)
|
||||
|
||||
|
||||
def _get_pdfa_def(icc_profile, icc_identifier, pdfmark, ascii_docinfo=False):
|
||||
def _get_pdfa_def(icc_profile, icc_identifier, pdfmark=None, ascii_docinfo=None):
|
||||
"""
|
||||
Create a Postscript pdfmark file for Ghostscript.
|
||||
|
||||
@@ -232,43 +157,18 @@ def _get_pdfa_def(icc_profile, icc_identifier, pdfmark, ascii_docinfo=False):
|
||||
:param icc_profile: filename of the ICC profile to include in pdfmark
|
||||
:param icc_identifier: ICC identifier such as 'sRGB'
|
||||
:param pdfmark: a dictionary containing keys to include the pdfmark
|
||||
:param ascii_docinfo: if True, the docinfo block must be encoded in pure
|
||||
ASCII and may not contain UTF-16BE-BOM-hex encoded strings, as
|
||||
required for Ghostscript 9.24+
|
||||
:param ascii_docinfo: parameter is no longer meaningful
|
||||
|
||||
:returns: a string containing the entire pdfmark
|
||||
|
||||
"""
|
||||
|
||||
# Ghostscript <= 9.21 has a bug where null entries in DOCINFO might produce
|
||||
# ERROR: VMerror (-25) on closing pdfwrite device.
|
||||
# https://bugs.ghostscript.com/show_bug.cgi?id=697684
|
||||
# Work around this by only adding keys that have a nontrivial value
|
||||
docinfo_keys = ('/Title', '/Author', '/Subject', '/Creator', '/Keywords')
|
||||
|
||||
def docinfo_gen():
|
||||
if not ascii_docinfo:
|
||||
docinfo_line_template = ' {key} <{value}>'
|
||||
encode = encode_text_string
|
||||
else:
|
||||
docinfo_line_template = ' {key} ({value})'
|
||||
encode = _encode_ascii
|
||||
yield from _get_pdfmark_dates(pdfmark)
|
||||
for key in docinfo_keys:
|
||||
if key in pdfmark and pdfmark[key].strip() != '':
|
||||
line = docinfo_line_template.format(
|
||||
key=key, value=encode(pdfmark[key]))
|
||||
yield line
|
||||
docinfo = '\n'.join(docinfo_gen())
|
||||
|
||||
t = Template(pdfa_def_template)
|
||||
result = t.substitute(icc_profile=icc_profile,
|
||||
icc_identifier=icc_identifier,
|
||||
docinfo=docinfo)
|
||||
icc_identifier=icc_identifier)
|
||||
return result
|
||||
|
||||
|
||||
def generate_pdfa_ps(target_filename, pdfmark, icc='sRGB', ascii_docinfo=False):
|
||||
def generate_pdfa_ps(target_filename, pdfmark=None, icc='sRGB', ascii_docinfo=None):
|
||||
if icc == 'sRGB':
|
||||
icc_profile = SRGB_ICC_PROFILE
|
||||
else:
|
||||
@@ -285,7 +185,7 @@ def generate_pdfa_ps(target_filename, pdfmark, icc='sRGB', ascii_docinfo=False):
|
||||
hex_icc_profile = hexlify(bytes_icc_profile)
|
||||
icc_profile = '<' + hex_icc_profile.decode('ascii') + '>'
|
||||
|
||||
ps = _get_pdfa_def(icc_profile, icc, pdfmark, ascii_docinfo=ascii_docinfo)
|
||||
ps = _get_pdfa_def(icc_profile, icc, pdfmark)
|
||||
|
||||
# We should have encoded everything to pure ASCII by this point, and
|
||||
# to be safe, only allow ASCII in PostScript
|
||||
@@ -302,34 +202,16 @@ def file_claims_pdfa(filename):
|
||||
This checks if the XMP metadata contains a PDF/A marker.
|
||||
"""
|
||||
|
||||
xmp = file_to_dict(filename)
|
||||
if not xmp:
|
||||
return {'pass': False, 'output': 'pdf',
|
||||
'conformance': 'No XMP metadata'}
|
||||
|
||||
if not consts.XMP_NS_PDFA_ID in xmp:
|
||||
pdf = pikepdf.open(filename)
|
||||
pdfmeta = pdf.open_metadata()
|
||||
if not pdfmeta.pdfa_status:
|
||||
return {'pass': False, 'output': 'pdf',
|
||||
'conformance': 'No PDF/A metadata in XMP'}
|
||||
|
||||
pdfa_node = xmp[consts.XMP_NS_PDFA_ID]
|
||||
def read_node(node, key):
|
||||
return next(
|
||||
(v for k, v, meta in node if k == key), ''
|
||||
)
|
||||
|
||||
part = read_node(pdfa_node, 'pdfaid:part')
|
||||
conformance = read_node(pdfa_node, 'pdfaid:conformance')
|
||||
|
||||
part_conformance = part + conformance
|
||||
valid_part_conforms = {'1A', '1B', '2A', '2B', '2U', '3A', '3B', '3U'}
|
||||
|
||||
conformance = 'PDF/A-{}'.format(
|
||||
part_conformance)
|
||||
|
||||
conformance = 'PDF/A-{}'.format(pdfmeta.pdfa_status)
|
||||
pdfa_dict = {}
|
||||
if part_conformance in valid_part_conforms:
|
||||
if pdfmeta.pdfa_status in valid_part_conforms:
|
||||
pdfa_dict['pass'] = True
|
||||
pdfa_dict['output'] = 'pdfa'
|
||||
pdfa_dict['conformance'] = conformance
|
||||
|
||||
return pdfa_dict
|
||||
|
||||
@@ -22,15 +22,19 @@ from enum import Enum
|
||||
from math import hypot, isclose
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock
|
||||
from warnings import warn
|
||||
import re
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
from .exec import ghostscript
|
||||
from .helpers import fspath
|
||||
|
||||
from pikepdf import PdfMatrix
|
||||
import pikepdf
|
||||
|
||||
from . import ghosttext
|
||||
from .layout import get_page_analysis, get_text_boxes
|
||||
|
||||
from ..exceptions import EncryptedPdfError
|
||||
from ..helpers import fspath
|
||||
|
||||
|
||||
Colorspace = Enum('Colorspace',
|
||||
'gray rgb cmyk lab icc index sep devn pattern jpeg2000')
|
||||
|
||||
@@ -38,17 +42,6 @@ Encoding = Enum('Encoding',
|
||||
'ccitt jpeg jpeg2000 jbig2 asciihex ascii85 lzw flate ' + \
|
||||
'runlength')
|
||||
|
||||
# Forgive me for I have sinned
|
||||
# I am using regular expressions to parse XML. However the XML in this case,
|
||||
# generated by Ghostscript, is self-consistent enough to be parseable.
|
||||
regex_remove_char_tags = re.compile(br"""
|
||||
<char\b
|
||||
(?: [^>] # anything single character but >
|
||||
| \">\" # special case: trap ">"
|
||||
)*
|
||||
/> # terminate with '/>'
|
||||
""", re.VERBOSE)
|
||||
|
||||
FRIENDLY_COLORSPACE = {
|
||||
'/DeviceGray': Colorspace.gray,
|
||||
'/CalGray': Colorspace.gray,
|
||||
@@ -104,23 +97,30 @@ InlineSettings = namedtuple('InlineSettings',
|
||||
['iimage', 'shorthand', 'stack_depth'])
|
||||
|
||||
ContentsInfo = namedtuple('ContentsInfo',
|
||||
['xobject_settings', 'inline_images', 'found_text'])
|
||||
['xobject_settings', 'inline_images', 'found_vector'])
|
||||
|
||||
TextboxInfo = namedtuple('TextboxInfo',
|
||||
['bbox', 'is_visible', 'is_corrupt'])
|
||||
|
||||
|
||||
class VectorInfo:
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
def _normalize_stack(operations):
|
||||
"""Convert runs of qQ's in the stack into single operations"""
|
||||
for operands, command in operations:
|
||||
command = str(command)
|
||||
if re.match(r'Q*q+$', command): # Zero or more Q, one or more q
|
||||
for char in command: # Split into individual
|
||||
yield ([], char) # Yield individual
|
||||
|
||||
def _normalize_stack(graphobjs):
|
||||
"""Convert runs of qQ's in the stack into single graphobjs"""
|
||||
for operands, operator in graphobjs:
|
||||
operator = str(operator)
|
||||
if re.match(r'Q*q+$', operator): # Zero or more Q, one or more q
|
||||
for char in operator: # Split into individual
|
||||
yield ([], char) # Yield individual
|
||||
else:
|
||||
yield (operands, command)
|
||||
yield (operands, operator)
|
||||
|
||||
|
||||
def _interpret_contents(contentstream, initial_shorthand=UNIT_SQUARE):
|
||||
"""Interpret the PDF content stream
|
||||
"""Interpret the PDF content stream.
|
||||
|
||||
The stack represents the state of the PDF graphics stack. We are only
|
||||
interested in the current transformation matrix (CTM) so we only track
|
||||
@@ -138,53 +138,61 @@ def _interpret_contents(contentstream, initial_shorthand=UNIT_SQUARE):
|
||||
|
||||
PDF units suit our needs so we initialize ctm to the identity matrix.
|
||||
|
||||
According to the PDF specification, the maximum stack depth is 32. Other
|
||||
viewers tolerate some amount beyond this. We issue a warning if the
|
||||
stack depth exceeds the spec limit and set a hard limit beyond this to
|
||||
bound our memory requirements. If the stack underflows behavior is
|
||||
undefined in the spec, but we just pretend nothing happened and leave the
|
||||
CTM unchanged.
|
||||
"""
|
||||
|
||||
stack = []
|
||||
ctm = PdfMatrix(initial_shorthand)
|
||||
xobject_settings = []
|
||||
inline_images = []
|
||||
found_text = False
|
||||
text_operators = set(['Tj', 'TJ', '"', "'"])
|
||||
operator_whitelist = """q Q Do cm TJ Tj " ' BI ID EI"""
|
||||
found_vector = False
|
||||
vector_ops = set('S s f F f* B B* b b*'.split())
|
||||
image_ops = set('BI ID EI q Q Do cm'.split())
|
||||
operator_whitelist = ' '.join(vector_ops | image_ops)
|
||||
|
||||
for n, op in enumerate(_normalize_stack(
|
||||
for n, graphobj in enumerate(_normalize_stack(
|
||||
pikepdf.parse_content_stream(contentstream, operator_whitelist))):
|
||||
operands, command = op
|
||||
|
||||
if command == 'q':
|
||||
operands, operator = graphobj
|
||||
if operator == 'q':
|
||||
stack.append(ctm)
|
||||
if len(stack) > 32:
|
||||
raise RuntimeError(
|
||||
"PDF graphics stack overflow, command %i" % n)
|
||||
elif command == 'Q':
|
||||
if len(stack) > 32: # See docstring
|
||||
if len(stack) > 128:
|
||||
raise RuntimeError(
|
||||
"PDF graphics stack overflowed hard limit, operator %i" % n)
|
||||
warn("PDF graphics stack overflowed spec limit")
|
||||
elif operator == 'Q':
|
||||
try:
|
||||
ctm = stack.pop()
|
||||
except IndexError:
|
||||
raise RuntimeError(
|
||||
"PDF graphics stack underflow, command %i" % n)
|
||||
elif command == 'cm':
|
||||
# Keeping the ctm the same seems to be the only sensible thing
|
||||
# to do. Just pretend nothing happened, keep calm and carry on.
|
||||
warn("PDF graphics stack underflowed - PDF may be malformed")
|
||||
elif operator == 'cm':
|
||||
ctm = PdfMatrix(operands) @ ctm
|
||||
elif command == 'Do':
|
||||
elif operator == 'Do':
|
||||
image_name = operands[0]
|
||||
settings = XobjectSettings(
|
||||
name=image_name, shorthand=ctm.shorthand,
|
||||
stack_depth=len(stack))
|
||||
xobject_settings.append(settings)
|
||||
elif command == 'INLINE IMAGE':
|
||||
elif operator == 'INLINE IMAGE': # BI/ID/EI are grouped into this
|
||||
iimage = operands[0]
|
||||
inline = InlineSettings(
|
||||
iimage=iimage, shorthand=ctm.shorthand,
|
||||
stack_depth=len(stack))
|
||||
inline_images.append(inline)
|
||||
elif command in text_operators:
|
||||
found_text = True
|
||||
|
||||
elif operator in vector_ops:
|
||||
found_vector = True
|
||||
|
||||
return ContentsInfo(
|
||||
xobject_settings=xobject_settings,
|
||||
inline_images=inline_images,
|
||||
found_text=found_text)
|
||||
found_vector=found_vector)
|
||||
|
||||
|
||||
def _get_dpi(ctm_shorthand, image_size):
|
||||
@@ -388,7 +396,6 @@ def _find_regular_images(container, contentsinfo):
|
||||
that contains images.
|
||||
|
||||
Generates images with their DPI at time of drawing.
|
||||
|
||||
"""
|
||||
|
||||
for pdfimage, xobj in _image_xobjects(container):
|
||||
@@ -438,11 +445,11 @@ def _find_form_xobject_images(pdf, container, contentsinfo):
|
||||
# but in practice both Form XObjects and multiple drawing of the
|
||||
# same object are both very rare.
|
||||
ctm_shorthand = settings.shorthand
|
||||
yield from _find_images(
|
||||
yield from _process_content_streams(
|
||||
pdf=pdf, container=form_xobject, shorthand=ctm_shorthand)
|
||||
|
||||
|
||||
def _find_images(*, pdf, container, shorthand=None):
|
||||
def _process_content_streams(*, pdf, container, shorthand=None):
|
||||
"""Find all individual instances of images drawn in the container
|
||||
|
||||
Usually the container is a page, but it may also be a Form XObject.
|
||||
@@ -451,7 +458,7 @@ def _find_images(*, pdf, container, shorthand=None):
|
||||
in an XObject.
|
||||
|
||||
Form XObjects may include inline images, XObject images,
|
||||
and recursively, other Form XObjects; and also vector drawing commands.
|
||||
and recursively, other Form XObjects; and also vector graphic objects.
|
||||
|
||||
Every instance of an image being drawn somewhere is flattened and
|
||||
treated as a unique image, since if the same image is drawn multiple times
|
||||
@@ -483,48 +490,13 @@ def _find_images(*, pdf, container, shorthand=None):
|
||||
|
||||
contentsinfo = _interpret_contents(container, initial_shorthand)
|
||||
|
||||
if contentsinfo.found_vector:
|
||||
yield VectorInfo()
|
||||
yield from _find_inline_images(contentsinfo)
|
||||
yield from _find_regular_images(container, contentsinfo)
|
||||
yield from _find_form_xobject_images(pdf, container, contentsinfo)
|
||||
|
||||
|
||||
def _page_get_textblocks(infile, pageno, xmltext):
|
||||
"""Smarter text detection"""
|
||||
|
||||
root = xmltext
|
||||
if not hasattr(xmltext, 'findall'):
|
||||
return []
|
||||
|
||||
def blocks():
|
||||
for span in root.findall('.//span'):
|
||||
bbox_str = span.attrib['bbox']
|
||||
font_size = span.attrib['size']
|
||||
pts = [int(pt) for pt in bbox_str.split()]
|
||||
pts[1] = pts[1] - int(float(font_size) + 0.5)
|
||||
bbox = tuple(pts)
|
||||
yield bbox
|
||||
|
||||
def joined_blocks():
|
||||
prev = None
|
||||
for bbox in blocks():
|
||||
if prev is None:
|
||||
prev = bbox
|
||||
if bbox[1] == prev[1] and bbox[3] == prev[3]:
|
||||
gap = prev[2] - bbox[0]
|
||||
height = bbox[3] - bbox[1]
|
||||
if gap < height:
|
||||
# Join boxes
|
||||
prev = (prev[0], prev[1], bbox[2], bbox[3])
|
||||
continue
|
||||
# yield previously joined bboxes and start anew
|
||||
yield prev
|
||||
prev = bbox
|
||||
if prev is not None:
|
||||
yield prev
|
||||
|
||||
return [block for block in joined_blocks()]
|
||||
|
||||
|
||||
def _page_has_text(text_blocks, page_width, page_height):
|
||||
"""Smarter text detection that ignores text in margins"""
|
||||
|
||||
@@ -532,18 +504,19 @@ def _page_has_text(text_blocks, page_width, page_height):
|
||||
|
||||
margin_ratio = 0.125
|
||||
interior_bbox = (
|
||||
margin_ratio * pw, margin_ratio * ph,
|
||||
(1 - margin_ratio) * pw, (1 - margin_ratio) * ph
|
||||
margin_ratio * pw, # left
|
||||
(1 - margin_ratio) * ph, # top
|
||||
(1 - margin_ratio) * pw, # right
|
||||
margin_ratio * ph # bottom (first quadrant: bottom < top)
|
||||
)
|
||||
|
||||
def rects_intersect(a, b):
|
||||
"""
|
||||
Where (a,b) are 4-tuple rects (left-0, top-1, right-2, bottom-3)
|
||||
https://stackoverflow.com/questions/306316/determine-if-two-rectangles-overlap-each-other
|
||||
Negative signs to account for our coordinates being in the fourth quadrant
|
||||
and the formula assuming the first
|
||||
Formula assumes all boxes are in first quadrant
|
||||
"""
|
||||
return a[0] < b[2] and a[2] > b[0] and -a[1] > -b[3] and -a[3] < -b[1]
|
||||
return a[0] < b[2] and a[2] > b[0] and a[1] > b[3] and a[3] < b[1]
|
||||
|
||||
has_text = False
|
||||
for bbox in text_blocks:
|
||||
@@ -553,22 +526,43 @@ def _page_has_text(text_blocks, page_width, page_height):
|
||||
return has_text
|
||||
|
||||
|
||||
def simplify_textboxes(miner):
|
||||
"""Extract only limited content from text boxes
|
||||
|
||||
We do this to save memory and ensure that our objects are pickleable.
|
||||
"""
|
||||
for box in get_text_boxes(miner):
|
||||
first_line = box._objs[0]
|
||||
first_char = first_line._objs[0]
|
||||
|
||||
visible = (first_char.rendermode != 3)
|
||||
corrupt = (first_char.get_text() == '\ufffd')
|
||||
yield TextboxInfo(box.bbox, visible, corrupt)
|
||||
|
||||
|
||||
def _pdf_get_pageinfo(pdf, pageno: int, infile, xmltext):
|
||||
pageinfo = {}
|
||||
pageinfo['pageno'] = pageno
|
||||
pageinfo['images'] = []
|
||||
|
||||
page = pdf.pages[pageno]
|
||||
|
||||
pageinfo['textinfo'] = _page_get_textblocks(
|
||||
fspath(infile), pageno, xmltext=xmltext)
|
||||
|
||||
mediabox = [Decimal(d) for d in page.MediaBox.as_list()]
|
||||
width_pt = mediabox[2] - mediabox[0]
|
||||
height_pt = mediabox[3] - mediabox[1]
|
||||
|
||||
if xmltext is not None:
|
||||
bboxes = ghosttext.page_get_textblocks(
|
||||
fspath(infile), pageno, xmltext=xmltext, height=height_pt)
|
||||
pageinfo['bboxes'] = bboxes
|
||||
else:
|
||||
pscript5_mode = str(pdf.docinfo.get('/Creator')).startswith('PScript5')
|
||||
miner = get_page_analysis(infile, pageno, pscript5_mode)
|
||||
pageinfo['textboxes'] = list(simplify_textboxes(miner))
|
||||
bboxes = (box.bbox for box in pageinfo['textboxes'])
|
||||
|
||||
pageinfo['has_text'] = _page_has_text(
|
||||
pageinfo['textinfo'], width_pt, height_pt)
|
||||
bboxes, width_pt, height_pt
|
||||
)
|
||||
|
||||
userunit = page.get('/UserUnit', Decimal(1.0))
|
||||
if not isinstance(userunit, Decimal):
|
||||
@@ -583,9 +577,16 @@ def _pdf_get_pageinfo(pdf, pageno: int, infile, xmltext):
|
||||
pageinfo['rotate'] = 0
|
||||
|
||||
userunit_shorthand = (userunit, 0, 0, userunit, 0, 0)
|
||||
pageinfo['images'] = [im for im in
|
||||
_find_images(pdf=pdf, container=page,
|
||||
shorthand=userunit_shorthand)]
|
||||
contentsinfo = [ci for ci in
|
||||
_process_content_streams(pdf=pdf, container=page,
|
||||
shorthand=userunit_shorthand)]
|
||||
|
||||
pageinfo['has_vector'] = False
|
||||
if any(isinstance(ci, VectorInfo) for ci in contentsinfo):
|
||||
pageinfo['has_vector'] = True
|
||||
|
||||
pageinfo['images'] = [im for im in contentsinfo
|
||||
if isinstance(im, ImageInfo)]
|
||||
if pageinfo['images']:
|
||||
xres = Decimal(max(image.xres for image in pageinfo['images']))
|
||||
yres = Decimal(max(image.yres for image in pageinfo['images']))
|
||||
@@ -598,47 +599,33 @@ def _pdf_get_pageinfo(pdf, pageno: int, infile, xmltext):
|
||||
return pageinfo
|
||||
|
||||
|
||||
def _pdf_get_all_pageinfo(infile, log=None):
|
||||
def _pdf_get_all_pageinfo(infile, detailed_analysis=False, log=None):
|
||||
if not log:
|
||||
log = Mock()
|
||||
|
||||
pdf = pikepdf.open(infile)
|
||||
|
||||
existing_text = ghostscript.extract_text(infile, pageno=None)
|
||||
existing_text = regex_remove_char_tags.sub(b' ', existing_text)
|
||||
|
||||
try:
|
||||
root = ET.fromstringlist([
|
||||
b'<document>\n', existing_text, b'</document>\n'
|
||||
])
|
||||
page_xml = root.findall('page')
|
||||
except ET.ParseError as e:
|
||||
log.error(
|
||||
"An error occurred while attempting to retrieve existing text in "
|
||||
"the input file. Will attempt to continue assuming that there is "
|
||||
"no existing text in the file. The error was:")
|
||||
log.error(e)
|
||||
page_xml = [None] * len(pdf.pages)
|
||||
|
||||
page_count_difference = len(pdf.pages) - len(page_xml)
|
||||
if page_count_difference != 0:
|
||||
log.error("The number of pages in the input file is inconsistent.")
|
||||
if page_count_difference > 0:
|
||||
page_xml.extend([None] * page_count_difference)
|
||||
if pdf.is_encrypted:
|
||||
raise EncryptedPdfError() # Triggered by encryption with empty passwd
|
||||
if detailed_analysis:
|
||||
pages_xml = None
|
||||
else:
|
||||
pages_xml = ghosttext.extract_text_xml(infile, pdf, pageno=None, log=log)
|
||||
|
||||
pages = []
|
||||
for n in range(len(pdf.pages)):
|
||||
page = PageInfo(pdf, n, infile, page_xml[n])
|
||||
page_xml = pages_xml[n] if pages_xml else None
|
||||
page = PageInfo(pdf, n, infile, page_xml, detailed_analysis)
|
||||
pages.append(page)
|
||||
|
||||
return pages, pdf
|
||||
|
||||
|
||||
class PageInfo:
|
||||
def __init__(self, pdf, pageno, infile, xmltext):
|
||||
def __init__(self, pdf, pageno, infile, xmltext, detailed_analysis=False):
|
||||
self._pageno = pageno
|
||||
self._infile = infile
|
||||
self._pageinfo = _pdf_get_pageinfo(pdf, pageno, infile, xmltext)
|
||||
self._detailed_analysis = detailed_analysis
|
||||
|
||||
@property
|
||||
def pageno(self):
|
||||
@@ -648,6 +635,16 @@ class PageInfo:
|
||||
def has_text(self):
|
||||
return self._pageinfo['has_text']
|
||||
|
||||
@property
|
||||
def has_corrupt_text(self):
|
||||
if not self._detailed_analysis:
|
||||
raise NotImplementedError('Did not do detailed analysis')
|
||||
return any(tbox.is_corrupt for tbox in self._pageinfo['textboxes'])
|
||||
|
||||
@property
|
||||
def has_vector(self):
|
||||
return self._pageinfo['has_vector']
|
||||
|
||||
@property
|
||||
def width_inches(self):
|
||||
return self._pageinfo['width_inches']
|
||||
@@ -679,8 +676,26 @@ class PageInfo:
|
||||
def images(self):
|
||||
return self._pageinfo['images']
|
||||
|
||||
def get_textareas(self):
|
||||
yield from self._pageinfo['textinfo']
|
||||
def get_textareas(self, visible=None, corrupt=None):
|
||||
def predicate(obj, want_visible, want_corrupt):
|
||||
result = True
|
||||
if want_visible is not None:
|
||||
if obj.is_visible != want_visible:
|
||||
result = False
|
||||
if want_corrupt is not None:
|
||||
if obj.is_corrupt != want_corrupt:
|
||||
result = False
|
||||
return result
|
||||
|
||||
if 'textboxes' not in self._pageinfo:
|
||||
if visible is not None and corrupt is not None:
|
||||
raise NotImplementedError(
|
||||
'Ghostscript textboxes cannot be classified')
|
||||
return self._pageinfo['bboxes']
|
||||
|
||||
return (obj.bbox for obj in self._pageinfo['textboxes']
|
||||
if predicate(obj, visible, corrupt))
|
||||
|
||||
|
||||
@property
|
||||
def xres(self):
|
||||
@@ -712,13 +727,14 @@ class PageInfo:
|
||||
|
||||
|
||||
class PdfInfo:
|
||||
"""Get summary information about a PDF
|
||||
"""Get summary information about a PDF"""
|
||||
|
||||
"""
|
||||
def __init__(self, infile, log=None):
|
||||
def __init__(self, infile, detailed_page_analysis=False, log=None):
|
||||
self._infile = infile
|
||||
self._pages, pdf = _pdf_get_all_pageinfo(infile, log=log)
|
||||
self._pages, pdf = _pdf_get_all_pageinfo(
|
||||
infile, detailed_page_analysis, log=log)
|
||||
self._needs_rendering = pdf.root.get('/NeedsRendering', False)
|
||||
self._has_acroform = '/AcroForm' in pdf.root
|
||||
|
||||
@property
|
||||
def pages(self):
|
||||
@@ -733,6 +749,10 @@ class PdfInfo:
|
||||
def has_userunit(self):
|
||||
return any(page.userunit != 1.0 for page in self.pages)
|
||||
|
||||
@property
|
||||
def has_acroform(self):
|
||||
return self._has_acroform
|
||||
|
||||
@property
|
||||
def filename(self):
|
||||
if not isinstance(self._infile, (str, Path)):
|
||||
@@ -0,0 +1,96 @@
|
||||
# © 2018 James R. Barlow: github.com/jbarlow83
|
||||
#
|
||||
# This file is part of OCRmyPDF.
|
||||
#
|
||||
# OCRmyPDF is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# OCRmyPDF is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with OCRmyPDF. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import re
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
from ..exec import ghostscript
|
||||
|
||||
# Forgive me for I have sinned
|
||||
# I am using regular expressions to parse XML. However the XML in this case,
|
||||
# generated by Ghostscript, is self-consistent enough to be parseable.
|
||||
regex_remove_char_tags = re.compile(br"""
|
||||
<char\b
|
||||
(?: [^>] # anything single character but >
|
||||
| \">\" # special case: trap ">"
|
||||
)*
|
||||
/> # terminate with '/>'
|
||||
""", re.VERBOSE)
|
||||
|
||||
|
||||
def page_get_textblocks(infile, pageno, xmltext, height):
|
||||
"""Get text boxes out of Ghostscript txtwrite xml"""
|
||||
|
||||
root = xmltext
|
||||
if not hasattr(xmltext, 'findall'):
|
||||
return []
|
||||
|
||||
def blocks():
|
||||
for span in root.findall('.//span'):
|
||||
bbox_str = span.attrib['bbox']
|
||||
font_size = span.attrib['size']
|
||||
pts = [int(pt) for pt in bbox_str.split()]
|
||||
pts[1] = pts[1] - int(float(font_size) + 0.5)
|
||||
bbox_topdown = tuple(pts)
|
||||
bb = bbox_topdown
|
||||
bbox_bottomup = (bb[0], height - bb[3], bb[2], height - bb[1])
|
||||
yield bbox_bottomup
|
||||
|
||||
def joined_blocks():
|
||||
prev = None
|
||||
for bbox in blocks():
|
||||
if prev is None:
|
||||
prev = bbox
|
||||
if bbox[1] == prev[1] and bbox[3] == prev[3]:
|
||||
gap = prev[2] - bbox[0]
|
||||
height = abs(bbox[3] - bbox[1])
|
||||
if gap < height:
|
||||
# Join boxes
|
||||
prev = (prev[0], prev[1], bbox[2], bbox[3])
|
||||
continue
|
||||
# yield previously joined bboxes and start anew
|
||||
yield prev
|
||||
prev = bbox
|
||||
if prev is not None:
|
||||
yield prev
|
||||
|
||||
return [block for block in joined_blocks()]
|
||||
|
||||
|
||||
def extract_text_xml(infile, pdf, pageno=None, log=None):
|
||||
existing_text = ghostscript.extract_text(infile, pageno=None)
|
||||
existing_text = regex_remove_char_tags.sub(b' ', existing_text)
|
||||
|
||||
try:
|
||||
root = ET.fromstringlist([
|
||||
b'<document>\n', existing_text, b'</document>\n'
|
||||
])
|
||||
page_xml = root.findall('page')
|
||||
except ET.ParseError as e:
|
||||
log.error(
|
||||
"An error occurred while attempting to retrieve existing text in "
|
||||
"the input file. Will attempt to continue assuming that there is "
|
||||
"no existing text in the file. The error was:")
|
||||
log.error(e)
|
||||
page_xml = [None] * len(pdf.pages)
|
||||
|
||||
page_count_difference = len(pdf.pages) - len(page_xml)
|
||||
if page_count_difference != 0:
|
||||
log.error("The number of pages in the input file is inconsistent.")
|
||||
if page_count_difference > 0:
|
||||
page_xml.extend([None] * page_count_difference)
|
||||
return page_xml
|
||||
@@ -0,0 +1,237 @@
|
||||
# © 2018 James R. Barlow: github.com/jbarlow83
|
||||
#
|
||||
# This file is part of OCRmyPDF.
|
||||
#
|
||||
# OCRmyPDF is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# OCRmyPDF is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with OCRmyPDF. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import re
|
||||
from math import copysign
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pdfminer.encodingdb
|
||||
import pdfminer.pdfdevice
|
||||
import pdfminer.pdfinterp
|
||||
from pdfminer.converter import PDFLayoutAnalyzer
|
||||
from pdfminer.glyphlist import glyphname2unicode
|
||||
from pdfminer.layout import (LAParams, LTChar, LTContainer, LTLayoutContainer,
|
||||
LTPage, LTTextBox, LTTextLine)
|
||||
from pdfminer.pdfdocument import PDFTextExtractionNotAllowed
|
||||
from pdfminer.pdffont import (PDFCIDFont, PDFFont, PDFType3Font,
|
||||
PDFUnicodeNotDefined, PDFSimpleFont)
|
||||
from pdfminer.pdfpage import PDFPage
|
||||
from pdfminer.utils import bbox2str, fsplit, matrix2str
|
||||
|
||||
from ..exceptions import EncryptedPdfError
|
||||
|
||||
STRIP_NAME = re.compile(r'[0-9]+')
|
||||
|
||||
#
|
||||
# Unconditional pdfminer patches
|
||||
#
|
||||
|
||||
def name2unicode(name):
|
||||
"""Fix pdfminer's name2unicode function
|
||||
|
||||
Font cids that are mapped to names of the form /g123 seem to be, by convention
|
||||
characters with no corresponding Unicode entry. These can be subsetted fonts
|
||||
or symbolic fonts. There seems to be no way to map /g123 fonts to Unicode,
|
||||
barring a ToUnicode data structure.
|
||||
"""
|
||||
if name in glyphname2unicode:
|
||||
return glyphname2unicode[name]
|
||||
if name.startswith('g') or name.startswith('a'):
|
||||
raise KeyError(name)
|
||||
if name.startswith('uni'):
|
||||
try:
|
||||
return chr(int(name[3:], 16))
|
||||
except ValueError: # Not hexadecimal
|
||||
raise KeyError(name)
|
||||
m = STRIP_NAME.search(name)
|
||||
if not m:
|
||||
raise KeyError(name)
|
||||
return chr(int(m.group(0)))
|
||||
pdfminer.encodingdb.name2unicode = name2unicode
|
||||
|
||||
original_PDFFont_init = PDFFont.__init__
|
||||
def PDFFont__init__(self, descriptor, widths, default_width=None):
|
||||
original_PDFFont_init(self, descriptor, widths, default_width)
|
||||
# PDF spec says descent should be negative
|
||||
# A font with a positive descent implies it floats entirely above the
|
||||
# baseline, i.e. it's not really a baseline anymore. I have fonts that
|
||||
# claim a positive descent, but treating descent as positive always seems
|
||||
# to misposition text.
|
||||
if self.descent > 0:
|
||||
self.descent = -self.descent
|
||||
PDFFont.__init__ = PDFFont__init__
|
||||
|
||||
original_PDFSimpleFont_init = PDFSimpleFont.__init__
|
||||
def PDFSimpleFont__init__(self, descriptor, widths, spec):
|
||||
# Font encoding is specified either by a name of
|
||||
# built-in encoding or a dictionary that describes
|
||||
# the differences.
|
||||
original_PDFSimpleFont_init(self, descriptor, widths, spec)
|
||||
# pdfminer is incorrect. If there is no ToUnicode and no Encoding, do not
|
||||
# assume Unicode conversion is possible. RM 9.10.2
|
||||
if not self.unicode_map and 'Encoding' not in spec:
|
||||
self.cid2unicode = {}
|
||||
return
|
||||
PDFSimpleFont.__init__ = PDFSimpleFont__init__
|
||||
#
|
||||
# pdfminer patches when creator is PScript5.dll
|
||||
#
|
||||
|
||||
def PDFType3Font__PScript5_get_height(self):
|
||||
h = self.bbox[3]-self.bbox[1]
|
||||
if h == 0:
|
||||
h = self.ascent - self.descent
|
||||
return h * copysign(1.0, self.vscale)
|
||||
|
||||
def PDFType3Font__PScript5_get_descent(self):
|
||||
return self.descent * copysign(1.0, self.vscale)
|
||||
|
||||
def PDFType3Font__PScript5_get_ascent(self):
|
||||
return self.ascent * copysign(1.0, self.vscale)
|
||||
|
||||
|
||||
class LTStateAwareChar(LTChar):
|
||||
"""A subclass of LTChar that tracks text render mode at time of drawing"""
|
||||
|
||||
__slots__ = (
|
||||
'rendermode', '_text', 'matrix', 'fontname', 'adv', 'upright', 'size',
|
||||
'width', 'height', 'bbox', 'x0', 'x1', 'y0', 'y1'
|
||||
)
|
||||
|
||||
def __init__(self, matrix, font, fontsize, scaling, rise, text, textwidth,
|
||||
textdisp, textstate, *args):
|
||||
super().__init__(matrix, font, fontsize, scaling, rise, text, textwidth,
|
||||
textdisp, *args)
|
||||
self.rendermode = textstate.render
|
||||
|
||||
def is_compatible(self, obj):
|
||||
"""Check if characters can be combined into a textline
|
||||
|
||||
We consider characters compatible if:
|
||||
- the Unicode mapping is known, and both have the same render mode
|
||||
- the Unicode mapping is unknown but both are part of the same font
|
||||
"""
|
||||
both_unicode_mapped = (isinstance(self._text, str) and
|
||||
isinstance(obj._text, str))
|
||||
try:
|
||||
if both_unicode_mapped:
|
||||
return self.rendermode == obj.rendermode
|
||||
font0, _ = self._text
|
||||
font1, _ = obj._text
|
||||
return font0 == font1 and self.rendermode == obj.rendermode
|
||||
except (ValueError, AttributeError):
|
||||
return False
|
||||
|
||||
def get_text(self):
|
||||
if isinstance(self._text, tuple):
|
||||
return '�'
|
||||
return self._text
|
||||
|
||||
def __repr__(self):
|
||||
return ('<%s %s matrix=%s rendermode=%r font=%r adv=%s text=%r>' %
|
||||
(self.__class__.__name__, bbox2str(self.bbox),
|
||||
matrix2str(self.matrix), self.rendermode, self.fontname, self.adv,
|
||||
self.get_text()))
|
||||
|
||||
|
||||
class TextPositionTracker(PDFLayoutAnalyzer):
|
||||
"""A page layout analyzer that pays attention to text visibility"""
|
||||
|
||||
def __init__(self, rsrcmgr, pageno=1, laparams=None):
|
||||
super().__init__(rsrcmgr, pageno, laparams)
|
||||
self.textstate = None
|
||||
self.result = None
|
||||
|
||||
def begin_page(self, page, ctm):
|
||||
super().begin_page(page, ctm)
|
||||
self.cur_item = LTPage(self.pageno, page.mediabox)
|
||||
|
||||
def end_page(self, page):
|
||||
assert not self._stack, str(len(self._stack))
|
||||
assert isinstance(self.cur_item, LTPage), str(type(self.cur_item))
|
||||
if self.laparams is not None:
|
||||
self.cur_item.analyze(self.laparams)
|
||||
self.pageno += 1
|
||||
self.receive_layout(self.cur_item)
|
||||
|
||||
def render_string(self, textstate, seq, *args):
|
||||
self.textstate = textstate.copy()
|
||||
super().render_string(self.textstate, seq, *args)
|
||||
|
||||
def render_char(self, matrix, font, fontsize, scaling, rise, cid, *args):
|
||||
try:
|
||||
text = font.to_unichr(cid)
|
||||
assert isinstance(text, str), str(type(text))
|
||||
except PDFUnicodeNotDefined:
|
||||
text = self.handle_undefined_char(font, cid)
|
||||
textwidth = font.char_width(cid)
|
||||
textdisp = font.char_disp(cid)
|
||||
item = LTStateAwareChar(
|
||||
matrix, font, fontsize, scaling, rise, text,
|
||||
textwidth, textdisp, self.textstate, *args)
|
||||
self.cur_item.add(item)
|
||||
return item.adv
|
||||
|
||||
def handle_undefined_char(self, font, cid):
|
||||
#log.info('undefined: %r, %r', font, cid)
|
||||
return (font.fontname, cid)
|
||||
|
||||
def receive_layout(self, ltpage):
|
||||
self.result = ltpage
|
||||
|
||||
def get_result(self):
|
||||
return self.result
|
||||
|
||||
|
||||
def get_page_analysis(infile, pageno, pscript5_mode):
|
||||
rman = pdfminer.pdfinterp.PDFResourceManager(caching=True)
|
||||
dev = TextPositionTracker(rman, laparams=LAParams())
|
||||
interp = pdfminer.pdfinterp.PDFPageInterpreter(rman, dev)
|
||||
|
||||
if pscript5_mode:
|
||||
patcher = patch.multiple(
|
||||
'pdfminer.pdffont.PDFType3Font',
|
||||
spec=True,
|
||||
get_ascent=PDFType3Font__PScript5_get_ascent,
|
||||
get_descent=PDFType3Font__PScript5_get_descent,
|
||||
get_height=PDFType3Font__PScript5_get_height
|
||||
)
|
||||
patcher.start()
|
||||
|
||||
try:
|
||||
with Path(infile).open('rb') as f:
|
||||
page = PDFPage.get_pages(f, pagenos=[pageno], maxpages=0)
|
||||
interp.process_page(next(page))
|
||||
except PDFTextExtractionNotAllowed:
|
||||
raise EncryptedPdfError()
|
||||
finally:
|
||||
if pscript5_mode:
|
||||
patcher.stop()
|
||||
|
||||
return dev.get_result()
|
||||
|
||||
|
||||
def get_text_boxes(obj):
|
||||
for child in obj:
|
||||
if isinstance(child, (LTTextBox)):
|
||||
yield child
|
||||
else:
|
||||
try:
|
||||
yield from get_text_boxes(child)
|
||||
except TypeError:
|
||||
continue
|
||||
+9
-4
@@ -18,13 +18,18 @@
|
||||
import sys
|
||||
import os
|
||||
import platform
|
||||
|
||||
pytest_plugins = ['helpers_namespace']
|
||||
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from subprocess import Popen, PIPE
|
||||
|
||||
pytest_plugins = ['helpers_namespace']
|
||||
import pytest
|
||||
|
||||
try:
|
||||
from pytest_cov.embed import cleanup_on_sigterm
|
||||
except ImportError:
|
||||
pass
|
||||
else:
|
||||
cleanup_on_sigterm()
|
||||
|
||||
# pylint: disable=E1101
|
||||
# pytest.helpers is dynamic so it confuses pylint
|
||||
|
||||
@@ -139,11 +139,21 @@ licensed under the specified license.
|
||||
- @jbarlow83
|
||||
- @jbarlow83
|
||||
- CC-BY-SA 4.0
|
||||
* - truetype_font_nomapping.pdf
|
||||
- example of a PDF with an embedded subsetted TrueType font with no Unicode mapping
|
||||
- @jbarlow83
|
||||
- @jbarlow83
|
||||
- CC-BY-SA 4.0
|
||||
* - trivial.pdf
|
||||
- smallest possible valid PDF-1.3 with all required fields
|
||||
- @jbarlow83
|
||||
- @jbarlow83
|
||||
- CC-BY-SA 4.0
|
||||
* - type3_font_nomapping.pdf
|
||||
- example of a PDF with an embedded subsetted TrueType font with no Unicode mapping
|
||||
- @jbarlow83
|
||||
- @jbarlow83
|
||||
- CC-BY-SA 4.0
|
||||
* - vector.pdf
|
||||
- a PDF with vector art and text rendered as curves with no fonts
|
||||
- @Catscratch
|
||||
|
||||
Binary file not shown.
Binary file not shown.
+62
-1
@@ -18,9 +18,14 @@
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import pytest
|
||||
import sys
|
||||
from pickle import dumps, loads
|
||||
|
||||
import pytest
|
||||
from PIL import Image, ImageChops
|
||||
|
||||
import ocrmypdf.leptonica as lept
|
||||
from ocrmypdf.helpers import fspath
|
||||
|
||||
|
||||
def test_colormap_backgroundnorm(resources):
|
||||
@@ -28,3 +33,59 @@ def test_colormap_backgroundnorm(resources):
|
||||
# can handle that case
|
||||
pix = lept.Pix.open(resources / 'baiona_colormapped.png')
|
||||
pix.background_norm()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def crom_pix(resources):
|
||||
pix = lept.Pix.open(resources / 'crom.png')
|
||||
im = Image.open(resources / 'crom.png')
|
||||
return pix, im
|
||||
|
||||
|
||||
def test_pix_basic(crom_pix):
|
||||
pix, im = crom_pix
|
||||
|
||||
assert pix.width == im.width
|
||||
assert pix.height == im.height
|
||||
assert pix.mode == im.mode
|
||||
|
||||
|
||||
def test_pil_conversion(crom_pix):
|
||||
pix, im = crom_pix
|
||||
|
||||
# Check for pixel perfect
|
||||
assert ImageChops.difference(pix.topil(), im).getbbox() is None
|
||||
|
||||
|
||||
def test_pix_otsu(crom_pix):
|
||||
pix, _ = crom_pix
|
||||
im1bpp = pix.otsu_adaptive_threshold()
|
||||
assert im1bpp.mode == '1'
|
||||
|
||||
|
||||
def test_crop(resources):
|
||||
pix = lept.Pix.open(resources / 'linn.png')
|
||||
foreground = pix.crop_to_foreground()
|
||||
assert foreground.width < pix.width
|
||||
|
||||
|
||||
def test_clean_bg(resources):
|
||||
pix = lept.Pix.open(resources / 'congress.jpg')
|
||||
imbg = pix.clean_background_to_white()
|
||||
|
||||
|
||||
def test_pickle(crom_pix):
|
||||
pix, _ = crom_pix
|
||||
pickled = dumps(pix)
|
||||
pix2 = loads(pickled)
|
||||
assert pix.mode == pix2.mode
|
||||
|
||||
|
||||
def test_leptonica_compile(tmpdir):
|
||||
from ocrmypdf.lib.compile_leptonica import ffibuilder
|
||||
|
||||
# Compile the library but build it somewhere that won't interfere with
|
||||
# existing compiled library. Also compile in API mode so that we test
|
||||
# the interfaces, even though we use it ABI mode.
|
||||
ffibuilder.compile(tmpdir=fspath(tmpdir),
|
||||
target=fspath(tmpdir / 'lepttest.*'))
|
||||
|
||||
+28
-4
@@ -28,7 +28,7 @@ import PIL
|
||||
import pytest
|
||||
|
||||
from ocrmypdf.pdfinfo import PdfInfo, Colorspace, Encoding
|
||||
from ocrmypdf.exceptions import ExitCode
|
||||
from ocrmypdf.exceptions import ExitCode, MissingDependencyError
|
||||
from ocrmypdf.exec import ghostscript, qpdf, tesseract
|
||||
from ocrmypdf.pdfa import file_claims_pdfa
|
||||
from ocrmypdf.leptonica import Pix
|
||||
@@ -190,11 +190,22 @@ def test_force_ocr(spoof_tesseract_cache, resources, outpdf):
|
||||
|
||||
def test_skip_ocr(spoof_tesseract_cache, resources, outpdf):
|
||||
out = check_ocrmypdf(resources / 'graph_ocred.pdf', outpdf, '-s',
|
||||
env=spoof_tesseract_cache)
|
||||
env=spoof_tesseract_cache)
|
||||
pdfinfo = PdfInfo(out)
|
||||
assert pdfinfo[0].has_text
|
||||
|
||||
|
||||
def test_redo_ocr(spoof_tesseract_cache, resources, outpdf):
|
||||
in_ = resources / 'graph_ocred.pdf'
|
||||
before = PdfInfo(in_, detailed_page_analysis=True)
|
||||
out = check_ocrmypdf(in_, outpdf, '--redo-ocr',
|
||||
env=spoof_tesseract_cache)
|
||||
after = PdfInfo(out, detailed_page_analysis=True)
|
||||
assert before[0].has_text and after[0].has_text
|
||||
assert before[0].get_textareas() != after[0].get_textareas(), \
|
||||
"Expected text to be different after re-OCR"
|
||||
|
||||
|
||||
def test_argsfile(spoof_tesseract_noop, resources, outdir):
|
||||
path_argsfile = outdir / 'test_argsfile.txt'
|
||||
with open(str(path_argsfile), 'w') as argsfile:
|
||||
@@ -208,7 +219,7 @@ def test_argsfile(spoof_tesseract_noop, resources, outdir):
|
||||
@pytest.mark.parametrize('renderer', RENDERERS)
|
||||
def test_ocr_timeout(renderer, resources, outpdf):
|
||||
out = check_ocrmypdf(resources / 'skew.pdf', outpdf,
|
||||
'--tesseract-timeout', '0.01',
|
||||
'--tesseract-timeout', '0',
|
||||
'--pdf-renderer', renderer)
|
||||
pdfinfo = PdfInfo(out)
|
||||
assert not pdfinfo[0].has_text
|
||||
@@ -377,7 +388,7 @@ def test_tesseract_image_too_big(renderer, spoof_tesseract_big_image_error,
|
||||
def test_algo4(resources, spoof_tesseract_noop, outpdf):
|
||||
p, _, _ = run_ocrmypdf(resources / 'encrypted_algo4.pdf', outpdf,
|
||||
env=spoof_tesseract_noop)
|
||||
assert p.returncode == ExitCode.ok
|
||||
assert p.returncode == ExitCode.encrypted_pdf
|
||||
|
||||
|
||||
@pytest.mark.parametrize('renderer', RENDERERS)
|
||||
@@ -934,3 +945,16 @@ def test_livecycle(resources, no_outpdf):
|
||||
)
|
||||
|
||||
assert p.returncode == ExitCode.input_file, err
|
||||
|
||||
|
||||
def test_version_check():
|
||||
from ocrmypdf.exec import get_version
|
||||
|
||||
with pytest.raises(MissingDependencyError):
|
||||
get_version('NOT_FOUND_UNLIKELY_ON_PATH')
|
||||
|
||||
with pytest.raises(MissingDependencyError):
|
||||
get_version('sh', version_arg='-c')
|
||||
|
||||
with pytest.raises(MissingDependencyError):
|
||||
get_version('echo')
|
||||
|
||||
+12
-15
@@ -25,11 +25,13 @@ from unittest.mock import patch
|
||||
import datetime
|
||||
|
||||
import pikepdf
|
||||
from pikepdf.models.metadata import decode_pdf_date
|
||||
|
||||
from ocrmypdf.exceptions import ExitCode
|
||||
from ocrmypdf.helpers import fspath
|
||||
from ocrmypdf.pdfa import (
|
||||
file_claims_pdfa, encode_pdf_date, decode_pdf_date, generate_pdfa_ps,
|
||||
file_claims_pdfa,
|
||||
generate_pdfa_ps,
|
||||
SRGB_ICC_PROFILE
|
||||
)
|
||||
from ocrmypdf.exec import ghostscript
|
||||
@@ -43,6 +45,8 @@ except ImportError:
|
||||
# pylint: disable=no-member
|
||||
# pylint: disable=w0612
|
||||
|
||||
pytestmark = pytest.mark.filterwarnings('ignore:.*XMLParser.*:DeprecationWarning')
|
||||
|
||||
check_ocrmypdf = pytest.helpers.check_ocrmypdf
|
||||
run_ocrmypdf = pytest.helpers.run_ocrmypdf
|
||||
spoof = pytest.helpers.spoof
|
||||
@@ -63,7 +67,7 @@ def test_preserve_metadata(spoof_tesseract_noop, output_type,
|
||||
pdf_after = pikepdf.open(output)
|
||||
|
||||
for key in ('/Title', '/Author'):
|
||||
assert pdf_before.metadata[key] == pdf_after.metadata[key]
|
||||
assert pdf_before.docinfo[key] == pdf_after.docinfo[key]
|
||||
|
||||
pdfa_info = file_claims_pdfa(str(output))
|
||||
assert pdfa_info['output'] == output_type
|
||||
@@ -90,15 +94,12 @@ def test_override_metadata(spoof_tesseract_noop, output_type, resources,
|
||||
before = pikepdf.open(input_file)
|
||||
after = pikepdf.open(outpdf)
|
||||
|
||||
if ghostscript.version() >= '9.24':
|
||||
pytest.xfail('Ghostscript 9.24+ does not support Unicode DOCINFO')
|
||||
assert after.docinfo.Title == german, after.docinfo
|
||||
assert after.docinfo.Author == chinese, after.docinfo
|
||||
assert after.docinfo.get('/Keywords', '') == ''
|
||||
|
||||
assert after.metadata.Title == german, after.metadata
|
||||
assert after.metadata.Author == chinese, after.metadata
|
||||
assert after.metadata.get('/Keywords', '') == ''
|
||||
|
||||
before_date = decode_pdf_date(str(before.metadata.CreationDate))
|
||||
after_date = decode_pdf_date(str(after.metadata.CreationDate))
|
||||
before_date = decode_pdf_date(str(before.docinfo.CreationDate))
|
||||
after_date = decode_pdf_date(str(after.docinfo.CreationDate))
|
||||
assert before_date == after_date
|
||||
|
||||
pdfa_info = file_claims_pdfa(outpdf)
|
||||
@@ -162,11 +163,7 @@ def test_creation_date_preserved(spoof_tesseract_noop, output_type, resources,
|
||||
after = pdf_after.trailer.get('/Info', {})
|
||||
|
||||
if not before:
|
||||
# If there was input creation date, none should be output
|
||||
# because of Ghostscript quirks we set it to null
|
||||
# This test would be better if we had a test file with /DocumentInfo but
|
||||
# no /CreationDate, which we don't
|
||||
assert after.get('/CreationDate', '') == ''
|
||||
assert after.get('/CreationDate', '') != ''
|
||||
else:
|
||||
# We expect that the creation date stayed the same
|
||||
date_before = decode_pdf_date(str(before['/CreationDate']))
|
||||
|
||||
+48
-3
@@ -27,7 +27,6 @@ import shutil
|
||||
import pytest
|
||||
import img2pdf
|
||||
import sys
|
||||
import PyPDF2 as pypdf
|
||||
import pikepdf
|
||||
import pickle
|
||||
|
||||
@@ -145,13 +144,13 @@ def test_pickle(resources):
|
||||
# For multiprocessing we must be able to pickle our information - if
|
||||
# this fails then we are probably storing some unpickleabe pikepdf or
|
||||
# other external data around
|
||||
filename = resources / 'formxobject.pdf'
|
||||
filename = resources / 'graph_ocred.pdf'
|
||||
pdf = pdfinfo.PdfInfo(filename)
|
||||
pickle.dumps(pdf)
|
||||
|
||||
|
||||
def test_regex():
|
||||
rx = pdfinfo.regex_remove_char_tags
|
||||
rx = pdfinfo.ghosttext.regex_remove_char_tags
|
||||
|
||||
must_match = [
|
||||
b'<char bbox="0 108 0 108" c="/"/>',
|
||||
@@ -169,3 +168,49 @@ def test_regex():
|
||||
assert rx.match(s)
|
||||
for s in must_not_match:
|
||||
assert not rx.match(s)
|
||||
|
||||
|
||||
def test_vector(resources):
|
||||
filename = resources / 'vector.pdf'
|
||||
pdf = pdfinfo.PdfInfo(filename)
|
||||
assert pdf[0].has_vector
|
||||
assert not pdf[0].has_text
|
||||
|
||||
|
||||
def test_ocr_detection(resources):
|
||||
filename = resources / 'graph_ocred.pdf'
|
||||
pdf = pdfinfo.PdfInfo(filename)
|
||||
assert not pdf[0].has_vector
|
||||
assert pdf[0].has_text
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'testfile', ('truetype_font_nomapping.pdf', 'type3_font_nomapping.pdf')
|
||||
)
|
||||
def test_corrupt_font_detection(resources, testfile):
|
||||
filename = resources / testfile
|
||||
with pytest.raises(NotImplementedError):
|
||||
pdf = pdfinfo.PdfInfo(filename)
|
||||
pdf[0].has_corrupt_text
|
||||
|
||||
pdf = pdfinfo.PdfInfo(filename, detailed_page_analysis=True)
|
||||
assert pdf[0].has_corrupt_text
|
||||
|
||||
|
||||
def test_stack_abuse():
|
||||
p = pikepdf.Pdf.new()
|
||||
|
||||
stream = pikepdf.Stream(p, b'q ' * 35)
|
||||
with pytest.warns(None) as record:
|
||||
pdfinfo._interpret_contents(stream)
|
||||
assert 'overflowed' in str(record[0].message)
|
||||
|
||||
stream = pikepdf.Stream(p, b'q Q Q Q Q')
|
||||
with pytest.warns(None) as record:
|
||||
pdfinfo._interpret_contents(stream)
|
||||
assert 'underflowed' in str(record[0].message)
|
||||
|
||||
stream = pikepdf.Stream(p, b'q ' * 135)
|
||||
with pytest.warns(None):
|
||||
with pytest.raises(RuntimeError):
|
||||
pdfinfo._interpret_contents(stream)
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# © 2018 James R. Barlow: github.com/jbarlow83
|
||||
#
|
||||
# This file is part of OCRmyPDF.
|
||||
#
|
||||
# OCRmyPDF is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# OCRmyPDF is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with OCRmyPDF. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import pytest
|
||||
|
||||
import ocrmypdf.exec.qpdf as qpdf
|
||||
|
||||
def test_qpdf_error(resources):
|
||||
assert qpdf.check(resources / 'blank.pdf')
|
||||
assert not qpdf.check(__file__)
|
||||
+14
-1
@@ -17,6 +17,7 @@
|
||||
|
||||
import logging
|
||||
from io import BytesIO
|
||||
from unittest.mock import Mock
|
||||
|
||||
from PIL import Image
|
||||
import pytest
|
||||
@@ -25,7 +26,7 @@ import pikepdf
|
||||
|
||||
from ocrmypdf import leptonica
|
||||
from ocrmypdf.pdfinfo import PdfInfo
|
||||
from ocrmypdf.exec import ghostscript
|
||||
from ocrmypdf.exec import ghostscript, tesseract
|
||||
from ocrmypdf.helpers import fspath
|
||||
|
||||
|
||||
@@ -169,6 +170,8 @@ def test_rotate_deskew_timeout(resources, outdir):
|
||||
check_ocrmypdf(
|
||||
resources / 'rotated_skew.pdf',
|
||||
outdir / 'deskewed.pdf',
|
||||
'--rotate-pages',
|
||||
'--rotate-pages-threshold', '0',
|
||||
'--deskew',
|
||||
'--tesseract-timeout', '0',
|
||||
'--pdf-renderer', 'sandwich'
|
||||
@@ -226,3 +229,13 @@ def test_rotate_page_level(image_angle, page_angle, resources, outdir):
|
||||
assert p.returncode == 0, err
|
||||
|
||||
assert check_monochrome_correlation(outdir, reference, 1, out, 1) > 0.2
|
||||
|
||||
|
||||
def test_tesseract_orientation(resources, tmpdir):
|
||||
pix = leptonica.Pix.open(resources / 'crom.png')
|
||||
pix_rotated = pix.rotate_orth(2) # 180 degrees clockwise
|
||||
pix_rotated.write_implied_format(tmpdir / '000001.png')
|
||||
|
||||
log = Mock()
|
||||
tesseract.get_orientation( # Test results of this are unreliable
|
||||
tmpdir / '000001.png', engine_mode='3', timeout=10, log=log)
|
||||
|
||||
+19
-3
@@ -16,8 +16,9 @@
|
||||
# along with OCRmyPDF. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import pytest
|
||||
from ocrmypdf.exceptions import ExitCode
|
||||
from ocrmypdf.exceptions import ExitCode, MissingDependencyError
|
||||
from ocrmypdf.exec import tesseract
|
||||
from ocrmypdf.helpers import fspath
|
||||
from ocrmypdf import pdfinfo
|
||||
import sys
|
||||
import os
|
||||
@@ -43,6 +44,7 @@ def _ensure_tess4():
|
||||
tess4 = Path(os.environ['OCRMYPDF_TESS4'])
|
||||
assert tess4.is_file()
|
||||
env['PATH'] = tess4.parent + ':' + env['PATH']
|
||||
env['OCRMYPDF_TESS4'] = os.environ['OCRMYPDF_TESS4']
|
||||
return env
|
||||
|
||||
raise EnvironmentError("Can't find Tesseract 4")
|
||||
@@ -56,9 +58,12 @@ def ensure_tess4():
|
||||
@contextmanager
|
||||
def modified_os_environ(env):
|
||||
old_env = os.environ.copy()
|
||||
os.environ = env
|
||||
os.environ.update(env)
|
||||
yield
|
||||
os.environ = old_env
|
||||
for key in env:
|
||||
del os.environ[key]
|
||||
if key in old_env:
|
||||
os.environ[key] = old_env[key]
|
||||
|
||||
|
||||
def tess4_available():
|
||||
@@ -149,3 +154,14 @@ def test_content_preservation(ensure_tess4, resources, outpdf):
|
||||
info = pdfinfo.PdfInfo(outpdf)
|
||||
page = info[0]
|
||||
assert len(page.images) > 1, "masks were rasterized"
|
||||
|
||||
|
||||
def test_no_languages(ensure_tess4, tmpdir):
|
||||
env = ensure_tess4
|
||||
(tmpdir / 'tessdata').mkdir()
|
||||
env['TESSDATA_PREFIX'] = fspath(tmpdir)
|
||||
|
||||
with modified_os_environ(env):
|
||||
with pytest.raises(MissingDependencyError):
|
||||
tesseract.languages.cache_clear()
|
||||
tesseract.languages()
|
||||
|
||||
Reference in New Issue
Block a user