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 |
@@ -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')
|
||||
@@ -0,0 +1,10 @@
|
||||
build:
|
||||
image: latest
|
||||
|
||||
python:
|
||||
version: 3.6
|
||||
|
||||
formats:
|
||||
- pdf
|
||||
|
||||
requirements_file: requirements/main.txt
|
||||
@@ -1,7 +1,7 @@
|
||||
OCRmyPDF
|
||||
========
|
||||
|
||||
[![Travis build status][travis]](https://travis-ci.org/jbarlow83/OCRmyPDF) [![PyPI version][pypi]](https://pypi.org/project/ocrmypdf/) ![Homebrew version][homebrew]
|
||||
[![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"
|
||||
|
||||
@@ -9,6 +9,8 @@ OCRmyPDF
|
||||
|
||||
[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.
|
||||
|
||||
```bash
|
||||
|
||||
+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)
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -129,7 +129,7 @@ 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.
|
||||
* ``--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::
|
||||
|
||||
|
||||
@@ -13,6 +13,32 @@ 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
|
||||
------
|
||||
|
||||
@@ -5,7 +5,7 @@ chardet == 3.0.4
|
||||
cffi == 1.11.5
|
||||
img2pdf == 0.3.1
|
||||
pdfminer.six == 20181108
|
||||
pikepdf == 0.3.7
|
||||
pikepdf == 0.9.1
|
||||
Pillow >= 5.0.0, != 5.1.0 ; sys_platform == "darwin"
|
||||
pycparser == 2.19
|
||||
python-xmp-toolkit == 2.0.1
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -248,14 +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.3.0, < 0.4', # pure Python, so track HEAD closely
|
||||
'pdfminer.six == 20181108',
|
||||
'pikepdf >= 0.3.7, < 0.4',
|
||||
'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',
|
||||
],
|
||||
|
||||
+80
-66
@@ -29,9 +29,11 @@ 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
|
||||
from .exceptions import UnsupportedImageFormatError, \
|
||||
@@ -167,7 +169,7 @@ def repair_and_parse_pdf(
|
||||
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:
|
||||
@@ -598,10 +600,6 @@ def select_ocr_image(
|
||||
options = context.get_options()
|
||||
pageinfo = get_pageinfo(image, context)
|
||||
|
||||
if options.force_ocr:
|
||||
re_symlink(image, output_file, log)
|
||||
return
|
||||
|
||||
with Image.open(image) as im:
|
||||
from PIL import ImageColor
|
||||
from PIL import ImageDraw
|
||||
@@ -613,25 +611,27 @@ def select_ocr_image(
|
||||
xres, yres = im.info['dpi']
|
||||
log.debug('resolution %r %r', xres, yres)
|
||||
|
||||
mask = None # Exclude both visible and invisible text from OCR
|
||||
if options.redo_ocr:
|
||||
mask = True # Mask visible text, but not invisible text
|
||||
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 = 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)
|
||||
del draw
|
||||
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)
|
||||
@@ -645,6 +645,7 @@ def select_ocr_image(
|
||||
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)
|
||||
@@ -794,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 ''
|
||||
@@ -839,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(
|
||||
@@ -870,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(
|
||||
@@ -1119,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,
|
||||
@@ -1128,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]
|
||||
)
|
||||
|
||||
+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
|
||||
|
||||
+32
-20
@@ -21,6 +21,7 @@
|
||||
# Python FFI wrapper for Leptonica library
|
||||
|
||||
from collections.abc import Sequence
|
||||
from contextlib import suppress
|
||||
from ctypes.util import find_library
|
||||
from functools import lru_cache
|
||||
from io import BytesIO
|
||||
@@ -36,12 +37,12 @@ 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."""
|
||||
print("leptonica.py:", *objs, file=sys.stderr)
|
||||
@@ -410,8 +411,9 @@ class Pix(LeptonicaObject):
|
||||
smoothx, smoothy = kernel_size
|
||||
p_pix = ffi.new('PIX **')
|
||||
|
||||
pix = Pix(lept.pixConvertTo8(self._cdata, 0))
|
||||
result = lept.pixOtsuAdaptiveThreshold(
|
||||
self._cdata,
|
||||
pix._cdata,
|
||||
sx, sy,
|
||||
smoothx, smoothy,
|
||||
scorefract,
|
||||
@@ -432,8 +434,9 @@ class Pix(LeptonicaObject):
|
||||
if isinstance(mask, Pix):
|
||||
mask = mask._cdata
|
||||
|
||||
pix = Pix(lept.pixConvertTo8(self._cdata, 0))
|
||||
thresh_pix = lept.pixOtsuThreshOnBackgroundNorm(
|
||||
self._cdata,
|
||||
pix._cdata,
|
||||
mask,
|
||||
sx, sy,
|
||||
thresh, mincount, bgval,
|
||||
@@ -560,22 +563,30 @@ class Pix(LeptonicaObject):
|
||||
return Pix(lept.pixInvert(ffi.NULL, self._cdata))
|
||||
|
||||
def locate_barcodes(self):
|
||||
with _LeptonicaErrorTrap():
|
||||
pix = Pix(lept.pixConvertTo8(self._cdata, 0))
|
||||
pixa_candidates = PixArray(lept.pixExtractBarcodes(pix._cdata, 0))
|
||||
sarray = StringArray(lept.pixReadBarcodes(pixa_candidates._cdata,
|
||||
lept.L_BF_ANY,
|
||||
lept.L_USE_WIDTHS,
|
||||
ffi.NULL,
|
||||
0))
|
||||
for n, s in enumerate(sarray):
|
||||
decoded = s.decode()
|
||||
if s.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))
|
||||
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:
|
||||
@@ -740,6 +751,7 @@ class Sel(LeptonicaObject):
|
||||
|
||||
@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])
|
||||
|
||||
@@ -299,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
|
||||
)
|
||||
|
||||
|
||||
|
||||
+25
-141
@@ -32,14 +32,19 @@ Ghostscript's handling of pdfmark.
|
||||
"""
|
||||
|
||||
from binascii import hexlify
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
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
|
||||
|
||||
|
||||
ICC_PROFILE_RELPATH = 'data/sRGB.icc'
|
||||
@@ -56,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
|
||||
@@ -89,6 +91,7 @@ def
|
||||
"""
|
||||
|
||||
|
||||
@deprecated
|
||||
def encode_text_string(s: str) -> str:
|
||||
"""
|
||||
Encode text string to hex string for use in a PDF
|
||||
@@ -134,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.
|
||||
|
||||
@@ -230,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:
|
||||
@@ -283,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
|
||||
@@ -300,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,6 +22,7 @@ from enum import Enum
|
||||
from math import hypot, isclose
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock
|
||||
from warnings import warn
|
||||
import re
|
||||
|
||||
from pikepdf import PdfMatrix
|
||||
@@ -30,6 +31,7 @@ import pikepdf
|
||||
from . import ghosttext
|
||||
from .layout import get_page_analysis, get_text_boxes
|
||||
|
||||
from ..exceptions import EncryptedPdfError
|
||||
from ..helpers import fspath
|
||||
|
||||
|
||||
@@ -135,6 +137,13 @@ def _interpret_contents(contentstream, initial_shorthand=UNIT_SQUARE):
|
||||
page.
|
||||
|
||||
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 = []
|
||||
@@ -149,18 +158,20 @@ def _interpret_contents(contentstream, initial_shorthand=UNIT_SQUARE):
|
||||
for n, graphobj in enumerate(_normalize_stack(
|
||||
pikepdf.parse_content_stream(contentstream, operator_whitelist))):
|
||||
operands, operator = graphobj
|
||||
|
||||
if operator == 'q':
|
||||
stack.append(ctm)
|
||||
if len(stack) > 32:
|
||||
raise RuntimeError(
|
||||
"PDF graphics stack overflow, operator %i" % n)
|
||||
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, operator %i" % n)
|
||||
# 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 operator == 'Do':
|
||||
@@ -539,12 +550,12 @@ def _pdf_get_pageinfo(pdf, pageno: int, infile, xmltext):
|
||||
width_pt = mediabox[2] - mediabox[0]
|
||||
height_pt = mediabox[3] - mediabox[1]
|
||||
|
||||
if xmltext:
|
||||
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.metadata.get('/Creator')).startswith('PScript5')
|
||||
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'])
|
||||
@@ -588,12 +599,14 @@ def _pdf_get_pageinfo(pdf, pageno: int, infile, xmltext):
|
||||
return pageinfo
|
||||
|
||||
|
||||
def _pdf_get_all_pageinfo(infile, detailed_page_analysis, log=None):
|
||||
def _pdf_get_all_pageinfo(infile, detailed_analysis=False, log=None):
|
||||
if not log:
|
||||
log = Mock()
|
||||
|
||||
pdf = pikepdf.open(infile)
|
||||
if not detailed_page_analysis:
|
||||
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)
|
||||
@@ -601,17 +614,18 @@ def _pdf_get_all_pageinfo(infile, detailed_page_analysis, log=None):
|
||||
pages = []
|
||||
for n in range(len(pdf.pages)):
|
||||
page_xml = pages_xml[n] if pages_xml else None
|
||||
page = PageInfo(pdf, n, infile, page_xml)
|
||||
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):
|
||||
@@ -623,6 +637,8 @@ class PageInfo:
|
||||
|
||||
@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
|
||||
|
||||
@@ -29,7 +29,7 @@ from pdfminer.layout import (LAParams, LTChar, LTContainer, LTLayoutContainer,
|
||||
LTPage, LTTextBox, LTTextLine)
|
||||
from pdfminer.pdfdocument import PDFTextExtractionNotAllowed
|
||||
from pdfminer.pdffont import (PDFCIDFont, PDFFont, PDFType3Font,
|
||||
PDFUnicodeNotDefined)
|
||||
PDFUnicodeNotDefined, PDFSimpleFont)
|
||||
from pdfminer.pdfpage import PDFPage
|
||||
from pdfminer.utils import bbox2str, fsplit, matrix2str
|
||||
|
||||
@@ -42,7 +42,7 @@ STRIP_NAME = re.compile(r'[0-9]+')
|
||||
#
|
||||
|
||||
def name2unicode(name):
|
||||
"""Fix pdfminer's regex in name2unicode function
|
||||
"""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
|
||||
@@ -51,8 +51,13 @@ def name2unicode(name):
|
||||
"""
|
||||
if name in glyphname2unicode:
|
||||
return glyphname2unicode[name]
|
||||
if name.startswith('g'):
|
||||
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)
|
||||
@@ -71,6 +76,18 @@ def PDFFont__init__(self, descriptor, widths, default_width=None):
|
||||
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
|
||||
#
|
||||
@@ -196,15 +213,15 @@ def get_page_analysis(infile, pageno, pscript5_mode):
|
||||
)
|
||||
patcher.start()
|
||||
|
||||
with Path(infile).open('rb') as f:
|
||||
page = PDFPage.get_pages(f, pagenos=[pageno], maxpages=0)
|
||||
try:
|
||||
try:
|
||||
with Path(infile).open('rb') as f:
|
||||
page = PDFPage.get_pages(f, pagenos=[pageno], maxpages=0)
|
||||
interp.process_page(next(page))
|
||||
except PDFTextExtractionNotAllowed as e:
|
||||
raise EncryptedPdfError()
|
||||
finally:
|
||||
if pscript5_mode:
|
||||
patcher.stop()
|
||||
except PDFTextExtractionNotAllowed:
|
||||
raise EncryptedPdfError()
|
||||
finally:
|
||||
if pscript5_mode:
|
||||
patcher.stop()
|
||||
|
||||
return dev.get_result()
|
||||
|
||||
|
||||
@@ -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.
+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']))
|
||||
|
||||
+32
-1
@@ -27,7 +27,6 @@ import shutil
|
||||
import pytest
|
||||
import img2pdf
|
||||
import sys
|
||||
import PyPDF2 as pypdf
|
||||
import pikepdf
|
||||
import pickle
|
||||
|
||||
@@ -183,3 +182,35 @@ def test_ocr_detection(resources):
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user