519 lines
18 KiB
Python
519 lines
18 KiB
Python
|
#!venv/bin python3
|
|||
|
#
|
|||
|
# Copyright (c) 2010, Jonathan Brinley
|
|||
|
# Original version from: https://github.com/jbrinley/HocrConverter
|
|||
|
#
|
|||
|
# Copyright (c) 2013-14, Julien Pfefferkorn
|
|||
|
# Modifications
|
|||
|
#
|
|||
|
# Copyright (c) 2015-16, James R. Barlow
|
|||
|
# Set text to transparent
|
|||
|
|
|||
|
# Copyright (c) 2022, WordMord & Alex Roidl
|
|||
|
# Set text back to visible and change bounding boxes
|
|||
|
#
|
|||
|
# Permission is hereby granted, free of charge, to any person obtaining a
|
|||
|
# copy of this software and associated documentation files (the
|
|||
|
# "Software"), to deal in the Software without restriction, including
|
|||
|
# without limitation the rights to use, copy, modify, merge, publish,
|
|||
|
# distribute, sublicense, and/or sell copies of the Software, and to
|
|||
|
# permit persons to whom the Software is furnished to do so, subject to
|
|||
|
# the following conditions:
|
|||
|
#
|
|||
|
# The above copyright notice and this permission notice shall be included
|
|||
|
# in all copies or substantial portions of the Software.
|
|||
|
#
|
|||
|
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
|
|||
|
# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|||
|
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
|||
|
# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
|||
|
# CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
|||
|
# TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
|||
|
# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|||
|
|
|||
|
import argparse
|
|||
|
import os
|
|||
|
import re
|
|||
|
from itertools import chain
|
|||
|
from math import atan, cos, sin
|
|||
|
from pathlib import Path
|
|||
|
from typing import Any, NamedTuple, Optional, Tuple, Union
|
|||
|
from xml.etree import ElementTree
|
|||
|
|
|||
|
from reportlab.lib.colors import black, cyan, magenta, red
|
|||
|
from reportlab.lib.units import inch
|
|||
|
from reportlab.pdfgen.canvas import Canvas
|
|||
|
from reportlab.pdfbase import pdfmetrics
|
|||
|
from reportlab.pdfbase.ttfonts import TTFont
|
|||
|
from reportlab.pdfbase.pdfmetrics import registerFontFamily
|
|||
|
|
|||
|
|
|||
|
|
|||
|
# According to Wikipedia these languages are supported in the ISO-8859-1 character
|
|||
|
# set, meaning reportlab can generate them and they are compatible with hocr,
|
|||
|
# assuming Tesseract has the necessary languages installed. Note that there may
|
|||
|
# not be language packs for them.
|
|||
|
HOCR_OK_LANGS = frozenset(
|
|||
|
[
|
|||
|
# Languages fully covered by Latin-1:
|
|||
|
'afr', # Afrikaans
|
|||
|
'alb', # Albanian
|
|||
|
'ast', # Leonese
|
|||
|
'baq', # Basque
|
|||
|
'bre', # Breton
|
|||
|
'cos', # Corsican
|
|||
|
'eng', # English
|
|||
|
'eus', # Basque
|
|||
|
'fao', # Faoese
|
|||
|
'gla', # Scottish Gaelic
|
|||
|
'glg', # Galician
|
|||
|
'glv', # Manx
|
|||
|
'ice', # Icelandic
|
|||
|
'ind', # Indonesian
|
|||
|
'isl', # Icelandic
|
|||
|
'ita', # Italian
|
|||
|
'ltz', # Luxembourgish
|
|||
|
'mal', # Malay Rumi
|
|||
|
'mga', # Irish
|
|||
|
'nor', # Norwegian
|
|||
|
'oci', # Occitan
|
|||
|
'por', # Portugeuse
|
|||
|
'roh', # Romansh
|
|||
|
'sco', # Scots
|
|||
|
'sma', # Sami
|
|||
|
'spa', # Spanish
|
|||
|
'sqi', # Albanian
|
|||
|
'swa', # Swahili
|
|||
|
'swe', # Swedish
|
|||
|
'tgl', # Tagalog
|
|||
|
'wln', # Walloon
|
|||
|
# Languages supported by Latin-1 except for a few rare characters that OCR
|
|||
|
# is probably not trained to recognize anyway:
|
|||
|
'cat', # Catalan
|
|||
|
'cym', # Welsh
|
|||
|
'dan', # Danish
|
|||
|
'deu', # German
|
|||
|
'dut', # Dutch
|
|||
|
'est', # Estonian
|
|||
|
'fin', # Finnish
|
|||
|
'fra', # French
|
|||
|
'hun', # Hungarian
|
|||
|
'kur', # Kurdish
|
|||
|
'nld', # Dutch
|
|||
|
'wel', # Welsh
|
|||
|
]
|
|||
|
)
|
|||
|
|
|||
|
|
|||
|
Element = ElementTree.Element
|
|||
|
|
|||
|
|
|||
|
class Rect(NamedTuple): # pylint: disable=inherit-non-class
|
|||
|
"""A rectangle for managing PDF coordinates."""
|
|||
|
|
|||
|
x1: Any
|
|||
|
y1: Any
|
|||
|
x2: Any
|
|||
|
y2: Any
|
|||
|
|
|||
|
|
|||
|
class HocrTransformError(Exception):
|
|||
|
pass
|
|||
|
|
|||
|
|
|||
|
class HocrTransform:
|
|||
|
|
|||
|
"""
|
|||
|
A class for converting documents from the hOCR format.
|
|||
|
For details of the hOCR format, see:
|
|||
|
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, *, hocr_filename: Union[str, Path], dpi: float):
|
|||
|
self.dpi = dpi
|
|||
|
self.hocr = ElementTree.parse(os.fspath(hocr_filename))
|
|||
|
|
|||
|
# if the hOCR file has a namespace, ElementTree requires its use to
|
|||
|
# find elements
|
|||
|
matches = re.match(r'({.*})html', self.hocr.getroot().tag)
|
|||
|
self.xmlns = ''
|
|||
|
if matches:
|
|||
|
self.xmlns = matches.group(1)
|
|||
|
|
|||
|
# get dimension in pt (not pixel!!!!) of the OCRed image
|
|||
|
self.width, self.height = None, None
|
|||
|
for div in self.hocr.findall(self._child_xpath('div', 'ocr_page')):
|
|||
|
coords = self.element_coordinates(div)
|
|||
|
pt_coords = self.pt_from_pixel(coords)
|
|||
|
self.width = pt_coords.x2 - pt_coords.x1
|
|||
|
self.height = pt_coords.y2 - pt_coords.y1
|
|||
|
# there shouldn't be more than one, and if there is, we don't want
|
|||
|
# it
|
|||
|
break
|
|||
|
if self.width is None or self.height is None:
|
|||
|
raise HocrTransformError("hocr file is missing page dimensions")
|
|||
|
|
|||
|
def __str__(self): # pragma: no cover
|
|||
|
"""
|
|||
|
Return the textual content of the HTML body
|
|||
|
"""
|
|||
|
if self.hocr is None:
|
|||
|
return ''
|
|||
|
body = self.hocr.find(self._child_xpath('body'))
|
|||
|
if body:
|
|||
|
return self._get_element_text(body)
|
|||
|
else:
|
|||
|
return ''
|
|||
|
|
|||
|
def _get_element_text(self, element: Element):
|
|||
|
"""
|
|||
|
Return the textual content of the element and its children
|
|||
|
"""
|
|||
|
text = ''
|
|||
|
if element.text is not None:
|
|||
|
text += element.text
|
|||
|
for child in element:
|
|||
|
text += self._get_element_text(child)
|
|||
|
if element.tail is not None:
|
|||
|
text += element.tail
|
|||
|
return text
|
|||
|
|
|||
|
@classmethod
|
|||
|
def element_coordinates(cls, element: Element) -> Rect:
|
|||
|
"""
|
|||
|
Returns a tuple containing the coordinates of the bounding box around
|
|||
|
an element
|
|||
|
"""
|
|||
|
out = Rect._make(0 for _ in range(4))
|
|||
|
if 'title' in element.attrib:
|
|||
|
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: Element) -> Tuple[float, float]:
|
|||
|
"""
|
|||
|
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, 0.0)
|
|||
|
|
|||
|
def pt_from_pixel(self, pxl) -> Rect:
|
|||
|
"""
|
|||
|
Returns the quantity in PDF units (pt) given quantity in pixels
|
|||
|
"""
|
|||
|
return Rect._make((c / self.dpi * inch) for c in pxl)
|
|||
|
|
|||
|
def _child_xpath(self, html_tag: str, html_class: Optional[str] = None) -> str:
|
|||
|
xpath = f".//{self.xmlns}{html_tag}"
|
|||
|
if html_class:
|
|||
|
xpath += f"[@class='{html_class}']"
|
|||
|
return xpath
|
|||
|
|
|||
|
@classmethod
|
|||
|
def replace_unsupported_chars(cls, s: str) -> str:
|
|||
|
"""
|
|||
|
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)
|
|||
|
"""
|
|||
|
return s.translate(cls.ligatures)
|
|||
|
|
|||
|
def topdown_position(self, element):
|
|||
|
pxl_line_coords = self.element_coordinates(element)
|
|||
|
line_box = self.pt_from_pixel(pxl_line_coords)
|
|||
|
# Coordinates here are still in the hocr coordinate system, so 0 on the y axis
|
|||
|
# is the top of the page and increasing values of y will move towards the
|
|||
|
# bottom of the page.
|
|||
|
return line_box.y2
|
|||
|
|
|||
|
def to_pdf(
|
|||
|
self,
|
|||
|
*,
|
|||
|
out_filename: Path,
|
|||
|
image_filename: Optional[Path] = None,
|
|||
|
show_bounding_boxes: bool = False,
|
|||
|
fontname: str = "Helvetica",
|
|||
|
invisible_text: bool = False,
|
|||
|
interword_spaces: bool = False,
|
|||
|
) -> None:
|
|||
|
"""
|
|||
|
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
|
|||
|
the hOCR file.
|
|||
|
The image need not be identical to the image used to create the hOCR
|
|||
|
file.
|
|||
|
It can have a lower resolution, different color mode, etc.
|
|||
|
|
|||
|
Arguments:
|
|||
|
out_filename: Path of PDF to write.
|
|||
|
image_filename: Image to use for this file. If omitted, the OCR text
|
|||
|
is shown.
|
|||
|
show_bounding_boxes: Show bounding boxes around various text regions,
|
|||
|
for debugging.
|
|||
|
fontname: Name of font to use.
|
|||
|
invisible_text: If True, text is rendered invisible so that is
|
|||
|
selectable but never drawn. If False, text is visible and may
|
|||
|
be seen if the image is skipped or deleted in Acrobat.
|
|||
|
interword_spaces: If True, insert spaces between words rather than
|
|||
|
drawing each word without spaces. Generally this improves text
|
|||
|
extraction.
|
|||
|
"""
|
|||
|
# create the PDF file
|
|||
|
# page size in points (1/72 in.)
|
|||
|
|
|||
|
pdfmetrics.registerFont(TTFont('Greek', 'static/fonts/greek.ttf'))
|
|||
|
pdfmetrics.registerFont(TTFont('GreekB', 'static/fonts/greek-bold.ttf'))
|
|||
|
registerFontFamily('Greek', normal='Greek', bold='GreekB')
|
|||
|
|
|||
|
pdf = Canvas(
|
|||
|
os.fspath(out_filename),
|
|||
|
pagesize=(self.width, self.height),
|
|||
|
pageCompression=1,
|
|||
|
)
|
|||
|
|
|||
|
if image_filename is not None:
|
|||
|
pdf.drawImage(
|
|||
|
os.fspath(image_filename), 0, 0, width=self.width, height=self.height
|
|||
|
)
|
|||
|
|
|||
|
# draw bounding box for each paragraph
|
|||
|
# light blue for bounding box of paragraph
|
|||
|
pdf.setStrokeColor(black)
|
|||
|
# light blue for bounding box of paragraph
|
|||
|
pdf.setFillColor(black)
|
|||
|
pdf.setLineWidth(1) # no line for bounding box
|
|||
|
for elem in self.hocr.iterfind(self._child_xpath('p', 'ocr_par')):
|
|||
|
elemtxt = self._get_element_text(elem).rstrip()
|
|||
|
if len(elemtxt) == 0:
|
|||
|
continue
|
|||
|
|
|||
|
pxl_coords = self.element_coordinates(elem)
|
|||
|
pt = self.pt_from_pixel(pxl_coords)
|
|||
|
|
|||
|
# draw the bbox border
|
|||
|
if show_bounding_boxes: # pragma: no cover
|
|||
|
pdf.rect(
|
|||
|
pt.x1, self.height - pt.y2, pt.x2 - pt.x1, pt.y2 - pt.y1, fill=1
|
|||
|
)
|
|||
|
|
|||
|
found_lines = False
|
|||
|
for line in sorted(
|
|||
|
chain(
|
|||
|
self.hocr.iterfind(self._child_xpath('span', 'ocr_header')),
|
|||
|
self.hocr.iterfind(self._child_xpath('span', 'ocr_line')),
|
|||
|
self.hocr.iterfind(self._child_xpath('span', 'ocr_textfloat')),
|
|||
|
),
|
|||
|
key=self.topdown_position,
|
|||
|
):
|
|||
|
found_lines = True
|
|||
|
self._do_line(
|
|||
|
pdf,
|
|||
|
line,
|
|||
|
"ocrx_word",
|
|||
|
fontname,
|
|||
|
invisible_text,
|
|||
|
interword_spaces,
|
|||
|
show_bounding_boxes,
|
|||
|
)
|
|||
|
|
|||
|
if not found_lines:
|
|||
|
# Tesseract did not report any lines (just words)
|
|||
|
root = self.hocr.find(self._child_xpath('div', 'ocr_page'))
|
|||
|
self._do_line(
|
|||
|
pdf,
|
|||
|
root,
|
|||
|
"ocrx_word",
|
|||
|
fontname,
|
|||
|
invisible_text,
|
|||
|
interword_spaces,
|
|||
|
show_bounding_boxes,
|
|||
|
)
|
|||
|
# put the image on the page, scaled to fill the page
|
|||
|
|
|||
|
|
|||
|
# finish up the page and save it
|
|||
|
pdf.showPage()
|
|||
|
pdf.save()
|
|||
|
|
|||
|
@classmethod
|
|||
|
def polyval(cls, poly, x): # pragma: no cover
|
|||
|
return x * poly[0] + poly[1]
|
|||
|
|
|||
|
def _do_line(
|
|||
|
self,
|
|||
|
pdf: Canvas,
|
|||
|
line: Optional[Element],
|
|||
|
elemclass: str,
|
|||
|
fontname: str,
|
|||
|
invisible_text: bool,
|
|||
|
interword_spaces: bool,
|
|||
|
show_bounding_boxes: bool,
|
|||
|
):
|
|||
|
if not line:
|
|||
|
return
|
|||
|
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 * 1.2
|
|||
|
#fontsize = 10.5
|
|||
|
text.setFont('Greek', fontsize)
|
|||
|
#if invisible_text:
|
|||
|
# 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 False: # pragma: no cover
|
|||
|
# draw the baseline in magenta, dashed
|
|||
|
pdf.setDash()
|
|||
|
pdf.setStrokeColor(magenta)
|
|||
|
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.setStrokeColor(red)
|
|||
|
|
|||
|
#text.setTextTransform(cos_a, -sin_a, sin_a, cos_a, line_box.x1, baseline_y2)
|
|||
|
text.setTextOrigin(line_box.x1, baseline_y2)
|
|||
|
##pdf.translate(line_box.x1, baseline_y2)
|
|||
|
pdf.setFillColor(black) # text in black
|
|||
|
|
|||
|
elements = line.findall(self._child_xpath('span', 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 False:
|
|||
|
# 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 False: # pragma: no cover
|
|||
|
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)
|
|||
|
|
|||
|
# If reportlab tells us this word is 0 units wide, our best seems
|
|||
|
# to be to suppress this text
|
|||
|
if font_width > 0:
|
|||
|
#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",
|
|||
|
default=False,
|
|||
|
help='Show bounding boxes borders',
|
|||
|
)
|
|||
|
parser.add_argument(
|
|||
|
'-r',
|
|||
|
'--resolution',
|
|||
|
type=int,
|
|||
|
default=300,
|
|||
|
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(hocr_filename=args.hocrfile, dpi=args.resolution)
|
|||
|
hocr.to_pdf(
|
|||
|
out_filename=args.outputfile,
|
|||
|
image_filename=args.image,
|
|||
|
show_bounding_boxes=args.boundingboxes,
|
|||
|
interword_spaces=args.interword_spaces,
|
|||
|
)
|
|||
|
|