# 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
import unicodedata
from dataclasses import dataclass
from math import atan, cos, degrees, radians, sin, sqrt
from pathlib import Path
from typing import cast

from fpdf import FPDF
from fpdf.enums import PDFResourceType, TextMode
from fpdf.fonts import TTFFont
from pikepdf import Matrix, Rectangle

from ocrmypdf.font import FontManager, MultiFontManager
from ocrmypdf.models.ocr_element import OcrClass, OcrElement

log = logging.getLogger(__name__)


def _is_rtl_text(text: str) -> bool:
    """Check if text is right-to-left based on Unicode bidi properties.

    Looks for the first character with a strong directional type
    (R, AL, or L) to determine the text's base direction.
    """
    for char in text:
        bidi = unicodedata.bidirectional(char)
        if bidi in ('R', 'AL'):
            return True
        if bidi == 'L':
            return False
    return False


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 WordRenderData:
    """Rendering parameters for a single word on a line."""

    text: str
    x_baseline: float
    font_family: str
    word_tz: float
    is_rtl: bool


@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,
        image: Path | None = None,
        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)
            image: Optional path to image to overlay on top of the text layer,
                creating a sandwich PDF (text underneath, image on top)
            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.image = image
        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] = {}
        # Track whether we've already logged the info-level suppression message
        self._logged_aspect_ratio_suppression = False

    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_mode = TextMode.INVISIBLE
        else:
            pdf.text_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
        # fpdf2's add_page() stub says format: str, but its docstring and
        # get_page_format() helper confirm a (width, height) tuple is
        # supported too - the annotation on add_page() itself is just wrong.
        pdf.add_page(
            format=cast(
                'str',
                (
                    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)

        # Place image on top of text layer (sandwich mode)
        if self.image is not None:
            pdf.image(
                str(self.image),
                x=0,
                y=0,
                w=self.coord_transform.page_width_pt,
                h=self.coord_transform.page_height_pt,
            )

    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

        # Read baseline early so we can detect rotation from steep slopes.
        # When Tesseract doesn't report textangle for rotated text, the
        # rotation gets encoded as a very steep baseline slope instead.
        slope = 0.0
        intercept_pt = 0.0
        has_meaningful_baseline = False
        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
            has_meaningful_baseline = True

        # Detect text rotation from steep baseline slope.
        # A slope magnitude > 1.0 corresponds to > 45° from horizontal,
        # which indicates the line is rotated, not merely skewed.
        if textangle == 0.0 and abs(slope) > 1.0:
            textangle = degrees(atan(slope))
            # The original baseline slope and intercept are not meaningful
            # after extracting rotation; recalculate intercept from font
            # metrics below.
            slope = 0.0
            has_meaningful_baseline = False

        # 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 intercept
        if not has_meaningful_baseline:
            # No baseline provided or baseline was used for rotation detection:
            # calculate intercept 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()

        # Collect words to render
        words: list[OcrElement | None] = [
            w for w in line.children if w.ocr_class == OcrClass.WORD and w.text
        ]

        # Suppress lines where the text aspect ratio is implausible.
        # This catches cases where Tesseract failed to detect rotation
        # entirely (slope=0, no textangle) and produced garbage text in a
        # bounding box whose shape doesn't match the text content at all.
        if not self._check_aspect_ratio_plausible(
            pdf,
            words,
            font_size,
            slope_angle_deg,
            line_size_width,
            line_size_height,
            line_language,
        ):
            return

        word_render_data: list[WordRenderData] = []
        for word in words:
            if word is None or not word.text or word.bbox is None:
                continue

            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

            # 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
                )

            # Get x position in baseline coordinate system
            box_llx, _, _, _ = transform_box(
                inv_baseline_matrix,
                word_left_pt,
                word_top_pt,
                word_right_pt,
                word_bottom_pt,
            )

            # Select font and compute word-only Tz
            font_manager = self.multi_font_manager.select_font_for_word(
                word.text, line_language
            )
            font_family = self._register_font(pdf, font_manager)
            pdf.set_font(font_family, size=font_size)

            # For RTL words with invisible text, we use encode_text()
            # (which maps characters 1:1 in logical order) combined with
            # a -1 x-scale text matrix. This avoids an fpdf2 issue where
            # shaped RTL ligature glyphs (e.g. lam-alef) get multi-char
            # CMap entries whose character order is reversed by the bidi
            # algorithm during text extraction.
            # Since the text is invisible, glyph mirroring is harmless.
            # Compute Tz using unshaped widths to match encode_text().
            word_is_rtl = self.invisible_text and _is_rtl_text(word.text)
            if word_is_rtl:
                saved_shaping = pdf.text_shaping
                pdf.text_shaping = None
                natural_width = pdf.get_string_width(word.text)
                pdf.text_shaping = saved_shaping
            else:
                natural_width = pdf.get_string_width(word.text)
            if natural_width > 0 and word_width_pt > 0:
                word_tz = (word_width_pt / natural_width) * 100
            else:
                word_tz = 100.0

            word_render_data.append(
                WordRenderData(
                    text=word.text,
                    x_baseline=box_llx,
                    font_family=font_family,
                    word_tz=word_tz,
                    is_rtl=word_is_rtl,
                )
            )

        if not word_render_data:
            return

        # Emit single BT block for the entire line using raw PDF operators.
        # This avoids a poppler bug where Tz (horizontal scaling) is not
        # carried across BT/ET boundaries, affecting all poppler-based tools
        # and viewers (Evince, pdftotext, etc.). By keeping all words in a
        # single BT block with relative Td positioning and per-word Tz, we
        # ensure correct inter-word spacing.
        self._emit_line_bt_block(
            pdf,
            word_render_data,
            baseline_matrix,
            font_size,
            total_rotation_deg,
        )

    def _check_aspect_ratio_plausible(
        self,
        pdf: FPDF,
        words: list[OcrElement | None],
        font_size: float,
        slope_angle_deg: float,
        line_size_width: float,
        line_size_height: float,
        line_language: str | None,
    ) -> bool:
        """Check whether the line's aspect ratio is plausible for its text.

        Compares the aspect ratio of the OCR bounding box to the aspect ratio
        the text would have if rendered normally (accounting for baseline
        slope). A large mismatch indicates Tesseract misread rotated text
        without detecting the rotation.

        Returns:
            True if plausible (rendering should proceed), False to suppress.
        """
        if line_size_width <= 0 or line_size_height <= 0 or font_size <= 0:
            return True

        # Fast path: most lines are wider than they are tall, which is
        # the normal shape for horizontal text. Only tall-narrow boxes
        # (height > width) need the expensive font measurement check.
        if line_size_width >= line_size_height:
            return True

        line_text = ' '.join(w.text for w in words if w is not None and w.text)
        if not line_text:
            return True

        # Measure the natural rendered width of the line text
        font_manager = self.multi_font_manager.select_font_for_word(
            line_text, line_language
        )
        font_family = self._register_font(pdf, font_manager)
        pdf.set_font(font_family, size=round(font_size))
        natural_width = pdf.get_string_width(line_text)

        if natural_width <= 0:
            return True

        # Compute the AABB the text would occupy considering baseline slope
        theta = radians(abs(slope_angle_deg))
        expected_w = natural_width * cos(theta) + font_size * sin(theta)
        expected_h = natural_width * sin(theta) + font_size * cos(theta)

        if expected_h <= 0:
            return True

        actual_aspect = line_size_width / line_size_height
        expected_aspect = expected_w / expected_h
        ratio = actual_aspect / expected_aspect

        if ratio >= 0.1:
            return True

        # Implausible aspect ratio — suppress this line
        log.debug(
            "Suppressing text with improbable aspect ratio: "
            "actual=%.3f expected=%.3f ratio=%.4f text=%r",
            actual_aspect,
            expected_aspect,
            ratio,
            line_text[:80],
        )
        if not self._logged_aspect_ratio_suppression:
            log.info("Suppressing OCR output text with improbable aspect ratio")
            self._logged_aspect_ratio_suppression = True
        return False

    def _emit_line_bt_block(
        self,
        pdf: FPDF,
        word_render_data: list[WordRenderData],
        baseline_matrix: Matrix,
        font_size: float,
        total_rotation_deg: float,
    ) -> None:
        """Emit a single BT block for the entire line using raw PDF operators.

        Writes all words in a single BT..ET block with relative Td positioning
        and per-word Tz. Each non-last word gets a trailing space appended, with
        Tz calculated so the rendered width of "word " spans from the current
        word's start to the next word's start. This works around a poppler bug
        where Tz is not carried across BT/ET boundaries, which affects all
        poppler-based viewers and tools (Evince, pdftotext, etc.).

        Args:
            pdf: FPDF instance
            word_render_data: List of WordRenderData, one per word on this line
            baseline_matrix: Transform from baseline coords to page coords
            font_size: Font size in points
            total_rotation_deg: Total rotation angle (textangle + slope)
        """
        page_height = self.coord_transform.page_height_pt

        # Compute baseline direction in PDF coordinates for rotation
        has_rotation = abs(total_rotation_deg) > 0.01
        bx0, by0_fpdf = transform_point(baseline_matrix, 0, 0)
        by0_pdf = page_height - by0_fpdf

        ops: list[str] = []

        if has_rotation:
            # Compute direction vector along the baseline in PDF coordinates
            bx1, by1_fpdf = transform_point(baseline_matrix, 100, 0)
            by1_pdf = page_height - by1_fpdf
            dx = bx1 - bx0
            dy = by1_pdf - by0_pdf
            length = sqrt(dx * dx + dy * dy)
            if length > 0:
                cos_a = dx / length
                sin_a = dy / length
            else:
                cos_a = 1.0
                sin_a = 0.0

            # Save graphics state, apply rotation+translation via cm.
            # The cm maps local coordinates (baseline-aligned, x along text)
            # to PDF page coordinates.
            ops.append('q')
            ops.append(
                f'{cos_a:.6f} {sin_a:.6f} {-sin_a:.6f} {cos_a:.6f} '
                f'{bx0:.2f} {by0_pdf:.2f} cm'
            )

        # Begin text object
        ops.append('BT')

        # Text render mode: 3 = invisible, 0 = fill
        tr = 3 if self.invisible_text else 0
        ops.append(f'{tr} Tr')

        # Initial text position
        first_x_baseline = word_render_data[0].x_baseline
        if has_rotation:
            # In the cm-transformed space, origin is at the baseline start
            ops.append(f'{first_x_baseline:.2f} 0 Td')
        else:
            # Direct PDF coordinates
            page_x, page_y_fpdf = transform_point(baseline_matrix, first_x_baseline, 0)
            page_y_pdf = page_height - page_y_fpdf
            ops.append(f'{page_x:.2f} {page_y_pdf:.2f} Td')

        prev_font_family: str | None = None
        prev_x_baseline = first_x_baseline

        for i, word in enumerate(word_render_data):
            is_last = i == len(word_render_data) - 1

            # Set font if changed
            if word.font_family != prev_font_family:
                pdf.set_font(word.font_family, size=font_size)
                # We only ever register fonts via add_font() with a TTF file
                # (see _register_font), so set_font() always resolves to a
                # TTFFont, never a built-in CoreFont or leaves it unset.
                assert pdf.current_font is not None
                # Register font resource on this page
                pdf._resource_catalog.add(
                    PDFResourceType.FONT, pdf.current_font.i, pdf.page
                )
                ops.append(f'/F{pdf.current_font.i} {pdf.font_size_pt:.2f} Tf')
                prev_font_family = word.font_family

            # Relative positioning (for words after the first)
            if i > 0:
                if has_rotation:
                    # In rotated space, advance is purely along x-axis
                    dx_baseline = word.x_baseline - prev_x_baseline
                    ops.append(f'{dx_baseline:.2f} 0 Td')
                else:
                    # Non-rotated: compute delta in PDF coordinates
                    px_prev, py_prev_f = transform_point(
                        baseline_matrix, prev_x_baseline, 0
                    )
                    px_curr, py_curr_f = transform_point(
                        baseline_matrix, word.x_baseline, 0
                    )
                    dx_pdf = px_curr - px_prev
                    # Flip y delta for PDF coordinates (y-up)
                    dy_pdf = -(py_curr_f - py_prev_f)
                    ops.append(f'{dx_pdf:.2f} {dy_pdf:.2f} Td')

            # Determine text to render
            if not is_last:
                next_word = word_render_data[i + 1]
                advance = next_word.x_baseline - word.x_baseline

                # Add trailing space for text extraction unless both are CJK
                if advance > 0 and not (
                    self._is_cjk_only(word.text) and self._is_cjk_only(next_word.text)
                ):
                    text_to_render = word.text + ' '
                else:
                    text_to_render = word.text
            else:
                text_to_render = word.text

            # Use word_tz (fits word into its hOCR bbox) — Td handles
            # inter-word gaps, so Tz should not stretch to fill them.
            ops.append(f'{word.word_tz:.2f} Tz')
            ops.append(self._encode_shaped_text(pdf, text_to_render, word.is_rtl))

            prev_x_baseline = word.x_baseline

        # End text object
        ops.append('ET')

        if has_rotation:
            ops.append('Q')

        pdf._out('\n'.join(ops))

        # Reset fpdf2's internal stretching tracking so subsequent API calls
        # don't think Tz is still set from our raw operators
        pdf.font_stretching = 100

    def _encode_shaped_text(self, pdf: FPDF, text: str, is_rtl: bool = False) -> str:
        """Encode text using HarfBuzz text shaping for complex script support.

        Unlike font.encode_text() which maps unicode characters one-by-one to
        glyph IDs, this uses HarfBuzz to handle BiDi reordering, Arabic joining
        forms, Devanagari conjuncts, and other complex script shaping. Falls
        back to encode_text() when text shaping is not enabled.

        For RTL words with invisible text, we use encode_text() instead of
        shape_text(). fpdf2's shape_text() produces RTL ligature glyphs
        (e.g. lam-alef) with multi-character CMap entries whose character
        order gets reversed by the bidi algorithm during text extraction,
        producing garbled output (e.g. "سالح" instead of "سلاح").
        encode_text() maps characters 1:1 in logical order, giving correct
        extraction. Since the text is invisible (Tr=3), the lack of proper
        joining forms and ligature shaping is harmless.
        """
        font = pdf.current_font
        # We only ever register fonts via add_font() with a TTF file (see
        # _register_font), so current_font is always a TTFFont - never the
        # built-in CoreFont (which lacks shape_text()/escape_text()) or None.
        assert isinstance(font, TTFFont)
        if is_rtl:
            # Reverse the text so that after bidi reversal by the text
            # extractor, the characters end up in correct logical order.
            # The text cursor advances left-to-right from the word's left
            # edge (set by Td), so characters are positioned left-to-right
            # in the PDF. The extractor sees RTL characters in L-to-R
            # positions and applies bidi reversal, which reverses them.
            # By pre-reversing, the double reversal yields the original.
            return font.encode_text(text[::-1])
        if pdf.text_shaping and pdf.text_shaping.get("use_shaping_engine"):
            shaped = font.shape_text(text, pdf.font_size_pt, pdf.text_shaping)
            if shaped:
                mapped = "".join(
                    chr(ti["mapped_char"])
                    for ti in shaped
                    if ti["mapped_char"] is not None
                )
                if mapped:
                    return f"({font.escape_text(mapped)}) Tj"
        return font.encode_text(text)

    def _is_cjk_only(self, text: str) -> bool:
        """Check if text contains only CJK characters.

        CJK scripts don't use spaces between words, so we should not insert
        spaces between adjacent CJK words.

        Args:
            text: Text to check

        Returns:
            True if text contains only CJK characters
        """
        for char in text:
            cp = ord(char)
            # Check if character is in CJK ranges
            if not (
                0x4E00 <= cp <= 0x9FFF  # CJK Unified Ideographs
                or 0x3400 <= cp <= 0x4DBF  # CJK Extension A
                or 0x20000 <= cp <= 0x2A6DF  # CJK Extension B
                or 0x2A700 <= cp <= 0x2B73F  # CJK Extension C
                or 0x2B740 <= cp <= 0x2B81F  # CJK Extension D
                or 0x2B820 <= cp <= 0x2CEAF  # CJK Extension E
                or 0x2CEB0 <= cp <= 0x2EBEF  # CJK Extension F
                or 0x30000 <= cp <= 0x3134F  # CJK Extension G
                or 0x3040 <= cp <= 0x309F  # Hiragana
                or 0x30A0 <= cp <= 0x30FF  # Katakana
                or 0x31F0 <= cp <= 0x31FF  # Katakana Phonetic Extensions
                or 0xAC00 <= cp <= 0xD7AF  # Hangul Syllables
                or 0x1100 <= cp <= 0x11FF  # Hangul Jamo
                or 0x3130 <= cp <= 0x318F  # Hangul Compatibility Jamo
                or 0xA960 <= cp <= 0xA97F  # Hangul Jamo Extended-A
                or 0xD7B0 <= cp <= 0xD7FF  # Hangul Jamo Extended-B
                or 0x3000 <= cp <= 0x303F  # CJK Symbols and Punctuation
                or 0xFF00 <= cp <= 0xFFEF  # Halfwidth and Fullwidth Forms
            ):
                return False
        return True

    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_mode = TextMode.INVISIBLE
        else:
            pdf.text_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))
