Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e88ec9822b | ||
|
|
5ffd2f5c96 | ||
|
|
11fdb4c5d8 | ||
|
|
319aff6d09 | ||
|
|
a614fa3400 | ||
|
|
8d691391ac | ||
|
|
0089a84c94 | ||
|
|
90676e1c6a | ||
|
|
062901be43 | ||
|
|
b195d79b50 | ||
|
|
6d7ee98721 | ||
|
|
995f8c106b | ||
|
|
7cc104b138 | ||
|
|
b3a7299a62 | ||
|
|
0e7a4deaec | ||
|
|
b4d66650bd | ||
|
|
4986afca28 | ||
|
|
2b6004a82b | ||
|
|
04c54a7c31 | ||
|
|
7ae6c5ae87 | ||
|
|
f6c70312c9 | ||
|
|
9fd9c7a51f | ||
|
|
422e619978 | ||
|
|
e6e34251c6 |
@@ -7,6 +7,7 @@ tasks.py
|
||||
.bash_history
|
||||
.ruffus_history.sqlite
|
||||
.idea/
|
||||
.pytest_cache/
|
||||
|
||||
# Package building
|
||||
*.egg-info/
|
||||
|
||||
+1
-1
@@ -126,7 +126,7 @@ Requirements
|
||||
|
||||
Runs on CPython 3.6, and requires external program installations of Ghostscript, Tesseract OCR, QPDF, and Leptonica. ocrmypdf is pure Python, but uses CFFI to portably generate library bindings.
|
||||
|
||||
Python 3.5 is fully supported, but support will end at the end of 2017, to allow f-strings in the code base.
|
||||
Python 3.5 is also supported.
|
||||
|
||||
Press & Media
|
||||
-------------
|
||||
|
||||
+3
-3
@@ -43,7 +43,7 @@ OCRmyPDF checks the environment variable ``OCRMYPDF_TESSERACT`` for the full pat
|
||||
|
||||
A Tesseract environment variable that overrides the path to Tesseract's data files.
|
||||
|
||||
For example, if you are testing tesseract 4.00 and don't wish to disturb use an existing tesseract 3.04 installation, you can launch OCRmyPDF as follows:
|
||||
For example, if you are testing tesseract 4.00 and don't wish to use an existing tesseract 3.04 installation, you can launch OCRmyPDF as follows:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
@@ -115,7 +115,7 @@ rendering
|
||||
Creating a new PDF from other data (such as an existing PDF).
|
||||
|
||||
|
||||
OCRmyPDF has three PDF renderers: ``sandwich``, ``hocr``, ``tesseract``. The renderer may be selected using ``--pdf-renderer``. The default is ``auto`` which lets OCRmyPDF select the renderer to use. Currently, ``auto`` selects ``sandwich`` for Tesseract 3.05.01, and newer, ``hocr`` for older versions of Tesseract.
|
||||
OCRmyPDF has three PDF renderers: ``sandwich``, ``hocr``, ``tesseract``. The renderer may be selected using ``--pdf-renderer``. The default is ``auto`` which lets OCRmyPDF select the renderer to use. Currently, ``auto`` selects ``sandwich`` for Tesseract 3.05.01 or newer, or ``hocr`` for older versions of Tesseract.
|
||||
|
||||
The ``sandwich`` renderer
|
||||
"""""""""""""""""""""""""
|
||||
@@ -136,7 +136,7 @@ This works in all versions of Tesseract.
|
||||
The ``tesseract`` renderer
|
||||
""""""""""""""""""""""""""
|
||||
|
||||
The ``tesseract`` renderer creates a PDF with the image and text layers precomposed, meaning that it always transcodes, loses image quality and rasterizes and vector objects. It does a better job on non-Latin text and document structure than ``hocr``.
|
||||
The ``tesseract`` renderer creates a PDF with the image and text layers precomposed, meaning that it always transcodes, loses image quality and rasterizes any vector objects. It does a better job on non-Latin text and document structure than ``hocr``.
|
||||
|
||||
If a PDF created with this renderer using Tesseract versions older than 3.05.00 is then passed through Ghostscript's pdfwrite feature, the OCR text *may* be corrupted. The ``--output-type=pdfa`` argument will produce a warning in this situation.
|
||||
|
||||
|
||||
@@ -83,6 +83,7 @@ OCRmyPDF is limited by the Tesseract OCR engine. As such it experiences these l
|
||||
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.
|
||||
|
||||
Ghostscript also imposes some limitations:
|
||||
|
||||
|
||||
@@ -5,6 +5,19 @@ OCRmyPDF uses `semantic versioning <http://semver.org/>`_ for its command line i
|
||||
|
||||
The OCRmyPDF package itself does not contain a public API, although it is fairly stable and breaking changes are usually timed with a major release. A future release will clearly define the stable public API.
|
||||
|
||||
v5.7.0
|
||||
------
|
||||
|
||||
- Fixed an issue that caused poor CPU utilization on machines more than 4 cores when running Tesseract 4. (Related to issue #217.)
|
||||
|
||||
- The 'hocr' renderer has been improved. The 'sandwich' and 'tesseract' renderers are still better for most use cases, but 'hocr' may be useful for people who work with the PDF.js renderer in English/ASCII languages. (#225)
|
||||
|
||||
+ It now formats text in a matter that is easier for certain PDF viewers to select and extract copy and paste text. This should help macOS Preview and PDF.js in particular.
|
||||
+ The appearance of selected text and behavior of selecting text is improved.
|
||||
+ The PDF content stream now uses relative moves, making it more compact and easier for viewers to determine when two words on the same line.
|
||||
+ It can now deal with text on a skewed baseline.
|
||||
+ Thanks to @cforcey for the pull request, @jbreiden for many helpful suggestions, @ctbarbour for another round of improvements, and @acaloiaro for an independent review.
|
||||
|
||||
v5.6.3
|
||||
------
|
||||
|
||||
|
||||
+73
-50
@@ -27,7 +27,8 @@ from .helpers import is_iterable_notstr, re_symlink, is_file_writable
|
||||
from .exec import tesseract, qpdf, ghostscript
|
||||
from . import PROGRAM_NAME, VERSION
|
||||
|
||||
from .exceptions import ExitCode, ExitCodeException, MissingDependencyError
|
||||
from .exceptions import ExitCode, ExitCodeException, MissingDependencyError, \
|
||||
InputFileError, BadArgsError, OutputFileAccessError
|
||||
from . import exceptions as ocrmypdf_exceptions
|
||||
from ._unicodefun import verify_python3_env
|
||||
|
||||
@@ -479,6 +480,12 @@ def check_options_metadata(options, log):
|
||||
))
|
||||
|
||||
|
||||
def check_options_pillow(options, log):
|
||||
PIL.Image.MAX_IMAGE_PIXELS = int(options.max_image_mpixels * 1000000)
|
||||
if PIL.Image.MAX_IMAGE_PIXELS == 0:
|
||||
PIL.Image.MAX_IMAGE_PIXELS = None
|
||||
|
||||
|
||||
def check_options(options, log):
|
||||
try:
|
||||
check_options_languages(options, log)
|
||||
@@ -488,6 +495,7 @@ def check_options(options, log):
|
||||
check_options_preprocessing(options, log)
|
||||
check_options_ocr_behavior(options, log)
|
||||
check_options_advanced(options, log)
|
||||
check_options_pillow(options, log)
|
||||
except ValueError as e:
|
||||
log.error(e)
|
||||
sys.exit(ExitCode.bad_args)
|
||||
@@ -625,6 +633,21 @@ def do_ruffus_exception(ruffus_five_tuple, options, log):
|
||||
def traverse_ruffus_exception(e_args, options, log):
|
||||
"""Walk through a RethrownJobError and find the first exception.
|
||||
|
||||
Ruffus flattens exception to 5 element tuples. Because of a bug
|
||||
in <= 2.6.3 it may present either the single:
|
||||
(task, job, exc, value, stack)
|
||||
or something like:
|
||||
[[(task, job, exc, value, stack)]]
|
||||
|
||||
Generally cross-process exception marshalling doesn't work well
|
||||
and ruffus doesn't support because BaseException has its own
|
||||
implementation of __reduce__ that attempts to reconstruct the
|
||||
exception based on e.__init__(e.args).
|
||||
|
||||
Attempting to log the exception directly marshalls it to the logger
|
||||
which is probably in another process, so it's better to log only
|
||||
data from the exception at this point.
|
||||
|
||||
The exit code will be based on this, even if multiple exceptions occurred
|
||||
at the same time."""
|
||||
|
||||
@@ -656,6 +679,9 @@ def check_closed_streams(options):
|
||||
|
||||
"""
|
||||
|
||||
if sys.version_info[0:3] >= (3, 6, 4):
|
||||
return True # Issued fixed in Python 3.6.4+
|
||||
|
||||
if sys.stderr is None:
|
||||
sys.stderr = open(os.devnull, 'w')
|
||||
|
||||
@@ -695,6 +721,42 @@ def log_page_orientations(pdfinfo, _log):
|
||||
_log.info('Page orientations detected: ' + ' '.join(orientations))
|
||||
|
||||
|
||||
def preamble(_log):
|
||||
_log.debug('ocrmypdf ' + VERSION)
|
||||
_log.debug('tesseract ' + tesseract.version())
|
||||
_log.debug('qpdf ' + qpdf.version())
|
||||
|
||||
|
||||
def check_input_file(options, _log, start_input_file):
|
||||
if options.input_file == '-':
|
||||
# stdin
|
||||
_log.info('reading file from standard input')
|
||||
with open(start_input_file, 'wb') as stream_buffer:
|
||||
from shutil import copyfileobj
|
||||
copyfileobj(sys.stdin.buffer, stream_buffer)
|
||||
else:
|
||||
try:
|
||||
re_symlink(options.input_file, start_input_file, _log)
|
||||
except FileNotFoundError:
|
||||
_log.error("File not found - " + options.input_file)
|
||||
raise InputFileError()
|
||||
|
||||
|
||||
def check_output_file(options, _log):
|
||||
if options.output_file == '-':
|
||||
if sys.stdout.isatty():
|
||||
_log.error(textwrap.dedent("""\
|
||||
Output was set to stdout '-' but it looks like stdout
|
||||
is connected to a terminal. Please redirect stdout to a
|
||||
file."""))
|
||||
raise BadArgsError()
|
||||
elif not is_file_writable(options.output_file):
|
||||
_log.error(
|
||||
"Output file location (" + options.output_file + ") " +
|
||||
"is not a writable file.")
|
||||
raise OutputFileAccessError()
|
||||
|
||||
|
||||
def run_pipeline():
|
||||
options = parser.parse_args()
|
||||
options.verbose_abbreviated_path = 1
|
||||
@@ -706,16 +768,9 @@ def run_pipeline():
|
||||
|
||||
_log, _log_mutex = proxy_logger.make_shared_logger_and_proxy(
|
||||
logging_factory, __name__, logger_args)
|
||||
_log.debug('ocrmypdf ' + VERSION)
|
||||
_log.debug('tesseract ' + tesseract.version())
|
||||
_log.debug('qpdf ' + qpdf.version())
|
||||
|
||||
preamble(_log)
|
||||
check_options(options, _log)
|
||||
|
||||
PIL.Image.MAX_IMAGE_PIXELS = int(options.max_image_mpixels * 1000000)
|
||||
if PIL.Image.MAX_IMAGE_PIXELS == 0:
|
||||
PIL.Image.MAX_IMAGE_PIXELS = None
|
||||
|
||||
# Complain about qpdf version < 7.0.0
|
||||
# Suppress the warning if in the test suite, since there are no PPAs
|
||||
# for qpdf 7.0.0 for Ubuntu trusty (i.e. Travis)
|
||||
@@ -730,6 +785,13 @@ def run_pipeline():
|
||||
# options.input_file, options.pdf_renderer are already bound.)
|
||||
if not options.jobs:
|
||||
options.jobs = available_cpu_count()
|
||||
|
||||
# Performance is improved by setting Tesseract to single threaded. In tests
|
||||
# this gives better throughput than letting a smaller number of Tesseract
|
||||
# jobs run multithreaded.
|
||||
if tesseract.v4():
|
||||
os.environ.setdefault('OMP_THREAD_LIMIT', '1')
|
||||
|
||||
try:
|
||||
work_folder = mkdtemp(prefix="com.github.ocrmypdf.")
|
||||
options.history_file = os.path.join(
|
||||
@@ -737,31 +799,8 @@ def run_pipeline():
|
||||
start_input_file = os.path.join(
|
||||
work_folder, 'origin')
|
||||
|
||||
if options.input_file == '-':
|
||||
# stdin
|
||||
_log.info('reading file from standard input')
|
||||
with open(start_input_file, 'wb') as stream_buffer:
|
||||
from shutil import copyfileobj
|
||||
copyfileobj(sys.stdin.buffer, stream_buffer)
|
||||
else:
|
||||
try:
|
||||
re_symlink(options.input_file, start_input_file, _log)
|
||||
except FileNotFoundError:
|
||||
_log.error("File not found - " + options.input_file)
|
||||
return ExitCode.input_file
|
||||
|
||||
if options.output_file == '-':
|
||||
if sys.stdout.isatty():
|
||||
_log.error(textwrap.dedent("""\
|
||||
Output was set to stdout '-' but it looks like stdout
|
||||
is connected to a terminal. Please redirect stdout to a
|
||||
file."""))
|
||||
return ExitCode.bad_args
|
||||
elif not is_file_writable(options.output_file):
|
||||
_log.error(
|
||||
"Output file location (" + options.output_file + ") " +
|
||||
"is not a writable file.")
|
||||
return ExitCode.file_access_error
|
||||
check_input_file(options, _log, start_input_file)
|
||||
check_output_file(options, _log)
|
||||
|
||||
manager = JobContextManager()
|
||||
manager.register('JobContext', JobContext) # pylint: disable=no-member
|
||||
@@ -777,22 +816,6 @@ def run_pipeline():
|
||||
except ruffus_exceptions.RethrownJobError as e:
|
||||
if options.verbose:
|
||||
_log.debug(str(e)) # stringify exception so logger doesn't have to
|
||||
|
||||
# Ruffus flattens exception to 5 element tuples. Because of a bug
|
||||
# in <= 2.6.3 it may present either the single:
|
||||
# (task, job, exc, value, stack)
|
||||
# or something like:
|
||||
# [[(task, job, exc, value, stack)]]
|
||||
#
|
||||
# Generally cross-process exception marshalling doesn't work well
|
||||
# and ruffus doesn't support because BaseException has its own
|
||||
# implementation of __reduce__ that attempts to reconstruct the
|
||||
# exception based on e.__init__(e.args).
|
||||
#
|
||||
# Attempting to log the exception directly marshalls it to the logger
|
||||
# which is probably in another process, so it's better to log only
|
||||
# data from the exception at this point.
|
||||
|
||||
exitcode = traverse_ruffus_exception(e.args, options, _log)
|
||||
if exitcode is None:
|
||||
_log.error("Unexpected ruffus exception: " + str(e))
|
||||
|
||||
@@ -22,6 +22,10 @@ class ExitCodeException(Exception):
|
||||
exit_code = ExitCode.other_error
|
||||
|
||||
|
||||
class BadArgsError(ExitCodeException):
|
||||
exit_code = ExitCode.bad_args
|
||||
|
||||
|
||||
class PdfMergeFailedError(ExitCodeException):
|
||||
exit_code = ExitCode.input_file
|
||||
|
||||
@@ -38,6 +42,10 @@ class DpiError(ExitCodeException):
|
||||
exit_code = ExitCode.input_file
|
||||
|
||||
|
||||
class OutputFileAccessError(ExitCodeException):
|
||||
exit_code = ExitCode.file_access_error
|
||||
|
||||
|
||||
class PriorOcrFoundError(ExitCodeException):
|
||||
exit_code = ExitCode.already_done_ocr
|
||||
|
||||
|
||||
+2
-3
@@ -71,10 +71,9 @@ def is_file_writable(test_file):
|
||||
# defaults to strict=False. This implements strict=False like behavior
|
||||
# for Python 3.5.
|
||||
if sys.version_info[0:2] <= (3, 5):
|
||||
resolve = lambda: Path(os.path.realpath(str(p)))
|
||||
p = Path(os.path.realpath(str(p)))
|
||||
else:
|
||||
resolve = lambda: p.resolve(strict=False)
|
||||
p = resolve()
|
||||
p = p.resolve(strict=False)
|
||||
|
||||
# p.is_file() throws an exception in some cases
|
||||
if p.exists() and p.is_file():
|
||||
|
||||
+159
-63
@@ -33,6 +33,7 @@ from reportlab.lib.units import inch
|
||||
from xml.etree import ElementTree
|
||||
from PIL import Image
|
||||
from collections import namedtuple
|
||||
from math import atan, sin, cos
|
||||
import re
|
||||
import argparse
|
||||
|
||||
@@ -49,13 +50,24 @@ class HocrTransform():
|
||||
"""
|
||||
A class for converting documents from the hOCR format.
|
||||
For details of the hOCR format, see:
|
||||
http://docs.google.com/View?docid=dfxcv4vc_67g844kf
|
||||
http://kba.cloud/hocr-spec/
|
||||
"""
|
||||
|
||||
box_pattern = re.compile(r'bbox((\s+\d+){4})')
|
||||
baseline_pattern = re.compile(r'''
|
||||
baseline \s+
|
||||
([\-\+]?\d*\.?\d*) \s+ # +/- decimal float
|
||||
([\-\+]?\d+) # +/- int''', re.VERBOSE)
|
||||
ligatures = str.maketrans({
|
||||
'ff': 'ff',
|
||||
'ffi': 'ffi',
|
||||
'ffl': 'ffl',
|
||||
'fi': 'fi',
|
||||
'fl': 'fl',
|
||||
})
|
||||
|
||||
def __init__(self, hocrFileName, dpi):
|
||||
self.dpi = dpi
|
||||
self.boxPattern = re.compile(r'bbox((\s+\d+){4})')
|
||||
|
||||
self.hocr = ElementTree.parse(hocrFileName)
|
||||
|
||||
# if the hOCR file has a namespace, ElementTree requires its use to
|
||||
@@ -104,19 +116,31 @@ class HocrTransform():
|
||||
text += element.tail
|
||||
return text
|
||||
|
||||
def element_coordinates(self, element):
|
||||
@classmethod
|
||||
def element_coordinates(cls, element):
|
||||
"""
|
||||
Returns a tuple containing the coordinates of the bounding box around
|
||||
an element
|
||||
"""
|
||||
out = (0, 0, 0, 0)
|
||||
if 'title' in element.attrib:
|
||||
matches = self.boxPattern.search(element.attrib['title'])
|
||||
matches = cls.box_pattern.search(element.attrib['title'])
|
||||
if matches:
|
||||
coords = matches.group(1).split()
|
||||
out = Rect._make(int(coords[n]) for n in range(4))
|
||||
return out
|
||||
|
||||
@classmethod
|
||||
def baseline(cls, element):
|
||||
"""
|
||||
Returns a tuple containing the baseline slope and intercept.
|
||||
"""
|
||||
if 'title' in element.attrib:
|
||||
matches = cls.baseline_pattern.search(element.attrib['title'])
|
||||
if matches:
|
||||
return float(matches.group(1)), int(matches.group(2))
|
||||
return (0, 0)
|
||||
|
||||
def pt_from_pixel(self, pxl):
|
||||
"""
|
||||
Returns the quantity in PDF units (pt) given quantity in pixels
|
||||
@@ -124,20 +148,17 @@ class HocrTransform():
|
||||
return Rect._make(
|
||||
(c / self.dpi * inch) for c in pxl)
|
||||
|
||||
def replace_unsupported_chars(self, s):
|
||||
@classmethod
|
||||
def replace_unsupported_chars(cls, s):
|
||||
"""
|
||||
Given an input string, returns the corresponding string that:
|
||||
- is available in the helvetica facetype
|
||||
- does not contain any ligature (to allow easy search in the PDF file)
|
||||
"""
|
||||
# The 'u' before the character to replace indicates that it is a
|
||||
# unicode character
|
||||
s = s.replace(u"fl", "fl")
|
||||
s = s.replace(u"fi", "fi")
|
||||
return s
|
||||
return s.translate(cls.ligatures)
|
||||
|
||||
def to_pdf(self, outFileName, imageFileName=None, showBoundingboxes=False,
|
||||
fontname="Helvetica", invisibleText=False):
|
||||
fontname="Helvetica", invisibleText=False, interwordSpaces=False):
|
||||
"""
|
||||
Creates a PDF file with an image superimposed on top of the text.
|
||||
Text is positioned according to the bounding box of the lines in
|
||||
@@ -172,57 +193,19 @@ class HocrTransform():
|
||||
pdf.rect(
|
||||
pt.x1, self.height - pt.y2, pt.x2 - pt.x1, pt.y2 - pt.y1,
|
||||
fill=1)
|
||||
|
||||
found_lines = False
|
||||
for line in self.hocr.findall(
|
||||
".//%sspan[@class='%s']" % (self.xmlns, "ocr_line")):
|
||||
found_lines = True
|
||||
self._do_line(pdf, line, "ocrx_word", fontname, invisibleText,
|
||||
interwordSpaces, showBoundingboxes)
|
||||
|
||||
# check if element with class 'ocrx_word' are available
|
||||
# otherwise use 'ocr_line' as fallback
|
||||
elemclass = "ocr_line"
|
||||
if self.hocr.find(
|
||||
".//%sspan[@class='ocrx_word']" % (self.xmlns)) is not None:
|
||||
elemclass = "ocrx_word"
|
||||
|
||||
# itterate all text elements
|
||||
# light green for bounding box of word/line
|
||||
pdf.setStrokeColorRGB(1, 0, 0)
|
||||
pdf.setLineWidth(0.5) # bounding box line width
|
||||
pdf.setDash(6, 3) # bounding box is dashed
|
||||
pdf.setFillColorRGB(0, 0, 0) # text in black
|
||||
for elem in self.hocr.findall(
|
||||
".//%sspan[@class='%s']" % (self.xmlns, elemclass)):
|
||||
|
||||
elemtxt = self._get_element_text(elem).rstrip()
|
||||
|
||||
elemtxt = self.replace_unsupported_chars(elemtxt)
|
||||
|
||||
if len(elemtxt) == 0:
|
||||
continue
|
||||
|
||||
pxl_coords = self.element_coordinates(elem)
|
||||
pt = self.pt_from_pixel(pxl_coords)
|
||||
|
||||
# draw the bbox border
|
||||
if showBoundingboxes:
|
||||
pdf.rect(
|
||||
pt.x1, self.height - pt.y2, pt.x2 - pt.x1, pt.y2 - pt.y1,
|
||||
fill=0)
|
||||
|
||||
text = pdf.beginText()
|
||||
fontsize = pt.y2 - pt.y1
|
||||
text.setFont(fontname, fontsize)
|
||||
if invisibleText:
|
||||
text.setTextRenderMode(3) # Invisible (indicates OCR text)
|
||||
|
||||
# set cursor to bottom left corner of bbox (adjust for dpi)
|
||||
text.setTextOrigin(pt.x1, self.height - pt.y2)
|
||||
|
||||
# scale the width of the text to fill the width of the bbox
|
||||
text.setHorizScale(
|
||||
100 * (pt.x2 - pt.x1) / pdf.stringWidth(
|
||||
elemtxt, fontname, fontsize))
|
||||
|
||||
# write the text to the page
|
||||
text.textLine(elemtxt)
|
||||
pdf.drawText(text)
|
||||
|
||||
if not found_lines:
|
||||
# Tesseract did not report any lines (just words)
|
||||
root = self.hocr.find(".//%sdiv[@class='%s']" % (self.xmlns, "ocr_page"))
|
||||
self._do_line(pdf, root, "ocrx_word", fontname, invisibleText,
|
||||
interwordSpaces, showBoundingboxes)
|
||||
# put the image on the page, scaled to fill the page
|
||||
if imageFileName is not None:
|
||||
pdf.drawImage(imageFileName, 0, 0,
|
||||
@@ -233,6 +216,117 @@ class HocrTransform():
|
||||
pdf.save()
|
||||
|
||||
|
||||
@classmethod
|
||||
def polyval(cls, poly, x):
|
||||
return x * poly[0] + poly[1]
|
||||
|
||||
|
||||
def _do_line(self, pdf, line, elemclass, fontname, invisibleText,
|
||||
interwordSpaces, showBoundingboxes):
|
||||
pxl_line_coords = self.element_coordinates(line)
|
||||
line_box = self.pt_from_pixel(pxl_line_coords)
|
||||
line_height = line_box.y2 - line_box.y1
|
||||
|
||||
slope, pxl_intercept = self.baseline(line)
|
||||
if abs(slope) < 0.005:
|
||||
slope = 0.0
|
||||
angle = atan(slope)
|
||||
cos_a, sin_a = cos(angle), sin(angle)
|
||||
|
||||
text = pdf.beginText()
|
||||
intercept = pxl_intercept / self.dpi * inch
|
||||
|
||||
# Don't allow the font to break out of the bounding box. Division by
|
||||
# cos_a accounts for extra clearance between the glyph's vertical axis
|
||||
# on a sloped baseline and the edge of the bounding box.
|
||||
fontsize = (line_height - abs(intercept)) / cos_a
|
||||
text.setFont(fontname, fontsize)
|
||||
if invisibleText:
|
||||
text.setTextRenderMode(3) # Invisible (indicates OCR text)
|
||||
|
||||
# Intercept is normally negative, so this places it above the bottom
|
||||
# of the line box
|
||||
baseline_y2 = self.height - (line_box.y2 + intercept)
|
||||
|
||||
if showBoundingboxes:
|
||||
# draw the baseline in magenta, dashed
|
||||
pdf.setDash()
|
||||
pdf.setStrokeColorRGB(0.95, 0.65, 0.95)
|
||||
pdf.setLineWidth(0.5)
|
||||
# negate slope because it is defined as a rise/run in pixel
|
||||
# coordinates and page coordinates have the y axis flipped
|
||||
pdf.line(line_box.x1,
|
||||
baseline_y2,
|
||||
line_box.x2,
|
||||
self.polyval((-slope, baseline_y2),
|
||||
line_box.x2 - line_box.x1))
|
||||
# light green for bounding box of word/line
|
||||
pdf.setDash(6, 3)
|
||||
pdf.setStrokeColorRGB(1, 0, 0)
|
||||
|
||||
text.setTextTransform(
|
||||
cos_a, -sin_a, sin_a, cos_a,
|
||||
line_box.x1, baseline_y2
|
||||
)
|
||||
pdf.setFillColorRGB(0, 0, 0) # text in black
|
||||
|
||||
elements = line.findall(
|
||||
".//%sspan[@class='%s']" % (self.xmlns, elemclass))
|
||||
for elem in elements:
|
||||
elemtxt = self._get_element_text(elem).strip()
|
||||
elemtxt = self.replace_unsupported_chars(elemtxt)
|
||||
if elemtxt == '':
|
||||
continue
|
||||
|
||||
pxl_coords = self.element_coordinates(elem)
|
||||
box = self.pt_from_pixel(pxl_coords)
|
||||
if interwordSpaces:
|
||||
# if `--interword-spaces` is true, append a space
|
||||
# to the end of each text element to allow simpler PDF viewers
|
||||
# such as PDF.js to better recognize words in search and copy
|
||||
# and paste. Do not remove space from last word in line, even
|
||||
# though it would look better, because it will interfere with
|
||||
# naive text extraction. \n does not work either.
|
||||
elemtxt += ' '
|
||||
box = Rect._make((
|
||||
box.x1,
|
||||
line_box.y1,
|
||||
box.x2 + pdf.stringWidth(' ', fontname, line_height),
|
||||
line_box.y2))
|
||||
box_width = box.x2 - box.x1
|
||||
font_width = pdf.stringWidth(elemtxt, fontname, fontsize)
|
||||
|
||||
# draw the bbox border
|
||||
if showBoundingboxes:
|
||||
pdf.rect(
|
||||
box.x1,
|
||||
self.height - line_box.y2,
|
||||
box_width,
|
||||
line_height,
|
||||
fill=0)
|
||||
|
||||
# Adjust relative position of cursor
|
||||
# This is equivalent to:
|
||||
# text.setTextOrigin(pt.x1, self.height - line_box.y2)
|
||||
# but the former generates a full text reposition matrix (Tm) in the
|
||||
# content stream while this issues a "offset" (Td) command.
|
||||
# .moveCursor() is relative to start of the text line, where the
|
||||
# "text line" means whatever reportlab defines it as. Do not use
|
||||
# use .getCursor(), since moveCursor() rather unintuitively plans
|
||||
# its moves relative to .getStartOfLine().
|
||||
# For skewed lines, in the text transform we set up a rotated
|
||||
# coordinate system, so we don't have to account for the
|
||||
# incremental offset. Surprisingly most PDF viewers can handle this.
|
||||
cursor = text.getStartOfLine()
|
||||
dx = box.x1 - cursor[0]
|
||||
dy = baseline_y2 - cursor[1]
|
||||
text.moveCursor(dx, dy)
|
||||
|
||||
text.setHorizScale(100 * box_width / font_width)
|
||||
text.textOut(elemtxt)
|
||||
pdf.drawText(text)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description='Convert hocr file to PDF')
|
||||
parser.add_argument('-b', '--boundingboxes', action="store_true",
|
||||
@@ -242,10 +336,12 @@ if __name__ == "__main__":
|
||||
help='Resolution of the image that was OCRed')
|
||||
parser.add_argument('-i', '--image', default=None,
|
||||
help='Path to the image to be placed above the text')
|
||||
parser.add_argument('--interword-spaces', action='store_true',
|
||||
default=False, help='Add spaces between words')
|
||||
parser.add_argument('hocrfile', help='Path to the hocr file to be parsed')
|
||||
parser.add_argument(
|
||||
'outputfile', help='Path to the PDF file to be generated')
|
||||
args = parser.parse_args()
|
||||
|
||||
hocr = HocrTransform(args.hocrfile, args.resolution)
|
||||
hocr.to_pdf(args.outputfile, args.image, args.boundingboxes)
|
||||
hocr.to_pdf(args.outputfile, args.image, args.boundingboxes, interwordSpaces=args.interword_spaces)
|
||||
|
||||
+4
-10
@@ -640,8 +640,8 @@ def render_hocr_page(
|
||||
|
||||
hocrtransform = HocrTransform(hocr, dpi)
|
||||
hocrtransform.to_pdf(output_file, imageFileName=None,
|
||||
showBoundingboxes=False, invisibleText=True)
|
||||
|
||||
showBoundingboxes=False, invisibleText=True,
|
||||
interwordSpaces=True)
|
||||
|
||||
def flatten_groups(groups):
|
||||
for obj in groups:
|
||||
@@ -665,8 +665,8 @@ def render_hocr_debug_page(
|
||||
|
||||
hocrtransform = HocrTransform(hocr, dpi)
|
||||
hocrtransform.to_pdf(output_file, imageFileName=None,
|
||||
showBoundingboxes=True, invisibleText=False)
|
||||
|
||||
showBoundingboxes=True, invisibleText=False,
|
||||
interwordSpaces=True)
|
||||
|
||||
def combine_layers(
|
||||
infiles,
|
||||
@@ -1105,8 +1105,6 @@ def build_pipeline(options, work_folder, log, context):
|
||||
extras=[log, context])
|
||||
task_ocr_tesseract_hocr.graphviz(fillcolor='"#00cc66"')
|
||||
task_ocr_tesseract_hocr.active_if(options.pdf_renderer == 'hocr')
|
||||
if tesseract.v4():
|
||||
task_ocr_tesseract_hocr.jobs_limit(2) # Uses multi-core on its own
|
||||
|
||||
task_select_visible_page_image = main_pipeline.collate(
|
||||
task_func=select_visible_page_image,
|
||||
@@ -1159,8 +1157,6 @@ def build_pipeline(options, work_folder, log, context):
|
||||
extras=[log, context])
|
||||
task_ocr_tesseract_textonly_pdf.graphviz(fillcolor='"#ff69b4"')
|
||||
task_ocr_tesseract_textonly_pdf.active_if(options.pdf_renderer == 'sandwich')
|
||||
if tesseract.v4():
|
||||
task_ocr_tesseract_textonly_pdf.jobs_limit(2)
|
||||
|
||||
task_combine_layers = main_pipeline.collate(
|
||||
task_func=combine_layers,
|
||||
@@ -1184,8 +1180,6 @@ def build_pipeline(options, work_folder, log, context):
|
||||
extras=[log, context])
|
||||
task_ocr_tesseract_and_render_pdf.graphviz(fillcolor='"#66ccff"')
|
||||
task_ocr_tesseract_and_render_pdf.active_if(options.pdf_renderer == 'tesseract')
|
||||
if tesseract.v4():
|
||||
task_ocr_tesseract_and_render_pdf.jobs_limit(2) # Uses multi-core
|
||||
|
||||
# PDF/A
|
||||
task_generate_postscript_stub = main_pipeline.transform(
|
||||
|
||||
Reference in New Issue
Block a user