Integrate fpdf2 renderer and remove legacy hOCR renderer
- Update pipeline to use fpdf2 renderer as default - Remove legacy hocrtransform PDF renderer (_font.py, _hocr.py, pdf_renderer.py) - Update CLI and options for fpdf2 renderer - Add fpdf2 dependency to pyproject.toml - Update graft module for fpdf2 multi-page rendering
This commit is contained in:
+427
-190
@@ -7,30 +7,150 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ocrmypdf.hocrtransform import OcrElement
|
||||
|
||||
from pikepdf import (
|
||||
Dictionary,
|
||||
Matrix,
|
||||
Name,
|
||||
Operator,
|
||||
Page,
|
||||
Pdf,
|
||||
PdfError,
|
||||
Stream,
|
||||
parse_content_stream,
|
||||
unparse_content_stream,
|
||||
)
|
||||
|
||||
from ocrmypdf._jobcontext import PdfContext
|
||||
from ocrmypdf._pipeline import VECTOR_PAGE_DPI
|
||||
|
||||
|
||||
class RenderMode(Enum):
|
||||
"""Controls where the OCR text layer is placed relative to page content.
|
||||
|
||||
ON_TOP: Text layer renders above page content (reserved for future use).
|
||||
UNDERNEATH: Text layer renders below page content (current default behavior).
|
||||
"""
|
||||
|
||||
ON_TOP = 0
|
||||
UNDERNEATH = 1
|
||||
|
||||
|
||||
@dataclass
|
||||
class Fpdf2PageInfo:
|
||||
"""Information needed to render and graft an fpdf2 page."""
|
||||
|
||||
pageno: int
|
||||
hocr_path: Path
|
||||
dpi: float
|
||||
autorotate_correction: int
|
||||
emplaced_page: bool
|
||||
|
||||
|
||||
@dataclass
|
||||
class Fpdf2ParsedPage:
|
||||
"""Parsed page data ready for fpdf2 rendering."""
|
||||
|
||||
pageno: int
|
||||
ocr_tree: OcrElement
|
||||
dpi: float
|
||||
autorotate_correction: int
|
||||
emplaced_page: bool
|
||||
|
||||
|
||||
def _compute_text_misalignment(
|
||||
content_rotation: int, autorotate_correction: int, emplaced_page: bool
|
||||
) -> int:
|
||||
"""Compute rotation needed to align text layer with page content.
|
||||
|
||||
Args:
|
||||
content_rotation: Original page /Rotate value (degrees).
|
||||
autorotate_correction: Rotation applied during rasterization (degrees).
|
||||
emplaced_page: Whether the page content was replaced with rasterized image.
|
||||
|
||||
Returns:
|
||||
Rotation in degrees to apply to text layer to align with content.
|
||||
"""
|
||||
if emplaced_page:
|
||||
# New image is upright after autorotation was applied
|
||||
content_rotation = autorotate_correction
|
||||
text_rotation = autorotate_correction
|
||||
return (text_rotation - content_rotation) % 360
|
||||
|
||||
|
||||
def _compute_page_rotation(
|
||||
content_rotation: int, autorotate_correction: int, emplaced_page: bool
|
||||
) -> int:
|
||||
"""Compute final page /Rotate value after grafting.
|
||||
|
||||
Args:
|
||||
content_rotation: Original page /Rotate value (degrees).
|
||||
autorotate_correction: Rotation applied during rasterization (degrees).
|
||||
emplaced_page: Whether the page content was replaced with rasterized image.
|
||||
|
||||
Returns:
|
||||
Final /Rotate value for the page.
|
||||
"""
|
||||
if emplaced_page:
|
||||
content_rotation = autorotate_correction
|
||||
return (content_rotation - autorotate_correction) % 360
|
||||
|
||||
|
||||
def _build_text_layer_ctm(
|
||||
text_width: float,
|
||||
text_height: float,
|
||||
page_width: float,
|
||||
page_height: float,
|
||||
page_origin_x: float,
|
||||
page_origin_y: float,
|
||||
text_rotation: int,
|
||||
):
|
||||
"""Build transformation matrix to align text layer with page content.
|
||||
|
||||
Args:
|
||||
text_width: Width of text layer mediabox.
|
||||
text_height: Height of text layer mediabox.
|
||||
page_width: Width of target page mediabox.
|
||||
page_height: Height of target page mediabox.
|
||||
page_origin_x: X origin of target page mediabox.
|
||||
page_origin_y: Y origin of target page mediabox.
|
||||
text_rotation: Rotation in degrees (clockwise) to apply to text layer.
|
||||
|
||||
Returns:
|
||||
pikepdf.Matrix transformation matrix, or None if no rotation needed.
|
||||
"""
|
||||
if text_rotation == 0:
|
||||
return None
|
||||
|
||||
from pikepdf import Matrix
|
||||
|
||||
wt, ht = text_width, text_height
|
||||
|
||||
# Center text, rotate, scale to fit page, then position at page origin
|
||||
translate = Matrix().translated(-wt / 2, -ht / 2)
|
||||
untranslate = Matrix().translated(page_width / 2, page_height / 2)
|
||||
corner = Matrix().translated(page_origin_x, page_origin_y)
|
||||
|
||||
# Negate rotation because input is clockwise angle
|
||||
rotate = Matrix().rotated(-text_rotation % 360)
|
||||
|
||||
# Swap dimensions if 90 or 270 degree rotation
|
||||
if text_rotation in (90, 270):
|
||||
wt, ht = ht, wt
|
||||
|
||||
# Scale to fit page dimensions
|
||||
scale_x = page_width / wt if wt else 1.0
|
||||
scale_y = page_height / ht if ht else 1.0
|
||||
scale = Matrix().scaled(scale_x, scale_y)
|
||||
|
||||
return translate @ rotate @ scale @ untranslate @ corner
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
MAX_REPLACE_PAGES = 100
|
||||
|
||||
@@ -41,22 +161,6 @@ def _ensure_dictionary(obj: Dictionary | Stream, name: Name):
|
||||
return obj[name]
|
||||
|
||||
|
||||
def _update_resources(
|
||||
*,
|
||||
obj: Dictionary | Stream,
|
||||
font: Dictionary | None,
|
||||
font_key: Name | None,
|
||||
):
|
||||
"""Update this obj's fonts with a reference to the Glyphless font.
|
||||
|
||||
obj can be a page or Form XObject.
|
||||
"""
|
||||
resources = _ensure_dictionary(obj, Name.Resources)
|
||||
fonts = _ensure_dictionary(resources, Name.Font)
|
||||
if font_key is not None and font_key not in fonts:
|
||||
fonts[font_key] = font
|
||||
|
||||
|
||||
def strip_invisible_text(pdf: Pdf, page: Page):
|
||||
stream = []
|
||||
in_text_obj = False
|
||||
@@ -105,27 +209,38 @@ class OcrGrafter:
|
||||
self.path_base = context.origin
|
||||
|
||||
self.pdf_base = Pdf.open(self.path_base)
|
||||
self.font: Dictionary | None = None
|
||||
self.font_key: Name | None = None
|
||||
|
||||
self.pdfinfo = context.pdfinfo
|
||||
self.output_file = context.get_path('graft_layers.pdf')
|
||||
|
||||
self.emplacements = 1
|
||||
self.interim_count = 0
|
||||
self.render_mode = RenderMode.UNDERNEATH
|
||||
|
||||
# Check renderer type
|
||||
pdf_renderer = context.options.pdf_renderer
|
||||
self.use_sandwich_renderer = pdf_renderer == 'sandwich'
|
||||
|
||||
# For fpdf2: accumulate pages before rendering
|
||||
self.fpdf2_renderer_pages: list[Fpdf2PageInfo] = []
|
||||
|
||||
def graft_page(
|
||||
self,
|
||||
*,
|
||||
pageno: int,
|
||||
image: Path | None,
|
||||
textpdf: Path | None,
|
||||
ocr_output: Path | None,
|
||||
autorotate_correction: int,
|
||||
):
|
||||
if textpdf and not self.font:
|
||||
self.font, self.font_key = self._find_font(textpdf)
|
||||
"""Graft OCR output onto a page of the base PDF.
|
||||
|
||||
Args:
|
||||
pageno: Zero-based page number.
|
||||
image: Path to the visible page image PDF, or None if not replacing.
|
||||
ocr_output: Path to OCR output file. For fpdf2 renderer this is an
|
||||
hOCR file; for sandwich renderer this is a text-only PDF.
|
||||
autorotate_correction: Orientation correction in degrees (0, 90, 180, 270).
|
||||
"""
|
||||
# Handle image emplacement first
|
||||
emplaced_page = False
|
||||
content_rotation = self.pdfinfo[pageno].rotation
|
||||
path_image = Path(image).resolve() if image else None
|
||||
@@ -144,195 +259,317 @@ class OcrGrafter:
|
||||
del self.pdf_base.pages[-1]
|
||||
emplaced_page = True
|
||||
|
||||
# Calculate if the text is misaligned compared to the content
|
||||
if emplaced_page:
|
||||
content_rotation = autorotate_correction
|
||||
text_rotation = autorotate_correction
|
||||
text_misaligned = (text_rotation - content_rotation) % 360
|
||||
log.debug(
|
||||
f"Text rotation: (text, autorotate, content) -> text misalignment = "
|
||||
f"({text_rotation}, {autorotate_correction}, {content_rotation}) -> "
|
||||
f"{text_misaligned}"
|
||||
)
|
||||
|
||||
if textpdf and self.font:
|
||||
if self.font_key is None:
|
||||
raise ValueError("Font key is not set")
|
||||
# Graft the text layer onto this page, whether new or old, possibly
|
||||
# rotating the text layer by the amount is misaligned.
|
||||
strip_old = self.context.options.redo_ocr
|
||||
self._graft_text_layer(
|
||||
page_num=pageno + 1,
|
||||
textpdf=textpdf,
|
||||
font=self.font,
|
||||
font_key=self.font_key,
|
||||
text_rotation=text_misaligned,
|
||||
strip_old_text=strip_old,
|
||||
)
|
||||
|
||||
# Correct the overall page rotation if needed, now that the text and content
|
||||
# are aligned
|
||||
page_rotation = (content_rotation - autorotate_correction) % 360
|
||||
self.pdf_base.pages[pageno].Rotate = page_rotation
|
||||
log.debug(
|
||||
f"Page rotation: (content, auto) -> page = "
|
||||
f"({content_rotation}, {autorotate_correction}) -> {page_rotation}"
|
||||
)
|
||||
if self.emplacements % MAX_REPLACE_PAGES == 0:
|
||||
self.save_and_reload()
|
||||
|
||||
def save_and_reload(self) -> None:
|
||||
"""Save and reload the Pdf.
|
||||
|
||||
This will keep a lid on our memory usage for very large files. Attach
|
||||
the font to page 1 even if page 1 doesn't use it, so we have a way to get it
|
||||
back.
|
||||
"""
|
||||
page0 = self.pdf_base.pages[0]
|
||||
_update_resources(obj=page0.obj, font=self.font, font_key=self.font_key)
|
||||
|
||||
# We cannot read and write the same file, that will corrupt it
|
||||
# but we don't to keep more copies than we need to. Delete intermediates.
|
||||
# {interim_count} is the opened file we were updating
|
||||
# {interim_count - 1} can be deleted
|
||||
# {interim_count + 1} is the new file will produce and open
|
||||
old_file = self.output_file.with_suffix(f'.working{self.interim_count - 1}.pdf')
|
||||
if not self.context.options.keep_temporary_files:
|
||||
with suppress(FileNotFoundError):
|
||||
old_file.unlink()
|
||||
|
||||
next_file = self.output_file.with_suffix(
|
||||
f'.working{self.interim_count + 1}.pdf'
|
||||
)
|
||||
self.pdf_base.save(next_file)
|
||||
self.pdf_base.close()
|
||||
|
||||
self.pdf_base = Pdf.open(next_file)
|
||||
self.font, self.font_key = None, None # Ensure we reacquire this information
|
||||
self.interim_count += 1
|
||||
if self.use_sandwich_renderer:
|
||||
# Sandwich renderer: graft pre-rendered PDF immediately
|
||||
if ocr_output:
|
||||
text_misaligned = _compute_text_misalignment(
|
||||
content_rotation, autorotate_correction, emplaced_page
|
||||
)
|
||||
self._graft_sandwich_text_layer(
|
||||
pageno=pageno,
|
||||
textpdf=ocr_output,
|
||||
text_rotation=text_misaligned,
|
||||
)
|
||||
page_rotation = _compute_page_rotation(
|
||||
content_rotation, autorotate_correction, emplaced_page
|
||||
)
|
||||
self.pdf_base.pages[pageno].Rotate = page_rotation
|
||||
else:
|
||||
# fpdf2 renderer: accumulate page info for batch rendering.
|
||||
# The hOCR coordinates are in the corrected (upright) coordinate system.
|
||||
# We store autorotate_correction and emplaced_page to set the final
|
||||
# page /Rotate tag after grafting.
|
||||
if ocr_output:
|
||||
dpi = self.pdfinfo[pageno].dpi.to_scalar()
|
||||
self.fpdf2_renderer_pages.append(
|
||||
Fpdf2PageInfo(
|
||||
pageno=pageno,
|
||||
hocr_path=ocr_output,
|
||||
dpi=dpi,
|
||||
autorotate_correction=autorotate_correction,
|
||||
emplaced_page=emplaced_page,
|
||||
)
|
||||
)
|
||||
|
||||
def finalize(self):
|
||||
if self.fpdf2_renderer_pages:
|
||||
# Render all pages with fpdf2, then graft
|
||||
self._render_and_graft_fpdf2_pages()
|
||||
|
||||
self.pdf_base.save(self.output_file)
|
||||
self.pdf_base.close()
|
||||
return self.output_file
|
||||
|
||||
def _find_font(self, text: Path) -> tuple[Dictionary | None, Name | None]:
|
||||
"""Copy a font from the filename text into pdf_base."""
|
||||
font, font_key = None, None
|
||||
possible_font_names = ('/f-0-0', '/F1')
|
||||
try:
|
||||
with Pdf.open(text) as pdf_text:
|
||||
try:
|
||||
pdf_text_fonts = pdf_text.pages[0].Resources.get(
|
||||
Name.Font, Dictionary()
|
||||
)
|
||||
except (AttributeError, IndexError, KeyError):
|
||||
return None, None
|
||||
if not isinstance(pdf_text_fonts, Dictionary):
|
||||
log.warning("Page fonts are not stored in a dictionary")
|
||||
return None, None
|
||||
pdf_text_font = None
|
||||
for f in possible_font_names:
|
||||
pdf_text_font = pdf_text_fonts.get(f, None)
|
||||
if pdf_text_font is not None:
|
||||
font_key = Name(f)
|
||||
break
|
||||
if pdf_text_font:
|
||||
font = self.pdf_base.copy_foreign(pdf_text_font)
|
||||
if not isinstance(font, Dictionary):
|
||||
log.warning("Font is not a dictionary")
|
||||
font, font_key = None, None
|
||||
return font, font_key
|
||||
except (FileNotFoundError, PdfError):
|
||||
# PdfError occurs if a 0-length file is written e.g. due to OCR timeout
|
||||
return None, None
|
||||
def _render_and_graft_fpdf2_pages(self):
|
||||
"""Render all pages to multi-page PDF with shared fonts, then graft."""
|
||||
from ocrmypdf.hocrtransform.hocr_parser import HocrParser
|
||||
|
||||
def _graft_text_layer(
|
||||
log.info(
|
||||
"Rendering %d pages with fpdf2",
|
||||
len(self.fpdf2_renderer_pages),
|
||||
)
|
||||
|
||||
font_dir = Path(__file__).parent / "data"
|
||||
|
||||
# Parse all hOCR files and collect OcrElements
|
||||
pages_data: list[Fpdf2ParsedPage] = []
|
||||
for page_info in self.fpdf2_renderer_pages:
|
||||
if page_info.hocr_path.stat().st_size == 0:
|
||||
continue # Skip empty pages
|
||||
|
||||
# Parse hOCR to OcrElement
|
||||
parser = HocrParser(page_info.hocr_path)
|
||||
ocr_tree = parser.parse()
|
||||
|
||||
# Use DPI from hOCR (scan_res) which reflects actual rasterization DPI.
|
||||
# Fall back to pdfinfo DPI or VECTOR_PAGE_DPI for vector-only pages.
|
||||
effective_dpi = ocr_tree.dpi or page_info.dpi or float(VECTOR_PAGE_DPI)
|
||||
pages_data.append(
|
||||
Fpdf2ParsedPage(
|
||||
pageno=page_info.pageno,
|
||||
ocr_tree=ocr_tree,
|
||||
dpi=effective_dpi,
|
||||
autorotate_correction=page_info.autorotate_correction,
|
||||
emplaced_page=page_info.emplaced_page,
|
||||
)
|
||||
)
|
||||
|
||||
if not pages_data:
|
||||
return # No pages to render
|
||||
|
||||
# Render all pages to single PDF
|
||||
multi_page_pdf_path = self.context.get_path('fpdf2_multipage.pdf')
|
||||
|
||||
from ocrmypdf.font import MultiFontManager
|
||||
from ocrmypdf.fpdf_renderer import Fpdf2MultiPageRenderer
|
||||
|
||||
multi_font_manager = MultiFontManager(font_dir)
|
||||
# Build renderer input as (pageno, ocr_tree, dpi) tuples
|
||||
renderer_pages_data = [
|
||||
(parsed.pageno, parsed.ocr_tree, parsed.dpi) for parsed in pages_data
|
||||
]
|
||||
renderer = Fpdf2MultiPageRenderer(
|
||||
pages_data=renderer_pages_data,
|
||||
multi_font_manager=multi_font_manager,
|
||||
invisible_text=True,
|
||||
)
|
||||
|
||||
renderer.render(multi_page_pdf_path)
|
||||
|
||||
# Now graft each page from the multi-page PDF
|
||||
with Pdf.open(multi_page_pdf_path) as pdf_text:
|
||||
for idx, parsed in enumerate(pages_data):
|
||||
# Copy page from multi-page PDF
|
||||
text_page = pdf_text.pages[idx]
|
||||
|
||||
content_rotation = self.pdfinfo[parsed.pageno].rotation
|
||||
text_misaligned = _compute_text_misalignment(
|
||||
content_rotation,
|
||||
parsed.autorotate_correction,
|
||||
parsed.emplaced_page,
|
||||
)
|
||||
self._graft_fpdf2_text_layer(
|
||||
parsed.pageno, text_page, text_misaligned
|
||||
)
|
||||
|
||||
page_rotation = _compute_page_rotation(
|
||||
content_rotation,
|
||||
parsed.autorotate_correction,
|
||||
parsed.emplaced_page,
|
||||
)
|
||||
self.pdf_base.pages[parsed.pageno].Rotate = page_rotation
|
||||
|
||||
# Clean up multi-page PDF if not keeping temp files
|
||||
if not self.context.options.keep_temporary_files:
|
||||
with suppress(FileNotFoundError):
|
||||
multi_page_pdf_path.unlink()
|
||||
|
||||
def _graft_fpdf2_text_layer(
|
||||
self, pageno: int, text_page: Page, text_rotation: int
|
||||
):
|
||||
"""Graft a single text page onto the base PDF.
|
||||
|
||||
Similar to existing _graft_text_layer but works with
|
||||
already-rendered pikepdf Page instead of file path.
|
||||
|
||||
Args:
|
||||
pageno: Zero-based page number.
|
||||
text_page: The text-only PDF page to graft.
|
||||
text_rotation: Rotation to apply to align text with content (degrees).
|
||||
"""
|
||||
from pikepdf import Array
|
||||
|
||||
base_page = self.pdf_base.pages[pageno]
|
||||
|
||||
# Extract content stream from text_page
|
||||
text_contents = text_page.Contents.read_bytes()
|
||||
|
||||
# Get the mediabox from the text page
|
||||
mediabox = Array([float(x) for x in text_page.mediabox]) # type: ignore[misc]
|
||||
wt = float(mediabox[2]) - float(mediabox[0])
|
||||
ht = float(mediabox[3]) - float(mediabox[1])
|
||||
|
||||
# Get base page mediabox
|
||||
base_mediabox = base_page.mediabox
|
||||
wp = float(base_mediabox[2]) - float(base_mediabox[0])
|
||||
hp = float(base_mediabox[3]) - float(base_mediabox[1])
|
||||
|
||||
# Create Form XObject from text page content
|
||||
base_resources = _ensure_dictionary(base_page.obj, Name.Resources)
|
||||
base_xobjs = _ensure_dictionary(base_resources, Name.XObject)
|
||||
text_xobj_name = Name.random(prefix="OCR-")
|
||||
xobj = self.pdf_base.make_stream(text_contents)
|
||||
base_xobjs[text_xobj_name] = xobj
|
||||
xobj.Type = Name.XObject
|
||||
xobj.Subtype = Name.Form
|
||||
xobj.FormType = 1
|
||||
xobj.BBox = mediabox
|
||||
|
||||
# Copy resources from text page's Resources to xobj
|
||||
# We need to handle this carefully since text_page is from a foreign PDF
|
||||
if hasattr(text_page, 'Resources') and text_page.Resources:
|
||||
# Create empty Resources dictionary for xobj
|
||||
xobj_resources = _ensure_dictionary(xobj, Name.Resources)
|
||||
|
||||
# Copy fonts if they exist
|
||||
if Name.Font in text_page.Resources:
|
||||
xobj_fonts = _ensure_dictionary(xobj_resources, Name.Font)
|
||||
text_fonts = text_page.Resources[Name.Font]
|
||||
# Copy each font from the foreign PDF
|
||||
for font_name, font_obj in text_fonts.items():
|
||||
xobj_fonts[font_name] = self.pdf_base.copy_foreign(font_obj)
|
||||
|
||||
# Copy ExtGState (graphics state) if it exists - needed for transparency
|
||||
if Name.ExtGState in text_page.Resources:
|
||||
xobj_extstates = _ensure_dictionary(xobj_resources, Name.ExtGState)
|
||||
text_extstates = text_page.Resources[Name.ExtGState]
|
||||
# Copy each graphics state from the foreign PDF
|
||||
for gs_name, gs_obj in text_extstates.items():
|
||||
xobj_extstates[gs_name] = self.pdf_base.copy_foreign(gs_obj)
|
||||
|
||||
# Build transformation matrix for rotation and scaling
|
||||
ctm = _build_text_layer_ctm(
|
||||
wt, ht, wp, hp, float(base_mediabox[0]), float(base_mediabox[1]),
|
||||
text_rotation
|
||||
)
|
||||
if ctm is not None:
|
||||
pdf_draw_xobj = (
|
||||
(b'q %s cm\n' % ctm.encode())
|
||||
+ (b'%s Do\n' % text_xobj_name)
|
||||
+ b'Q\n'
|
||||
)
|
||||
else:
|
||||
pdf_draw_xobj = b'q\n' + (b'%s Do\n' % text_xobj_name) + b'\nQ\n'
|
||||
|
||||
new_text_layer = Stream(self.pdf_base, pdf_draw_xobj)
|
||||
|
||||
# Strip old invisible text if redo_ocr is enabled
|
||||
if self.context.options.redo_ocr:
|
||||
strip_invisible_text(self.pdf_base, base_page)
|
||||
|
||||
# Add text layer to base page
|
||||
base_page.contents_coalesce()
|
||||
base_page.contents_add(
|
||||
new_text_layer, prepend=self.render_mode == RenderMode.UNDERNEATH
|
||||
)
|
||||
base_page.contents_coalesce()
|
||||
|
||||
def _graft_sandwich_text_layer(
|
||||
self,
|
||||
*,
|
||||
page_num: int,
|
||||
pageno: int,
|
||||
textpdf: Path,
|
||||
font: Dictionary,
|
||||
font_key: Name,
|
||||
text_rotation: int,
|
||||
strip_old_text: bool,
|
||||
):
|
||||
"""Insert the text layer from text page 0 on to pdf_base at page_num."""
|
||||
# pylint: disable=invalid-name
|
||||
"""Graft a pre-rendered text-only PDF onto the base PDF.
|
||||
|
||||
log.debug("Grafting")
|
||||
This is used by the sandwich renderer which generates PDFs directly
|
||||
from Tesseract rather than going through hOCR.
|
||||
"""
|
||||
from pikepdf import PdfError
|
||||
|
||||
log.debug("Grafting sandwich text layer")
|
||||
if Path(textpdf).stat().st_size == 0:
|
||||
return
|
||||
|
||||
# This is a pointer indicating a specific page in the base file
|
||||
with Pdf.open(textpdf) as pdf_text:
|
||||
pdf_text_contents = pdf_text.pages[0].Contents.read_bytes()
|
||||
try:
|
||||
with Pdf.open(textpdf) as pdf_text:
|
||||
pdf_text_contents = pdf_text.pages[0].Contents.read_bytes()
|
||||
|
||||
base_page = self.pdf_base.pages.p(page_num)
|
||||
base_page = self.pdf_base.pages[pageno]
|
||||
|
||||
# The text page always will be oriented up by this stage but the original
|
||||
# content may have a rotation applied. Wrap the text stream with a rotation
|
||||
# so it will be oriented the same way as the rest of the page content.
|
||||
# (Previous versions OCRmyPDF rotated the content layer to match the text.)
|
||||
mediabox = pdf_text.pages[0].mediabox
|
||||
wt, ht = mediabox[2] - mediabox[0], mediabox[3] - mediabox[1]
|
||||
# Get font from the text PDF
|
||||
pdf_text_fonts = pdf_text.pages[0].Resources.get(
|
||||
Name.Font, Dictionary()
|
||||
)
|
||||
font = None
|
||||
font_key = None
|
||||
for f in ('/f-0-0', '/F1'):
|
||||
pdf_text_font = pdf_text_fonts.get(f, None)
|
||||
if pdf_text_font is not None:
|
||||
font_key = Name(f)
|
||||
font = self.pdf_base.copy_foreign(pdf_text_font)
|
||||
break
|
||||
|
||||
mediabox = base_page.mediabox
|
||||
wp, hp = mediabox[2] - mediabox[0], mediabox[3] - mediabox[1]
|
||||
# Get mediabox dimensions for rotation calculations
|
||||
mediabox = pdf_text.pages[0].mediabox
|
||||
wt = float(mediabox[2]) - float(mediabox[0])
|
||||
ht = float(mediabox[3]) - float(mediabox[1])
|
||||
|
||||
translate = Matrix().translated(-wt / 2, -ht / 2)
|
||||
untranslate = Matrix().translated(wp / 2, hp / 2)
|
||||
corner = Matrix().translated(mediabox[0], mediabox[1])
|
||||
# -rotation because the input is a clockwise angle and this formula
|
||||
# uses CCW
|
||||
text_rotation = -text_rotation % 360
|
||||
rotate = Matrix().rotated(text_rotation)
|
||||
base_mediabox = base_page.mediabox
|
||||
wp = float(base_mediabox[2]) - float(base_mediabox[0])
|
||||
hp = float(base_mediabox[3]) - float(base_mediabox[1])
|
||||
|
||||
# Because of rounding of DPI, we might get a text layer that is not
|
||||
# identically sized to the target page. Scale to adjust. Normally this
|
||||
# is within 0.998.
|
||||
if text_rotation in (90, 270):
|
||||
wt, ht = ht, wt
|
||||
scale_x = wp / wt
|
||||
scale_y = hp / ht
|
||||
# Build transformation matrix for rotation and scaling
|
||||
ctm = _build_text_layer_ctm(
|
||||
wt, ht, wp, hp,
|
||||
float(base_mediabox[0]), float(base_mediabox[1]),
|
||||
text_rotation
|
||||
)
|
||||
log.debug("Grafting with ctm %r", ctm)
|
||||
|
||||
# log.debug('%r', scale_x, scale_y)
|
||||
scale = Matrix().scaled(scale_x, scale_y)
|
||||
# Create Form XObject
|
||||
base_resources = _ensure_dictionary(base_page.obj, Name.Resources)
|
||||
base_xobjs = _ensure_dictionary(base_resources, Name.XObject)
|
||||
text_xobj_name = Name.random(prefix="OCR-")
|
||||
xobj = self.pdf_base.make_stream(pdf_text_contents)
|
||||
base_xobjs[text_xobj_name] = xobj
|
||||
xobj.Type = Name.XObject
|
||||
xobj.Subtype = Name.Form
|
||||
xobj.FormType = 1
|
||||
xobj.BBox = base_mediabox
|
||||
|
||||
# Translate the text so it is centered at (0, 0), rotate it there, adjust
|
||||
# for a size different between initial and text PDF, then untranslate, and
|
||||
# finally move the lower left corner to match the mediabox.
|
||||
ctm = translate @ rotate @ scale @ untranslate @ corner
|
||||
log.debug("Grafting with ctm %r", ctm)
|
||||
# Add font to xobj resources
|
||||
if font_key is not None and font is not None:
|
||||
xobj_resources = _ensure_dictionary(xobj, Name.Resources)
|
||||
xobj_fonts = _ensure_dictionary(xobj_resources, Name.Font)
|
||||
if font_key not in xobj_fonts:
|
||||
xobj_fonts[font_key] = font
|
||||
|
||||
base_resources = _ensure_dictionary(base_page.obj, Name.Resources)
|
||||
base_xobjs = _ensure_dictionary(base_resources, Name.XObject)
|
||||
text_xobj_name = Name.random(prefix="OCR-")
|
||||
xobj = self.pdf_base.make_stream(pdf_text_contents)
|
||||
base_xobjs[text_xobj_name] = xobj
|
||||
xobj.Type = Name.XObject
|
||||
xobj.Subtype = Name.Form
|
||||
xobj.FormType = 1
|
||||
xobj.BBox = mediabox
|
||||
_update_resources(obj=xobj, font=font, font_key=font_key)
|
||||
if ctm is not None:
|
||||
pdf_draw_xobj = (
|
||||
(b'q %s cm\n' % ctm.encode())
|
||||
+ (b'%s Do\n' % text_xobj_name)
|
||||
+ b'\nQ\n'
|
||||
)
|
||||
else:
|
||||
pdf_draw_xobj = b'q\n' + (b'%s Do\n' % text_xobj_name) + b'\nQ\n'
|
||||
new_text_layer = Stream(self.pdf_base, pdf_draw_xobj)
|
||||
|
||||
pdf_draw_xobj = (
|
||||
(b'q %s cm\n' % ctm.encode()) + (b'%s Do\n' % text_xobj_name) + b'\nQ\n'
|
||||
)
|
||||
new_text_layer = Stream(self.pdf_base, pdf_draw_xobj)
|
||||
if self.context.options.redo_ocr:
|
||||
strip_invisible_text(self.pdf_base, base_page)
|
||||
base_page.contents_coalesce()
|
||||
base_page.contents_add(
|
||||
new_text_layer, prepend=self.render_mode == RenderMode.UNDERNEATH
|
||||
)
|
||||
base_page.contents_coalesce()
|
||||
|
||||
if strip_old_text:
|
||||
strip_invisible_text(self.pdf_base, base_page)
|
||||
base_page.contents_coalesce()
|
||||
if self.render_mode == RenderMode.ON_TOP:
|
||||
# Add q/Q to ensure content we append is drawn correctly
|
||||
# Strictly speaking this needs to trace the whole q/Q stack in case
|
||||
# stack is not balanced.
|
||||
original = base_page.Contents.read_bytes()
|
||||
base_page.Contents.write(b'q\n' + original + b'\nQ\n')
|
||||
base_page.contents_add(
|
||||
new_text_layer, prepend=self.render_mode == RenderMode.UNDERNEATH
|
||||
)
|
||||
base_page.contents_coalesce()
|
||||
|
||||
_update_resources(obj=base_page.obj, font=font, font_key=font_key)
|
||||
# Add font to page resources
|
||||
if font_key is not None and font is not None:
|
||||
page_resources = _ensure_dictionary(base_page.obj, Name.Resources)
|
||||
page_fonts = _ensure_dictionary(page_resources, Name.Font)
|
||||
if font_key not in page_fonts:
|
||||
page_fonts[font_key] = font
|
||||
except (FileNotFoundError, PdfError):
|
||||
# PdfError occurs if a 0-length file is written e.g. due to OCR timeout
|
||||
pass
|
||||
|
||||
@@ -199,9 +199,12 @@ class OCROptions(BaseModel):
|
||||
@classmethod
|
||||
def validate_pdf_renderer(cls, v):
|
||||
"""Validate PDF renderer is one of the allowed values."""
|
||||
valid_renderers = {'auto', 'hocr', 'sandwich', 'hocrdebug'}
|
||||
if v not in valid_renderers:
|
||||
raise ValueError(f"pdf_renderer must be one of {valid_renderers}")
|
||||
valid_renderers = {'auto', 'sandwich', 'fpdf2'}
|
||||
# Legacy hocr/hocrdebug are accepted but redirected to fpdf2
|
||||
legacy_renderers = {'hocr', 'hocrdebug'}
|
||||
all_accepted = valid_renderers | legacy_renderers
|
||||
if v not in all_accepted:
|
||||
raise ValueError(f"pdf_renderer must be one of {all_accepted}")
|
||||
return v
|
||||
|
||||
@field_validator('rasterizer')
|
||||
|
||||
@@ -36,8 +36,6 @@ from ocrmypdf.exceptions import (
|
||||
UnsupportedImageFormatError,
|
||||
)
|
||||
from ocrmypdf.helpers import IMG2PDF_KWARGS, Resolution, safe_symlink
|
||||
from ocrmypdf.hocrtransform import DebugRenderOptions, HocrTransform
|
||||
from ocrmypdf.hocrtransform._font import Courier
|
||||
from ocrmypdf.pdfa import generate_pdfa_ps
|
||||
from ocrmypdf.pdfinfo import Colorspace, Encoding, FloatRect, PageInfo, PdfInfo
|
||||
from ocrmypdf.pluginspec import OrientationConfidence
|
||||
@@ -774,41 +772,6 @@ def create_pdf_page_from_image(
|
||||
return output_file
|
||||
|
||||
|
||||
def render_hocr_page(hocr: Path, page_context: PageContext) -> Path:
|
||||
"""Render the hOCR page to a PDF."""
|
||||
options = page_context.options
|
||||
output_file = page_context.get_path('ocr_hocr.pdf')
|
||||
if hocr.stat().st_size == 0:
|
||||
# If hOCR file is empty (skipped page marker), create an empty PDF file
|
||||
output_file.touch()
|
||||
return output_file
|
||||
|
||||
dpi = get_page_square_dpi(page_context, calculate_image_dpi(page_context))
|
||||
debug_kwargs = {}
|
||||
if options.pdf_renderer == 'hocrdebug':
|
||||
debug_kwargs = dict(
|
||||
debug_render_options=DebugRenderOptions(
|
||||
render_baseline=True,
|
||||
render_triangle=True,
|
||||
render_line_bbox=False,
|
||||
render_word_bbox=True,
|
||||
render_paragraph_bbox=False,
|
||||
render_space_bbox=False,
|
||||
),
|
||||
font=Courier(),
|
||||
)
|
||||
HocrTransform(
|
||||
hocr_filename=hocr,
|
||||
dpi=dpi.to_scalar(),
|
||||
**debug_kwargs, # square
|
||||
).to_pdf(
|
||||
out_filename=output_file,
|
||||
image_filename=None,
|
||||
invisible_text=True if not debug_kwargs else False,
|
||||
)
|
||||
return output_file
|
||||
|
||||
|
||||
def ocr_engine_textonly_pdf(
|
||||
input_image: Path, page_context: PageContext
|
||||
) -> tuple[Path, Path]:
|
||||
|
||||
@@ -17,10 +17,7 @@ from ocrmypdf._concurrent import Executor
|
||||
from ocrmypdf._graft import OcrGrafter
|
||||
from ocrmypdf._jobcontext import PageContext, PdfContext
|
||||
from ocrmypdf._options import OCROptions
|
||||
from ocrmypdf._pipeline import (
|
||||
copy_final,
|
||||
render_hocr_page,
|
||||
)
|
||||
from ocrmypdf._pipeline import copy_final
|
||||
from ocrmypdf._pipelines._common import (
|
||||
HOCRResult,
|
||||
do_get_pdfinfo,
|
||||
@@ -46,9 +43,8 @@ def _exec_hocrtransform_sync(page_context: PageContext) -> HOCRResult:
|
||||
# No hOCR file, so no OCR was performed on this page.
|
||||
return HOCRResult(pageno=page_context.pageno)
|
||||
hocr_result = HOCRResult.from_json(hocr_json.read_text())
|
||||
hocr_result.textpdf = render_hocr_page(
|
||||
page_context.get_path('ocr_hocr.hocr'), page_context
|
||||
)
|
||||
# hOCR path is passed directly to the grafting phase where fpdf2 renders it
|
||||
hocr_result.textpdf = page_context.get_path('ocr_hocr.hocr')
|
||||
return hocr_result
|
||||
|
||||
|
||||
@@ -71,7 +67,7 @@ def exec_hocr_to_ocr_pdf(context: PdfContext, executor: Executor) -> Sequence[st
|
||||
ocrgraft.graft_page(
|
||||
pageno=result.pageno,
|
||||
image=result.pdf_page_from_image,
|
||||
textpdf=result.textpdf,
|
||||
ocr_output=result.textpdf,
|
||||
autorotate_correction=result.orientation_correction,
|
||||
)
|
||||
pbar.update()
|
||||
|
||||
@@ -25,7 +25,6 @@ from ocrmypdf._pipeline import (
|
||||
merge_sidecars,
|
||||
ocr_engine_hocr,
|
||||
ocr_engine_textonly_pdf,
|
||||
render_hocr_page,
|
||||
triage,
|
||||
validate_pdfinfo_options,
|
||||
)
|
||||
@@ -59,14 +58,13 @@ def _image_to_ocr_text(
|
||||
) -> tuple[Path, Path]:
|
||||
"""Run OCR engine on image to create OCR PDF and text file."""
|
||||
options = page_context.options
|
||||
# Handle 'auto' pdf_renderer by defaulting to 'hocr'
|
||||
pdf_renderer = options.pdf_renderer
|
||||
if pdf_renderer == 'auto':
|
||||
pdf_renderer = 'hocr'
|
||||
|
||||
if pdf_renderer.startswith('hocr'):
|
||||
hocr_out, text_out = ocr_engine_hocr(ocr_image_out, page_context)
|
||||
ocr_out = render_hocr_page(hocr_out, page_context)
|
||||
# fpdf2 is the default renderer (auto resolves to fpdf2)
|
||||
if pdf_renderer in ('auto', 'fpdf2'):
|
||||
# fpdf2 renderer uses hOCR as intermediate format.
|
||||
# The hOCR is passed to the grafting phase where fpdf2 renders it in batch.
|
||||
ocr_out, text_out = ocr_engine_hocr(ocr_image_out, page_context)
|
||||
elif pdf_renderer == 'sandwich':
|
||||
ocr_out, text_out = ocr_engine_textonly_pdf(ocr_image_out, page_context)
|
||||
else:
|
||||
@@ -114,7 +112,7 @@ def exec_concurrent(context: PdfContext, executor: Executor) -> Sequence[str]:
|
||||
ocrgraft.graft_page(
|
||||
pageno=result.pageno,
|
||||
image=result.pdf_page_from_image,
|
||||
textpdf=result.ocr,
|
||||
ocr_output=result.ocr,
|
||||
autorotate_correction=result.orientation_correction,
|
||||
)
|
||||
pbar.update(0.5)
|
||||
|
||||
@@ -97,6 +97,9 @@ class ValidationCoordinator:
|
||||
|
||||
def _validate_cross_cutting_concerns(self, options: OCROptions) -> None:
|
||||
"""Validate cross-cutting concerns that span multiple plugins."""
|
||||
# Handle deprecated pdf_renderer values
|
||||
self._handle_deprecated_pdf_renderer(options)
|
||||
|
||||
# Validate mutually exclusive OCR options
|
||||
exclusive_options = sum(
|
||||
1 for opt in [options.force_ocr, options.skip_text, options.redo_ocr] if opt
|
||||
@@ -132,3 +135,15 @@ class ValidationCoordinator:
|
||||
"--pdfa-image-compression argument only applies when "
|
||||
"--output-type is one of 'pdfa', 'pdfa-1', or 'pdfa-2'"
|
||||
)
|
||||
|
||||
def _handle_deprecated_pdf_renderer(self, options: OCROptions) -> None:
|
||||
"""Handle deprecated pdf_renderer values by redirecting to fpdf2."""
|
||||
if options.pdf_renderer in ('hocr', 'hocrdebug'):
|
||||
log.info(
|
||||
"The '%s' PDF renderer has been removed. Using 'fpdf2' instead, "
|
||||
"which provides full international language support, proper RTL "
|
||||
"rendering, and improved text positioning.",
|
||||
options.pdf_renderer,
|
||||
)
|
||||
# Modify the options object to use fpdf2
|
||||
object.__setattr__(options, 'pdf_renderer', 'fpdf2')
|
||||
|
||||
@@ -379,18 +379,21 @@ class TesseractOcrEngine(OcrEngine):
|
||||
def _determine_renderer(options):
|
||||
"""Determine the PDF renderer to use based on options and languages."""
|
||||
if options.pdf_renderer == 'auto':
|
||||
if {'ara', 'heb', 'fas', 'per'} & set(options.languages):
|
||||
log.info("Using sandwich renderer since there is an RTL language")
|
||||
return 'sandwich'
|
||||
else:
|
||||
return 'hocr'
|
||||
return 'fpdf2'
|
||||
return options.pdf_renderer
|
||||
|
||||
@staticmethod
|
||||
def creator_tag(options):
|
||||
renderer = TesseractOcrEngine._determine_renderer(options)
|
||||
tag = '-PDF' if renderer == 'sandwich' else '-hOCR'
|
||||
return f"Tesseract OCR{tag} {TesseractOcrEngine.version()}"
|
||||
match renderer:
|
||||
case 'hocr':
|
||||
return f"OCRmyPDF hOCR + Tesseract OCR {TesseractOcrEngine.version()}"
|
||||
case 'fpdf2':
|
||||
return f"OCRmyPDF fpdf2 + Tesseract OCR {TesseractOcrEngine.version()}"
|
||||
case "sandwich":
|
||||
return f"Tesseract OCR + PDF {TesseractOcrEngine.version()}"
|
||||
case _:
|
||||
return f"Tesseract OCR {TesseractOcrEngine.version()}"
|
||||
|
||||
def __str__(self):
|
||||
return f"Tesseract OCR {TesseractOcrEngine.version()}"
|
||||
|
||||
+6
-3
@@ -368,10 +368,13 @@ Online documentation is located at:
|
||||
)
|
||||
advanced.add_argument(
|
||||
'--pdf-renderer',
|
||||
choices=['auto', 'hocr', 'sandwich', 'hocrdebug'],
|
||||
choices=['auto', 'hocr', 'sandwich', 'hocrdebug', 'fpdf2'],
|
||||
default='auto',
|
||||
help="Choose OCR PDF renderer - the default option is to let OCRmyPDF "
|
||||
"choose. See documentation for discussion.",
|
||||
help="Choose OCR PDF renderer. 'auto' (recommended) uses fpdf2, which "
|
||||
"provides full international language support including RTL scripts, "
|
||||
"proper text positioning, and invisible text that becomes visible when "
|
||||
"selected. 'sandwich' renders text as a background layer. Legacy 'hocr' "
|
||||
"and 'hocrdebug' options are deprecated and will use fpdf2.",
|
||||
)
|
||||
advanced.add_argument(
|
||||
'--rasterizer',
|
||||
|
||||
@@ -15,16 +15,14 @@ The architecture separates parsing from rendering, allowing:
|
||||
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
|
||||
- Fpdf2PdfRenderer: Renders OcrElement trees to PDF text layers (via fpdf2)
|
||||
|
||||
For PDF rendering, use the fpdf2_renderer module:
|
||||
from ocrmypdf.fpdf_renderer import Fpdf2PdfRenderer, DebugRenderOptions
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ocrmypdf.hocrtransform._hocr import (
|
||||
HocrTransform,
|
||||
HocrTransformError,
|
||||
)
|
||||
from ocrmypdf.hocrtransform.hocr_parser import (
|
||||
HocrParseError,
|
||||
HocrParser,
|
||||
@@ -36,20 +34,11 @@ from ocrmypdf.hocrtransform.ocr_element import (
|
||||
OcrClass,
|
||||
OcrElement,
|
||||
)
|
||||
from ocrmypdf.hocrtransform.pdf_renderer import (
|
||||
DebugRenderOptions,
|
||||
PdfTextRenderer,
|
||||
)
|
||||
|
||||
__all__ = (
|
||||
# Backward-compatible API
|
||||
'HocrTransform',
|
||||
'HocrTransformError',
|
||||
'DebugRenderOptions',
|
||||
# New separated components
|
||||
# hOCR parsing
|
||||
'HocrParser',
|
||||
'HocrParseError',
|
||||
'PdfTextRenderer',
|
||||
# OCR element data model
|
||||
'OcrElement',
|
||||
'OcrClass',
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
# SPDX-FileCopyrightText: 2023 James R. Barlow
|
||||
# SPDX-FileCopyrightText: 2023-2025 James R. Barlow
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""Simple CLI for testing HOCR."""
|
||||
"""Simple CLI for testing HOCR to PDF conversion using fpdf2 renderer."""
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
from ocrmypdf.hocrtransform import HocrTransform
|
||||
from ocrmypdf.font import MultiFontManager
|
||||
from ocrmypdf.fpdf_renderer import DebugRenderOptions, Fpdf2PdfRenderer
|
||||
from ocrmypdf.hocrtransform.hocr_parser import HocrParser
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description='Convert hocr file to PDF')
|
||||
@@ -14,7 +17,7 @@ if __name__ == "__main__":
|
||||
'--boundingboxes',
|
||||
action="store_true",
|
||||
default=False,
|
||||
help='Show bounding boxes borders',
|
||||
help='Show bounding boxes borders (debug mode)',
|
||||
)
|
||||
parser.add_argument(
|
||||
'-r',
|
||||
@@ -27,14 +30,44 @@ if __name__ == "__main__":
|
||||
'-i',
|
||||
'--image',
|
||||
default=None,
|
||||
help='Path to the image to be placed above the text',
|
||||
help='Path to the image to be placed above the text (not yet supported)',
|
||||
)
|
||||
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,
|
||||
# Parse hOCR file
|
||||
hocr_parser = HocrParser(args.hocrfile)
|
||||
ocr_page = hocr_parser.parse()
|
||||
|
||||
# Use DPI from hOCR if available, otherwise use command-line resolution
|
||||
dpi = ocr_page.dpi or args.resolution
|
||||
|
||||
# Setup debug render options if requested
|
||||
debug_options = None
|
||||
if args.boundingboxes:
|
||||
debug_options = DebugRenderOptions(
|
||||
render_line_bbox=True,
|
||||
render_word_bbox=True,
|
||||
render_baseline=True,
|
||||
)
|
||||
|
||||
# Create multi-font manager with default font directory
|
||||
font_dir = Path(__file__).parent.parent / "data"
|
||||
multi_font_manager = MultiFontManager(font_dir)
|
||||
|
||||
# Render to PDF using fpdf2
|
||||
renderer = Fpdf2PdfRenderer(
|
||||
page=ocr_page,
|
||||
dpi=dpi,
|
||||
multi_font_manager=multi_font_manager,
|
||||
invisible_text=not args.boundingboxes, # Visible text in debug mode
|
||||
debug_render_options=debug_options,
|
||||
)
|
||||
renderer.render(Path(args.outputfile))
|
||||
|
||||
if args.image:
|
||||
print(
|
||||
f"Warning: Image overlay (--image {args.image}) is not yet supported "
|
||||
"with the fpdf2 renderer."
|
||||
)
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
# SPDX-FileCopyrightText: 2023 James R. Barlow
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import unicodedata
|
||||
import zlib
|
||||
from importlib.resources import files as package_files
|
||||
|
||||
from pikepdf import (
|
||||
Dictionary,
|
||||
Name,
|
||||
Pdf,
|
||||
)
|
||||
from pikepdf.canvas import Font
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EncodableFont(Font):
|
||||
def text_encode(self, text: str) -> bytes:
|
||||
raise NotImplementedError()
|
||||
|
||||
|
||||
class GlyphlessFont(EncodableFont):
|
||||
CID_TO_GID_DATA = zlib.compress(b"\x00\x01" * 65536)
|
||||
GLYPHLESS_FONT_NAME = 'pdf.ttf'
|
||||
GLYPHLESS_FONT = (package_files('ocrmypdf.data') / GLYPHLESS_FONT_NAME).read_bytes()
|
||||
CHAR_ASPECT = 2
|
||||
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
def text_width(self, text: str, fontsize: float) -> float:
|
||||
"""Estimate the width of a text string when rendered with the given font."""
|
||||
# NFKC: split ligatures, combine diacritics
|
||||
return len(unicodedata.normalize("NFKC", text)) * (fontsize / self.CHAR_ASPECT)
|
||||
|
||||
def text_encode(self, text: str) -> bytes:
|
||||
return text.encode('utf-16be')
|
||||
|
||||
def register(self, pdf: Pdf):
|
||||
"""Register the glyphless font.
|
||||
|
||||
Create several data structures in the Pdf to describe the font. While it create
|
||||
the data, a reference should be set in at least one page's /Resources dictionary
|
||||
to retain the font in the output PDF and ensure it is usable on that page.
|
||||
"""
|
||||
PLACEHOLDER = Name.Placeholder
|
||||
|
||||
basefont = pdf.make_indirect(
|
||||
Dictionary(
|
||||
BaseFont=Name.GlyphLessFont,
|
||||
DescendantFonts=[PLACEHOLDER],
|
||||
Encoding=Name("/Identity-H"),
|
||||
Subtype=Name.Type0,
|
||||
ToUnicode=PLACEHOLDER,
|
||||
Type=Name.Font,
|
||||
)
|
||||
)
|
||||
cid_font_type2 = pdf.make_indirect(
|
||||
Dictionary(
|
||||
BaseFont=Name.GlyphLessFont,
|
||||
CIDToGIDMap=PLACEHOLDER,
|
||||
CIDSystemInfo=Dictionary(
|
||||
Ordering="Identity",
|
||||
Registry="Adobe",
|
||||
Supplement=0,
|
||||
),
|
||||
FontDescriptor=PLACEHOLDER,
|
||||
Subtype=Name.CIDFontType2,
|
||||
Type=Name.Font,
|
||||
DW=1000 // self.CHAR_ASPECT,
|
||||
)
|
||||
)
|
||||
basefont.DescendantFonts = [cid_font_type2]
|
||||
cid_font_type2.CIDToGIDMap = pdf.make_stream(
|
||||
self.CID_TO_GID_DATA, Filter=Name.FlateDecode
|
||||
)
|
||||
basefont.ToUnicode = pdf.make_stream(
|
||||
b"/CIDInit /ProcSet findresource begin\n"
|
||||
b"12 dict begin\n"
|
||||
b"begincmap\n"
|
||||
b"/CIDSystemInfo\n"
|
||||
b"<<\n"
|
||||
b" /Registry (Adobe)\n"
|
||||
b" /Ordering (UCS)\n"
|
||||
b" /Supplement 0\n"
|
||||
b">> def\n"
|
||||
b"/CMapName /Adobe-Identify-UCS def\n"
|
||||
b"/CMapType 2 def\n"
|
||||
b"1 begincodespacerange\n"
|
||||
b"<0000> <FFFF>\n"
|
||||
b"endcodespacerange\n"
|
||||
b"1 beginbfrange\n"
|
||||
b"<0000> <FFFF> <0000>\n"
|
||||
b"endbfrange\n"
|
||||
b"endcmap\n"
|
||||
b"CMapName currentdict /CMap defineresource pop\n"
|
||||
b"end\n"
|
||||
b"end\n"
|
||||
)
|
||||
font_descriptor = pdf.make_indirect(
|
||||
Dictionary(
|
||||
Ascent=1000,
|
||||
CapHeight=1000,
|
||||
Descent=-1,
|
||||
Flags=5, # Fixed pitch and symbolic
|
||||
FontBBox=[0, 0, 1000 // self.CHAR_ASPECT, 1000],
|
||||
FontFile2=PLACEHOLDER,
|
||||
FontName=Name.GlyphLessFont,
|
||||
ItalicAngle=0,
|
||||
StemV=80,
|
||||
Type=Name.FontDescriptor,
|
||||
)
|
||||
)
|
||||
font_descriptor.FontFile2 = pdf.make_stream(self.GLYPHLESS_FONT)
|
||||
cid_font_type2.FontDescriptor = font_descriptor
|
||||
return basefont
|
||||
|
||||
|
||||
class Courier(EncodableFont):
|
||||
"""Courier font."""
|
||||
|
||||
def text_width(self, text: str, fontsize: float) -> float:
|
||||
"""Estimate the width of a text string when rendered with the given font."""
|
||||
return len(text) * fontsize
|
||||
|
||||
def text_encode(self, text: str) -> bytes:
|
||||
return text.encode('pdfdoc', errors='ignore')
|
||||
|
||||
def register(self, pdf: Pdf) -> Dictionary:
|
||||
"""Register the font."""
|
||||
return pdf.make_indirect(
|
||||
Dictionary(
|
||||
BaseFont=Name.Courier,
|
||||
Type=Name.Font,
|
||||
Subtype=Name.Type1,
|
||||
)
|
||||
)
|
||||
@@ -1,147 +0,0 @@
|
||||
# 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
|
||||
|
||||
"""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 warnings
|
||||
from pathlib import Path
|
||||
|
||||
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__)
|
||||
|
||||
|
||||
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/.
|
||||
|
||||
This class provides backward compatibility with existing code. Internally,
|
||||
it uses the new HocrParser and PdfTextRenderer components.
|
||||
"""
|
||||
|
||||
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.
|
||||
|
||||
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:
|
||||
warnings.warn(
|
||||
"Use debug_render_options instead of debug parameter",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
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._fontname = fontname
|
||||
self._font = font
|
||||
self._hocr_filename = Path(hocr_filename)
|
||||
|
||||
# Parse the hOCR file
|
||||
try:
|
||||
parser = HocrParser(hocr_filename)
|
||||
self._page = parser.parse()
|
||||
except HocrParseError as e:
|
||||
raise HocrTransformError(str(e)) from e
|
||||
|
||||
if self._page.bbox is None:
|
||||
raise HocrTransformError("hocr file is missing page dimensions")
|
||||
|
||||
# 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,
|
||||
*,
|
||||
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.
|
||||
|
||||
Args:
|
||||
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.
|
||||
"""
|
||||
renderer = PdfTextRenderer(
|
||||
page=self._page,
|
||||
dpi=self.dpi,
|
||||
fontname=self._fontname,
|
||||
font=self._font,
|
||||
debug_render_options=self.render_options,
|
||||
)
|
||||
|
||||
renderer.render(
|
||||
out_filename=out_filename,
|
||||
image_filename=image_filename,
|
||||
invisible_text=invisible_text,
|
||||
)
|
||||
|
||||
@property
|
||||
def page(self):
|
||||
"""Get the parsed OcrElement page.
|
||||
|
||||
Returns:
|
||||
The root OcrElement representing the parsed page
|
||||
"""
|
||||
return self._page
|
||||
@@ -1,544 +0,0 @@
|
||||
# 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,
|
||||
)
|
||||
Reference in New Issue
Block a user