537 lines
20 KiB
Python
537 lines
20 KiB
Python
# SPDX-FileCopyrightText: 2010 Jonathan Brinley
|
|
# SPDX-FileCopyrightText: 2013-2014 Julien Pfefferkorn
|
|
# SPDX-FileCopyrightText: 2023 James R. Barlow
|
|
# SPDX-FileCopyrightText: 2025 Odin Dahlström
|
|
# SPDX-License-Identifier: MIT
|
|
|
|
"""hOCR transform implementation."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
import re
|
|
import unicodedata
|
|
from dataclasses import dataclass
|
|
from itertools import pairwise
|
|
from math import atan, pi
|
|
from pathlib import Path
|
|
from xml.etree import ElementTree
|
|
|
|
from pikepdf import Matrix, Name, Rectangle
|
|
from pikepdf.canvas import (
|
|
BLACK,
|
|
BLUE,
|
|
CYAN,
|
|
DARKGREEN,
|
|
GREEN,
|
|
MAGENTA,
|
|
RED,
|
|
Canvas,
|
|
Text,
|
|
TextDirection,
|
|
)
|
|
|
|
from ocrmypdf.hocrtransform._font import EncodableFont as Font
|
|
from ocrmypdf.hocrtransform._font import GlyphlessFont
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
INCH = 72.0
|
|
|
|
Element = ElementTree.Element
|
|
|
|
|
|
@dataclass
|
|
class DebugRenderOptions:
|
|
"""A class for managing rendering options."""
|
|
|
|
render_paragraph_bbox: bool = False
|
|
render_baseline: bool = False
|
|
render_triangle: bool = False
|
|
render_line_bbox: bool = False
|
|
render_word_bbox: bool = False
|
|
render_space_bbox: bool = False
|
|
|
|
|
|
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.github.io/hocr-spec/1.2/.
|
|
"""
|
|
|
|
box_pattern = re.compile(
|
|
r'''
|
|
bbox \s+
|
|
(\d+) \s+ # left: uint
|
|
(\d+) \s+ # top: uint
|
|
(\d+) \s+ # right: uint
|
|
(\d+) # bottom: uint
|
|
''',
|
|
re.VERBOSE,
|
|
)
|
|
baseline_pattern = re.compile(
|
|
r'''
|
|
baseline \s+
|
|
([\-\+]?\d*\.?\d*) \s+ # +/- decimal float
|
|
([\-\+]?\d+) # +/- int
|
|
''',
|
|
re.VERBOSE,
|
|
)
|
|
textangle_pattern = re.compile(
|
|
r'''
|
|
textangle \s+
|
|
([\-\+]?\d*\.?\d*) # +/- decimal float
|
|
''',
|
|
re.VERBOSE,
|
|
)
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
hocr_filename: str | Path,
|
|
dpi: float,
|
|
debug: bool = False,
|
|
fontname: Name = Name("/f-0-0"),
|
|
font: Font = GlyphlessFont(),
|
|
debug_render_options: DebugRenderOptions | None = None,
|
|
):
|
|
"""Initialize the HocrTransform object."""
|
|
if debug:
|
|
log.warning("Use debug_render_options instead", DeprecationWarning)
|
|
self.render_options = DebugRenderOptions(
|
|
render_baseline=debug,
|
|
render_triangle=debug,
|
|
render_line_bbox=False,
|
|
render_word_bbox=debug,
|
|
render_paragraph_bbox=False,
|
|
render_space_bbox=False,
|
|
)
|
|
else:
|
|
self.render_options = debug_render_options or DebugRenderOptions()
|
|
self.dpi = dpi
|
|
self.hocr = ElementTree.parse(os.fspath(hocr_filename))
|
|
self._fontname = fontname
|
|
self._font = font
|
|
|
|
# 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)
|
|
|
|
for div in self.hocr.findall(self._child_xpath('div', 'ocr_page')):
|
|
coords = self.element_coordinates(div)
|
|
if not coords:
|
|
raise HocrTransformError("hocr file is missing page dimensions")
|
|
self.width = (coords.urx - coords.llx) / (self.dpi / INCH)
|
|
self.height = (coords.ury - coords.lly) / (self.dpi / INCH)
|
|
# Stop after first div that has page coordinates
|
|
break
|
|
|
|
def _get_element_text(self, element: Element) -> str:
|
|
"""Return the textual content of the element and its children."""
|
|
text = element.text if element.text is not None else ''
|
|
for child in element:
|
|
text += self._get_element_text(child)
|
|
text += element.tail if element.tail is not None else ''
|
|
return text
|
|
|
|
@classmethod
|
|
def element_coordinates(cls, element: Element) -> Rectangle | None:
|
|
"""Get coordinates of the bounding box around an element."""
|
|
matches = cls.box_pattern.search(element.attrib.get('title', ''))
|
|
if not matches:
|
|
return None
|
|
return Rectangle(
|
|
float(matches.group(1)), # llx = left
|
|
float(matches.group(2)), # lly = top
|
|
float(matches.group(3)), # urx = right
|
|
float(matches.group(4)), # ury = bottom
|
|
)
|
|
|
|
@classmethod
|
|
def baseline(cls, element: Element) -> tuple[float, float]:
|
|
"""Get baseline's slope and intercept."""
|
|
matches = cls.baseline_pattern.search(element.attrib.get('title', ''))
|
|
if not matches:
|
|
return (0.0, 0.0)
|
|
return float(matches.group(1)), int(matches.group(2))
|
|
|
|
@classmethod
|
|
def textangle(cls, element: Element) -> float:
|
|
"""Get text angle of an element."""
|
|
matches = cls.textangle_pattern.search(element.attrib.get('title', ''))
|
|
if not matches:
|
|
return 0.0
|
|
return float(matches.group(1))
|
|
|
|
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 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,
|
|
invisible_text: bool = True,
|
|
) -> 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.
|
|
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.
|
|
"""
|
|
# create the PDF file
|
|
# page size in points (1/72 in.)
|
|
canvas = Canvas(page_size=(self.width, self.height))
|
|
canvas.add_font(self._fontname, self._font)
|
|
page_matrix = (
|
|
Matrix()
|
|
.translated(0, self.height)
|
|
.scaled(1, -1)
|
|
.scaled(INCH / self.dpi, INCH / self.dpi)
|
|
)
|
|
log.debug(page_matrix)
|
|
with canvas.do.save_state(cm=page_matrix):
|
|
self._debug_draw_paragraph_boxes(canvas)
|
|
found_lines = False
|
|
for par in self.hocr.iterfind(self._child_xpath('p', 'ocr_par')):
|
|
for line in (
|
|
element
|
|
for element in par.iterfind(self._child_xpath('span'))
|
|
if 'class' in element.attrib
|
|
and element.attrib['class']
|
|
in {'ocr_header', 'ocr_line', 'ocr_textfloat', 'ocr_caption'}
|
|
):
|
|
found_lines = True
|
|
direction = self._get_text_direction(par)
|
|
inject_word_breaks = self._get_inject_word_breaks(par)
|
|
self._do_line(
|
|
canvas,
|
|
line,
|
|
"ocrx_word",
|
|
invisible_text,
|
|
direction,
|
|
inject_word_breaks,
|
|
)
|
|
|
|
if not found_lines:
|
|
# Tesseract did not report any lines (just words)
|
|
root = self.hocr.find(self._child_xpath('div', 'ocr_page'))
|
|
direction = self._get_text_direction(root)
|
|
self._do_line(
|
|
canvas,
|
|
root,
|
|
"ocrx_word",
|
|
invisible_text,
|
|
direction,
|
|
True,
|
|
)
|
|
# put the image on the page, scaled to fill the page
|
|
if image_filename is not None:
|
|
canvas.do.draw_image(
|
|
image_filename, 0, 0, width=self.width, height=self.height
|
|
)
|
|
|
|
# finish up the page and save it
|
|
canvas.to_pdf().save(out_filename)
|
|
|
|
def _get_text_direction(self, par):
|
|
"""Get the text direction of the paragraph.
|
|
|
|
Arabic, Hebrew, Persian, are right-to-left languages.
|
|
"""
|
|
return (
|
|
TextDirection.RTL
|
|
if par.attrib.get('dir', 'ltr') == 'rtl'
|
|
else TextDirection.LTR
|
|
)
|
|
|
|
def _get_inject_word_breaks(self, par):
|
|
"""Determine whether word breaks should be injected.
|
|
|
|
In Chinese, Japanese, and Korean, word breaks are not injected, because
|
|
words are usually one or two characters and separators are usually explicit.
|
|
In all other languages, we inject word breaks to help word segmentation.
|
|
"""
|
|
lang = par.attrib.get('lang', '')
|
|
log.debug(lang)
|
|
if lang in {'chi_sim', 'chi_tra', 'jpn', 'kor'}:
|
|
return False
|
|
return True
|
|
|
|
@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,
|
|
invisible_text: bool,
|
|
text_direction: TextDirection,
|
|
inject_word_breaks: bool,
|
|
):
|
|
"""Render the text for a given line.
|
|
|
|
The canvas's coordinate system must be configured so that hOCR pixel
|
|
coordinates are mapped to PDF coordinates.
|
|
"""
|
|
if line is None:
|
|
return
|
|
# line_min_aabb (which is created from the "bbox" hOCR property) is so named
|
|
# because a Rectangle instance is always an AABB (it has no orientation).
|
|
# However, this means that for non-zero values of the "textangle" hOCR
|
|
# property, line_min_aabb is not the true bounding box of the hOCR line,
|
|
# but rather the minimum AABB that encloses the bounding box of the line.
|
|
# The true bounding box of the line must be seen as an OBB, due to the
|
|
# existance of the "textangle" hOCR property.
|
|
line_min_aabb = self.element_coordinates(line)
|
|
if not line_min_aabb:
|
|
return
|
|
if line_min_aabb.ury <= line_min_aabb.lly:
|
|
log.error(
|
|
"line box is invalid so we cannot render it: box=%s text=%s",
|
|
line_min_aabb,
|
|
self._get_element_text(line),
|
|
)
|
|
return
|
|
self._debug_draw_line_bbox(canvas, line_min_aabb)
|
|
|
|
# Even though line_min_aabb is not the true bounding box of the line,
|
|
# it is still possible to derive an AABB (Rectangle) from it that is
|
|
# the same size as the true bounding box of the line,
|
|
# if we use a coordinate system that is axis-aligned with respect to
|
|
# the rotation of the OBB (textangle).
|
|
# line_size_aabb_matrix is a transform matrix for such a coordinate
|
|
# system, and line_size_aabb is thus an AABB with the same
|
|
# size as the true bounding box of the line.
|
|
top_left_corner = (line_min_aabb.llx, line_min_aabb.lly)
|
|
line_size_aabb_matrix = (
|
|
Matrix()
|
|
.translated(*top_left_corner)
|
|
# Note: negative sign (textangle is counter-clockwise, see hOCR spec)
|
|
.rotated(-self.textangle(line))
|
|
)
|
|
line_size_aabb = line_size_aabb_matrix.inverse().transform(line_min_aabb)
|
|
|
|
slope, intercept = self.baseline(line)
|
|
if abs(slope) < 0.005:
|
|
slope = 0.0
|
|
slope_angle = atan(slope)
|
|
|
|
# Final PDF-perspective (bottom-left corner) transform matrix for the
|
|
# text baseline, which has an intercept and slope relative to the OBB.
|
|
# See "bbox", "textangle" and "baseline" in the hOCR spec for more details.
|
|
baseline_matrix = (
|
|
line_size_aabb_matrix
|
|
# Translate from hOCR perspective (top-left corner) to PDF perspective
|
|
# (bottom-left corner).
|
|
# Note: it would be incorrect to use line_min_aabb.height here because
|
|
# it is not the true height of the OBB of the line, if textangle != 0.
|
|
.translated(0, line_size_aabb.height)
|
|
.translated(0, intercept)
|
|
.rotated(slope_angle / pi * 180)
|
|
)
|
|
|
|
with canvas.do.save_state(cm=baseline_matrix):
|
|
text = Text(direction=text_direction)
|
|
fontsize = line_size_aabb.height + intercept
|
|
text.font(self._fontname, fontsize)
|
|
text.render_mode(3 if invisible_text else 0)
|
|
|
|
self._debug_draw_baseline(
|
|
canvas, baseline_matrix.inverse().transform(line_min_aabb), 0
|
|
)
|
|
|
|
canvas.do.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,
|
|
baseline_matrix,
|
|
text,
|
|
fontsize,
|
|
elem,
|
|
next_elem,
|
|
text_direction,
|
|
inject_word_breaks,
|
|
)
|
|
canvas.do.draw_text(text)
|
|
|
|
def _do_line_word(
|
|
self,
|
|
canvas: Canvas,
|
|
line_matrix: Matrix,
|
|
text: Text,
|
|
fontsize: float,
|
|
elem: Element | None,
|
|
next_elem: Element | None,
|
|
text_direction: TextDirection,
|
|
inject_word_breaks: bool,
|
|
):
|
|
"""Render the text for a single word."""
|
|
if elem is None:
|
|
return
|
|
elemtxt = self.normalize_text(self._get_element_text(elem).strip())
|
|
if elemtxt == '':
|
|
return
|
|
|
|
hocr_box = self.element_coordinates(elem)
|
|
if hocr_box is None:
|
|
return
|
|
box = line_matrix.inverse().transform(hocr_box)
|
|
font_width = self._font.text_width(elemtxt, fontsize)
|
|
|
|
# Debug sketches
|
|
self._debug_draw_word_triangle(canvas, box)
|
|
self._debug_draw_word_bbox(canvas, box)
|
|
|
|
# If this word is 0 units wide, our best bet seems to be to suppress this text
|
|
if text_direction == TextDirection.RTL:
|
|
log.info("RTL: %s", elemtxt)
|
|
if font_width > 0:
|
|
if text_direction == TextDirection.LTR:
|
|
text.text_transform(Matrix(1, 0, 0, -1, box.llx, 0))
|
|
elif text_direction == TextDirection.RTL:
|
|
text.text_transform(Matrix(-1, 0, 0, -1, box.llx + box.width, 0))
|
|
text.horiz_scale(100 * box.width / font_width)
|
|
text.show(self._font.text_encode(elemtxt))
|
|
|
|
# Get coordinates of the next word (if there is one)
|
|
hocr_next_box = (
|
|
self.element_coordinates(next_elem) if next_elem is not None else None
|
|
)
|
|
if hocr_next_box is None:
|
|
return
|
|
# Render a space between this word and the next word. The explicit space helps
|
|
# PDF viewers identify the word break, and horizontally scaling it to
|
|
# occupy the space the between the words helps the PDF viewer
|
|
# avoid combiningthewordstogether.
|
|
if not inject_word_breaks:
|
|
return
|
|
next_box = line_matrix.inverse().transform(hocr_next_box)
|
|
if text_direction == TextDirection.LTR:
|
|
space_box = Rectangle(box.urx, box.lly, next_box.llx, next_box.ury)
|
|
elif text_direction == TextDirection.RTL:
|
|
space_box = Rectangle(next_box.urx, box.lly, box.llx, next_box.ury)
|
|
self._debug_draw_space_bbox(canvas, space_box)
|
|
space_width = self._font.text_width(' ', fontsize)
|
|
if space_width > 0 and space_box.width > 0:
|
|
if text_direction == TextDirection.LTR:
|
|
text.text_transform(Matrix(1, 0, 0, -1, space_box.llx, 0))
|
|
elif text_direction == TextDirection.RTL:
|
|
text.text_transform(
|
|
Matrix(-1, 0, 0, -1, space_box.llx + space_box.width, 0)
|
|
)
|
|
text.horiz_scale(100 * space_box.width / space_width)
|
|
text.show(self._font.text_encode(' '))
|
|
|
|
def _debug_draw_paragraph_boxes(self, canvas: Canvas, color=CYAN):
|
|
"""Draw boxes around paragraphs in the document."""
|
|
if not self.render_options.render_paragraph_bbox: # pragma: no cover
|
|
return
|
|
with canvas.do.save_state():
|
|
# draw box around paragraph
|
|
canvas.do.stroke_color(color).line_width(0.1)
|
|
for elem in self.hocr.iterfind(self._child_xpath('p', 'ocr_par')):
|
|
elemtxt = self._get_element_text(elem).strip()
|
|
if len(elemtxt) == 0:
|
|
continue
|
|
ocr_par = self.element_coordinates(elem)
|
|
if ocr_par is None:
|
|
continue
|
|
canvas.do.rect(
|
|
ocr_par.llx, ocr_par.lly, ocr_par.width, ocr_par.height, fill=False
|
|
)
|
|
|
|
def _debug_draw_line_bbox(self, canvas: Canvas, line_box: Rectangle, color=BLUE):
|
|
"""Render the bounding box of a text line."""
|
|
if not self.render_options.render_line_bbox: # pragma: no cover
|
|
return
|
|
with canvas.do.save_state():
|
|
canvas.do.stroke_color(color).line_width(0.15).rect(
|
|
line_box.llx, line_box.lly, line_box.width, line_box.height, fill=False
|
|
)
|
|
|
|
def _debug_draw_word_triangle(
|
|
self, canvas: Canvas, box: Rectangle, color=RED, line_width=0.1
|
|
):
|
|
"""Render a triangle that conveys word height and drawing direction."""
|
|
if not self.render_options.render_triangle: # pragma: no cover
|
|
return
|
|
with canvas.do.save_state():
|
|
canvas.do.stroke_color(color).line_width(line_width).line(
|
|
box.llx, box.lly, box.urx, box.lly
|
|
).line(box.urx, box.lly, box.llx, box.ury).line(
|
|
box.llx, box.lly, box.llx, box.ury
|
|
)
|
|
|
|
def _debug_draw_word_bbox(
|
|
self, canvas: Canvas, box: Rectangle, color=GREEN, line_width=0.1
|
|
):
|
|
"""Render a box depicting the word."""
|
|
if not self.render_options.render_word_bbox: # pragma: no cover
|
|
return
|
|
with canvas.do.save_state():
|
|
canvas.do.stroke_color(color).line_width(line_width).rect(
|
|
box.llx, box.lly, box.width, box.height, fill=False
|
|
)
|
|
|
|
def _debug_draw_space_bbox(
|
|
self, canvas: Canvas, box: Rectangle, color=DARKGREEN, line_width=0.1
|
|
):
|
|
"""Render a box depicting the space between two words."""
|
|
if not self.render_options.render_space_bbox: # pragma: no cover
|
|
return
|
|
with canvas.do.save_state():
|
|
canvas.do.fill_color(color).line_width(line_width).rect(
|
|
box.llx, box.lly, box.width, box.height, fill=True
|
|
)
|
|
|
|
def _debug_draw_baseline(
|
|
self,
|
|
canvas: Canvas,
|
|
line_box: Rectangle,
|
|
baseline_lly,
|
|
color=MAGENTA,
|
|
line_width=0.25,
|
|
):
|
|
"""Render the text baseline."""
|
|
if not self.render_options.render_baseline:
|
|
return
|
|
with canvas.do.save_state():
|
|
canvas.do.stroke_color(color).line_width(line_width).line(
|
|
line_box.llx,
|
|
baseline_lly,
|
|
line_box.urx,
|
|
baseline_lly,
|
|
)
|