575 lines
19 KiB
Python
Executable File
575 lines
19 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
# SPDX-FileCopyrightText: 2010 Jonathan Brinley
|
||
# SPDX-FileCopyrightText: 2013-2014 Julien Pfefferkorn
|
||
# SPDX-FileCopyrightText: 2022 James R. Barlow
|
||
# SPDX-License-Identifier: MIT
|
||
|
||
"""Transform .hocr and page image to text PDF."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import os
|
||
import re
|
||
import unicodedata
|
||
from dataclasses import dataclass
|
||
from itertools import pairwise
|
||
from math import atan, cos, pi, sin
|
||
from pathlib import Path
|
||
from typing import Any, NamedTuple
|
||
from xml.etree import ElementTree
|
||
|
||
from pikepdf import PdfMatrix
|
||
|
||
from ocrmypdf.hocrtransform.backends import (
|
||
Canvas,
|
||
)
|
||
from ocrmypdf.hocrtransform.backends.pikepdf import PikepdfCanvas
|
||
from ocrmypdf.hocrtransform.backends.reportlab import (
|
||
ReportlabCanvas,
|
||
black,
|
||
blue,
|
||
cyan,
|
||
green,
|
||
inch,
|
||
magenta,
|
||
red,
|
||
)
|
||
|
||
# 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):
|
||
"""A rectangle for managing PDF coordinates."""
|
||
|
||
x1: Any
|
||
y1: Any
|
||
x2: Any
|
||
y2: Any
|
||
|
||
def transform(self, matrix, inverse=True):
|
||
"""Transform the rectangle by the given matrix."""
|
||
if inverse:
|
||
matrix = matrix.inverse()
|
||
return Rect._make(
|
||
[
|
||
matrix.a * self.x1 + matrix.c * self.y1 + matrix.e,
|
||
matrix.b * self.x1 + matrix.d * self.y1 + matrix.f,
|
||
matrix.a * self.x2 + matrix.c * self.y2 + matrix.e,
|
||
matrix.b * self.x2 + matrix.d * self.y2 + matrix.f,
|
||
]
|
||
)
|
||
|
||
|
||
@dataclass
|
||
class DebugRenderOptions:
|
||
"""A class for managing rendering options."""
|
||
|
||
render_paragraph_bbox: bool
|
||
render_baseline: bool
|
||
render_triangle: bool
|
||
render_line_bbox: bool
|
||
render_word_bbox: bool
|
||
|
||
|
||
class HocrTransformError(Exception):
|
||
"""Error while applying hOCR transform."""
|
||
|
||
|
||
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: str | Path, dpi: float):
|
||
"""Initialize the HocrTransform object."""
|
||
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")
|
||
self.render_options = DebugRenderOptions(
|
||
render_baseline=True,
|
||
render_triangle=False,
|
||
render_line_bbox=False,
|
||
render_word_bbox=True,
|
||
render_paragraph_bbox=False,
|
||
)
|
||
|
||
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:
|
||
"""Get 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]:
|
||
"""Get baseline's 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, bottomup=False) -> Rect:
|
||
"""Returns the quantity in PDF units (pt) given quantity in pixels."""
|
||
if bottomup:
|
||
return Rect._make(
|
||
[
|
||
(pxl.x1 / self.dpi * inch),
|
||
self.height - (pxl.y2 / self.dpi * inch), # swap y1/y2
|
||
(pxl.x2 / self.dpi * inch),
|
||
self.height - (pxl.y1 / self.dpi * inch),
|
||
]
|
||
)
|
||
else:
|
||
return Rect._make(
|
||
(c / self.dpi * inch) for c in (pxl.x1, pxl.y1, pxl.x2, pxl.y2)
|
||
)
|
||
|
||
def _child_xpath(self, html_tag: str, html_class: str | None = 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:
|
||
"""Replaces characters with those available in the Helvetica typeface."""
|
||
return s.translate(cls.ligatures)
|
||
|
||
@classmethod
|
||
def normalize_text(cls, s: str) -> str:
|
||
"""Normalize the given text using the NFKC normalization form."""
|
||
return unicodedata.normalize("NFKC", s)
|
||
|
||
def to_pdf(
|
||
self,
|
||
*,
|
||
out_filename: Path,
|
||
image_filename: Path | None = 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.)
|
||
canvas = PikepdfCanvas( # ReportlabCanvas(
|
||
out_filename,
|
||
page_size=(self.width, self.height),
|
||
)
|
||
|
||
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, bottomup=True)
|
||
# draw cyan box around paragraph
|
||
if self.render_options.render_paragraph_bbox:
|
||
# pragma: no cover
|
||
canvas.set_stroke_color(cyan)
|
||
canvas.set_line_width(0.1) # no line for bounding box
|
||
canvas.rect(pt.x1, pt.y2, pt.x2 - pt.x1, pt.y2 - pt.y1, fill=0)
|
||
|
||
found_lines = False
|
||
for line in (
|
||
element
|
||
for element in self.hocr.iterfind(self._child_xpath('span'))
|
||
if 'class' in element.attrib
|
||
and element.attrib['class'] in {'ocr_header', 'ocr_line', 'ocr_textfloat'}
|
||
):
|
||
found_lines = True
|
||
self._do_line(
|
||
canvas,
|
||
line,
|
||
"ocrx_word",
|
||
fontname,
|
||
invisible_text,
|
||
interword_spaces,
|
||
)
|
||
|
||
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(
|
||
canvas,
|
||
root,
|
||
"ocrx_word",
|
||
fontname,
|
||
invisible_text,
|
||
interword_spaces,
|
||
)
|
||
# put the image on the page, scaled to fill the page
|
||
if image_filename is not None:
|
||
canvas.draw_image(
|
||
image_filename, 0, 0, width=self.width, height=self.height
|
||
)
|
||
|
||
# finish up the page and save it
|
||
canvas.save()
|
||
|
||
@classmethod
|
||
def polyval(cls, poly, x): # pragma: no cover
|
||
"""Calculate the value of a polynomial at a point."""
|
||
return x * poly[0] + poly[1]
|
||
|
||
def _do_line(
|
||
self,
|
||
canvas: Canvas,
|
||
line: Element | None,
|
||
elemclass: str,
|
||
fontname: str,
|
||
invisible_text: bool,
|
||
interword_spaces: bool,
|
||
):
|
||
if line is None:
|
||
return
|
||
pxl_line_coords = self.element_coordinates(line)
|
||
line_box = self.pt_from_pixel(pxl_line_coords, bottomup=True)
|
||
|
||
assert line_box.y2 > line_box.y1
|
||
|
||
# Baseline is a polynomial (usually straight line) in the coordinate system
|
||
# of the line
|
||
slope, pxl_intercept = self.baseline(line)
|
||
if abs(slope) < 0.005:
|
||
slope = 0.0
|
||
angle = atan(slope)
|
||
intercept = pxl_intercept / self.dpi * inch
|
||
|
||
# Enter a new coordinate system with the linebox at the origin
|
||
canvas.push()
|
||
line_matrix = (
|
||
PdfMatrix().translated(line_box.x1, line_box.y1).rotated(-angle / pi * 180)
|
||
)
|
||
canvas.cm(*line_matrix.shorthand)
|
||
|
||
cm_line_box = line_box.transform(line_matrix, inverse=True)
|
||
cm_line_height = cm_line_box.y2 - cm_line_box.y1
|
||
|
||
text = canvas.begin_text()
|
||
|
||
# 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 = cm_line_height - abs(intercept)
|
||
text.set_font(fontname, fontsize)
|
||
if invisible_text or True:
|
||
text.set_render_mode(3) # Invisible (indicates OCR text)
|
||
|
||
# Intercept is normally negative. Subtracting it will raise the baseline
|
||
# above the bottom of the bounding box (y1).
|
||
baseline_y1 = cm_line_box.y1 - intercept
|
||
|
||
self._do_debug_line_bbox(canvas, cm_line_box)
|
||
self._do_debug_baseline(canvas, 0, cm_line_box, baseline_y1)
|
||
text.set_text_transform(1, 0, 0, 1, line_box.x1, baseline_y1)
|
||
canvas.set_fill_color(black) # text in black
|
||
|
||
elements = line.findall(self._child_xpath('span', elemclass))
|
||
for elem, next_elem in pairwise(elements + [None]):
|
||
self._do_line_word(
|
||
canvas,
|
||
fontname,
|
||
interword_spaces,
|
||
cm_line_height,
|
||
line_matrix,
|
||
cm_line_box,
|
||
text,
|
||
fontsize,
|
||
elem,
|
||
next_elem,
|
||
)
|
||
canvas.draw_text(text)
|
||
canvas.pop()
|
||
|
||
def _do_line_word(
|
||
self,
|
||
canvas,
|
||
fontname,
|
||
interword_spaces,
|
||
line_height,
|
||
line_matrix,
|
||
cm_line_box,
|
||
text,
|
||
fontsize,
|
||
elem,
|
||
next_elem,
|
||
):
|
||
elemtxt = self._get_element_text(elem).strip()
|
||
elemtxt = self.normalize_text(elemtxt)
|
||
if elemtxt == '':
|
||
return
|
||
|
||
pxl_coords = self.element_coordinates(elem)
|
||
box = self.pt_from_pixel(pxl_coords, bottomup=True)
|
||
cm_box = box.transform(line_matrix, inverse=True)
|
||
# space_width = canvas.string_width(' ', fontname, fontsize)
|
||
|
||
box_width = cm_box.x2 - cm_box.x1
|
||
font_width = canvas.string_width(elemtxt, fontname, fontsize)
|
||
|
||
# draw the bbox border
|
||
self._do_debug_word_triangle(canvas, cm_box)
|
||
self._do_debug_word_bbox(canvas, line_height, cm_line_box, cm_box, box_width)
|
||
|
||
text.set_text_transform(1, 0, 0, 1, cm_box.x1, cm_line_box.y1)
|
||
|
||
# If reportlab tells us this word is 0 units wide, our best seems
|
||
# to be to suppress this text
|
||
if font_width > 0:
|
||
text.set_horiz_scale(100 * box_width / font_width)
|
||
text.show(elemtxt)
|
||
|
||
if interword_spaces and next_elem is not None:
|
||
next_box = self.pt_from_pixel(
|
||
self.element_coordinates(next_elem), bottomup=True
|
||
)
|
||
next_cm_box = next_box.transform(line_matrix, inverse=True)
|
||
space_box = Rect(cm_box.x2, cm_line_box.y1, next_cm_box.x1, cm_line_box.y2)
|
||
# self._do_debug_word_bbox(canvas, line_height, cm_line_box, space_box, 0)
|
||
text.set_text_transform(1, 0, 0, 1, space_box.x1, cm_line_box.y1)
|
||
space_width = canvas.string_width(' ', fontname, fontsize)
|
||
box_width = space_box.x2 - space_box.x1
|
||
text.set_horiz_scale(100 * box_width / space_width)
|
||
text.show(' ')
|
||
|
||
def _do_debug_line_bbox(self, canvas, line_box):
|
||
if not self.render_options.render_line_bbox: # pragma: no cover
|
||
return
|
||
canvas.push()
|
||
canvas.set_dashes()
|
||
canvas.set_stroke_color(blue)
|
||
canvas.set_line_width(0.15)
|
||
canvas.rect(
|
||
line_box.x1,
|
||
line_box.y1,
|
||
line_box.x2 - line_box.x1,
|
||
line_box.y2 - line_box.y1,
|
||
fill=0,
|
||
)
|
||
canvas.pop()
|
||
|
||
def _do_debug_word_triangle(
|
||
self,
|
||
canvas,
|
||
cm_box,
|
||
):
|
||
if not self.render_options.render_triangle: # pragma: no cover
|
||
return
|
||
canvas.push()
|
||
canvas.set_dashes()
|
||
canvas.set_stroke_color(red)
|
||
canvas.set_line_width(0.1)
|
||
# Draw a triangle that conveys word height and drawing direction
|
||
canvas.line(cm_box.x1, cm_box.y1, cm_box.x2, cm_box.y1) # across bottom
|
||
canvas.line(cm_box.x2, cm_box.y1, cm_box.x1, cm_box.y2) # diagonal
|
||
canvas.line(cm_box.x1, cm_box.y1, cm_box.x1, cm_box.y2) # rise
|
||
canvas.pop()
|
||
|
||
def _do_debug_word_bbox(self, canvas, line_height, cm_line_box, cm_box, box_width):
|
||
if not self.render_options.render_word_bbox: # pragma: no cover
|
||
return
|
||
canvas.push()
|
||
canvas.set_dashes()
|
||
canvas.set_stroke_color(green)
|
||
canvas.set_line_width(0.1)
|
||
canvas.rect(cm_box.x1, cm_line_box.y1, box_width, line_height, fill=0)
|
||
canvas.pop()
|
||
|
||
def _do_debug_baseline(self, canvas, slope, line_box, baseline_y1):
|
||
if not self.render_options.render_baseline:
|
||
return
|
||
# draw the baseline in magenta, dashed
|
||
canvas.set_dashes()
|
||
canvas.set_stroke_color(magenta)
|
||
canvas.set_line_width(0.25)
|
||
# negate slope because it is defined as a rise/run in pixel
|
||
# coordinates and page coordinates have the y axis flipped
|
||
canvas.line(
|
||
line_box.x1,
|
||
baseline_y1,
|
||
line_box.x2,
|
||
baseline_y1,
|
||
# self.polyval((-slope, baseline_y1), line_box.x2 - line_box.x1),
|
||
)
|
||
|
||
|
||
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,
|
||
)
|