Add fpdf2-based PDF text layer renderer

Implement new PDF renderer using fpdf2 library that provides:
- Multilingual text support via font module
- Proper baseline and rotation handling
- Multi-page rendering with efficient font embedding
- Invisible but selectable text layer
This commit is contained in:
James R. Barlow
2026-01-06 13:45:14 -08:00
parent 64726f97b3
commit d72a494979
2 changed files with 643 additions and 0 deletions
+20
View File
@@ -0,0 +1,20 @@
# SPDX-FileCopyrightText: 2025 James R. Barlow
# SPDX-License-Identifier: MPL-2.0
"""fpdf2-based PDF renderer for OCR text layers.
This module provides the PDF renderer using fpdf2 for creating
searchable OCR text layers.
"""
from ocrmypdf.fpdf_renderer.renderer import (
DebugRenderOptions,
Fpdf2MultiPageRenderer,
Fpdf2PdfRenderer,
)
__all__ = [
"DebugRenderOptions",
"Fpdf2PdfRenderer",
"Fpdf2MultiPageRenderer",
]
+623
View File
@@ -0,0 +1,623 @@
# SPDX-FileCopyrightText: 2025 James R. Barlow
# SPDX-License-Identifier: MPL-2.0
"""fpdf2-based PDF renderer for OCR text layers.
This module provides PDF rendering using fpdf2 for creating searchable
OCR text layers.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from math import atan, degrees
from pathlib import Path
from fpdf import FPDF
from fpdf.enums import TextMode
from pikepdf import Matrix, Rectangle
from ocrmypdf.font import FontManager, MultiFontManager
from ocrmypdf.hocrtransform.ocr_element import OcrClass, OcrElement
log = logging.getLogger(__name__)
def transform_point(matrix: Matrix, x: float, y: float) -> tuple[float, float]:
"""Transform a point (x, y) by a matrix.
Args:
matrix: pikepdf Matrix to apply
x: X coordinate
y: Y coordinate
Returns:
Tuple of (transformed_x, transformed_y)
"""
# Use a degenerate rectangle to transform a single point
rect = Rectangle(x, y, x, y)
transformed = matrix.transform(rect)
return (transformed.llx, transformed.lly)
def transform_box(
matrix: Matrix, left: float, top: float, right: float, bottom: float
) -> tuple[float, float, float, float]:
"""Transform a bounding box by a matrix.
Args:
matrix: pikepdf Matrix to apply
left: Left edge of box
top: Top edge of box
right: Right edge of box
bottom: Bottom edge of box
Returns:
Tuple of (llx, lly, width, height) of the transformed box
"""
rect = Rectangle(left, top, right, bottom)
transformed = matrix.transform(rect)
return (
transformed.llx,
transformed.lly,
transformed.width,
transformed.height,
)
@dataclass
class DebugRenderOptions:
"""Options for debug visualization during rendering.
When enabled, draws colored lines/shapes to visualize OCR structure.
"""
render_baseline: bool = False # Magenta lines along baselines
render_line_bbox: bool = False # Blue rectangles around lines
render_word_bbox: bool = False # Green rectangles around words
class CoordinateTransform:
"""Manages coordinate transformations for fpdf2 rendering.
Handles conversion from OCR pixel coordinates (top-left origin) to
PDF points. fpdf2 uses top-left origin like hOCR, so no Y-flip needed.
"""
def __init__(self, dpi: float, page_width_px: float, page_height_px: float):
"""Initialize coordinate transform."""
self.dpi = dpi
self.page_width_px = page_width_px
self.page_height_px = page_height_px
@property
def page_width_pt(self) -> float:
"""Page width in PDF points."""
return self.page_width_px * 72.0 / self.dpi
@property
def page_height_pt(self) -> float:
"""Page height in PDF points."""
return self.page_height_px * 72.0 / self.dpi
def px_to_pt(self, value: float) -> float:
"""Convert pixels to PDF points."""
return value * 72.0 / self.dpi
def bbox_to_pt(self, bbox) -> tuple[float, float, float, float]:
"""Convert BoundingBox from pixels to points."""
return (
self.px_to_pt(bbox.left),
self.px_to_pt(bbox.top),
self.px_to_pt(bbox.right),
self.px_to_pt(bbox.bottom),
)
class Fpdf2PdfRenderer:
"""Renders OcrElement trees to PDF using fpdf2.
This class provides the core rendering logic for converting OCR output
into PDF text layers using fpdf2's text drawing capabilities.
"""
def __init__(
self,
page: OcrElement,
dpi: float,
multi_font_manager: MultiFontManager,
invisible_text: bool = True,
debug_render_options: DebugRenderOptions | None = None,
):
"""Initialize renderer.
Args:
page: Root OcrElement (must be ocr_page)
dpi: Source image DPI
multi_font_manager: MultiFontManager instance
invisible_text: If True, render text as invisible (text mode 3)
debug_render_options: Options for debug visualization
Raises:
ValueError: If page is not an ocr_page or lacks a bounding box
"""
if page.ocr_class != OcrClass.PAGE:
raise ValueError("Root element must be ocr_page")
if page.bbox is None:
raise ValueError("Page must have bounding box")
self.page = page
self.dpi = dpi
self.multi_font_manager = multi_font_manager
self.invisible_text = invisible_text
self.debug_options = debug_render_options or DebugRenderOptions()
# Setup coordinate transform
self.coord_transform = CoordinateTransform(
dpi=dpi,
page_width_px=page.bbox.width,
page_height_px=page.bbox.height,
)
# Registered fonts: font_path -> fpdf_family_name
self._registered_fonts: dict[str, str] = {}
def render(self, output_path: Path) -> None:
"""Render page to PDF file.
Args:
output_path: Output PDF file path
"""
# Create PDF with custom page size
pdf = FPDF(
unit="pt",
format=(
self.coord_transform.page_width_pt,
self.coord_transform.page_height_pt,
),
)
pdf.set_auto_page_break(auto=False)
# Enable text shaping for complex scripts
pdf.set_text_shaping(True)
# Disable cell margin to ensure precise text positioning
# fpdf2's cell() adds c_margin padding by default, which shifts text
pdf.c_margin = 0
# Set text mode for invisible text
if self.invisible_text:
pdf.text_rendering_mode = TextMode.INVISIBLE
else:
pdf.text_rendering_mode = TextMode.FILL
# Render content to PDF
self.render_to_pdf(pdf)
# Write PDF
pdf.output(str(output_path))
def render_to_pdf(self, pdf: FPDF) -> None:
"""Render page content to an existing FPDF instance.
This method adds a page and renders all content. Used by both
single-page rendering and multi-page rendering.
Args:
pdf: FPDF instance to render into
"""
# Add page with correct dimensions
pdf.add_page(
format=(
self.coord_transform.page_width_pt,
self.coord_transform.page_height_pt,
)
)
# Render all paragraphs
for para in self.page.paragraphs:
self._render_paragraph(pdf, para)
# If no paragraphs, render lines directly
if not self.page.paragraphs:
for line in self.page.lines:
self._render_line(pdf, line)
def _register_font(self, pdf: FPDF, font_manager: FontManager) -> str:
"""Register font with fpdf2 if not already registered.
Args:
pdf: FPDF instance
font_manager: FontManager containing the font
Returns:
Font family name to use with pdf.set_font()
"""
font_path_str = str(font_manager.font_path)
if font_path_str not in self._registered_fonts:
# Use the font filename stem as the family name
family_name = font_manager.font_path.stem
pdf.add_font(family=family_name, fname=font_path_str)
self._registered_fonts[font_path_str] = family_name
return self._registered_fonts[font_path_str]
def _render_paragraph(self, pdf: FPDF, para: OcrElement) -> None:
"""Render a paragraph element.
Args:
pdf: FPDF instance
para: Paragraph OCR element
"""
for line in para.children:
if line.ocr_class in OcrClass.LINE_TYPES:
self._render_line(pdf, line)
def _render_line(self, pdf: FPDF, line: OcrElement) -> None:
"""Render a line element with baseline support.
Strategy (following pikepdf reference implementation):
1. Create a baseline_matrix that transforms from hOCR coordinates to
a coordinate system aligned with the text baseline
2. For each word, transform its hOCR bbox using baseline_matrix.inverse()
to get its position in the baseline coordinate system
3. Render words along the baseline with horizontal scaling
Args:
pdf: FPDF instance
line: Line OCR element
"""
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.text if hasattr(line, 'text') else '',
)
return
# Convert line bbox to PDF points
line_left_pt = self.coord_transform.px_to_pt(line.bbox.left)
line_top_pt = self.coord_transform.px_to_pt(line.bbox.top)
line_right_pt = self.coord_transform.px_to_pt(line.bbox.right)
line_bottom_pt = self.coord_transform.px_to_pt(line.bbox.bottom)
# Note: line_width_pt and line_height_pt not needed since we compute
# dimensions in the un-rotated coordinate system via matrix transform
# Debug rendering: draw line bbox (in page coordinates)
if self.debug_options.render_line_bbox:
self._render_debug_line_bbox(
pdf, line_left_pt, line_top_pt, line_right_pt, line_bottom_pt
)
# Get textangle (rotation of the entire line)
textangle = line.textangle or 0.0
# Build line_size_aabb_matrix: transforms from page coords to un-rotated
# line coords. The hOCR bbox is the minimum axis-aligned bounding box
# enclosing the rotated text.
# Start at top-left corner of line bbox, then rotate by -textangle
line_size_aabb_matrix = (
Matrix()
.translated(line_left_pt, line_top_pt)
.rotated(-textangle) # textangle is counter-clockwise per hOCR spec
)
# Get the line dimensions in the un-rotated coordinate system
# Transform line bbox corners to get the un-rotated dimensions
inv_line_matrix = line_size_aabb_matrix.inverse()
# Transform bottom-right corner to get line dimensions in rotated space
_, _, line_size_width, line_size_height = transform_box(
inv_line_matrix, line_left_pt, line_top_pt, line_right_pt, line_bottom_pt
)
# Get baseline information (slope and intercept)
slope = 0.0
intercept_pt = 0.0
if line.baseline is not None:
slope = line.baseline.slope
intercept_pt = self.coord_transform.px_to_pt(line.baseline.intercept)
if abs(slope) < 0.005:
slope = 0.0
else:
# No baseline provided: calculate from font metrics
default_font_manager = self.multi_font_manager.fonts['NotoSans-Regular']
ascent, descent, units_per_em = default_font_manager.get_font_metrics()
ascent_norm = ascent / units_per_em
descent_norm = descent / units_per_em
# Baseline intercept based on font metrics
intercept_pt = (
-abs(descent_norm)
* line_size_height
/ (ascent_norm + abs(descent_norm))
)
slope_angle_deg = degrees(atan(slope)) if slope != 0.0 else 0.0
# Build baseline_matrix: transforms from page coords to baseline coords
# 1. Start with line_size_aabb_matrix (translates to line corner, rotates)
# 2. Translate down to bottom of un-rotated line (line_size_height)
# 3. Apply baseline intercept offset
# 4. Rotate by baseline slope
baseline_matrix = (
line_size_aabb_matrix.translated(
0, line_size_height
) # Move to bottom of line
.translated(0, intercept_pt) # Apply baseline intercept
.rotated(slope_angle_deg) # Rotate by baseline slope
)
# Calculate font size: height from baseline to top of line
font_size = line_size_height + intercept_pt
if font_size < 1.0:
font_size = line_size_height * 0.8
# Total rotation for rendering (textangle + slope)
total_rotation_deg = -textangle + slope_angle_deg
# Debug rendering: draw baseline
if self.debug_options.render_baseline:
# Baseline starts at origin in baseline coords, extends line width
baseline_start = transform_point(baseline_matrix, 0, 0)
baseline_end = transform_point(baseline_matrix, line_size_width, 0)
pdf.set_draw_color(255, 0, 255) # Magenta
pdf.set_line_width(0.75)
pdf.line(
baseline_start[0], baseline_start[1], baseline_end[0], baseline_end[1]
)
# Extract line language for font selection
line_language = line.language
# Get inverse of baseline_matrix for transforming word bboxes
inv_baseline_matrix = baseline_matrix.inverse()
# Render each word
for word in line.children:
if word.ocr_class == OcrClass.WORD and word.text:
self._render_word(
pdf,
word,
baseline_matrix,
inv_baseline_matrix,
font_size,
total_rotation_deg,
line_language,
)
def _render_word(
self,
pdf: FPDF,
word: OcrElement,
baseline_matrix: Matrix,
inv_baseline_matrix: Matrix,
font_size: float,
rotation_deg: float,
line_language: str | None,
) -> None:
"""Render a word using word bbox positioning.
Position text so its visual bounding box matches the hOCR word bbox.
This provides more accurate placement than baseline-relative positioning
because we match the actual glyph bounds rather than relying on font
metrics which may not exactly match the OCR'd text appearance.
Args:
pdf: FPDF instance
word: Word OCR element
baseline_matrix: Transform from baseline coords to page coords
inv_baseline_matrix: Transform from page coords to baseline coords
font_size: Font size in points (from line calculation)
rotation_deg: Total rotation angle for text
line_language: Language code from line for font selection
"""
if not word.text or word.bbox is None:
return
# Select appropriate font for this word
font_manager = self.multi_font_manager.select_font_for_word(
word.text, line_language
)
# Register font with fpdf2
font_family = self._register_font(pdf, font_manager)
# Convert word bbox to PDF points
word_left_pt = self.coord_transform.px_to_pt(word.bbox.left)
word_top_pt = self.coord_transform.px_to_pt(word.bbox.top)
word_right_pt = self.coord_transform.px_to_pt(word.bbox.right)
word_bottom_pt = self.coord_transform.px_to_pt(word.bbox.bottom)
word_width_pt = word_right_pt - word_left_pt
# Transform word bbox into baseline coordinate system to get x position
box_llx, _, _, _ = transform_box(
inv_baseline_matrix,
word_left_pt,
word_top_pt,
word_right_pt,
word_bottom_pt,
)
# Debug rendering: draw word bbox (in page coordinates)
if self.debug_options.render_word_bbox:
self._render_debug_word_bbox(
pdf, word_left_pt, word_top_pt, word_right_pt, word_bottom_pt
)
# Use line-based font_size for consistent vertical sizing
word_font_size = font_size
# Set font
pdf.set_font(font_family, size=word_font_size)
# Calculate natural text width at this font size
natural_width = pdf.get_string_width(word.text)
# Calculate horizontal scale to fit word bbox width
if natural_width > 0 and word_width_pt > 0:
scale_x = (word_width_pt / natural_width) * 100
else:
scale_x = 100
# Apply horizontal stretching
pdf.set_stretching(scale_x)
# Get left side bearing of first character to compensate for glyph offset
lsb_pt = font_manager.get_left_side_bearing(word.text[0], word_font_size)
# Transform the baseline-relative x position back to page coordinates
# The word sits at (box_llx, 0) in baseline coords (on the baseline)
page_x, page_y = transform_point(baseline_matrix, box_llx, 0)
# Adjust x position to account for lsb (scaled by horizontal stretch)
adjusted_x = page_x - lsb_pt * (scale_x / 100)
# Calculate y position based on baseline
# In fpdf2, set_xy(x, y) positions text such that the baseline is at:
# baseline_y = set_y + font_size * (ascent / (ascent + |descent|))
# We want baseline at page_y, so:
# page_y = set_y + font_size * (ascent / (ascent + |descent|))
# set_y = page_y - font_size * (ascent / (ascent + |descent|))
ascent, descent, _ = font_manager.get_font_metrics()
total_height = ascent + abs(descent)
baseline_offset_ratio = ascent / total_height
adjusted_y = page_y - word_font_size * baseline_offset_ratio
# Position and draw text with rotation
if abs(rotation_deg) > 0.1:
with pdf.rotation(-rotation_deg, x=page_x, y=page_y):
pdf.set_xy(adjusted_x, adjusted_y)
pdf.cell(text=word.text)
else:
pdf.set_xy(adjusted_x, adjusted_y)
pdf.cell(text=word.text)
# Reset stretching
pdf.set_stretching(100)
def _render_debug_line_bbox(
self,
pdf: FPDF,
left: float,
top: float,
right: float,
bottom: float,
) -> None:
"""Draw a blue box around the line bbox."""
pdf.set_draw_color(0, 0, 255) # Blue
pdf.set_line_width(0.5)
pdf.rect(left, top, right - left, bottom - top)
def _render_debug_baseline(
self,
pdf: FPDF,
x: float,
y: float,
width: float,
rotation_deg: float,
) -> None:
"""Draw a magenta line along the baseline."""
pdf.set_draw_color(255, 0, 255) # Magenta
pdf.set_line_width(0.75)
if abs(rotation_deg) > 0.1:
with pdf.rotation(rotation_deg, x=x, y=y):
pdf.line(x, y, x + width, y)
else:
pdf.line(x, y, x + width, y)
def _render_debug_word_bbox(
self,
pdf: FPDF,
left: float,
top: float,
right: float,
bottom: float,
) -> None:
"""Draw a green box around the word bbox."""
pdf.set_draw_color(0, 255, 0) # Green
pdf.set_line_width(0.3)
pdf.rect(left, top, right - left, bottom - top)
class Fpdf2MultiPageRenderer:
"""Renders multiple OcrElement pages into a single PDF.
This class handles multi-page documents by delegating to Fpdf2PdfRenderer
for each page while sharing a single FPDF instance and font registration.
"""
def __init__(
self,
pages_data: list[tuple[int, OcrElement, float]],
multi_font_manager: MultiFontManager,
invisible_text: bool = True,
debug_render_options: DebugRenderOptions | None = None,
):
"""Initialize multi-page renderer.
Args:
pages_data: List of (pageno, ocr_tree, dpi) tuples
multi_font_manager: Shared multi-font manager for all pages
invisible_text: Whether to render invisible text
debug_render_options: Options for debug visualization
"""
self.pages_data = pages_data
self.multi_font_manager = multi_font_manager
self.invisible_text = invisible_text
self.debug_options = debug_render_options or DebugRenderOptions()
def render(self, output_path: Path) -> None:
"""Render all pages to a single multi-page PDF.
Args:
output_path: Output PDF file path
"""
if not self.pages_data:
raise ValueError("No pages to render")
# Create PDF (page size will be set per-page)
pdf = FPDF(unit="pt")
pdf.set_auto_page_break(auto=False)
pdf.set_text_shaping(True)
# Disable cell margin to ensure precise text positioning
# fpdf2's cell() adds c_margin padding by default, which shifts text
pdf.c_margin = 0
# Set text mode for invisible text
if self.invisible_text:
pdf.text_rendering_mode = TextMode.INVISIBLE
else:
pdf.text_rendering_mode = TextMode.FILL
# Shared font registration across all pages
shared_registered_fonts: dict[str, str] = {}
# Render each page using Fpdf2PdfRenderer
for _pageno, page, dpi in self.pages_data:
if page.bbox is None:
continue
# Create a renderer for this page
page_renderer = Fpdf2PdfRenderer(
page=page,
dpi=dpi,
multi_font_manager=self.multi_font_manager,
invisible_text=self.invisible_text,
debug_render_options=self.debug_options,
)
# Share font registration to avoid re-registering fonts
page_renderer._registered_fonts = shared_registered_fonts
# Render page content to the shared PDF
page_renderer.render_to_pdf(pdf)
# Write PDF
pdf.output(str(output_path))