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:
@@ -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
@@ -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,
|
||||
)
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user