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:
James R. Barlow
2026-01-06 13:45:44 -08:00
parent d72a494979
commit 7a4b98974c
15 changed files with 627 additions and 1113 deletions
+2
View File
@@ -13,6 +13,7 @@ license = "MPL-2.0"
requires-python = ">=3.10"
dependencies = [
"deprecation>=2.1.0",
"fpdf2>=2.8.0",
"img2pdf>=0.5",
"packaging>=20",
"pdfminer.six>=20220319",
@@ -23,6 +24,7 @@ dependencies = [
"pydantic>=2.12.5",
"pypdfium2>=5.0.0",
"rich>=13",
"uharfbuzz>=0.53.2",
]
authors = [{ name = "James R. Barlow", email = "james@purplerock.ca" }]
classifiers = [
+427 -190
View File
@@ -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
+6 -3
View File
@@ -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')
-37
View File
@@ -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]:
+4 -8
View File
@@ -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()
+6 -8
View File
@@ -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)
+15
View File
@@ -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')
+10 -7
View File
@@ -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
View File
@@ -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',
+5 -16
View File
@@ -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',
+42 -9
View File
@@ -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."
)
-141
View File
@@ -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,
)
)
-147
View File
@@ -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
-544
View File
@@ -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,
)
Generated
+104
View File
@@ -512,6 +512,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190, upload-time = "2025-02-24T04:41:32.565Z" },
]
[[package]]
name = "defusedxml"
version = "0.7.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520, upload-time = "2021-03-08T10:59:26.269Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" },
]
[[package]]
name = "deprecated"
version = "1.3.1"
@@ -575,6 +584,77 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" },
]
[[package]]
name = "fonttools"
version = "4.61.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ec/ca/cf17b88a8df95691275a3d77dc0a5ad9907f328ae53acbe6795da1b2f5ed/fonttools-4.61.1.tar.gz", hash = "sha256:6675329885c44657f826ef01d9e4fb33b9158e9d93c537d84ad8399539bc6f69", size = 3565756, upload-time = "2025-12-12T17:31:24.246Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5b/94/8a28707adb00bed1bf22dac16ccafe60faf2ade353dcb32c3617ee917307/fonttools-4.61.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7c7db70d57e5e1089a274cbb2b1fd635c9a24de809a231b154965d415d6c6d24", size = 2854799, upload-time = "2025-12-12T17:29:27.5Z" },
{ url = "https://files.pythonhosted.org/packages/94/93/c2e682faaa5ee92034818d8f8a8145ae73eb83619600495dcf8503fa7771/fonttools-4.61.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5fe9fd43882620017add5eabb781ebfbc6998ee49b35bd7f8f79af1f9f99a958", size = 2403032, upload-time = "2025-12-12T17:29:30.115Z" },
{ url = "https://files.pythonhosted.org/packages/f1/62/1748f7e7e1ee41aa52279fd2e3a6d0733dc42a673b16932bad8e5d0c8b28/fonttools-4.61.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8db08051fc9e7d8bc622f2112511b8107d8f27cd89e2f64ec45e9825e8288da", size = 4897863, upload-time = "2025-12-12T17:29:32.535Z" },
{ url = "https://files.pythonhosted.org/packages/69/69/4ca02ee367d2c98edcaeb83fc278d20972502ee071214ad9d8ca85e06080/fonttools-4.61.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a76d4cb80f41ba94a6691264be76435e5f72f2cb3cab0b092a6212855f71c2f6", size = 4859076, upload-time = "2025-12-12T17:29:34.907Z" },
{ url = "https://files.pythonhosted.org/packages/8c/f5/660f9e3cefa078861a7f099107c6d203b568a6227eef163dd173bfc56bdc/fonttools-4.61.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a13fc8aeb24bad755eea8f7f9d409438eb94e82cf86b08fe77a03fbc8f6a96b1", size = 4875623, upload-time = "2025-12-12T17:29:37.33Z" },
{ url = "https://files.pythonhosted.org/packages/63/d1/9d7c5091d2276ed47795c131c1bf9316c3c1ab2789c22e2f59e0572ccd38/fonttools-4.61.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b846a1fcf8beadeb9ea4f44ec5bdde393e2f1569e17d700bfc49cd69bde75881", size = 4993327, upload-time = "2025-12-12T17:29:39.781Z" },
{ url = "https://files.pythonhosted.org/packages/6f/2d/28def73837885ae32260d07660a052b99f0aa00454867d33745dfe49dbf0/fonttools-4.61.1-cp310-cp310-win32.whl", hash = "sha256:78a7d3ab09dc47ac1a363a493e6112d8cabed7ba7caad5f54dbe2f08676d1b47", size = 1502180, upload-time = "2025-12-12T17:29:42.217Z" },
{ url = "https://files.pythonhosted.org/packages/63/fa/bfdc98abb4dd2bd491033e85e3ba69a2313c850e759a6daa014bc9433b0f/fonttools-4.61.1-cp310-cp310-win_amd64.whl", hash = "sha256:eff1ac3cc66c2ac7cda1e64b4e2f3ffef474b7335f92fc3833fc632d595fcee6", size = 1550654, upload-time = "2025-12-12T17:29:44.564Z" },
{ url = "https://files.pythonhosted.org/packages/69/12/bf9f4eaa2fad039356cc627587e30ed008c03f1cebd3034376b5ee8d1d44/fonttools-4.61.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c6604b735bb12fef8e0efd5578c9fb5d3d8532d5001ea13a19cddf295673ee09", size = 2852213, upload-time = "2025-12-12T17:29:46.675Z" },
{ url = "https://files.pythonhosted.org/packages/ac/49/4138d1acb6261499bedde1c07f8c2605d1d8f9d77a151e5507fd3ef084b6/fonttools-4.61.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5ce02f38a754f207f2f06557523cd39a06438ba3aafc0639c477ac409fc64e37", size = 2401689, upload-time = "2025-12-12T17:29:48.769Z" },
{ url = "https://files.pythonhosted.org/packages/e5/fe/e6ce0fe20a40e03aef906af60aa87668696f9e4802fa283627d0b5ed777f/fonttools-4.61.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77efb033d8d7ff233385f30c62c7c79271c8885d5c9657d967ede124671bbdfb", size = 5058809, upload-time = "2025-12-12T17:29:51.701Z" },
{ url = "https://files.pythonhosted.org/packages/79/61/1ca198af22f7dd22c17ab86e9024ed3c06299cfdb08170640e9996d501a0/fonttools-4.61.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:75c1a6dfac6abd407634420c93864a1e274ebc1c7531346d9254c0d8f6ca00f9", size = 5036039, upload-time = "2025-12-12T17:29:53.659Z" },
{ url = "https://files.pythonhosted.org/packages/99/cc/fa1801e408586b5fce4da9f5455af8d770f4fc57391cd5da7256bb364d38/fonttools-4.61.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0de30bfe7745c0d1ffa2b0b7048fb7123ad0d71107e10ee090fa0b16b9452e87", size = 5034714, upload-time = "2025-12-12T17:29:55.592Z" },
{ url = "https://files.pythonhosted.org/packages/bf/aa/b7aeafe65adb1b0a925f8f25725e09f078c635bc22754f3fecb7456955b0/fonttools-4.61.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:58b0ee0ab5b1fc9921eccfe11d1435added19d6494dde14e323f25ad2bc30c56", size = 5158648, upload-time = "2025-12-12T17:29:57.861Z" },
{ url = "https://files.pythonhosted.org/packages/99/f9/08ea7a38663328881384c6e7777bbefc46fd7d282adfd87a7d2b84ec9d50/fonttools-4.61.1-cp311-cp311-win32.whl", hash = "sha256:f79b168428351d11e10c5aeb61a74e1851ec221081299f4cf56036a95431c43a", size = 2280681, upload-time = "2025-12-12T17:29:59.943Z" },
{ url = "https://files.pythonhosted.org/packages/07/ad/37dd1ae5fa6e01612a1fbb954f0927681f282925a86e86198ccd7b15d515/fonttools-4.61.1-cp311-cp311-win_amd64.whl", hash = "sha256:fe2efccb324948a11dd09d22136fe2ac8a97d6c1347cf0b58a911dcd529f66b7", size = 2331951, upload-time = "2025-12-12T17:30:02.254Z" },
{ url = "https://files.pythonhosted.org/packages/6f/16/7decaa24a1bd3a70c607b2e29f0adc6159f36a7e40eaba59846414765fd4/fonttools-4.61.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:f3cb4a569029b9f291f88aafc927dd53683757e640081ca8c412781ea144565e", size = 2851593, upload-time = "2025-12-12T17:30:04.225Z" },
{ url = "https://files.pythonhosted.org/packages/94/98/3c4cb97c64713a8cf499b3245c3bf9a2b8fd16a3e375feff2aed78f96259/fonttools-4.61.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41a7170d042e8c0024703ed13b71893519a1a6d6e18e933e3ec7507a2c26a4b2", size = 2400231, upload-time = "2025-12-12T17:30:06.47Z" },
{ url = "https://files.pythonhosted.org/packages/b7/37/82dbef0f6342eb01f54bca073ac1498433d6ce71e50c3c3282b655733b31/fonttools-4.61.1-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10d88e55330e092940584774ee5e8a6971b01fc2f4d3466a1d6c158230880796", size = 4954103, upload-time = "2025-12-12T17:30:08.432Z" },
{ url = "https://files.pythonhosted.org/packages/6c/44/f3aeac0fa98e7ad527f479e161aca6c3a1e47bb6996b053d45226fe37bf2/fonttools-4.61.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:15acc09befd16a0fb8a8f62bc147e1a82817542d72184acca9ce6e0aeda9fa6d", size = 5004295, upload-time = "2025-12-12T17:30:10.56Z" },
{ url = "https://files.pythonhosted.org/packages/14/e8/7424ced75473983b964d09f6747fa09f054a6d656f60e9ac9324cf40c743/fonttools-4.61.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e6bcdf33aec38d16508ce61fd81838f24c83c90a1d1b8c68982857038673d6b8", size = 4944109, upload-time = "2025-12-12T17:30:12.874Z" },
{ url = "https://files.pythonhosted.org/packages/c8/8b/6391b257fa3d0b553d73e778f953a2f0154292a7a7a085e2374b111e5410/fonttools-4.61.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5fade934607a523614726119164ff621e8c30e8fa1ffffbbd358662056ba69f0", size = 5093598, upload-time = "2025-12-12T17:30:15.79Z" },
{ url = "https://files.pythonhosted.org/packages/d9/71/fd2ea96cdc512d92da5678a1c98c267ddd4d8c5130b76d0f7a80f9a9fde8/fonttools-4.61.1-cp312-cp312-win32.whl", hash = "sha256:75da8f28eff26defba42c52986de97b22106cb8f26515b7c22443ebc9c2d3261", size = 2269060, upload-time = "2025-12-12T17:30:18.058Z" },
{ url = "https://files.pythonhosted.org/packages/80/3b/a3e81b71aed5a688e89dfe0e2694b26b78c7d7f39a5ffd8a7d75f54a12a8/fonttools-4.61.1-cp312-cp312-win_amd64.whl", hash = "sha256:497c31ce314219888c0e2fce5ad9178ca83fe5230b01a5006726cdf3ac9f24d9", size = 2319078, upload-time = "2025-12-12T17:30:22.862Z" },
{ url = "https://files.pythonhosted.org/packages/4b/cf/00ba28b0990982530addb8dc3e9e6f2fa9cb5c20df2abdda7baa755e8fe1/fonttools-4.61.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8c56c488ab471628ff3bfa80964372fc13504ece601e0d97a78ee74126b2045c", size = 2846454, upload-time = "2025-12-12T17:30:24.938Z" },
{ url = "https://files.pythonhosted.org/packages/5a/ca/468c9a8446a2103ae645d14fee3f610567b7042aba85031c1c65e3ef7471/fonttools-4.61.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:dc492779501fa723b04d0ab1f5be046797fee17d27700476edc7ee9ae535a61e", size = 2398191, upload-time = "2025-12-12T17:30:27.343Z" },
{ url = "https://files.pythonhosted.org/packages/a3/4b/d67eedaed19def5967fade3297fed8161b25ba94699efc124b14fb68cdbc/fonttools-4.61.1-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:64102ca87e84261419c3747a0d20f396eb024bdbeb04c2bfb37e2891f5fadcb5", size = 4928410, upload-time = "2025-12-12T17:30:29.771Z" },
{ url = "https://files.pythonhosted.org/packages/b0/8d/6fb3494dfe61a46258cd93d979cf4725ded4eb46c2a4ca35e4490d84daea/fonttools-4.61.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c1b526c8d3f615a7b1867f38a9410849c8f4aef078535742198e942fba0e9bd", size = 4984460, upload-time = "2025-12-12T17:30:32.073Z" },
{ url = "https://files.pythonhosted.org/packages/f7/f1/a47f1d30b3dc00d75e7af762652d4cbc3dff5c2697a0dbd5203c81afd9c3/fonttools-4.61.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:41ed4b5ec103bd306bb68f81dc166e77409e5209443e5773cb4ed837bcc9b0d3", size = 4925800, upload-time = "2025-12-12T17:30:34.339Z" },
{ url = "https://files.pythonhosted.org/packages/a7/01/e6ae64a0981076e8a66906fab01539799546181e32a37a0257b77e4aa88b/fonttools-4.61.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b501c862d4901792adaec7c25b1ecc749e2662543f68bb194c42ba18d6eec98d", size = 5067859, upload-time = "2025-12-12T17:30:36.593Z" },
{ url = "https://files.pythonhosted.org/packages/73/aa/28e40b8d6809a9b5075350a86779163f074d2b617c15d22343fce81918db/fonttools-4.61.1-cp313-cp313-win32.whl", hash = "sha256:4d7092bb38c53bbc78e9255a59158b150bcdc115a1e3b3ce0b5f267dc35dd63c", size = 2267821, upload-time = "2025-12-12T17:30:38.478Z" },
{ url = "https://files.pythonhosted.org/packages/1a/59/453c06d1d83dc0951b69ef692d6b9f1846680342927df54e9a1ca91c6f90/fonttools-4.61.1-cp313-cp313-win_amd64.whl", hash = "sha256:21e7c8d76f62ab13c9472ccf74515ca5b9a761d1bde3265152a6dc58700d895b", size = 2318169, upload-time = "2025-12-12T17:30:40.951Z" },
{ url = "https://files.pythonhosted.org/packages/32/8f/4e7bf82c0cbb738d3c2206c920ca34ca74ef9dabde779030145d28665104/fonttools-4.61.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fff4f534200a04b4a36e7ae3cb74493afe807b517a09e99cb4faa89a34ed6ecd", size = 2846094, upload-time = "2025-12-12T17:30:43.511Z" },
{ url = "https://files.pythonhosted.org/packages/71/09/d44e45d0a4f3a651f23a1e9d42de43bc643cce2971b19e784cc67d823676/fonttools-4.61.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d9203500f7c63545b4ce3799319fe4d9feb1a1b89b28d3cb5abd11b9dd64147e", size = 2396589, upload-time = "2025-12-12T17:30:45.681Z" },
{ url = "https://files.pythonhosted.org/packages/89/18/58c64cafcf8eb677a99ef593121f719e6dcbdb7d1c594ae5a10d4997ca8a/fonttools-4.61.1-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fa646ecec9528bef693415c79a86e733c70a4965dd938e9a226b0fc64c9d2e6c", size = 4877892, upload-time = "2025-12-12T17:30:47.709Z" },
{ url = "https://files.pythonhosted.org/packages/8a/ec/9e6b38c7ba1e09eb51db849d5450f4c05b7e78481f662c3b79dbde6f3d04/fonttools-4.61.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:11f35ad7805edba3aac1a3710d104592df59f4b957e30108ae0ba6c10b11dd75", size = 4972884, upload-time = "2025-12-12T17:30:49.656Z" },
{ url = "https://files.pythonhosted.org/packages/5e/87/b5339da8e0256734ba0dbbf5b6cdebb1dd79b01dc8c270989b7bcd465541/fonttools-4.61.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b931ae8f62db78861b0ff1ac017851764602288575d65b8e8ff1963fed419063", size = 4924405, upload-time = "2025-12-12T17:30:51.735Z" },
{ url = "https://files.pythonhosted.org/packages/0b/47/e3409f1e1e69c073a3a6fd8cb886eb18c0bae0ee13db2c8d5e7f8495e8b7/fonttools-4.61.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b148b56f5de675ee16d45e769e69f87623a4944f7443850bf9a9376e628a89d2", size = 5035553, upload-time = "2025-12-12T17:30:54.823Z" },
{ url = "https://files.pythonhosted.org/packages/bf/b6/1f6600161b1073a984294c6c031e1a56ebf95b6164249eecf30012bb2e38/fonttools-4.61.1-cp314-cp314-win32.whl", hash = "sha256:9b666a475a65f4e839d3d10473fad6d47e0a9db14a2f4a224029c5bfde58ad2c", size = 2271915, upload-time = "2025-12-12T17:30:57.913Z" },
{ url = "https://files.pythonhosted.org/packages/52/7b/91e7b01e37cc8eb0e1f770d08305b3655e4f002fc160fb82b3390eabacf5/fonttools-4.61.1-cp314-cp314-win_amd64.whl", hash = "sha256:4f5686e1fe5fce75d82d93c47a438a25bf0d1319d2843a926f741140b2b16e0c", size = 2323487, upload-time = "2025-12-12T17:30:59.804Z" },
{ url = "https://files.pythonhosted.org/packages/39/5c/908ad78e46c61c3e3ed70c3b58ff82ab48437faf84ec84f109592cabbd9f/fonttools-4.61.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:e76ce097e3c57c4bcb67c5aa24a0ecdbd9f74ea9219997a707a4061fbe2707aa", size = 2929571, upload-time = "2025-12-12T17:31:02.574Z" },
{ url = "https://files.pythonhosted.org/packages/bd/41/975804132c6dea64cdbfbaa59f3518a21c137a10cccf962805b301ac6ab2/fonttools-4.61.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9cfef3ab326780c04d6646f68d4b4742aae222e8b8ea1d627c74e38afcbc9d91", size = 2435317, upload-time = "2025-12-12T17:31:04.974Z" },
{ url = "https://files.pythonhosted.org/packages/b0/5a/aef2a0a8daf1ebaae4cfd83f84186d4a72ee08fd6a8451289fcd03ffa8a4/fonttools-4.61.1-cp314-cp314t-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a75c301f96db737e1c5ed5fd7d77d9c34466de16095a266509e13da09751bd19", size = 4882124, upload-time = "2025-12-12T17:31:07.456Z" },
{ url = "https://files.pythonhosted.org/packages/80/33/d6db3485b645b81cea538c9d1c9219d5805f0877fda18777add4671c5240/fonttools-4.61.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:91669ccac46bbc1d09e9273546181919064e8df73488ea087dcac3e2968df9ba", size = 5100391, upload-time = "2025-12-12T17:31:09.732Z" },
{ url = "https://files.pythonhosted.org/packages/6c/d6/675ba631454043c75fcf76f0ca5463eac8eb0666ea1d7badae5fea001155/fonttools-4.61.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c33ab3ca9d3ccd581d58e989d67554e42d8d4ded94ab3ade3508455fe70e65f7", size = 4978800, upload-time = "2025-12-12T17:31:11.681Z" },
{ url = "https://files.pythonhosted.org/packages/7f/33/d3ec753d547a8d2bdaedd390d4a814e8d5b45a093d558f025c6b990b554c/fonttools-4.61.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:664c5a68ec406f6b1547946683008576ef8b38275608e1cee6c061828171c118", size = 5006426, upload-time = "2025-12-12T17:31:13.764Z" },
{ url = "https://files.pythonhosted.org/packages/b4/40/cc11f378b561a67bea850ab50063366a0d1dd3f6d0a30ce0f874b0ad5664/fonttools-4.61.1-cp314-cp314t-win32.whl", hash = "sha256:aed04cabe26f30c1647ef0e8fbb207516fd40fe9472e9439695f5c6998e60ac5", size = 2335377, upload-time = "2025-12-12T17:31:16.49Z" },
{ url = "https://files.pythonhosted.org/packages/e4/ff/c9a2b66b39f8628531ea58b320d66d951267c98c6a38684daa8f50fb02f8/fonttools-4.61.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2180f14c141d2f0f3da43f3a81bc8aa4684860f6b0e6f9e165a4831f24e6a23b", size = 2400613, upload-time = "2025-12-12T17:31:18.769Z" },
{ url = "https://files.pythonhosted.org/packages/c7/4e/ce75a57ff3aebf6fc1f4e9d508b8e5810618a33d900ad6c19eb30b290b97/fonttools-4.61.1-py3-none-any.whl", hash = "sha256:17d2bf5d541add43822bcf0c43d7d847b160c9bb01d15d5007d84e2217aaa371", size = 1148996, upload-time = "2025-12-12T17:31:21.03Z" },
]
[[package]]
name = "fpdf2"
version = "2.8.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "defusedxml" },
{ name = "fonttools" },
{ name = "pillow" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e9/c0/784b130a28f4ed612e9aff26d1118e1f91005713dcd0a35e60b54d316b56/fpdf2-2.8.5.tar.gz", hash = "sha256:af4491ef2e0a5fe476f9d61362925658949c995f7e804438c0e81008f1550247", size = 336046, upload-time = "2025-10-29T14:17:59.569Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/35/a7/8532d8fffe6d1c388ad4941d678dd0da4d8da80434f2dbf4f35de0fa8029/fpdf2-2.8.5-py3-none-any.whl", hash = "sha256:2356b94e2a5fcbd1fe53ac5cbb83494e9003308860ab180050255ba50961d913", size = 301627, upload-time = "2025-10-29T14:17:57.685Z" },
]
[[package]]
name = "gitdb"
version = "4.0.12"
@@ -1319,6 +1399,7 @@ name = "ocrmypdf"
source = { editable = "." }
dependencies = [
{ name = "deprecation" },
{ name = "fpdf2" },
{ name = "img2pdf" },
{ name = "packaging" },
{ name = "pdfminer-six" },
@@ -1329,6 +1410,7 @@ dependencies = [
{ name = "pydantic" },
{ name = "pypdfium2" },
{ name = "rich" },
{ name = "uharfbuzz" },
]
[package.optional-dependencies]
@@ -1375,6 +1457,7 @@ dev = [
requires-dist = [
{ name = "coverage", extras = ["toml"], marker = "extra == 'test'", specifier = ">=6.2" },
{ name = "deprecation", specifier = ">=2.1.0" },
{ name = "fpdf2", specifier = ">=2.8.0" },
{ name = "hypothesis", marker = "extra == 'test'", specifier = ">=6.36.0" },
{ name = "img2pdf", specifier = ">=0.5" },
{ name = "myst-parser", marker = "extra == 'docs'", specifier = ">=4.0.1" },
@@ -1401,6 +1484,7 @@ requires-dist = [
{ name = "typer-slim", extras = ["standard"], marker = "extra == 'watcher'" },
{ name = "types-humanfriendly", marker = "extra == 'test'" },
{ name = "types-pillow", marker = "extra == 'test'" },
{ name = "uharfbuzz", specifier = ">=0.53.2" },
{ name = "watchdog", marker = "extra == 'watcher'", specifier = ">=1.0.2" },
]
provides-extras = ["docs", "extended-test", "test", "watcher", "webservice"]
@@ -2893,6 +2977,26 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload-time = "2025-03-23T13:54:41.845Z" },
]
[[package]]
name = "uharfbuzz"
version = "0.53.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/64/25/0323ac6cc4dc20c93d294d6deda891cd3b5d069309ed4256784a616c45bd/uharfbuzz-0.53.2.tar.gz", hash = "sha256:5151cbd986f080bbd2f4d531dbe9a03fb179cefb0fd864ba351aa522e58c9e23", size = 1712956, upload-time = "2025-12-28T01:03:24.303Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/41/72/1e19d87f3ba246cbc88dbd60d03e4cbac8df3aad5d84d22cf32fc2a50e79/uharfbuzz-0.53.2-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:56d0ceb8af633035b37e9bb8bd642a657eba64cb99636058075d40ad8188758f", size = 2720623, upload-time = "2025-12-28T01:03:04.215Z" },
{ url = "https://files.pythonhosted.org/packages/81/d1/999fde3cced5abdb21a99bdc66e9a430957d7b86aaf06acaf7f76f530a22/uharfbuzz-0.53.2-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f5c65204de46425f58e5b5a6892e7eba3cbcc7dba2c55d95760ba3356a1a546", size = 1646498, upload-time = "2025-12-28T01:03:06.18Z" },
{ url = "https://files.pythonhosted.org/packages/6d/ba/58551ae2ff695360b8e7922f82fa4ce3951cf31e6172039cccc37f87436f/uharfbuzz-0.53.2-cp310-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e9c211a0f576b145abdea67391847cc8e490ed7cfbd2b26e85d3d8035b1e3f60", size = 1704735, upload-time = "2025-12-28T01:03:07.952Z" },
{ url = "https://files.pythonhosted.org/packages/0e/26/3cbab4c18419103398904317c1e80115cfc28a82a0c1e5e05593a39de3c9/uharfbuzz-0.53.2-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e58af854612a9536cff7f440a520873e44cafba501245f473f90d4fb8a7da31d", size = 2663678, upload-time = "2025-12-28T01:03:09.792Z" },
{ url = "https://files.pythonhosted.org/packages/ce/3d/104d1ba2c0e535a0d9744e0a68f711d022c46ec7c7000f35eefd574ba197/uharfbuzz-0.53.2-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b1b0dee5060df82e2c09130bbef1e80979ce6b26dcfe6f5d9b9a77bc0ed4d8da", size = 2755854, upload-time = "2025-12-28T01:03:11.424Z" },
{ url = "https://files.pythonhosted.org/packages/37/0b/903fc46bd2407baf1cf4c922450f843c293c7b0dab90e7e190be502837e2/uharfbuzz-0.53.2-cp310-abi3-win32.whl", hash = "sha256:8b6a12c50bd94e1a2e9bbf04a737299a76b5ebedcba4c2bf4494233d5b298748", size = 996506, upload-time = "2025-12-28T01:03:13.433Z" },
{ url = "https://files.pythonhosted.org/packages/a7/a4/eb61d2007531634589cce9ee9928c6b6514b8aba01c79cd9b9cbbeff42a7/uharfbuzz-0.53.2-cp310-abi3-win_amd64.whl", hash = "sha256:741134803e14cbece5fde6189fcd4d97ba817fd572b604a3b7f29eb2343e3d11", size = 1244515, upload-time = "2025-12-28T01:03:15.143Z" },
{ url = "https://files.pythonhosted.org/packages/30/34/0ed81abe167c8ac7660c2e71ef99d8b7a7b85cb849f6e49f7fdf470a2052/uharfbuzz-0.53.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:d7b0c695c480d19e72f33a8326a7e553ed6f657e49d140c05c76fa7b38fa32f9", size = 1334807, upload-time = "2025-12-28T01:03:16.795Z" },
{ url = "https://files.pythonhosted.org/packages/b3/c4/c81544da27418a3dfbf8a5346c8ec6efbb0a3b5485c2626624d71c9814af/uharfbuzz-0.53.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ff46f74084bfdebddb5573a52921d213087a1b8f7a826790ab8da78416e79e44", size = 1235691, upload-time = "2025-12-28T01:03:18.558Z" },
{ url = "https://files.pythonhosted.org/packages/32/e9/e5f648bb7d1e34d23a46070cbd7cf2492bdd09f29233d467fcfb684cf838/uharfbuzz-0.53.2-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9de020cede02533a71f8042120ca0b771a8a853e7e0118a5fb7dba3280117ae", size = 1495155, upload-time = "2025-12-28T01:03:19.82Z" },
{ url = "https://files.pythonhosted.org/packages/08/7d/f0df05341c5348fed011a5f991c4a78a6346c6f478f0026f9973d5667ecd/uharfbuzz-0.53.2-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f703dca534f0cdff1920e7c7f2e4338e9a4dc27b1ad2ccdbe93ebbdbe6e050b5", size = 1558438, upload-time = "2025-12-28T01:03:21.249Z" },
{ url = "https://files.pythonhosted.org/packages/32/30/b1399f400b74a1aeffbd6c2570e78d611ec205a7ebbdd5c8e247c0592c84/uharfbuzz-0.53.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:ca7fea0ceab920c18e14a49d6414c63ce1c98e7d575ecd7717cd226411da1e9d", size = 1231881, upload-time = "2025-12-28T01:03:22.626Z" },
]
[[package]]
name = "urllib3"
version = "2.5.0"