From e6e34251c6a7d103d0b5fc44cc3614631d781c6f Mon Sep 17 00:00:00 2001 From: Tucker Barbour Date: Thu, 1 Mar 2018 13:23:14 +0000 Subject: [PATCH 01/16] Add option to explicitly add interword spaces to HOCR pdf-renderer This commit includes an optional work around for limitations of the PDF.js viewer described in https://github.com/jbarlow83/OCRmyPDF/issues/133. Here is explicitly add an addition space to text elements before drawing them on the PDF canvas when using the HOCR renderer. This option does not apply to other pdf renderers in OCRmyPDF and is turned off by default. --- ocrmypdf/__main__.py | 9 ++++++++- ocrmypdf/hocrtransform.py | 14 +++++++++++--- ocrmypdf/pipeline.py | 8 ++++---- 3 files changed, 23 insertions(+), 8 deletions(-) diff --git a/ocrmypdf/__main__.py b/ocrmypdf/__main__.py index 16b7f662..6af7af48 100755 --- a/ocrmypdf/__main__.py +++ b/ocrmypdf/__main__.py @@ -304,6 +304,9 @@ advanced.add_argument( advanced.add_argument( '--user-patterns', metavar='FILE', help="Specify the location of the Tesseract user patterns file.") +advanced.add_argument( + '--interword-spaces', action='store_true', + help="Add spaces between words with HOCR transformation.") debugging = parser.add_argument_group( "Debugging", @@ -463,7 +466,11 @@ def check_options_advanced(options, log): "--pdfa-image-compression argument has no effect when " "--output-type is not 'pdfa', 'pdfa-1', or 'pdfa-2'" ) - + if options.interword_spaces and options.pdf_renderer != 'hocr': + log.warning( + "--interword-spaces argument has no effect when " + "--pdf-renderer is not 'hocr'" + ) def check_options_metadata(options, log): import unicodedata diff --git a/ocrmypdf/hocrtransform.py b/ocrmypdf/hocrtransform.py index 75faf8d3..244eec0e 100755 --- a/ocrmypdf/hocrtransform.py +++ b/ocrmypdf/hocrtransform.py @@ -137,7 +137,7 @@ class HocrTransform(): return s 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 @@ -180,7 +180,7 @@ class HocrTransform(): ".//%sspan[@class='ocrx_word']" % (self.xmlns)) is not None: elemclass = "ocrx_word" - # itterate all text elements + # iterate all text elements # light green for bounding box of word/line pdf.setStrokeColorRGB(1, 0, 0) pdf.setLineWidth(0.5) # bounding box line width @@ -196,6 +196,12 @@ class HocrTransform(): if len(elemtxt) == 0: continue + # if the advanced option `--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 + if interwordSpaces: + elemtxt += ' ' + pxl_coords = self.element_coordinates(elem) pt = self.pt_from_pixel(pxl_coords) @@ -242,10 +248,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) diff --git a/ocrmypdf/pipeline.py b/ocrmypdf/pipeline.py index 79f129f5..35326b88 100644 --- a/ocrmypdf/pipeline.py +++ b/ocrmypdf/pipeline.py @@ -639,8 +639,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=options.interword_spaces) def flatten_groups(groups): for obj in groups: @@ -664,8 +664,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=options.interword_spaces) def combine_layers( infiles, From 422e61997856cc70674952baaa28d6a603b8884d Mon Sep 17 00:00:00 2001 From: Charles Forcey Date: Thu, 1 Mar 2018 12:37:41 -0500 Subject: [PATCH 02/16] Add a note to the documentation about interword-spaces --- docs/advanced.rst | 13 +++++++++++++ docs/introduction.rst | 3 ++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/docs/advanced.rst b/docs/advanced.rst index e86cfd3c..47a810e7 100644 --- a/docs/advanced.rst +++ b/docs/advanced.rst @@ -131,6 +131,8 @@ The ``hocr`` renderer The ``hocr`` renderer works with older versions of Tesseract. The image layer is copied from the original PDF page if possible, avoiding potentially lossy transcoding or loss of other PDF information. If preprocessing is specified, then the image layer is a new PDF. +When combined with an additional option ``--interword-spaces`, this renderer will append a space at the end of each recognized text element to help simpler viewers such as PDF.js correctly recognize words for search and copy and paste operations. + This works in all versions of Tesseract. The ``tesseract`` renderer @@ -141,3 +143,14 @@ The ``tesseract`` renderer creates a PDF with the image and text layers precompo 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. *This renderer is deprecated and will be removed whenever support for older versions of Tesseract is dropped.* + +Adding Interword Spaces +------------------------- + +OCRmyPDF has an option ``--interword-spaces`` that appends a space at the end of each text element. Without the space, simpler PDF viewers such as PDF.js have difficulty detecting individuals words and maintaining white space between them. As a result, searching for multi-word phrases and selecting text for copy and paste are severely impacted. With this option set, these viewers are able to locate multi-word phrases while more advanced viewers remain unaffected. + +.. code-block:: bash + + ocrmypdf --output-type pdf --interword-spaces --pdf-renderer hocr input.pdf output.pdf + +This option defaults to ``False`` and must be combined with ``--pdf-renderer hocr`` or it will be ignored with a warning. This works in all versions of Tesseract. diff --git a/docs/introduction.rst b/docs/introduction.rst index 988454fc..8264b001 100644 --- a/docs/introduction.rst +++ b/docs/introduction.rst @@ -82,7 +82,8 @@ 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. +* 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 opensource PDF viewers have a particularly hard time with spaces betweem words, OCRmyPDF does provide an optional command option ``--interword-spaces`` that appends a space to each text element as a workaround, but discourages its use unless absolutely necessary as it mixes document structure with graphical information that ideally should be left to the PDF viewer to interpret. This option produces output similar to the aptly named ``-sloppy-text`` option of pdfsandwich mentioned in the Similar Programs section below. Ghostscript also imposes some limitations: From 9fd9c7a51fb37c0ebf5d5f0f69ca58832c775ec0 Mon Sep 17 00:00:00 2001 From: Tucker Barbour Date: Fri, 2 Mar 2018 11:13:47 +0000 Subject: [PATCH 03/16] Scale BoundingBox and Text elements to account for additional space. Here we are manually scaling the pt width used for the BoundingBox and the Text element when manually adding whitespace to account for limitations of the PDF.js viewer. This fixes an initial regression noticed when selecting text elements in Chrome and PDFium. The width of the Text element and BoundBox had not been adjusted for the additional whitespace so the highlighting was offset slightly. --- ocrmypdf/hocrtransform.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/ocrmypdf/hocrtransform.py b/ocrmypdf/hocrtransform.py index 244eec0e..dd772391 100755 --- a/ocrmypdf/hocrtransform.py +++ b/ocrmypdf/hocrtransform.py @@ -196,14 +196,16 @@ class HocrTransform(): if len(elemtxt) == 0: continue + pxl_coords = self.element_coordinates(elem) + pt = self.pt_from_pixel(pxl_coords) + # if the advanced option `--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 if interwordSpaces: elemtxt += ' ' - - pxl_coords = self.element_coordinates(elem) - pt = self.pt_from_pixel(pxl_coords) + pt = Rect._make((pt.x1, pt.y1, + pt.x2 + pdf.stringWidth(' ', fontname, pt.y2 - pt.y1), pt.y2)) # draw the bbox border if showBoundingboxes: From f6c70312c9d5c5688028a0b73bde5ff9884d00e6 Mon Sep 17 00:00:00 2001 From: Tucker Barbour Date: Fri, 2 Mar 2018 14:26:13 +0000 Subject: [PATCH 04/16] Fix Homebrew python package Homebrew removed python3 and python now defaults to version 3. Here we use `brew upgrade python` to upgrade the pre-installed version of python to python3. --- .travis/osx_before_install.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis/osx_before_install.sh b/.travis/osx_before_install.sh index 91ad0276..3a47e571 100644 --- a/.travis/osx_before_install.sh +++ b/.travis/osx_before_install.sh @@ -8,7 +8,7 @@ brew update brew install openjpeg jbig2dec libtiff # image libraries brew install qpdf brew install ghostscript -brew install python3 +brew upgrade python # Brew removed python3 and python now defaults to python3 brew install libxml2 libffi leptonica brew install unpaper # optional brew install tesseract From 04c54a7c315d672f0f1af1d15e35d866b3bf857e Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Sat, 3 Mar 2018 02:25:25 -0800 Subject: [PATCH 05/16] Suppress spurious debug message in --output-type pdf --- ocrmypdf/exec/qpdf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ocrmypdf/exec/qpdf.py b/ocrmypdf/exec/qpdf.py index 85005892..f0345ff0 100644 --- a/ocrmypdf/exec/qpdf.py +++ b/ocrmypdf/exec/qpdf.py @@ -198,6 +198,6 @@ def merge(input_files, output_file, min_version=None, log=None, max_files=None): workqueue = next_workqueue next_workqueue = [] - re_symlink(workqueue.pop(), output_file) + re_symlink(workqueue.pop(), output_file, log) From 2b6004a82bec07068ddf7e9a197eab08c7d2bab5 Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Sat, 3 Mar 2018 00:19:55 -0800 Subject: [PATCH 06/16] hocr: Make words on line use the line height Seems to improve the behavior and appearance of selected text a fair bit. --- ocrmypdf/hocrtransform.py | 55 ++++++++++++++++++++++++--------------- 1 file changed, 34 insertions(+), 21 deletions(-) diff --git a/ocrmypdf/hocrtransform.py b/ocrmypdf/hocrtransform.py index dd772391..a74961fb 100755 --- a/ocrmypdf/hocrtransform.py +++ b/ocrmypdf/hocrtransform.py @@ -172,13 +172,35 @@ 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" + 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, + width=self.width, height=self.height) + + # finish up the page and save it + pdf.showPage() + pdf.save() + + + def _do_line(self, pdf, line, elemclass, fontname, invisibleText, + interwordSpaces, showBoundingboxes): + # line height + pxl_line_coords = self.element_coordinates(line) + pt_line = self.pt_from_pixel(pxl_line_coords) + line_height = pt_line.y2 - pt_line.y1 # iterate all text elements # light green for bounding box of word/line @@ -186,7 +208,7 @@ class HocrTransform(): 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( + for elem in line.findall( ".//%sspan[@class='%s']" % (self.xmlns, elemclass)): elemtxt = self._get_element_text(elem).rstrip() @@ -204,23 +226,23 @@ class HocrTransform(): # as PDF.js to better recognize words in search and copy and paste if interwordSpaces: elemtxt += ' ' - pt = Rect._make((pt.x1, pt.y1, - pt.x2 + pdf.stringWidth(' ', fontname, pt.y2 - pt.y1), pt.y2)) + pt = Rect._make((pt.x1, pt_line.y1, + pt.x2 + pdf.stringWidth(' ', fontname, line_height), pt_line.y2)) # draw the bbox border if showBoundingboxes: pdf.rect( - pt.x1, self.height - pt.y2, pt.x2 - pt.x1, pt.y2 - pt.y1, + pt.x1, self.height - pt_line.y2, pt.x2 - pt.x1, line_height, fill=0) text = pdf.beginText() - fontsize = pt.y2 - pt.y1 + fontsize = line_height 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) + text.setTextOrigin(pt.x1, self.height - pt_line.y2) # scale the width of the text to fill the width of the bbox text.setHorizScale( @@ -231,15 +253,6 @@ class HocrTransform(): text.textLine(elemtxt) pdf.drawText(text) - # put the image on the page, scaled to fill the page - if imageFileName is not None: - pdf.drawImage(imageFileName, 0, 0, - width=self.width, height=self.height) - - # finish up the page and save it - pdf.showPage() - pdf.save() - if __name__ == "__main__": parser = argparse.ArgumentParser(description='Convert hocr file to PDF') From 4986afca282946f741a58c3ad75278072f10ccef Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Sat, 3 Mar 2018 00:30:56 -0800 Subject: [PATCH 07/16] hocr: Refactor use of text object We don't need to declare the font on each word. No improvement for removing trailing space or adding \n --- ocrmypdf/hocrtransform.py | 30 +++++++++++++++++------------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/ocrmypdf/hocrtransform.py b/ocrmypdf/hocrtransform.py index a74961fb..eaf0f6f8 100755 --- a/ocrmypdf/hocrtransform.py +++ b/ocrmypdf/hocrtransform.py @@ -208,9 +208,16 @@ class HocrTransform(): 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 line.findall( - ".//%sspan[@class='%s']" % (self.xmlns, elemclass)): + text = pdf.beginText() + fontsize = line_height + text.setFont(fontname, fontsize) + if invisibleText: + text.setTextRenderMode(3) # Invisible (indicates OCR text) + + elements = line.findall( + ".//%sspan[@class='%s']" % (self.xmlns, elemclass)) + for n, elem in enumerate(elements): elemtxt = self._get_element_text(elem).rstrip() elemtxt = self.replace_unsupported_chars(elemtxt) @@ -221,10 +228,13 @@ class HocrTransform(): pxl_coords = self.element_coordinates(elem) pt = self.pt_from_pixel(pxl_coords) - # if the advanced option `--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 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 += ' ' pt = Rect._make((pt.x1, pt_line.y1, pt.x2 + pdf.stringWidth(' ', fontname, line_height), pt_line.y2)) @@ -235,12 +245,6 @@ class HocrTransform(): pt.x1, self.height - pt_line.y2, pt.x2 - pt.x1, line_height, fill=0) - text = pdf.beginText() - fontsize = line_height - 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_line.y2) @@ -250,8 +254,8 @@ class HocrTransform(): elemtxt, fontname, fontsize)) # write the text to the page - text.textLine(elemtxt) - pdf.drawText(text) + text.textOut(elemtxt) + pdf.drawText(text) if __name__ == "__main__": From b4d66650bd89e55b1d87a488c5c27ff83734b4b8 Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Sat, 3 Mar 2018 02:08:52 -0800 Subject: [PATCH 08/16] hocr: adjust text cursor with relative moves --- ocrmypdf/hocrtransform.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/ocrmypdf/hocrtransform.py b/ocrmypdf/hocrtransform.py index eaf0f6f8..9e87f1d3 100755 --- a/ocrmypdf/hocrtransform.py +++ b/ocrmypdf/hocrtransform.py @@ -215,6 +215,7 @@ class HocrTransform(): if invisibleText: text.setTextRenderMode(3) # Invisible (indicates OCR text) + text.setTextOrigin(pt_line.x1, self.height - pt_line.y2) elements = line.findall( ".//%sspan[@class='%s']" % (self.xmlns, elemclass)) for n, elem in enumerate(elements): @@ -245,8 +246,19 @@ class HocrTransform(): pt.x1, self.height - pt_line.y2, pt.x2 - pt.x1, line_height, fill=0) - # set cursor to bottom left corner of bbox (adjust for dpi) - text.setTextOrigin(pt.x1, self.height - pt_line.y2) + # Adjust relative position of cursor + # This is equivalent to: + # text.setTextOrigin(pt.x1, self.height - pt_line.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(). + cursor = text.getStartOfLine() + dx = pt.x1 - cursor[0] + dy = (self.height - pt_line.y2) - cursor[1] + text.moveCursor(dx, dy) # scale the width of the text to fill the width of the bbox text.setHorizScale( From 0e7a4deaec24d2d1bab8b883d43dc431748fc39f Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Sat, 3 Mar 2018 02:51:45 -0800 Subject: [PATCH 09/16] hocr: add baseline function, hocr doc link --- ocrmypdf/hocrtransform.py | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/ocrmypdf/hocrtransform.py b/ocrmypdf/hocrtransform.py index 9e87f1d3..258274d9 100755 --- a/ocrmypdf/hocrtransform.py +++ b/ocrmypdf/hocrtransform.py @@ -49,12 +49,16 @@ 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/ """ def __init__(self, hocrFileName, dpi): self.dpi = dpi self.boxPattern = re.compile(r'bbox((\s+\d+){4})') + self.baselinePattern = re.compile(r''' + baseline \s+ + ([\-\+]?\d*\.?\d*) \s+ # +/- decimal float + ([\-\+]?\d+) # +/- int''', re.VERBOSE) self.hocr = ElementTree.parse(hocrFileName) @@ -117,6 +121,16 @@ class HocrTransform(): out = Rect._make(int(coords[n]) for n in range(4)) return out + def baseline(self, element): + """ + Returns a tuple containing the baseline slope and intercept. + """ + if 'title' in element.attrib: + matches = self.baselinePattern.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 @@ -215,14 +229,16 @@ class HocrTransform(): if invisibleText: text.setTextRenderMode(3) # Invisible (indicates OCR text) + baseline = self.baseline(line) + if abs(baseline[0]) < 0.005: + baseline = (0, baseline[1]) + text.setTextOrigin(pt_line.x1, self.height - pt_line.y2) elements = line.findall( ".//%sspan[@class='%s']" % (self.xmlns, elemclass)) - for n, elem in enumerate(elements): + for elem in elements: elemtxt = self._get_element_text(elem).rstrip() - elemtxt = self.replace_unsupported_chars(elemtxt) - if len(elemtxt) == 0: continue From b3a7299a623607d02353aca86e6b1c91c8836e97 Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Sat, 3 Mar 2018 03:04:51 -0800 Subject: [PATCH 10/16] hocr: refactor/improve PEP8 a bit --- ocrmypdf/hocrtransform.py | 38 ++++++++++++++++++++++---------------- 1 file changed, 22 insertions(+), 16 deletions(-) diff --git a/ocrmypdf/hocrtransform.py b/ocrmypdf/hocrtransform.py index 258274d9..1c35370b 100755 --- a/ocrmypdf/hocrtransform.py +++ b/ocrmypdf/hocrtransform.py @@ -52,14 +52,21 @@ class HocrTransform(): 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': 'f‌f‌i', + 'ffl': 'f‌f‌l', + 'fi': 'fi', + 'fl': 'fl', + }) + def __init__(self, hocrFileName, dpi): self.dpi = dpi - self.boxPattern = re.compile(r'bbox((\s+\d+){4})') - self.baselinePattern = re.compile(r''' - baseline \s+ - ([\-\+]?\d*\.?\d*) \s+ # +/- decimal float - ([\-\+]?\d+) # +/- int''', re.VERBOSE) - self.hocr = ElementTree.parse(hocrFileName) # if the hOCR file has a namespace, ElementTree requires its use to @@ -108,25 +115,27 @@ 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 - def baseline(self, element): + @classmethod + def baseline(cls, element): """ Returns a tuple containing the baseline slope and intercept. """ if 'title' in element.attrib: - matches = self.baselinePattern.search(element.attrib['title']) + matches = cls.baseline_pattern.search(element.attrib['title']) if matches: return float(matches.group(1)), int(matches.group(2)) return (0, 0) @@ -138,17 +147,14 @@ 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, interwordSpaces=False): From 7cc104b138d657df2a58c4c8414183a7c9c458fb Mon Sep 17 00:00:00 2001 From: Jim Barlow Date: Mon, 5 Mar 2018 11:16:40 -0500 Subject: [PATCH 11/16] hocr: account for skewed baseline --- .gitignore | 1 + ocrmypdf/hocrtransform.py | 9 ++++++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index eeac20e1..14187069 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ tasks.py .bash_history .ruffus_history.sqlite .idea/ +.pytest_cache/ # Package building *.egg-info/ diff --git a/ocrmypdf/hocrtransform.py b/ocrmypdf/hocrtransform.py index 1c35370b..cbbdb27f 100755 --- a/ocrmypdf/hocrtransform.py +++ b/ocrmypdf/hocrtransform.py @@ -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 @@ -238,8 +239,14 @@ class HocrTransform(): baseline = self.baseline(line) if abs(baseline[0]) < 0.005: baseline = (0, baseline[1]) + angle = atan(baseline[0]) + cos_a, sin_a = cos(angle), sin(angle) + + text.setTextTransform( + cos_a, -sin_a, sin_a, cos_a, + pt_line.x1, self.height - pt_line.y2 + ) - text.setTextOrigin(pt_line.x1, self.height - pt_line.y2) elements = line.findall( ".//%sspan[@class='%s']" % (self.xmlns, elemclass)) for elem in elements: From 995f8c106b7341a2e0ce1070be52249eef0b070b Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Fri, 9 Mar 2018 07:45:41 -0800 Subject: [PATCH 12/16] hocr: account for baseline offset to position text more accurately --- ocrmypdf/hocrtransform.py | 34 ++++++++++++++++++++-------------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/ocrmypdf/hocrtransform.py b/ocrmypdf/hocrtransform.py index cbbdb27f..b8618def 100755 --- a/ocrmypdf/hocrtransform.py +++ b/ocrmypdf/hocrtransform.py @@ -223,30 +223,36 @@ class HocrTransform(): pt_line = self.pt_from_pixel(pxl_line_coords) line_height = pt_line.y2 - pt_line.y1 - # iterate 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 + slope, intercept = self.baseline(line) + if abs(slope) < 0.005: + slope = 0.0 + angle = atan(slope) + cos_a, sin_a = cos(angle), sin(angle) + # iterate all text elements text = pdf.beginText() - fontsize = line_height + fontsize = line_height - abs(intercept / self.dpi * inch) text.setFont(fontname, fontsize) if invisibleText: text.setTextRenderMode(3) # Invisible (indicates OCR text) - baseline = self.baseline(line) - if abs(baseline[0]) < 0.005: - baseline = (0, baseline[1]) - angle = atan(baseline[0]) - cos_a, sin_a = cos(angle), sin(angle) + baseline_y1 = self.height - pt_line.y2 - intercept / self.dpi * inch + + if showBoundingboxes: + # draw the baseline in magenta + pdf.setStrokeColorRGB(0.95, 0.65, 0.95) + pdf.setLineWidth(0.5) # bounding box line width + pdf.setDash(6, 3) # bounding box is dashed + pdf.line(pt_line.x1, baseline_y1, + pt_line.x2, baseline_y1 - slope * (pt_line.x2 - pt_line.x1)) + # light green for bounding box of word/line + pdf.setStrokeColorRGB(1, 0, 0) text.setTextTransform( cos_a, -sin_a, sin_a, cos_a, pt_line.x1, self.height - pt_line.y2 ) - + pdf.setFillColorRGB(0, 0, 0) # text in black elements = line.findall( ".//%sspan[@class='%s']" % (self.xmlns, elemclass)) for elem in elements: @@ -286,7 +292,7 @@ class HocrTransform(): # its moves relative to .getStartOfLine(). cursor = text.getStartOfLine() dx = pt.x1 - cursor[0] - dy = (self.height - pt_line.y2) - cursor[1] + dy = (self.height - pt_line.y2 + intercept / self.dpi * inch) - cursor[1] text.moveCursor(dx, dy) # scale the width of the text to fill the width of the bbox From 062901be43c2844480527e4fec6eb65c571b1a0f Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Tue, 13 Mar 2018 14:29:22 -0700 Subject: [PATCH 13/16] Some cleanup and variable renaming --- ocrmypdf/hocrtransform.py | 63 ++++++++++++++++++++++----------------- 1 file changed, 36 insertions(+), 27 deletions(-) diff --git a/ocrmypdf/hocrtransform.py b/ocrmypdf/hocrtransform.py index b8618def..b3d10609 100755 --- a/ocrmypdf/hocrtransform.py +++ b/ocrmypdf/hocrtransform.py @@ -216,12 +216,16 @@ 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): - # line height pxl_line_coords = self.element_coordinates(line) - pt_line = self.pt_from_pixel(pxl_line_coords) - line_height = pt_line.y2 - pt_line.y1 + line_box = self.pt_from_pixel(pxl_line_coords) + line_height = line_box.y2 - line_box.y1 slope, intercept = self.baseline(line) if abs(slope) < 0.005: @@ -229,41 +233,43 @@ class HocrTransform(): angle = atan(slope) cos_a, sin_a = cos(angle), sin(angle) - # iterate all text elements text = pdf.beginText() fontsize = line_height - abs(intercept / self.dpi * inch) text.setFont(fontname, fontsize) if invisibleText: text.setTextRenderMode(3) # Invisible (indicates OCR text) - baseline_y1 = self.height - pt_line.y2 - intercept / self.dpi * inch + baseline_y1 = self.height - (line_box.y2 + intercept / self.dpi * inch) if showBoundingboxes: - # draw the baseline in magenta + # draw the baseline in magenta, dashed pdf.setStrokeColorRGB(0.95, 0.65, 0.95) - pdf.setLineWidth(0.5) # bounding box line width - pdf.setDash(6, 3) # bounding box is dashed - pdf.line(pt_line.x1, baseline_y1, - pt_line.x2, baseline_y1 - slope * (pt_line.x2 - pt_line.x1)) + pdf.setLineWidth(0.5) + pdf.line(line_box.x1, + baseline_y1, + line_box.x2, + self.polyval((-slope, baseline_y1), + 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, - pt_line.x1, self.height - pt_line.y2 + line_box.x1, self.height - line_box.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).rstrip() + elemtxt = self._get_element_text(elem).strip() elemtxt = self.replace_unsupported_chars(elemtxt) - if len(elemtxt) == 0: + if elemtxt == '': continue pxl_coords = self.element_coordinates(elem) - pt = self.pt_from_pixel(pxl_coords) - + 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 @@ -272,18 +278,26 @@ class HocrTransform(): # though it would look better, because it will interfere with # naive text extraction. \n does not work either. elemtxt += ' ' - pt = Rect._make((pt.x1, pt_line.y1, - pt.x2 + pdf.stringWidth(' ', fontname, line_height), pt_line.y2)) + 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( - pt.x1, self.height - pt_line.y2, pt.x2 - pt.x1, line_height, + 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 - pt_line.y2) + # 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 @@ -291,16 +305,11 @@ class HocrTransform(): # use .getCursor(), since moveCursor() rather unintuitively plans # its moves relative to .getStartOfLine(). cursor = text.getStartOfLine() - dx = pt.x1 - cursor[0] - dy = (self.height - pt_line.y2 + intercept / self.dpi * inch) - cursor[1] + dx = box.x1 - cursor[0] + dy = (self.height - line_box.y2 + intercept / self.dpi * inch) - cursor[1] text.moveCursor(dx, dy) - # 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.setHorizScale(100 * box_width / font_width) text.textOut(elemtxt) pdf.drawText(text) From 90676e1c6a29feca9fa7e906faeb00bb39de07a1 Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Tue, 13 Mar 2018 14:45:31 -0700 Subject: [PATCH 14/16] hocr: Remove baseline dashes --- ocrmypdf/hocrtransform.py | 1 + 1 file changed, 1 insertion(+) diff --git a/ocrmypdf/hocrtransform.py b/ocrmypdf/hocrtransform.py index b3d10609..66327cc6 100755 --- a/ocrmypdf/hocrtransform.py +++ b/ocrmypdf/hocrtransform.py @@ -243,6 +243,7 @@ class HocrTransform(): if showBoundingboxes: # draw the baseline in magenta, dashed + pdf.setDash() pdf.setStrokeColorRGB(0.95, 0.65, 0.95) pdf.setLineWidth(0.5) pdf.line(line_box.x1, From 0089a84c9421ed839769ac3a855ad6ba449f73a3 Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Tue, 13 Mar 2018 14:51:47 -0700 Subject: [PATCH 15/16] hocr: Make interword spaces default and non-optional for hocr Update documentation to match. --- docs/advanced.rst | 13 ------------- docs/introduction.rst | 4 ++-- docs/release_notes.rst | 8 -------- ocrmypdf/__main__.py | 9 +-------- ocrmypdf/pipeline.py | 4 ++-- 5 files changed, 5 insertions(+), 33 deletions(-) diff --git a/docs/advanced.rst b/docs/advanced.rst index 47a810e7..e86cfd3c 100644 --- a/docs/advanced.rst +++ b/docs/advanced.rst @@ -131,8 +131,6 @@ The ``hocr`` renderer The ``hocr`` renderer works with older versions of Tesseract. The image layer is copied from the original PDF page if possible, avoiding potentially lossy transcoding or loss of other PDF information. If preprocessing is specified, then the image layer is a new PDF. -When combined with an additional option ``--interword-spaces`, this renderer will append a space at the end of each recognized text element to help simpler viewers such as PDF.js correctly recognize words for search and copy and paste operations. - This works in all versions of Tesseract. The ``tesseract`` renderer @@ -143,14 +141,3 @@ The ``tesseract`` renderer creates a PDF with the image and text layers precompo 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. *This renderer is deprecated and will be removed whenever support for older versions of Tesseract is dropped.* - -Adding Interword Spaces -------------------------- - -OCRmyPDF has an option ``--interword-spaces`` that appends a space at the end of each text element. Without the space, simpler PDF viewers such as PDF.js have difficulty detecting individuals words and maintaining white space between them. As a result, searching for multi-word phrases and selecting text for copy and paste are severely impacted. With this option set, these viewers are able to locate multi-word phrases while more advanced viewers remain unaffected. - -.. code-block:: bash - - ocrmypdf --output-type pdf --interword-spaces --pdf-renderer hocr input.pdf output.pdf - -This option defaults to ``False`` and must be combined with ``--pdf-renderer hocr`` or it will be ignored with a warning. This works in all versions of Tesseract. diff --git a/docs/introduction.rst b/docs/introduction.rst index 8264b001..1e36c5a2 100644 --- a/docs/introduction.rst +++ b/docs/introduction.rst @@ -82,8 +82,8 @@ 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 opensource PDF viewers have a particularly hard time with spaces betweem words, OCRmyPDF does provide an optional command option ``--interword-spaces`` that appends a space to each text element as a workaround, but discourages its use unless absolutely necessary as it mixes document structure with graphical information that ideally should be left to the PDF viewer to interpret. This option produces output similar to the aptly named ``-sloppy-text`` option of pdfsandwich mentioned in the Similar Programs section below. +* 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: diff --git a/docs/release_notes.rst b/docs/release_notes.rst index fc3b0054..41bc1972 100644 --- a/docs/release_notes.rst +++ b/docs/release_notes.rst @@ -5,14 +5,6 @@ OCRmyPDF uses `semantic versioning `_ 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. -next ----- - -- Fix issue #219: change how the final output file is created to avoid triggering permission errors when the output is a special file such as ``/dev/null`` -- The "encrypted PDF" error message was different depending on the type of PDF encryption. Now a single clear message appears for all types of PDF encryption. -- ocrmypdf is now in Homebrew. Homebrew users are advised to the version of ocrmypdf in the official homebrew-core formulas rather than the private tap. -- Some linting - v5.6.0 ------ diff --git a/ocrmypdf/__main__.py b/ocrmypdf/__main__.py index 9e33e0c5..94412453 100755 --- a/ocrmypdf/__main__.py +++ b/ocrmypdf/__main__.py @@ -304,9 +304,6 @@ advanced.add_argument( advanced.add_argument( '--user-patterns', metavar='FILE', help="Specify the location of the Tesseract user patterns file.") -advanced.add_argument( - '--interword-spaces', action='store_true', - help="Add spaces between words with HOCR transformation.") debugging = parser.add_argument_group( "Debugging", @@ -466,11 +463,7 @@ def check_options_advanced(options, log): "--pdfa-image-compression argument has no effect when " "--output-type is not 'pdfa', 'pdfa-1', or 'pdfa-2'" ) - if options.interword_spaces and options.pdf_renderer != 'hocr': - log.warning( - "--interword-spaces argument has no effect when " - "--pdf-renderer is not 'hocr'" - ) + def check_options_metadata(options, log): import unicodedata diff --git a/ocrmypdf/pipeline.py b/ocrmypdf/pipeline.py index 60073312..36981729 100644 --- a/ocrmypdf/pipeline.py +++ b/ocrmypdf/pipeline.py @@ -641,7 +641,7 @@ def render_hocr_page( hocrtransform = HocrTransform(hocr, dpi) hocrtransform.to_pdf(output_file, imageFileName=None, showBoundingboxes=False, invisibleText=True, - interwordSpaces=options.interword_spaces) + interwordSpaces=True) def flatten_groups(groups): for obj in groups: @@ -666,7 +666,7 @@ def render_hocr_debug_page( hocrtransform = HocrTransform(hocr, dpi) hocrtransform.to_pdf(output_file, imageFileName=None, showBoundingboxes=True, invisibleText=False, - interwordSpaces=options.interword_spaces) + interwordSpaces=True) def combine_layers( infiles, From a614fa3400a2f881ecc495eaa9fd592f8420336c Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Wed, 14 Mar 2018 17:05:40 -0700 Subject: [PATCH 16/16] hocr: simplify some math expressions and add comments --- ocrmypdf/hocrtransform.py | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/ocrmypdf/hocrtransform.py b/ocrmypdf/hocrtransform.py index 66327cc6..4c8bbe90 100755 --- a/ocrmypdf/hocrtransform.py +++ b/ocrmypdf/hocrtransform.py @@ -227,29 +227,38 @@ class HocrTransform(): line_box = self.pt_from_pixel(pxl_line_coords) line_height = line_box.y2 - line_box.y1 - slope, intercept = self.baseline(line) + 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() - fontsize = line_height - abs(intercept / self.dpi * inch) + 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) - baseline_y1 = self.height - (line_box.y2 + intercept / self.dpi * inch) + # 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_y1, + baseline_y2, line_box.x2, - self.polyval((-slope, baseline_y1), + self.polyval((-slope, baseline_y2), line_box.x2 - line_box.x1)) # light green for bounding box of word/line pdf.setDash(6, 3) @@ -257,7 +266,7 @@ class HocrTransform(): text.setTextTransform( cos_a, -sin_a, sin_a, cos_a, - line_box.x1, self.height - line_box.y2 + line_box.x1, baseline_y2 ) pdf.setFillColorRGB(0, 0, 0) # text in black @@ -305,9 +314,12 @@ class HocrTransform(): # "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 = (self.height - line_box.y2 + intercept / self.dpi * inch) - cursor[1] + dy = baseline_y2 - cursor[1] text.moveCursor(dx, dy) text.setHorizScale(100 * box_width / font_width)