Refactor hocrtransform: separate parsing from rendering

Split the hOCR transformation code into three distinct layers:

1. ocr_element.py - Generic OcrElement dataclass that represents OCR
   output structure from any source (hOCR, ALTO, custom engines).
   Includes helper classes: BoundingBox, Baseline, FontInfo.

2. hocr_parser.py - HocrParser class that parses hOCR XML files into
   OcrElement trees, extracting bbox, baseline, textangle, confidence,
   font info, direction, and language.

3. pdf_renderer.py - PdfTextRenderer class that renders OcrElement
   trees to PDF text layers, handling text positioning, baseline
   rotation, LTR/RTL, and word break injection.

The existing HocrTransform class is preserved for backward compatibility,
now delegating to the new components internally.

This separation enables:
- Support for non-hOCR OCR output formats
- Independent improvements to text rendering
- Reuse of OcrElement for other purposes

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
James R. Barlow
2025-12-21 16:17:22 -08:00
co-authored by Claude Opus 4.5
parent e162361d28
commit 9ea804aff5
5 changed files with 1440 additions and 460 deletions
+44 -3
View File
@@ -1,18 +1,59 @@
# SPDX-FileCopyrightText: 2023 James R. Barlow
# SPDX-FileCopyrightText: 2023-2025 James R. Barlow
# SPDX-License-Identifier: MIT
"""Transform .hocr and page image to text PDF."""
"""Transform OCR output to text-only PDFs.
This package provides tools for:
1. Parsing OCR output (hOCR format) into generic OcrElement structures
2. Rendering OcrElement structures to searchable PDF text layers
The architecture separates parsing from rendering, allowing:
- Support for multiple OCR input formats (hOCR, ALTO, custom engines)
- Independent improvements to text rendering
- Reuse of the OcrElement data model for other purposes
Main components:
- OcrElement: Generic dataclass representing OCR output structure
- HocrParser: Parses hOCR files into OcrElement trees
- PdfTextRenderer: Renders OcrElement trees to PDF text layers
- HocrTransform: Backward-compatible wrapper combining parser and renderer
"""
from __future__ import annotations
from ocrmypdf.hocrtransform._hocr import (
DebugRenderOptions,
HocrTransform,
HocrTransformError,
)
from ocrmypdf.hocrtransform.hocr_parser import (
HocrParseError,
HocrParser,
)
from ocrmypdf.hocrtransform.ocr_element import (
Baseline,
BoundingBox,
FontInfo,
OcrClass,
OcrElement,
)
from ocrmypdf.hocrtransform.pdf_renderer import (
DebugRenderOptions,
PdfTextRenderer,
)
__all__ = (
# Backward-compatible API
'HocrTransform',
'HocrTransformError',
'DebugRenderOptions',
# New separated components
'HocrParser',
'HocrParseError',
'PdfTextRenderer',
# OCR element data model
'OcrElement',
'OcrClass',
'BoundingBox',
'Baseline',
'FontInfo',
)
+64 -457
View File
@@ -1,58 +1,33 @@
# SPDX-FileCopyrightText: 2010 Jonathan Brinley
# SPDX-FileCopyrightText: 2013-2014 Julien Pfefferkorn
# SPDX-FileCopyrightText: 2023 James R. Barlow
# SPDX-FileCopyrightText: 2025 Odin Dahlström
# SPDX-FileCopyrightText: 2023-2025 James R. Barlow
# SPDX-FileCopyrightText: 2025 Odin Dahlstr\u00f6m
# SPDX-License-Identifier: MIT
"""hOCR transform implementation."""
"""hOCR transform implementation.
This module provides backward-compatible HocrTransform class that wraps the
new separated HocrParser and PdfTextRenderer components.
"""
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
import warnings
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 pikepdf import Name
from ocrmypdf.hocrtransform._font import EncodableFont as Font
from ocrmypdf.hocrtransform._font import GlyphlessFont
from ocrmypdf.hocrtransform.hocr_parser import HocrParseError, HocrParser
from ocrmypdf.hocrtransform.pdf_renderer import (
DebugRenderOptions,
PdfTextRenderer,
)
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."""
@@ -63,33 +38,10 @@ class HocrTransform:
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,
)
This class provides backward compatibility with existing code. Internally,
it uses the new HocrParser and PdfTextRenderer components.
"""
def __init__(
self,
@@ -101,9 +53,22 @@ class HocrTransform:
font: Font = GlyphlessFont(),
debug_render_options: DebugRenderOptions | None = None,
):
"""Initialize the HocrTransform object."""
"""Initialize the HocrTransform object.
Args:
hocr_filename: Path to the hOCR file
dpi: Resolution of the source image in dots per inch
debug: Deprecated; use debug_render_options instead
fontname: PDF font name to use
font: Font implementation for encoding and metrics
debug_render_options: Options for debug visualization
"""
if debug:
log.warning("Use debug_render_options instead", DeprecationWarning)
warnings.warn(
"Use debug_render_options instead of debug parameter",
DeprecationWarning,
stacklevel=2,
)
self.render_options = DebugRenderOptions(
render_baseline=debug,
render_triangle=debug,
@@ -114,74 +79,26 @@ class HocrTransform:
)
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
self._hocr_filename = Path(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)
# Parse the hOCR file
try:
parser = HocrParser(hocr_filename)
self._page = parser.parse()
except HocrParseError as e:
raise HocrTransformError(str(e)) from e
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
if self._page.bbox is None:
raise HocrTransformError("hocr file is missing page dimensions")
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)
# Calculate page size in PDF points
INCH = 72.0
self.width = self._page.bbox.width / (self.dpi / INCH)
self.height = self._page.bbox.height / (self.dpi / INCH)
def to_pdf(
self,
@@ -198,7 +115,7 @@ class HocrTransform:
file.
It can have a lower resolution, different color mode, etc.
Arguments:
Args:
out_filename: Path of PDF to write.
image_filename: Image to use for this file. If omitted, the OCR text
is shown.
@@ -206,335 +123,25 @@ class HocrTransform:
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)
renderer = PdfTextRenderer(
page=self._page,
dpi=self.dpi,
fontname=self._fontname,
font=self._font,
debug_render_options=self.render_options,
)
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
)
renderer.render(
out_filename=out_filename,
image_filename=image_filename,
invisible_text=invisible_text,
)
# finish up the page and save it
canvas.to_pdf().save(out_filename)
@property
def page(self):
"""Get the parsed OcrElement page.
def _get_text_direction(self, par):
"""Get the text direction of the paragraph.
Arabic, Hebrew, Persian, are right-to-left languages.
When the paragraph element is None, defaults to left-to-right.
Returns:
The root OcrElement representing the parsed page
"""
if par is None:
return TextDirection.LTR
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,
)
return self._page
+521
View File
@@ -0,0 +1,521 @@
# SPDX-FileCopyrightText: 2010 Jonathan Brinley
# SPDX-FileCopyrightText: 2013-2014 Julien Pfefferkorn
# SPDX-FileCopyrightText: 2023-2025 James R. Barlow
# SPDX-License-Identifier: MIT
"""Parser for hOCR format files.
This module provides functionality to parse hOCR files (HTML-based OCR format)
and convert them to the engine-agnostic OcrElement tree structure.
For details of the hOCR format, see:
http://kba.github.io/hocr-spec/1.2/
"""
from __future__ import annotations
import logging
import os
import re
import unicodedata
from pathlib import Path
from typing import Literal, cast
from xml.etree import ElementTree
from ocrmypdf.hocrtransform.ocr_element import (
Baseline,
BoundingBox,
FontInfo,
OcrClass,
OcrElement,
)
TextDirection = Literal["ltr", "rtl"]
log = logging.getLogger(__name__)
Element = ElementTree.Element
class HocrParseError(Exception):
"""Error while parsing hOCR file."""
class HocrParser:
"""Parser for hOCR format files.
Converts hOCR XML/HTML files into OcrElement trees.
The hOCR format uses HTML with special class attributes (ocr_page, ocr_line,
ocrx_word, etc.) and a title attribute containing properties like bbox,
baseline, and confidence scores.
"""
# Regex patterns for parsing hOCR title attributes
_bbox_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+ # slope: +/- decimal float
([\-\+]?\d+) # intercept: +/- int
''',
re.VERBOSE,
)
_textangle_pattern = re.compile(
r'''
textangle \s+
([\-\+]?\d*\.?\d*) # angle: +/- decimal float
''',
re.VERBOSE,
)
_x_wconf_pattern = re.compile(
r'''
x_wconf \s+
(\d+) # confidence: uint (0-100)
''',
re.VERBOSE,
)
_x_fsize_pattern = re.compile(
r'''
x_fsize \s+
(\d*\.?\d+) # font size: float
''',
re.VERBOSE,
)
_x_font_pattern = re.compile(
r'''
x_font \s+
(\S+) # font name: non-whitespace string
''',
re.VERBOSE,
)
_ppageno_pattern = re.compile(
r'''
ppageno \s+
(\d+) # page number: uint
''',
re.VERBOSE,
)
_scan_res_pattern = re.compile(
r'''
scan_res \s+
(\d+) \s+ # x resolution
(\d+) # y resolution
''',
re.VERBOSE,
)
def __init__(self, hocr_file: str | Path):
"""Initialize the parser with an hOCR file.
Args:
hocr_file: Path to the hOCR file to parse
Raises:
HocrParseError: If the file cannot be parsed
"""
self._hocr_path = Path(hocr_file)
try:
self._tree = ElementTree.parse(os.fspath(hocr_file))
except ElementTree.ParseError as e:
raise HocrParseError(f"Failed to parse hOCR file: {e}") from e
# Detect XML namespace
root_tag = self._tree.getroot().tag
matches = re.match(r'({.*})html', root_tag)
self._xmlns = matches.group(1) if matches else ''
def parse(self) -> OcrElement:
"""Parse the hOCR file and return an OcrElement tree.
Returns:
The root OcrElement (ocr_page) containing the document structure
Raises:
HocrParseError: If no ocr_page element is found
"""
# Find the first ocr_page element
page_div = self._tree.find(self._xpath('div', 'ocr_page'))
if page_div is None:
raise HocrParseError("No ocr_page element found in hOCR file")
return self._parse_page(page_div)
def _xpath(self, html_tag: str, html_class: str | None = None) -> str:
"""Build an XPath expression for finding elements.
Args:
html_tag: HTML tag name (e.g., 'div', 'span', 'p')
html_class: Optional class attribute to match
Returns:
XPath expression string
"""
xpath = f".//{self._xmlns}{html_tag}"
if html_class:
xpath += f"[@class='{html_class}']"
return xpath
def _parse_page(self, page_elem: Element) -> OcrElement:
"""Parse an ocr_page element.
Args:
page_elem: The XML element with class="ocr_page"
Returns:
OcrElement representing the page
"""
title = page_elem.attrib.get('title', '')
bbox = self._parse_bbox(title)
if bbox is None:
raise HocrParseError("ocr_page missing bbox")
# Parse page-level properties
page_number = self._parse_ppageno(title)
dpi = self._parse_scan_res(title)
page = OcrElement(
ocr_class=OcrClass.PAGE,
bbox=bbox,
page_number=page_number,
dpi=dpi,
)
# Parse child paragraphs
for par_elem in page_elem.iterfind(self._xpath('p', 'ocr_par')):
paragraph = self._parse_paragraph(par_elem)
if paragraph is not None:
page.children.append(paragraph)
# If no paragraphs found, check for words directly under page
# (some Tesseract output structures)
if not page.children:
for word_elem in page_elem.iterfind(self._xpath('span', 'ocrx_word')):
word = self._parse_word(word_elem)
if word is not None:
page.children.append(word)
return page
def _parse_paragraph(self, par_elem: Element) -> OcrElement | None:
"""Parse an ocr_par element.
Args:
par_elem: The XML element with class="ocr_par"
Returns:
OcrElement representing the paragraph, or None if empty
"""
title = par_elem.attrib.get('title', '')
bbox = self._parse_bbox(title)
# Get direction and language from attributes
dir_attr = par_elem.attrib.get('dir')
direction: TextDirection | None = (
cast(TextDirection, dir_attr) if dir_attr in ('ltr', 'rtl') else None
)
language = par_elem.attrib.get('lang')
paragraph = OcrElement(
ocr_class=OcrClass.PARAGRAPH,
bbox=bbox,
direction=direction,
language=language,
)
# Parse child lines
line_classes = {
'ocr_line',
'ocr_header',
'ocr_footer',
'ocr_caption',
'ocr_textfloat',
}
for span_elem in par_elem.iterfind(self._xpath('span')):
elem_class = span_elem.attrib.get('class', '')
if elem_class in line_classes:
line = self._parse_line(span_elem, elem_class, direction, language)
if line is not None:
paragraph.children.append(line)
# Return None if paragraph is empty
if not paragraph.children:
return None
return paragraph
def _parse_line(
self,
line_elem: Element,
ocr_class: str,
parent_direction: TextDirection | None,
parent_language: str | None,
) -> OcrElement | None:
"""Parse a line element (ocr_line, ocr_header, etc.).
Args:
line_elem: The XML element representing the line
ocr_class: The hOCR class of the line
parent_direction: Text direction inherited from parent
parent_language: Language inherited from parent
Returns:
OcrElement representing the line, or None if empty
"""
title = line_elem.attrib.get('title', '')
bbox = self._parse_bbox(title)
if bbox is None:
return None
baseline = self._parse_baseline(title)
textangle = self._parse_textangle(title)
# Inherit direction and language from parent if not specified
dir_attr = line_elem.attrib.get('dir')
if dir_attr in ('ltr', 'rtl'):
direction: TextDirection | None = cast(TextDirection, dir_attr)
else:
direction = parent_direction
language = line_elem.attrib.get('lang') or parent_language
line = OcrElement(
ocr_class=ocr_class,
bbox=bbox,
baseline=baseline,
textangle=textangle,
direction=direction,
language=language,
)
# Parse child words
for word_elem in line_elem.iterfind(self._xpath('span', 'ocrx_word')):
word = self._parse_word(word_elem)
if word is not None:
line.children.append(word)
# Return None if line has no words
if not line.children:
return None
return line
def _parse_word(self, word_elem: Element) -> OcrElement | None:
"""Parse an ocrx_word element.
Args:
word_elem: The XML element with class="ocrx_word"
Returns:
OcrElement representing the word, or None if empty
"""
title = word_elem.attrib.get('title', '')
bbox = self._parse_bbox(title)
# Get the text content
text = self._get_element_text(word_elem)
text = self._normalize_text(text)
if not text:
return None
# Parse confidence (x_wconf is 0-100, convert to 0.0-1.0)
confidence = self._parse_x_wconf(title)
if confidence is not None:
confidence = confidence / 100.0
# Parse font info
font = self._parse_font_info(title)
return OcrElement(
ocr_class=OcrClass.WORD,
bbox=bbox,
text=text,
confidence=confidence,
font=font,
)
def _get_element_text(self, element: Element) -> str:
"""Get the full text content of an element including children.
Args:
element: XML element
Returns:
Combined text content
"""
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
@staticmethod
def _normalize_text(text: str) -> str:
"""Normalize text using NFKC normalization.
This splits ligatures and combines diacritics.
Args:
text: Raw text
Returns:
Normalized text, stripped of leading/trailing whitespace
"""
return unicodedata.normalize("NFKC", text).strip()
def _parse_bbox(self, title: str) -> BoundingBox | None:
"""Parse a bbox from an hOCR title attribute.
Args:
title: The title attribute value
Returns:
BoundingBox or None if not found
"""
match = self._bbox_pattern.search(title)
if not match:
return None
try:
return BoundingBox(
left=float(match.group(1)),
top=float(match.group(2)),
right=float(match.group(3)),
bottom=float(match.group(4)),
)
except ValueError:
return None
def _parse_baseline(self, title: str) -> Baseline | None:
"""Parse baseline from an hOCR title attribute.
Args:
title: The title attribute value
Returns:
Baseline or None if not found
"""
match = self._baseline_pattern.search(title)
if not match:
return None
try:
return Baseline(
slope=float(match.group(1)) if match.group(1) else 0.0,
intercept=float(match.group(2)),
)
except ValueError:
return None
def _parse_textangle(self, title: str) -> float | None:
"""Parse textangle from an hOCR title attribute.
Args:
title: The title attribute value
Returns:
Angle in degrees or None if not found
"""
match = self._textangle_pattern.search(title)
if not match:
return None
try:
return float(match.group(1))
except ValueError:
return None
def _parse_x_wconf(self, title: str) -> float | None:
"""Parse word confidence from an hOCR title attribute.
Args:
title: The title attribute value
Returns:
Confidence (0-100) or None if not found
"""
match = self._x_wconf_pattern.search(title)
if not match:
return None
try:
return float(match.group(1))
except ValueError:
return None
def _parse_ppageno(self, title: str) -> int | None:
"""Parse physical page number from an hOCR title attribute.
Args:
title: The title attribute value
Returns:
Page number or None if not found
"""
match = self._ppageno_pattern.search(title)
if not match:
return None
try:
return int(match.group(1))
except ValueError:
return None
def _parse_scan_res(self, title: str) -> float | None:
"""Parse scan resolution (DPI) from an hOCR title attribute.
Args:
title: The title attribute value
Returns:
DPI (using first value if x and y differ) or None if not found
"""
match = self._scan_res_pattern.search(title)
if not match:
return None
try:
# Use the first (x) resolution value
return float(match.group(1))
except ValueError:
return None
def _parse_font_info(self, title: str) -> FontInfo | None:
"""Parse font information from an hOCR title attribute.
Args:
title: The title attribute value
Returns:
FontInfo or None if no font info found
"""
font_match = self._x_font_pattern.search(title)
size_match = self._x_fsize_pattern.search(title)
if not font_match and not size_match:
return None
return FontInfo(
name=font_match.group(1) if font_match else None,
size=float(size_match.group(1)) if size_match else None,
)
+267
View File
@@ -0,0 +1,267 @@
# SPDX-FileCopyrightText: 2025 James R. Barlow
# SPDX-License-Identifier: MPL-2.0
"""OCR element dataclasses for representing OCR output structure.
This module provides a generic, engine-agnostic representation of OCR output.
The OcrElement dataclass can represent structural units from any OCR source
(hOCR, ALTO, custom engines, etc.) in a unified format suitable for rendering.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Literal
@dataclass
class BoundingBox:
"""An axis-aligned bounding box in pixel coordinates.
Coordinates use top-left origin (standard for images and hOCR).
Attributes:
left: Left edge x-coordinate
top: Top edge y-coordinate
right: Right edge x-coordinate
bottom: Bottom edge y-coordinate
"""
left: float
top: float
right: float
bottom: float
@property
def width(self) -> float:
"""Width of the bounding box."""
return self.right - self.left
@property
def height(self) -> float:
"""Height of the bounding box."""
return self.bottom - self.top
def __post_init__(self):
"""Validate bounding box coordinates."""
if self.right < self.left:
raise ValueError(
f"Invalid bounding box: right ({self.right}) < left ({self.left})"
)
if self.bottom < self.top:
raise ValueError(
f"Invalid bounding box: bottom ({self.bottom}) < top ({self.top})"
)
@dataclass
class Baseline:
"""Text baseline information.
The baseline is represented as a linear equation: y = slope * x + intercept.
This describes the line along which text characters sit, relative to the
bottom-left corner of the line's bounding box.
In hOCR, the baseline is specified relative to the bottom of the line's bbox,
with the intercept being the vertical offset from the bottom and the slope
representing rotation (positive = ascending left-to-right).
Attributes:
slope: Slope of the baseline (rise over run)
intercept: Y-intercept of the baseline (vertical offset from bbox bottom)
"""
slope: float = 0.0
intercept: float = 0.0
@dataclass
class FontInfo:
"""Font information for text rendering.
Attributes:
name: Font family name (e.g., "Times New Roman")
size: Font size in points
bold: Whether the font is bold
italic: Whether the font is italic
monospace: Whether the font is monospace
serif: Whether the font is serif (vs sans-serif)
smallcaps: Whether the font uses small caps
underline: Whether the text is underlined
"""
name: str | None = None
size: float | None = None
bold: bool = False
italic: bool = False
monospace: bool = False
serif: bool = False
smallcaps: bool = False
underline: bool = False
@dataclass
class OcrElement:
"""A generic OCR element representing any structural unit of OCR output.
OcrElements form a tree structure where pages contain paragraphs, paragraphs
contain lines, lines contain words, etc. The specific hierarchy depends on
the OCR engine, but this dataclass can represent any of these levels.
The ocr_class field uses hOCR naming conventions (ocr_page, ocr_par, ocr_line,
ocrx_word, etc.) as a common vocabulary, but elements from other sources can
map to these classes.
Common hOCR classes:
- ocr_page: The root element for a page
- ocr_carea: A content/column area
- ocr_par: A paragraph
- ocr_line: A line of text
- ocr_header: A header line
- ocr_footer: A footer line
- ocr_caption: A caption line
- ocr_textfloat: A floating text element
- ocrx_word: A single word
Attributes:
ocr_class: The element type (e.g., "ocr_page", "ocr_line", "ocrx_word")
bbox: Axis-aligned bounding box in source pixel coordinates (top-left origin)
poly: Polygon vertices for oriented/non-rectangular bounds
text: Text content (primarily for leaf nodes like words)
confidence: OCR confidence score (0.0-1.0)
children: Child elements (hierarchical structure)
direction: Text direction ("ltr" or "rtl")
language: Language code (e.g., "eng", "deu", "chi_sim")
baseline: Text baseline information (slope and intercept)
textangle: Text rotation angle in degrees (counter-clockwise from horizontal)
font: Font information (name, size, style)
dpi: Image resolution in dots per inch (typically for page-level)
page_number: Physical page number (0-indexed)
logical_page_number: Logical page number (as printed on the page)
"""
ocr_class: str
# Bounding boxes
bbox: BoundingBox | None = None
poly: list[tuple[float, float]] | None = None
# Text content
text: str = ""
# Confidence (0.0-1.0)
confidence: float | None = None
# Children (hierarchical structure)
children: list[OcrElement] = field(default_factory=list)
# Text direction and language
direction: Literal["ltr", "rtl"] | None = None
language: str | None = None
# Baseline (for lines)
baseline: Baseline | None = None
# Rotation angle in degrees (counter-clockwise)
textangle: float | None = None
# Font information
font: FontInfo | None = None
# Page-level properties
dpi: float | None = None
page_number: int | None = None
logical_page_number: int | None = None
def iter_by_class(self, *ocr_classes: str) -> list[OcrElement]:
"""Iterate over all descendants matching the given class(es).
Args:
*ocr_classes: One or more ocr_class values to match
Returns:
List of all matching descendant elements (depth-first order)
"""
result = []
if self.ocr_class in ocr_classes:
result.append(self)
for child in self.children:
result.extend(child.iter_by_class(*ocr_classes))
return result
def find_by_class(self, *ocr_classes: str) -> OcrElement | None:
"""Find the first descendant matching the given class(es).
Args:
*ocr_classes: One or more ocr_class values to match
Returns:
The first matching element, or None if not found
"""
if self.ocr_class in ocr_classes:
return self
for child in self.children:
result = child.find_by_class(*ocr_classes)
if result is not None:
return result
return None
def get_text_recursive(self) -> str:
"""Get the combined text of this element and all descendants.
Returns:
Combined text content, with words separated by spaces
"""
if self.text:
return self.text
texts = [child.get_text_recursive() for child in self.children]
return " ".join(t for t in texts if t)
@property
def words(self) -> list[OcrElement]:
"""Get all word elements (ocrx_word) in this element's subtree."""
return self.iter_by_class("ocrx_word")
@property
def lines(self) -> list[OcrElement]:
"""Get all line elements in this element's subtree."""
return self.iter_by_class(
"ocr_line", "ocr_header", "ocr_footer", "ocr_caption", "ocr_textfloat"
)
@property
def paragraphs(self) -> list[OcrElement]:
"""Get all paragraph elements (ocr_par) in this element's subtree."""
return self.iter_by_class("ocr_par")
# Type alias for text direction
TextDirection = Literal["ltr", "rtl"]
# hOCR class constants for convenience
class OcrClass:
"""Constants for common OCR element classes."""
# Page-level
PAGE = "ocr_page"
CAREA = "ocr_carea"
# Block-level
PARAGRAPH = "ocr_par"
# Line-level
LINE = "ocr_line"
HEADER = "ocr_header"
FOOTER = "ocr_footer"
CAPTION = "ocr_caption"
TEXTFLOAT = "ocr_textfloat"
# Word-level
WORD = "ocrx_word"
# Character-level
CHAR = "ocrx_cinfo"
# Line types (for convenience)
LINE_TYPES = frozenset({LINE, HEADER, FOOTER, CAPTION, TEXTFLOAT})
+544
View File
@@ -0,0 +1,544 @@
# SPDX-FileCopyrightText: 2010 Jonathan Brinley
# SPDX-FileCopyrightText: 2013-2014 Julien Pfefferkorn
# SPDX-FileCopyrightText: 2023-2025 James R. Barlow
# SPDX-FileCopyrightText: 2025 Odin Dahlstr\u00f6m
# SPDX-License-Identifier: MIT
"""PDF text renderer for OcrElement structures.
This module provides functionality to render OcrElement trees to PDF files,
creating text layers that can be overlaid on scanned document images.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from itertools import pairwise
from math import atan, pi
from pathlib import Path
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
from ocrmypdf.hocrtransform.ocr_element import OcrClass, OcrElement
log = logging.getLogger(__name__)
INCH = 72.0
# CJK languages where word breaks should not be injected
CJK_LANGUAGES = frozenset({'chi_sim', 'chi_tra', 'jpn', 'kor'})
@dataclass
class DebugRenderOptions:
"""Options for debug visualization during rendering.
When enabled, these options draw colored boxes and lines to visualize
the OCR structure, which is helpful for debugging layout issues.
Attributes:
render_paragraph_bbox: Draw boxes around paragraphs (cyan)
render_baseline: Draw text baselines (magenta)
render_triangle: Draw direction triangles at word positions (red)
render_line_bbox: Draw boxes around lines (blue)
render_word_bbox: Draw boxes around words (green)
render_space_bbox: Draw boxes for inter-word spaces (dark green)
"""
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 PdfTextRenderer:
"""Renders OcrElement trees to PDF text layers.
This class takes an OcrElement tree (typically parsed from hOCR or
another OCR format) and renders it to a PDF file. The text is positioned
according to the bounding boxes in the OcrElement structure, allowing
it to be overlaid on scanned document images.
The renderer supports:
- Invisible text mode for selectable but hidden text
- Text direction (LTR and RTL)
- Baseline-aware positioning
- Text rotation (textangle)
- Word break injection for better PDF viewer segmentation
- Debug visualization options
"""
def __init__(
self,
*,
page: OcrElement,
dpi: float,
fontname: Name = Name("/f-0-0"),
font: Font | None = None,
debug_render_options: DebugRenderOptions | None = None,
):
"""Initialize the PDF text renderer.
Args:
page: The root OcrElement (should be ocr_page)
dpi: Resolution of the source image in dots per inch
fontname: PDF font name to use
font: Font implementation for encoding and metrics
debug_render_options: Options for debug visualization
"""
if page.ocr_class != OcrClass.PAGE:
raise ValueError(f"Expected ocr_page element, got {page.ocr_class}")
if page.bbox is None:
raise ValueError("Page element must have a bounding box")
self.page = page
self.dpi = dpi
self._fontname = fontname
self._font = font or GlyphlessFont()
self.render_options = debug_render_options or DebugRenderOptions()
# Calculate page size in PDF points (1/72 inch)
self.width = page.bbox.width / (self.dpi / INCH)
self.height = page.bbox.height / (self.dpi / INCH)
def render(
self,
*,
out_filename: Path,
image_filename: Path | None = None,
invisible_text: bool = True,
) -> None:
"""Render the OCR elements to a PDF file.
Creates a PDF file with text positioned according to the OcrElement
bounding boxes. Optionally overlays an image on top of the text.
Args:
out_filename: Path to write the PDF file
image_filename: Optional image to composite on top of text
invisible_text: If True, text is selectable but not visible.
If False, text is visible (useful for debugging).
"""
canvas = Canvas(page_size=(self.width, self.height))
canvas.add_font(self._fontname, self._font)
# Transform from hOCR pixel coordinates (top-left origin) to
# PDF coordinates (bottom-left origin)
page_matrix = (
Matrix()
.translated(0, self.height)
.scaled(1, -1)
.scaled(INCH / self.dpi, INCH / self.dpi)
)
log.debug("Page matrix: %s", page_matrix)
with canvas.do.save_state(cm=page_matrix):
self._render_debug_paragraph_boxes(canvas)
self._render_page_content(canvas, invisible_text)
# Overlay image if provided
if image_filename is not None:
canvas.do.draw_image(
image_filename, 0, 0, width=self.width, height=self.height
)
canvas.to_pdf().save(out_filename)
def _render_page_content(self, canvas: Canvas, invisible_text: bool) -> None:
"""Render all text content from the page.
Args:
canvas: The PDF canvas to render to
invisible_text: Whether text should be invisible
"""
found_lines = False
# Iterate through paragraphs and their lines
for paragraph in self.page.paragraphs:
direction = self._get_text_direction(paragraph)
inject_word_breaks = self._should_inject_word_breaks(paragraph)
for line in paragraph.lines:
found_lines = True
self._render_line(
canvas,
line,
invisible_text,
direction,
inject_word_breaks,
)
# Fallback: if no lines found in paragraphs, check for lines/words
# directly under page (some OCR output structures)
if not found_lines:
direction = self._get_text_direction(self.page)
inject_word_breaks = True
# Try to find lines directly under page
for line in self.page.lines:
found_lines = True
self._render_line(
canvas,
line,
invisible_text,
direction,
inject_word_breaks,
)
# If still no lines, render words directly
if not found_lines:
for word in self.page.words:
self._render_standalone_word(canvas, word, invisible_text)
def _get_text_direction(self, element: OcrElement) -> TextDirection:
"""Get the text direction for an element.
Args:
element: OcrElement to check
Returns:
TextDirection.LTR or TextDirection.RTL
"""
if element.direction == "rtl":
return TextDirection.RTL
return TextDirection.LTR
def _should_inject_word_breaks(self, element: OcrElement) -> bool:
"""Determine whether word breaks should be injected.
Word breaks are not injected for CJK languages where words are
typically one or two characters and separators are explicit.
Args:
element: OcrElement to check (typically a paragraph)
Returns:
True if word breaks should be injected
"""
language = element.language or ''
return language not in CJK_LANGUAGES
def _render_line(
self,
canvas: Canvas,
line: OcrElement,
invisible_text: bool,
text_direction: TextDirection,
inject_word_breaks: bool,
) -> None:
"""Render a line of text.
Args:
canvas: The PDF canvas (with page coordinate transform active)
line: The line element to render
invisible_text: Whether text should be invisible
text_direction: LTR or RTL text direction
inject_word_breaks: Whether to add spaces between words
"""
if line.bbox is None:
return
# Validate line bbox
if line.bbox.height <= 0:
log.error(
"line box is invalid so we cannot render it: box=%s text=%s",
line.bbox,
line.get_text_recursive(),
)
return
# Convert BoundingBox to Rectangle for pikepdf operations
line_min_aabb = Rectangle(
line.bbox.left,
line.bbox.top,
line.bbox.right,
line.bbox.bottom,
)
self._render_debug_line_bbox(canvas, line_min_aabb)
# Calculate the line's oriented bounding box transform
# The bbox from hOCR is the minimum AABB enclosing the rotated text
textangle = line.textangle or 0.0
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(-textangle)
)
line_size_aabb = line_size_aabb_matrix.inverse().transform(line_min_aabb)
# Get baseline information
slope = 0.0
intercept = 0.0
if line.baseline is not None:
slope = line.baseline.slope
intercept = line.baseline.intercept
if abs(slope) < 0.005:
slope = 0.0
slope_angle = atan(slope)
# Create the baseline transform matrix
# Translate from hOCR perspective (top-left) to PDF perspective (bottom-left)
baseline_matrix = (
line_size_aabb_matrix.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._render_debug_baseline(
canvas, baseline_matrix.inverse().transform(line_min_aabb), 0
)
canvas.do.fill_color(BLACK)
# Get words and render with inter-word spaces
words = line.children
for word, next_word in pairwise(words + [None]):
if word is not None:
self._render_word(
canvas,
baseline_matrix,
text,
fontsize,
word,
next_word,
text_direction,
inject_word_breaks,
)
canvas.do.draw_text(text)
def _render_word(
self,
canvas: Canvas,
line_matrix: Matrix,
text: Text,
fontsize: float,
word: OcrElement,
next_word: OcrElement | None,
text_direction: TextDirection,
inject_word_breaks: bool,
) -> None:
"""Render a single word.
Args:
canvas: The PDF canvas
line_matrix: Transform matrix for the line
text: Text object to add glyphs to
fontsize: Font size in points
word: The word element to render
next_word: The next word (for space calculation) or None
text_direction: LTR or RTL text direction
inject_word_breaks: Whether to add space after this word
"""
if word.bbox is None or not word.text:
return
# Convert to Rectangle for transform
hocr_box = Rectangle(
word.bbox.left, word.bbox.top, word.bbox.right, word.bbox.bottom
)
box = line_matrix.inverse().transform(hocr_box)
font_width = float(self._font.text_width(word.text, fontsize))
# Debug rendering
self._render_debug_word_triangle(canvas, box)
self._render_debug_word_bbox(canvas, box)
# Skip zero-width words
if font_width <= 0:
return
if text_direction == TextDirection.RTL:
log.info("RTL: %s", word.text)
# Position and scale the word
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(word.text))
# Render space to next word
if not inject_word_breaks or next_word is None or next_word.bbox is None:
return
next_hocr_box = Rectangle(
next_word.bbox.left,
next_word.bbox.top,
next_word.bbox.right,
next_word.bbox.bottom,
)
next_box = line_matrix.inverse().transform(next_hocr_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._render_debug_space_bbox(canvas, space_box)
space_width = float(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 _render_standalone_word(
self, canvas: Canvas, word: OcrElement, invisible_text: bool
) -> None:
"""Render a word that is not part of a line structure.
This is a fallback for OCR output that doesn't have line structure.
Args:
canvas: The PDF canvas
word: The word element to render
invisible_text: Whether text should be invisible
"""
if word.bbox is None or not word.text:
return
# Simple rendering without baseline adjustment
box = Rectangle(
word.bbox.left, word.bbox.top, word.bbox.right, word.bbox.bottom
)
fontsize = box.height
font_width = float(self._font.text_width(word.text, fontsize))
if font_width <= 0:
return
text = Text()
text.font(self._fontname, fontsize)
text.render_mode(3 if invisible_text else 0)
text.text_transform(Matrix(1, 0, 0, -1, box.llx, box.ury))
text.horiz_scale(100 * box.width / font_width)
text.show(self._font.text_encode(word.text))
canvas.do.fill_color(BLACK)
canvas.do.draw_text(text)
# Debug rendering methods
def _render_debug_paragraph_boxes(self, canvas: Canvas, color=CYAN) -> None:
"""Draw boxes around paragraphs."""
if not self.render_options.render_paragraph_bbox:
return
with canvas.do.save_state():
canvas.do.stroke_color(color).line_width(0.1)
for paragraph in self.page.paragraphs:
if paragraph.bbox is None:
continue
if not paragraph.get_text_recursive():
continue
canvas.do.rect(
paragraph.bbox.left,
paragraph.bbox.top,
paragraph.bbox.width,
paragraph.bbox.height,
fill=False,
)
def _render_debug_line_bbox(
self, canvas: Canvas, line_box: Rectangle, color=BLUE
) -> None:
"""Render the bounding box of a text line."""
if not self.render_options.render_line_bbox:
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 _render_debug_word_triangle(
self, canvas: Canvas, box: Rectangle, color=RED, line_width=0.1
) -> None:
"""Render a triangle that conveys word height and direction."""
if not self.render_options.render_triangle:
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 _render_debug_word_bbox(
self, canvas: Canvas, box: Rectangle, color=GREEN, line_width=0.1
) -> None:
"""Render a box depicting the word."""
if not self.render_options.render_word_bbox:
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 _render_debug_space_bbox(
self, canvas: Canvas, box: Rectangle, color=DARKGREEN, line_width=0.1
) -> None:
"""Render a box depicting the space between words."""
if not self.render_options.render_space_bbox:
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 _render_debug_baseline(
self,
canvas: Canvas,
line_box: Rectangle,
baseline_lly: float,
color=MAGENTA,
line_width=0.25,
) -> None:
"""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,
)